Skip to content
Merged
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
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>)) {
delete (info as Record<string, unknown>).summary;
}
}
return spec;
}
Expand Down
110 changes: 110 additions & 0 deletions packages/backend/src/mcp-server/mcp-server.service.spec.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>): 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' });
});
});
29 changes: 22 additions & 7 deletions packages/backend/src/mcp-server/mcp-server.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>): any {
const properties = schema?.properties as Record<string, any> | undefined;
Expand All @@ -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());
Expand Down
Loading