diff --git a/mcp-worker/src/index.ts b/mcp-worker/src/index.ts index 5a640d986..c6e1a3586 100644 --- a/mcp-worker/src/index.ts +++ b/mcp-worker/src/index.ts @@ -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' @@ -68,20 +69,16 @@ export class DevCycleMCP extends McpAgent { name: string, config: { description: string - annotations?: any - inputSchema?: any - outputSchema?: any + annotations?: Record + inputSchema?: unknown + outputSchema?: unknown }, - handler: (args: any) => Promise, + handler: (args: unknown) => Promise, ) => { 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 { diff --git a/mcp-worker/src/projectSelectionTools.ts b/mcp-worker/src/projectSelectionTools.ts index e5eeb50f1..5a8582401 100644 --- a/mcp-worker/src/projectSelectionTools.ts +++ b/mcp-worker/src/projectSelectionTools.ts @@ -160,7 +160,7 @@ export function registerProjectSelectionTools( annotations: { title: 'Select Project', }, - inputSchema: SelectProjectArgsSchema.shape, + inputSchema: SelectProjectArgsSchema, }, async (args: unknown) => { const validatedArgs = SelectProjectArgsSchema.parse(args) diff --git a/package.json b/package.json index b214c5bb5..7983f4e08 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/mcp/schema.e2e.test.ts b/src/mcp/schema.e2e.test.ts new file mode 100644 index 000000000..5d368466d --- /dev/null +++ b/src/mcp/schema.e2e.test.ts @@ -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 } +} + +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 + 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 + let apiClientStub: sinon.SinonStubbedInstance + + 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) + }) +}) diff --git a/src/mcp/server.ts b/src/mcp/server.ts index 5756fd5dc..148d0f375 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -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' @@ -17,7 +18,7 @@ if (ENABLE_OUTPUT_SCHEMAS) { export type ToolHandler = ( args: unknown, apiClient: IDevCycleApiClient, -) => Promise +) => Promise // Type for the server instance with our helper method export type DevCycleMCPServerInstance = { @@ -25,26 +26,15 @@ export type DevCycleMCPServerInstance = { name: string, config: { description: string - annotations?: any - inputSchema?: any - outputSchema?: any + annotations?: ToolAnnotations + inputSchema?: unknown + outputSchema?: unknown }, - handler: (args: any) => Promise, + handler: (args: unknown) => Promise, ) => 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 @@ -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, + handler: (args: unknown) => Promise, ) { - 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) + } + }, + ) } } diff --git a/src/mcp/tools/environmentTools.ts b/src/mcp/tools/environmentTools.ts index 2a260fe71..cdf5b8fb4 100644 --- a/src/mcp/tools/environmentTools.ts +++ b/src/mcp/tools/environmentTools.ts @@ -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) }, @@ -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) }, @@ -189,7 +189,7 @@ export function registerEnvironmentTools( // annotations: { // title: 'Create Environment', // }, - // inputSchema: CreateEnvironmentArgsSchema.shape, + // inputSchema: CreateEnvironmentArgsSchema, // }, // async (args: any) => { // const validatedArgs = CreateEnvironmentArgsSchema.parse(args) @@ -205,7 +205,7 @@ export function registerEnvironmentTools( // annotations: { // title: 'Update Environment', // }, - // inputSchema: UpdateEnvironmentArgsSchema.shape, + // inputSchema: UpdateEnvironmentArgsSchema, // }, // async (args: any) => { // const validatedArgs = UpdateEnvironmentArgsSchema.parse(args) diff --git a/src/mcp/tools/featureTools.ts b/src/mcp/tools/featureTools.ts index 87cf52e1a..84e4dd554 100644 --- a/src/mcp/tools/featureTools.ts +++ b/src/mcp/tools/featureTools.ts @@ -469,9 +469,9 @@ export function registerFeatureTools( title: 'List Feature Flags', readOnlyHint: true, }, - inputSchema: ListFeaturesArgsSchema.shape, + inputSchema: ListFeaturesArgsSchema, }, - async (args: any) => { + async (args: unknown) => { const validatedArgs = ListFeaturesArgsSchema.parse(args) return await listFeaturesHandler(validatedArgs, apiClient) }, @@ -490,9 +490,9 @@ export function registerFeatureTools( annotations: { title: 'Create Feature Flag', }, - inputSchema: CreateFeatureArgsSchema.shape, + inputSchema: CreateFeatureArgsSchema, }, - async (args: any) => { + async (args: unknown) => { const validatedArgs = CreateFeatureArgsSchema.parse(args) return await createFeatureHandler(validatedArgs, apiClient) }, @@ -507,9 +507,9 @@ export function registerFeatureTools( title: 'Update Feature Flag', destructiveHint: true, }, - inputSchema: UpdateFeatureArgsSchema.shape, + inputSchema: UpdateFeatureArgsSchema, }, - async (args: any) => { + async (args: unknown) => { const validatedArgs = UpdateFeatureArgsSchema.parse(args) return await updateFeatureHandler(validatedArgs, apiClient) }, @@ -524,9 +524,9 @@ export function registerFeatureTools( title: 'Update Feature Flag Status', destructiveHint: true, }, - inputSchema: UpdateFeatureStatusArgsSchema.shape, + inputSchema: UpdateFeatureStatusArgsSchema, }, - async (args: any) => { + async (args: unknown) => { const validatedArgs = UpdateFeatureStatusArgsSchema.parse(args) return await updateFeatureStatusHandler(validatedArgs, apiClient) }, @@ -541,9 +541,9 @@ export function registerFeatureTools( title: 'Delete Feature Flag', destructiveHint: true, }, - inputSchema: DeleteFeatureArgsSchema.shape, + inputSchema: DeleteFeatureArgsSchema, }, - async (args: any) => { + async (args: unknown) => { const validatedArgs = DeleteFeatureArgsSchema.parse(args) return await deleteFeatureHandler(validatedArgs, apiClient) }, @@ -558,9 +558,9 @@ export function registerFeatureTools( title: 'Get Feature Variations', readOnlyHint: true, }, - inputSchema: ListVariationsArgsSchema.shape, + inputSchema: ListVariationsArgsSchema, }, - async (args: any) => { + async (args: unknown) => { const validatedArgs = ListVariationsArgsSchema.parse(args) return await fetchFeatureVariationsHandler(validatedArgs, apiClient) }, @@ -574,9 +574,9 @@ export function registerFeatureTools( annotations: { title: 'Create Feature Variation', }, - inputSchema: CreateVariationArgsSchema.shape, + inputSchema: CreateVariationArgsSchema, }, - async (args: any) => { + async (args: unknown) => { const validatedArgs = CreateVariationArgsSchema.parse(args) return await createFeatureVariationHandler(validatedArgs, apiClient) }, @@ -591,9 +591,9 @@ export function registerFeatureTools( title: 'Update Feature Variation', destructiveHint: true, }, - inputSchema: UpdateVariationArgsSchema.shape, + inputSchema: UpdateVariationArgsSchema, }, - async (args: any) => { + async (args: unknown) => { const validatedArgs = UpdateVariationArgsSchema.parse(args) return await updateFeatureVariationHandler(validatedArgs, apiClient) }, @@ -608,9 +608,9 @@ export function registerFeatureTools( title: 'Set Feature Targeting', destructiveHint: true, }, - inputSchema: SetFeatureTargetingArgsSchema.shape, + inputSchema: SetFeatureTargetingArgsSchema, }, - async (args: any) => { + async (args: unknown) => { const validatedArgs = SetFeatureTargetingArgsSchema.parse(args) return await setFeatureTargetingHandler(validatedArgs, apiClient) }, @@ -625,9 +625,9 @@ export function registerFeatureTools( title: 'List Feature Targeting Rules', readOnlyHint: true, }, - inputSchema: ListFeatureTargetingArgsSchema.shape, + inputSchema: ListFeatureTargetingArgsSchema, }, - async (args: any) => { + async (args: unknown) => { const validatedArgs = ListFeatureTargetingArgsSchema.parse(args) return await listFeatureTargetingHandler(validatedArgs, apiClient) }, @@ -642,9 +642,9 @@ export function registerFeatureTools( title: 'Update Feature Targeting Rules', destructiveHint: true, }, - inputSchema: UpdateFeatureTargetingArgsSchema.shape, + inputSchema: UpdateFeatureTargetingArgsSchema, }, - async (args: any) => { + async (args: unknown) => { const validatedArgs = UpdateFeatureTargetingArgsSchema.parse(args) return await updateFeatureTargetingHandler(validatedArgs, apiClient) }, @@ -659,9 +659,9 @@ export function registerFeatureTools( title: 'Get Feature Audit Log History', readOnlyHint: true, }, - inputSchema: GetFeatureAuditLogHistoryArgsSchema.shape, + inputSchema: GetFeatureAuditLogHistoryArgsSchema, }, - async (args: any) => { + async (args: unknown) => { const validatedArgs = GetFeatureAuditLogHistoryArgsSchema.parse(args) return await getFeatureAuditLogHistoryHandler( diff --git a/src/mcp/tools/projectTools.ts b/src/mcp/tools/projectTools.ts index 53d45007d..80102d58b 100644 --- a/src/mcp/tools/projectTools.ts +++ b/src/mcp/tools/projectTools.ts @@ -130,9 +130,9 @@ export function registerProjectTools( title: 'List Projects', readOnlyHint: true, }, - inputSchema: ListProjectsArgsSchema.shape, + inputSchema: ListProjectsArgsSchema, }, - async (args: any) => { + async (args: unknown) => { const validatedArgs = ListProjectsArgsSchema.parse(args) return await listProjectsHandler(validatedArgs, apiClient) @@ -164,7 +164,7 @@ export function registerProjectTools( // annotations: { // title: 'Create Project', // }, - // inputSchema: CreateProjectArgsSchema.shape, + // inputSchema: CreateProjectArgsSchema, // }, // async (args: any) => { // const validatedArgs = CreateProjectArgsSchema.parse(args) @@ -181,7 +181,7 @@ export function registerProjectTools( // annotations: { // title: 'Update Project', // }, - // inputSchema: UpdateProjectArgsSchema.shape, + // inputSchema: UpdateProjectArgsSchema, // }, // async (args: any) => { // const validatedArgs = UpdateProjectArgsSchema.parse(args) diff --git a/src/mcp/tools/resultsTools.ts b/src/mcp/tools/resultsTools.ts index 8436b2c06..a32bf4162 100644 --- a/src/mcp/tools/resultsTools.ts +++ b/src/mcp/tools/resultsTools.ts @@ -111,9 +111,9 @@ export function registerResultsTools( title: 'Get Feature Total Evaluations', readOnlyHint: true, }, - inputSchema: GetFeatureTotalEvaluationsArgsSchema.shape, + inputSchema: GetFeatureTotalEvaluationsArgsSchema, }, - async (args: any) => { + async (args: unknown) => { const validatedArgs = GetFeatureTotalEvaluationsArgsSchema.parse(args) return await getFeatureTotalEvaluationsHandler( @@ -132,9 +132,9 @@ export function registerResultsTools( title: 'Get Project Total Evaluations', readOnlyHint: true, }, - inputSchema: GetProjectTotalEvaluationsArgsSchema.shape, + inputSchema: GetProjectTotalEvaluationsArgsSchema, }, - async (args: any) => { + async (args: unknown) => { const validatedArgs = GetProjectTotalEvaluationsArgsSchema.parse(args) return await getProjectTotalEvaluationsHandler( diff --git a/src/mcp/tools/selfTargetingTools.ts b/src/mcp/tools/selfTargetingTools.ts index 88af7c51a..71e3a478e 100644 --- a/src/mcp/tools/selfTargetingTools.ts +++ b/src/mcp/tools/selfTargetingTools.ts @@ -199,9 +199,9 @@ export function registerSelfTargetingTools( annotations: { title: 'Update Self-Targeting Identity', }, - inputSchema: UpdateSelfTargetingIdentityArgsSchema.shape, + inputSchema: UpdateSelfTargetingIdentityArgsSchema, }, - async (args: any) => { + async (args: unknown) => { const validatedArgs = UpdateSelfTargetingIdentityArgsSchema.parse(args) return await updateSelfTargetingIdentityHandler( @@ -235,9 +235,9 @@ export function registerSelfTargetingTools( annotations: { title: 'Set Self-Targeting Override For Feature/Environment', }, - inputSchema: SetSelfTargetingOverrideArgsSchema.shape, + inputSchema: SetSelfTargetingOverrideArgsSchema, }, - async (args: any) => { + async (args: unknown) => { const validatedArgs = SetSelfTargetingOverrideArgsSchema.parse(args) return await setSelfTargetingOverrideHandler( validatedArgs, @@ -254,9 +254,9 @@ export function registerSelfTargetingTools( annotations: { title: 'Clear Self-Targeting Override For Feature/Environment', }, - inputSchema: ClearSelfTargetingOverridesArgsSchema.shape, + inputSchema: ClearSelfTargetingOverridesArgsSchema, }, - async (args: any) => { + async (args: unknown) => { const validatedArgs = ClearSelfTargetingOverridesArgsSchema.parse(args) return await clearFeatureSelfTargetingOverridesHandler( diff --git a/src/mcp/tools/variableTools.ts b/src/mcp/tools/variableTools.ts index 39573f3e2..e37ce5974 100644 --- a/src/mcp/tools/variableTools.ts +++ b/src/mcp/tools/variableTools.ts @@ -138,9 +138,9 @@ export function registerVariableTools( title: 'List Variables', readOnlyHint: true, }, - inputSchema: ListVariablesArgsSchema.shape, + inputSchema: ListVariablesArgsSchema, }, - async (args: any) => { + async (args: unknown) => { const validatedArgs = ListVariablesArgsSchema.parse(args) return await listVariablesHandler(validatedArgs, apiClient) }, @@ -154,9 +154,9 @@ export function registerVariableTools( annotations: { title: 'Create Variable', }, - inputSchema: CreateVariableArgsSchema.shape, + inputSchema: CreateVariableArgsSchema, }, - async (args: any) => { + async (args: unknown) => { const validatedArgs = CreateVariableArgsSchema.parse(args) return await createVariableHandler(validatedArgs, apiClient) }, @@ -171,9 +171,9 @@ export function registerVariableTools( title: 'Update Variable', destructiveHint: true, }, - inputSchema: UpdateVariableArgsSchema.shape, + inputSchema: UpdateVariableArgsSchema, }, - async (args: any) => { + async (args: unknown) => { const validatedArgs = UpdateVariableArgsSchema.parse(args) return await updateVariableHandler(validatedArgs, apiClient) }, @@ -188,9 +188,9 @@ export function registerVariableTools( title: 'Delete Variable', destructiveHint: true, }, - inputSchema: DeleteVariableArgsSchema.shape, + inputSchema: DeleteVariableArgsSchema, }, - async (args: any) => { + async (args: unknown) => { const validatedArgs = DeleteVariableArgsSchema.parse(args) return await deleteVariableHandler(validatedArgs, apiClient) }, diff --git a/src/mcp/utils/__snapshots__/schema.test.ts.snap b/src/mcp/utils/__snapshots__/schema.test.ts.snap new file mode 100644 index 000000000..bbc91828c --- /dev/null +++ b/src/mcp/utils/__snapshots__/schema.test.ts.snap @@ -0,0 +1,73 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`schema conversion processToolConfig converts a Zod shape object 1`] = ` +"{ + \\"$ref\\": \\"#/definitions/shape_test_input\\", + \\"definitions\\": { + \\"shape_test_input\\": { + \\"type\\": \\"object\\", + \\"properties\\": { + \\"key\\": { + \\"type\\": \\"string\\", + \\"description\\": \\"k\\" + } + }, + \\"required\\": [ + \\"key\\" + ], + \\"additionalProperties\\": false + } + }, + \\"$schema\\": \\"http://json-schema.org/draft-07/schema#\\" +}" +`; + +exports[`schema conversion registerToolWithErrorHandling uses JSON Schema input 1`] = ` +"{ + \\"$ref\\": \\"#/definitions/tool_a_input\\", + \\"definitions\\": { + \\"tool_a_input\\": { + \\"type\\": \\"object\\", + \\"properties\\": { + \\"feature\\": { + \\"type\\": \\"string\\" + } + }, + \\"required\\": [ + \\"feature\\" + ], + \\"additionalProperties\\": false + } + }, + \\"$schema\\": \\"http://json-schema.org/draft-07/schema#\\" +}" +`; + +exports[`schema conversion toJsonSchema converts a Zod object schema 1`] = ` +"{ + \\"$ref\\": \\"#/definitions/Sample\\", + \\"definitions\\": { + \\"Sample\\": { + \\"type\\": \\"object\\", + \\"properties\\": { + \\"name\\": { + \\"type\\": \\"string\\", + \\"description\\": \\"n\\" + }, + \\"page\\": { + \\"type\\": \\"number\\", + \\"minimum\\": 1, + \\"default\\": 1, + \\"description\\": \\"p\\" + } + }, + \\"required\\": [ + \\"name\\" + ], + \\"additionalProperties\\": false, + \\"description\\": \\"root\\" + } + }, + \\"$schema\\": \\"http://json-schema.org/draft-07/schema#\\" +}" +`; diff --git a/src/mcp/utils/schema.test.ts b/src/mcp/utils/schema.test.ts new file mode 100644 index 000000000..e9f2a0c96 --- /dev/null +++ b/src/mcp/utils/schema.test.ts @@ -0,0 +1,71 @@ +import { expect } from 'chai' +import * as chai from 'chai' +import { jestSnapshotPlugin } from 'mocha-chai-jest-snapshot' +import { setCurrentTestFile } from '../../../test-utils' +import sinon from 'sinon' +import { z } from 'zod' +import { processToolConfig, toJsonSchema } from './schema' +import { DevCycleMCPServer } from '../server' + +function expectHasObjectSchema(json: any, defName?: string) { + if (json && json.type === 'object') { + expect(json).to.have.property('properties') + return + } + expect(json).to.have.property('$ref') + expect(json).to.have.property('definitions') + const name = defName || ((json.$ref as string).split('/').pop() as string) + expect(json.definitions).to.have.property(name) + const def = json.definitions[name] + expect(def).to.have.property('type', 'object') + expect(def).to.have.property('properties') +} + +describe('schema conversion', () => { + beforeEach(setCurrentTestFile(__filename)) + chai.use(jestSnapshotPlugin()) + it('toJsonSchema converts a Zod object schema', () => { + const Sample = z + .object({ + name: z.string().describe('n'), + page: z.number().min(1).default(1).describe('p'), + }) + .describe('root') + const json = toJsonSchema(Sample, 'Sample') as any + expect(json).to.be.an('object') + expectHasObjectSchema(json, 'Sample') + expect(JSON.stringify(json, null, 2)).toMatchSnapshot() + }) + + it('processToolConfig converts a Zod shape object', () => { + const Sample = z.object({ key: z.string().describe('k') }) + const config = processToolConfig('shape_test', { + description: 'd', + inputSchema: Sample.shape, + }) as any + expect(config).to.have.property('inputSchema') + expectHasObjectSchema(config.inputSchema) + expect(JSON.stringify(config.inputSchema, null, 2)).toMatchSnapshot() + }) + + it('registerToolWithErrorHandling uses JSON Schema input', () => { + const server = { registerTool: sinon.stub() } as any + const mcp = new DevCycleMCPServer(server) + const Args = z.object({ feature: z.string() }) + mcp.registerToolWithErrorHandling( + 'tool_a', + { + description: 'd', + inputSchema: Args.shape, + }, + async () => ({}), + ) + + const call = (server.registerTool as sinon.SinonStub).getCall(0) + expect(call).to.exist + const cfg = call.args[1] + expect(cfg).to.have.property('inputSchema') + expectHasObjectSchema(cfg.inputSchema) + expect(JSON.stringify(cfg.inputSchema, null, 2)).toMatchSnapshot() + }) +}) diff --git a/src/mcp/utils/schema.ts b/src/mcp/utils/schema.ts new file mode 100644 index 000000000..5667e1dd2 --- /dev/null +++ b/src/mcp/utils/schema.ts @@ -0,0 +1,119 @@ +import { z, ZodTypeAny, ZodRawShape } from 'zod' +import type { Tool, ToolAnnotations } from '@modelcontextprotocol/sdk/types.js' +import { zodToJsonSchema } from 'zod-to-json-schema' + +type JsonSchema = Record + +type ToolConfig = { + description: string + annotations?: ToolAnnotations + inputSchema?: unknown + outputSchema?: unknown +} + +const ENABLE_OUTPUT_SCHEMAS = process.env.ENABLE_OUTPUT_SCHEMAS === 'true' + +/** + * Narrow an unknown value to a Zod type. + * + * Why: tool registrations often pass either full Zod schemas or plain + * shapes (e.g., `SomeZodSchema.shape`). We need a reliable way to detect when + * we already have a Zod schema so we don't wrap it again. + */ +function isZodType(value: unknown): value is ZodTypeAny { + return !!value && typeof value === 'object' && 'safeParse' in value +} + +/** + * Convert a plain object shape into a Zod object when needed. + * + * Why: our tool modules frequently pass `SomeSchema.shape` which is a plain + * object of Zod fields. `zod-to-json-schema` expects a full Zod schema. + * Without wrapping, we would emit an empty `{}` JSON Schema, which broke MCP + * consumers (e.g., Copilot Chat would see tools with no parameters). + * + * This function is deliberately permissive so inline shapes also work. + */ +function maybeWrapShape(schema: unknown): ZodTypeAny | undefined { + if (!schema || typeof schema !== 'object') return undefined + // If it's already a Zod type, leave it + if (isZodType(schema)) return schema as ZodTypeAny + // Be permissive: treat any plain object as a Zod object shape. This covers + // passing `SomeSchema.shape` as well as inline shapes. + try { + return z.object(schema as ZodRawShape) + } catch { + return undefined + } +} + +/** + * Convert a Zod schema or shape to JSON Schema and normalize it for MCP. + * + * Why: some generated Zod schemas include arrays defined as `z.array(z.any())`. + * `zod-to-json-schema` may omit `items` for these, but MCP clients (notably + * Copilot Chat) require `items` to be present. We post-process the emitted + * schema to ensure every array node includes an `items` field. + */ +export function toJsonSchema( + schema: unknown, + name?: string, +): JsonSchema | undefined { + if (!schema) return undefined + const zodSchema = isZodType(schema) ? schema : maybeWrapShape(schema) + if (!zodSchema) return undefined + + const json = zodToJsonSchema( + zodSchema, + name ? { name } : undefined, + ) as JsonSchema + + // Normalize the schema for MCP consumers that require `items` on arrays. + // Some generated schemas (e.g., z.array(z.any())) omit `items`. Add a + // minimal empty schema so tools validate correctly. + function ensureArrayItems(node: unknown): void { + if (!node || typeof node !== 'object') return + const obj = node as Record + if (obj.type === 'array' && obj.items === undefined) { + obj.items = {} + } + for (const value of Object.values(obj)) { + ensureArrayItems(value) + } + } + ensureArrayItems(json) + return json +} + +type ToolRegistrationConfig = Omit + +const defaultObjectSchema: JsonSchema = { type: 'object', properties: {} } + +/** + * Prepare a tool registration payload for the MCP server. + * + * Why: centralizes JSON Schema conversion and applies safe defaults so a + * tool always has a valid `inputSchema`. Also supports optional output schema + * generation behind a feature flag. We pass a `name` to help + * `zod-to-json-schema` generate stable `$ref` entries for large schemas. + */ +export function processToolConfig( + name: string, + config: ToolConfig, +): ToolRegistrationConfig { + const processed: ToolRegistrationConfig = { + description: config.description, + annotations: config.annotations, + inputSchema: defaultObjectSchema, + } + + const inputJson = toJsonSchema(config.inputSchema, `${name}_input`) + if (inputJson) processed.inputSchema = inputJson + + if (ENABLE_OUTPUT_SCHEMAS) { + const outputJson = toJsonSchema(config.outputSchema, `${name}_output`) + if (outputJson) processed.outputSchema = outputJson + } + + return processed +} diff --git a/yarn.lock b/yarn.lock index 7f67363ab..a0e658e40 100644 --- a/yarn.lock +++ b/yarn.lock @@ -782,6 +782,7 @@ __metadata: typescript: "npm:^5.7.2" typescript-eslint: "npm:^8.21.0" zod: "npm:~3.25.76" + zod-to-json-schema: "npm:^3.24.6" bin: dvc: ./bin/run dvc-mcp: ./bin/mcp @@ -10806,7 +10807,7 @@ __metadata: languageName: node linkType: hard -"zod-to-json-schema@npm:^3.24.1": +"zod-to-json-schema@npm:^3.24.1, zod-to-json-schema@npm:^3.24.6": version: 3.24.6 resolution: "zod-to-json-schema@npm:3.24.6" peerDependencies: