diff --git a/packages/backend/src/connectors/parsers/openapi-3.1-normalizer.spec.ts b/packages/backend/src/connectors/parsers/openapi-3.1-normalizer.spec.ts index e16056fa..d0b010d3 100644 --- a/packages/backend/src/connectors/parsers/openapi-3.1-normalizer.spec.ts +++ b/packages/backend/src/connectors/parsers/openapi-3.1-normalizer.spec.ts @@ -183,6 +183,27 @@ describe('normalizeOpenApi31', () => { expect(result).toBe(spec); }); + it('strips info.summary (3.1-only field forbidden by 3.0 schema)', () => { + const spec: any = { + openapi: '3.1.0', + info: { title: 'X', summary: 'A short summary', version: '1' }, + paths: {}, + }; + normalizeOpenApi31(spec); + expect(spec.info.summary).toBeUndefined(); + expect(spec.info.title).toBe('X'); + }); + + it('leaves info untouched when there is no summary field', () => { + const spec: any = { + openapi: '3.1.0', + info: { title: 'X', version: '1' }, + paths: {}, + }; + normalizeOpenApi31(spec); + expect(spec.info).toEqual({ title: 'X', version: '1' }); + }); + it('relabels openapi: 3.1.x to 3.0.3 so swagger-parser accepts it', () => { const spec: any = { openapi: '3.1.0', info: { title: 'X', version: '1' }, paths: {} }; normalizeOpenApi31(spec); diff --git a/packages/backend/src/connectors/parsers/openapi-3.1-normalizer.ts b/packages/backend/src/connectors/parsers/openapi-3.1-normalizer.ts index fe781a0f..9be4ec5c 100644 --- a/packages/backend/src/connectors/parsers/openapi-3.1-normalizer.ts +++ b/packages/backend/src/connectors/parsers/openapi-3.1-normalizer.ts @@ -35,6 +35,14 @@ export function normalizeOpenApi31(spec: unknown): unknown { if (typeof obj.openapi === 'string' && obj.openapi.startsWith('3.1')) { obj.openapi = '3.0.3'; } + // 3.1 added info.summary; 3.0 forbids it. We don't currently call the + // schema validator on 3.1 specs (we use dereference()), so this is a + // defensive strip: it keeps the relabeled document safe if any future + // codepath does call validate(). + const info = obj.info; + if (info && typeof info === 'object' && 'summary' in (info as Record)) { + delete (info as Record).summary; + } } return spec; } diff --git a/packages/backend/src/mcp-server/mcp-server.service.spec.ts b/packages/backend/src/mcp-server/mcp-server.service.spec.ts new file mode 100644 index 00000000..e42a5515 --- /dev/null +++ b/packages/backend/src/mcp-server/mcp-server.service.spec.ts @@ -0,0 +1,110 @@ +import { z } from 'zod'; +import { McpServerService } from './mcp-server.service'; + +/** + * Direct unit coverage for the private `jsonSchemaToZod` helper. We bypass the + * Nest DI by instantiating with `Object.create` and reaching into the method + * — full controller-level tests would need Prisma, ConfigService, and the + * @rekog/mcp-nest registry, which is overkill for a pure transformer. + */ +function makeSchema(jsonSchema: Record): z.ZodTypeAny { + const svc = Object.create(McpServerService.prototype); + return (svc as any).jsonSchemaToZod(jsonSchema); +} + +describe('McpServerService.jsonSchemaToZod', () => { + it('accepts a numeric integer arg sent as a string ("5") and returns a number', () => { + const schema = makeSchema({ + type: 'object', + properties: { top_k: { type: 'integer', description: 'how many' } }, + required: ['top_k'], + }); + const parsed = schema.parse({ top_k: '5' }); + expect(parsed).toEqual({ top_k: 5 }); + expect(typeof (parsed as any).top_k).toBe('number'); + }); + + it('still rejects a non-numeric string for an integer field', () => { + const schema = makeSchema({ + type: 'object', + properties: { top_k: { type: 'integer' } }, + required: ['top_k'], + }); + expect(() => schema.parse({ top_k: 'abc' })).toThrow(); + }); + + it('accepts "1.5" for a number type', () => { + const schema = makeSchema({ + type: 'object', + properties: { score: { type: 'number' } }, + required: ['score'], + }); + expect(schema.parse({ score: '1.5' })).toEqual({ score: 1.5 }); + }); + + it('rejects a float "1.5" for an integer type', () => { + const schema = makeSchema({ + type: 'object', + properties: { count: { type: 'integer' } }, + required: ['count'], + }); + expect(() => schema.parse({ count: '1.5' })).toThrow(); + }); + + it('coerces "true" / "false" strings to boolean (well, anything truthy → true)', () => { + const schema = makeSchema({ + type: 'object', + properties: { active: { type: 'boolean' } }, + required: ['active'], + }); + // z.coerce.boolean treats any non-empty string as true. That matches how + // most MCP clients render checkbox state, but the consumer should be + // aware that "false" coerces to true. + expect(schema.parse({ active: 'true' })).toEqual({ active: true }); + expect(schema.parse({ active: true })).toEqual({ active: true }); + expect(schema.parse({ active: false })).toEqual({ active: false }); + expect(schema.parse({ active: 0 })).toEqual({ active: false }); + }); + + it('keeps an enum string field strict (no coercion)', () => { + const schema = makeSchema({ + type: 'object', + properties: { mode: { type: 'string', enum: ['fast', 'slow'] } }, + required: ['mode'], + }); + expect(schema.parse({ mode: 'fast' })).toEqual({ mode: 'fast' }); + expect(() => schema.parse({ mode: 'unknown' })).toThrow(); + }); + + it('coerces date-time strings to Date instances', () => { + const schema = makeSchema({ + type: 'object', + properties: { from: { type: 'string', format: 'date-time' } }, + required: ['from'], + }); + const parsed: any = schema.parse({ from: '2026-05-12T09:00:00Z' }); + expect(parsed.from).toBeInstanceOf(Date); + }); + + it('marks non-required fields as optional', () => { + const schema = makeSchema({ + type: 'object', + properties: { + a: { type: 'string' }, + b: { type: 'integer' }, + }, + required: ['a'], + }); + expect(schema.parse({ a: 'x' })).toEqual({ a: 'x' }); + expect(schema.parse({ a: 'x', b: '7' })).toEqual({ a: 'x', b: 7 }); + }); + + it('passes plain strings through unchanged', () => { + const schema = makeSchema({ + type: 'object', + properties: { q: { type: 'string' } }, + required: ['q'], + }); + expect(schema.parse({ q: 'Domoferm' })).toEqual({ q: 'Domoferm' }); + }); +}); diff --git a/packages/backend/src/mcp-server/mcp-server.service.ts b/packages/backend/src/mcp-server/mcp-server.service.ts index 633bef09..54bba8bd 100644 --- a/packages/backend/src/mcp-server/mcp-server.service.ts +++ b/packages/backend/src/mcp-server/mcp-server.service.ts @@ -309,7 +309,14 @@ export class McpServerService implements OnModuleInit { /** * Convert a JSON Schema object to a Zod schema for the MCP library. - * Handles the common types used in tool parameters. + * + * Numeric / boolean / date fields use `z.coerce.*` rather than `z.number()` + * etc. Several MCP clients (and AI tool-call layers in general) serialize + * every argument as a string before transport, so a tool with a numeric + * parameter would otherwise reject perfectly valid calls like + * `{ "top_k": "5" }` with "expected number, received string". Coercion + * still rejects non-numeric strings (e.g. `"abc"`), so we keep the + * validation signal where it matters. */ private jsonSchemaToZod(schema: Record): any { const properties = schema?.properties as Record | undefined; @@ -323,16 +330,24 @@ export class McpServerService implements OnModuleInit { switch (prop.type) { case 'string': - zodType = prop.enum - ? z.enum(prop.enum as [string, ...string[]]) - : z.string(); + if (prop.enum) { + zodType = z.enum(prop.enum as [string, ...string[]]); + } else if (prop.format === 'date-time' || prop.format === 'date') { + // Accept ISO date strings and Date-coercible inputs. + zodType = z.coerce.date(); + } else { + zodType = z.string(); + } break; - case 'number': case 'integer': - zodType = z.number(); + // .int() rejects floats; coerce handles string→number first. + zodType = z.coerce.number().int(); + break; + case 'number': + zodType = z.coerce.number(); break; case 'boolean': - zodType = z.boolean(); + zodType = z.coerce.boolean(); break; case 'array': zodType = z.array(z.any());