Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 7 additions & 10 deletions mcp-worker/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { registerAllToolsWithServer } from '../../src/mcp/tools/index'
// Import types
import { DevCycleMCPServerInstance } from '../../src/mcp/server'
import { handleToolError } from '../../src/mcp/utils/errorHandling'
import { processToolConfig } from '../../src/mcp/utils/schema'

import { registerProjectSelectionTools } from './projectSelectionTools'
import type { UserProps } from './types'
Expand Down Expand Up @@ -68,20 +69,16 @@ export class DevCycleMCP extends McpAgent<Env, DevCycleMCPState, UserProps> {
name: string,
config: {
description: string
annotations?: any
inputSchema?: any
outputSchema?: any
annotations?: Record<string, unknown>
inputSchema?: unknown
outputSchema?: unknown
},
handler: (args: any) => Promise<any>,
handler: (args: unknown) => Promise<unknown>,
) => {
this.server.registerTool(
name,
{
description: config.description,
annotations: config.annotations,
inputSchema: config.inputSchema || {},
},
async (args: any) => {
processToolConfig(name, config),
async (args: unknown) => {
try {
const result = await handler(args)
return {
Expand Down
2 changes: 1 addition & 1 deletion mcp-worker/src/projectSelectionTools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -160,7 +160,7 @@ export function registerProjectSelectionTools(
annotations: {
title: 'Select Project',
},
inputSchema: SelectProjectArgsSchema.shape,
inputSchema: SelectProjectArgsSchema,
},
async (args: unknown) => {
const validatedArgs = SelectProjectArgsSchema.parse(args)
Expand Down
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,8 @@
"parse-diff": "^0.9.0",
"recast": "^0.21.5",
"reflect-metadata": "^0.1.14",
"zod": "~3.25.76"
"zod": "~3.25.76",
"zod-to-json-schema": "^3.24.6"
},
"devDependencies": {
"@babel/code-frame": "^7.27.1",
Expand Down
119 changes: 119 additions & 0 deletions src/mcp/schema.e2e.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
import { expect } from '@oclif/test'
import sinon from 'sinon'
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import { DevCycleMCPServer } from './server'
import { DevCycleAuth } from './utils/auth'
import { DevCycleApiClient } from './utils/api'

function getRegisteredTool(registerToolStub: sinon.SinonStub, name: string) {
const call = registerToolStub.getCalls().find((c) => c.args[0] === name)
expect(call, `Tool ${name} should be registered`).to.exist
const [, config] = (call as sinon.SinonSpyCall).args
return config as { inputSchema?: Record<string, unknown> }
}

function assertArraysHaveItems(schema: unknown) {
const stack: unknown[] = [schema]
while (stack.length) {
const node = stack.pop()
if (!node || typeof node !== 'object') continue
const obj = node as Record<string, unknown>
if (obj.type === 'array') {
expect(obj).to.have.property('items')
}
for (const value of Object.values(obj)) stack.push(value)
}
}

function derefLocalRef(schema: any): any {
if (!schema || typeof schema !== 'object') return schema
const ref = (schema as any).$ref
if (typeof ref === 'string' && ref.startsWith('#/definitions/')) {
const key = ref.replace('#/definitions/', '')
if ((schema as any).definitions && (schema as any).definitions[key]) {
return (schema as any).definitions[key]
}
}
return schema
}

describe('MCP tool schema e2e', () => {
let server: McpServer
let mcpServer: DevCycleMCPServer
let authStub: sinon.SinonStubbedInstance<DevCycleAuth>
let apiClientStub: sinon.SinonStubbedInstance<DevCycleApiClient>

beforeEach(async () => {
server = { registerTool: sinon.stub() } as any
authStub = sinon.createStubInstance(DevCycleAuth)
apiClientStub = sinon.createStubInstance(DevCycleApiClient)
mcpServer = new DevCycleMCPServer(server)
Object.defineProperty(mcpServer, 'auth', {
value: authStub,
writable: true,
})
Object.defineProperty(mcpServer, 'apiClient', {
value: apiClientStub,
writable: true,
})
authStub.initialize.resolves()
await mcpServer.initialize()
})

afterEach(() => sinon.restore())

it('registers rich JSON Schemas for tools that require inputs', () => {
const registerToolStub = server.registerTool as sinon.SinonStub

// list_projects should expose pagination and sorting fields
const listProjects = getRegisteredTool(
registerToolStub,
'list_projects',
)
expect(listProjects.inputSchema).to.be.an('object')
const lpSchema = derefLocalRef(listProjects.inputSchema)
const lpProps = (lpSchema as any).properties || {}
expect(Object.keys(lpProps).length).to.be.greaterThan(0)
expect(lpProps).to.have.property('page')
expect(lpProps).to.have.property('perPage')

// list_environments should expose filter/pagination fields
const listEnvs = getRegisteredTool(
registerToolStub,
'list_environments',
)
const leSchema = derefLocalRef(listEnvs.inputSchema)
const leProps = (leSchema as any).properties || {}
expect(Object.keys(leProps).length).to.be.greaterThan(0)

// list_features should expose search/sort fields
const listFeatures = getRegisteredTool(
registerToolStub,
'list_features',
)
const lfSchema = derefLocalRef(listFeatures.inputSchema)
const lfProps = (lfSchema as any).properties || {}
expect(Object.keys(lfProps).length).to.be.greaterThan(0)
expect(lfProps).to.have.property('search')
})

it('ensures all array definitions include items in complex schemas (create_feature)', () => {
const registerToolStub = server.registerTool as sinon.SinonStub
const createFeature = getRegisteredTool(
registerToolStub,
'create_feature',
)
expect(createFeature.inputSchema).to.be.an('object')
assertArraysHaveItems(createFeature.inputSchema)
})

it('allows empty input schema for tools with no parameters', () => {
const registerToolStub = server.registerTool as sinon.SinonStub
const getCurrent = getRegisteredTool(
registerToolStub,
'get_current_project',
)
const props = (getCurrent.inputSchema as any).properties || {}
expect(Object.keys(props).length).to.equal(0)
})
})
66 changes: 30 additions & 36 deletions src/mcp/server.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import { Tool, ToolAnnotations } from '@modelcontextprotocol/sdk/types.js'
import type { ToolAnnotations } from '@modelcontextprotocol/sdk/types.js'
import { DevCycleAuth } from './utils/auth'
import { DevCycleApiClient } from './utils/api'
import { IDevCycleApiClient } from './api/interface'
import Writer from '../ui/writer'
import { handleToolError } from './utils/errorHandling'
import { registerAllToolsWithServer } from './tools'
import { processToolConfig } from './utils/schema'

// Environment variable to control output schema inclusion
const ENABLE_OUTPUT_SCHEMAS = process.env.ENABLE_OUTPUT_SCHEMAS === 'true'
Expand All @@ -17,34 +18,23 @@ if (ENABLE_OUTPUT_SCHEMAS) {
export type ToolHandler = (
args: unknown,
apiClient: IDevCycleApiClient,
) => Promise<any>
) => Promise<unknown>

// Type for the server instance with our helper method
export type DevCycleMCPServerInstance = {
registerToolWithErrorHandling: (
name: string,
config: {
description: string
annotations?: any
inputSchema?: any
outputSchema?: any
annotations?: ToolAnnotations
inputSchema?: unknown
outputSchema?: unknown
},
handler: (args: any) => Promise<any>,
handler: (args: unknown) => Promise<unknown>,
) => void
}

// Function to conditionally remove outputSchema from tool definitions
const processToolDefinitions = (tools: Tool[]): Tool[] => {
if (ENABLE_OUTPUT_SCHEMAS) {
return tools
}

// Remove outputSchema from all tools when disabled
return tools.map((tool) => {
const { outputSchema, ...toolWithoutSchema } = tool
return toolWithoutSchema
})
}
// (legacy helper removed; schema conversion handled centrally)

export class DevCycleMCPServer {
private auth: DevCycleAuth
Expand Down Expand Up @@ -82,27 +72,31 @@ export class DevCycleMCPServer {
name: string,
config: {
description: string
inputSchema?: any
outputSchema?: any
inputSchema?: unknown
outputSchema?: unknown
annotations?: ToolAnnotations
},
handler: (args: any) => Promise<any>,
handler: (args: unknown) => Promise<unknown>,
) {
this.server.registerTool(name, config, async (args: any) => {
try {
const result = await handler(args)
this.server.registerTool(
name,
processToolConfig(name, config),
async (args: unknown) => {
try {
const result = await handler(args)

return {
content: [
{
type: 'text' as const,
text: JSON.stringify(result, null, 2),
},
],
} as any
} catch (error) {
return handleToolError(error, name)
}
})
return {
content: [
{
type: 'text' as const,
text: JSON.stringify(result, null, 2),
},
],
}
} catch (error) {
return handleToolError(error, name)
}
},
)
}
}
12 changes: 6 additions & 6 deletions src/mcp/tools/environmentTools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -155,9 +155,9 @@ export function registerEnvironmentTools(
title: 'List Environments',
readOnlyHint: true,
},
inputSchema: ListEnvironmentsArgsSchema.shape,
inputSchema: ListEnvironmentsArgsSchema,
},
async (args: any) => {
async (args: unknown) => {
const validatedArgs = ListEnvironmentsArgsSchema.parse(args)
return await listEnvironmentsHandler(validatedArgs, apiClient)
},
Expand All @@ -172,9 +172,9 @@ export function registerEnvironmentTools(
title: 'Get SDK Keys',
readOnlyHint: true,
},
inputSchema: GetSdkKeysArgsSchema.shape,
inputSchema: GetSdkKeysArgsSchema,
},
async (args: any) => {
async (args: unknown) => {
const validatedArgs = GetSdkKeysArgsSchema.parse(args)
return await getSdkKeysHandler(validatedArgs, apiClient)
},
Expand All @@ -189,7 +189,7 @@ export function registerEnvironmentTools(
// annotations: {
// title: 'Create Environment',
// },
// inputSchema: CreateEnvironmentArgsSchema.shape,
// inputSchema: CreateEnvironmentArgsSchema,
// },
// async (args: any) => {
// const validatedArgs = CreateEnvironmentArgsSchema.parse(args)
Expand All @@ -205,7 +205,7 @@ export function registerEnvironmentTools(
// annotations: {
// title: 'Update Environment',
// },
// inputSchema: UpdateEnvironmentArgsSchema.shape,
// inputSchema: UpdateEnvironmentArgsSchema,
// },
// async (args: any) => {
// const validatedArgs = UpdateEnvironmentArgsSchema.parse(args)
Expand Down
Loading