diff --git a/services/mcp-server/src/modules/support/debug-pack.ts b/services/mcp-server/src/modules/support/debug-pack.ts index 1dc01c68..a21d961a 100644 --- a/services/mcp-server/src/modules/support/debug-pack.ts +++ b/services/mcp-server/src/modules/support/debug-pack.ts @@ -1,6 +1,10 @@ import { z } from 'zod'; import { registerTool } from '../tools/registry.js'; -import { telemetryClusters, diagnosticsCreateSession } from '../../lib/platform-client.js'; +import { + telemetryClusters, + diagnosticsCreateSession, + type TelemetryCluster, +} from '../../lib/platform-client.js'; import type { McpToolRequest } from '../tools/types.js'; const tokenOf = (req: McpToolRequest) => req.headers.authorization?.slice(7); @@ -41,7 +45,7 @@ registerTool({ const generatedAt = new Date().toISOString(); // Step 1: collect error clusters - let clusters: import('../../lib/platform-client.js').TelemetryCluster[] = []; + let clusters: TelemetryCluster[] = []; let clusterError: string | undefined; try { const result = await telemetryClusters({ from: args.from, to: args.to }, opts); diff --git a/services/mcp-server/src/modules/tools/registry.test.ts b/services/mcp-server/src/modules/tools/registry.test.ts index 67f63758..d31a1f1f 100644 --- a/services/mcp-server/src/modules/tools/registry.test.ts +++ b/services/mcp-server/src/modules/tools/registry.test.ts @@ -30,6 +30,39 @@ describe('tool registry', () => { it('getTool returns undefined for unknown tool', () => { expect(getTool('does.not.exist')).toBeUndefined(); }); + + it('zodToJsonSchema emits correct types for boolean/number/array fields', () => { + registerTool({ + name: 'test.types', + description: 'Type test', + requiredRole: 'viewer', + inputSchema: z.object({ + flag: z.boolean(), + count: z.coerce.number(), + tags: z.array(z.string()), + label: z.string().optional(), + enabled: z.boolean().default(false), + }), + async execute(args) { + return args; + }, + }); + const tool = listTools().find(t => t.name === 'test.types'); + const props = (tool?.inputSchema as { properties: Record }) + .properties; + expect(props.flag.type).toBe('boolean'); + expect(props.count.type).toBe('number'); + expect(props.tags.type).toBe('array'); + expect(props.label.type).toBe('string'); + // ZodDefault should unwrap to boolean, not 'string' + expect(props.enabled.type).toBe('boolean'); + // optional + default fields should NOT be in required + const required = tool?.inputSchema.required as string[]; + expect(required).toContain('flag'); + expect(required).toContain('count'); + expect(required).not.toContain('label'); + expect(required).not.toContain('enabled'); + }); }); describe('tool execute', () => { diff --git a/services/mcp-server/src/modules/tools/registry.ts b/services/mcp-server/src/modules/tools/registry.ts index 9709f67e..a864faf0 100644 --- a/services/mcp-server/src/modules/tools/registry.ts +++ b/services/mcp-server/src/modules/tools/registry.ts @@ -20,31 +20,26 @@ export function listTools(): McpToolMeta[] { })); } +type ZodDef = { + typeName: string; + description?: string; + innerType?: { _def: ZodDef }; + shape?: () => Record; +}; + /** Minimal Zod → JSON Schema converter (object shapes only — sufficient for tool docs) */ function zodToJsonSchema(schema: ZodTypeAny): Record { try { - const def = ( - schema as unknown as { _def: { typeName: string; shape?: () => Record } } - )._def; + const def = (schema as unknown as { _def: ZodDef })._def; if (def.typeName === 'ZodObject' && def.shape) { const shape = def.shape(); const properties: Record = {}; const required: string[] = []; for (const [key, field] of Object.entries(shape)) { - const fieldDef = ( - field as unknown as { - _def: { - typeName: string; - description?: string; - innerType?: { _def: { typeName: string } }; - }; - } - )._def; - const isOptional = fieldDef.typeName === 'ZodOptional'; - const innerTypeName = isOptional ? fieldDef.innerType?._def.typeName : fieldDef.typeName; - const jsonType = zodTypeNameToJsonType(innerTypeName ?? ''); - properties[key] = { type: jsonType, description: fieldDef.description ?? key }; - if (!isOptional) required.push(key); + const fieldDef = (field as unknown as { _def: ZodDef })._def; + const { resolvedType, isRequired, description } = resolveZodDef(fieldDef); + properties[key] = { ...resolvedType, description: description ?? key }; + if (isRequired) required.push(key); } return { type: 'object', properties, required }; } @@ -54,9 +49,48 @@ function zodToJsonSchema(schema: ZodTypeAny): Record { return { type: 'object' }; } -function zodTypeNameToJsonType(typeName: string): string { - if (typeName === 'ZodNumber' || typeName === 'ZodCoerce') return 'number'; - if (typeName === 'ZodBoolean') return 'boolean'; - if (typeName === 'ZodArray') return 'array'; - return 'string'; // ZodString, ZodEnum, ZodLiteral, unknown +/** + * Unwrap ZodOptional / ZodDefault wrappers and resolve the JSON type for a field. + * Returns the JSON Schema fragment plus whether the field is required. + */ +function resolveZodDef(def: ZodDef): { + resolvedType: Record; + isRequired: boolean; + description?: string; +} { + const description = def.description; + if (def.typeName === 'ZodOptional') { + const inner = def.innerType?._def; + return { + resolvedType: inner ? zodDefToJsonType(inner) : { type: 'string' }, + isRequired: false, + description, + }; + } + if (def.typeName === 'ZodDefault') { + const inner = def.innerType?._def; + return { + resolvedType: inner ? zodDefToJsonType(inner) : { type: 'string' }, + isRequired: false, // has default → not required in JSON Schema + description, + }; + } + return { resolvedType: zodDefToJsonType(def), isRequired: true, description }; +} + +function zodDefToJsonType(def: ZodDef): Record { + switch (def.typeName) { + case 'ZodObject': + return { type: 'object' }; + case 'ZodArray': + return { type: 'array' }; + case 'ZodBoolean': + return { type: 'boolean' }; + case 'ZodNumber': + return { type: 'number' }; + case 'ZodEnum': + return { type: 'string' }; + default: + return { type: 'string' }; + } }