diff --git a/lib/mcp-server.js b/lib/mcp-server.js index 535280c..7c1837b 100644 --- a/lib/mcp-server.js +++ b/lib/mcp-server.js @@ -1,39 +1,90 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { z } from "zod"; -import Ajv from "ajv"; import { BUILD, DATA_TOOLS, ENTITY, PRIMARY_MCP_PROTOCOL_VERSION } from "./registry.js"; import { TOOL_HANDLERS } from "./queries.js"; import { readResource, resourceInventory } from "./resources.js"; -const ajv = new Ajv({ allErrors: true, strict: false }); -const validators = new Map(DATA_TOOLS.map((tool) => [tool.id, ajv.compile(tool.inputSchema)])); - const annotations = { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }; -const zodForProperty = (schema = {}) => { +const JSON_SCHEMA_KEYWORDS = new Set([ + "additionalProperties", + "description", + "enum", + "exclusiveMaximum", + "exclusiveMinimum", + "items", + "maxItems", + "maxLength", + "maximum", + "minItems", + "minLength", + "minimum", + "pattern", + "properties", + "required", + "type" +]); + +const assertSupportedSchema = (schema = {}, path = "schema") => { + for (const keyword of Object.keys(schema)) { + if (!JSON_SCHEMA_KEYWORDS.has(keyword)) throw new Error(`Unsupported JSON Schema keyword at ${path}: ${keyword}`); + } + if (schema.type === "object") { + for (const [key, property] of Object.entries(schema.properties ?? {})) { + assertSupportedSchema(property, `${path}.properties.${key}`); + } + } + if (schema.type === "array" && schema.items) assertSupportedSchema(schema.items, `${path}.items`); +}; + +const zodForProperty = (schema = {}, path = "schema") => { + assertSupportedSchema(schema, path); let base; - if (schema.enum) base = z.enum(schema.enum); - else if (schema.type === "integer") base = z.number().int(); + if (schema.enum) { + if (!Array.isArray(schema.enum) || schema.enum.length === 0) { + throw new Error(`Unsupported empty enum at ${path}`); + } + base = z.enum(schema.enum); + } else if (schema.type === "integer") base = z.number().int(); else if (schema.type === "number") base = z.number(); else if (schema.type === "boolean") base = z.boolean(); - else base = z.string(); + else if (schema.type === "array") base = z.array(zodForProperty(schema.items ?? {}, `${path}.items`)); + else if (schema.type === "object") base = zodObjectFromJsonSchema(schema, path); + else if (schema.type === "string" || schema.type === undefined) base = z.string(); + else throw new Error(`Unsupported JSON Schema type at ${path}: ${schema.type}`); + if (schema.minimum !== undefined) base = base.min(schema.minimum); if (schema.maximum !== undefined) base = base.max(schema.maximum); if (schema.exclusiveMinimum !== undefined) base = base.gt(schema.exclusiveMinimum); + if (schema.exclusiveMaximum !== undefined) base = base.lt(schema.exclusiveMaximum); + if (schema.minLength !== undefined) base = base.min(schema.minLength); + if (schema.maxLength !== undefined) base = base.max(schema.maxLength); + if (schema.pattern !== undefined) base = base.regex(new RegExp(schema.pattern)); + if (schema.minItems !== undefined) base = base.min(schema.minItems); + if (schema.maxItems !== undefined) base = base.max(schema.maxItems); if (schema.description) base = base.describe(schema.description); return base; }; -const zodObjectFromJsonSchema = (schema) => { +const zodObjectFromJsonSchema = (schema, path = "schema") => { + assertSupportedSchema(schema, path); + if (schema.type !== "object") throw new Error(`Expected object schema at ${path}`); const required = new Set(schema.required ?? []); const shape = Object.fromEntries(Object.entries(schema.properties ?? {}).map(([key, prop]) => { - const value = zodForProperty(prop); + const value = zodForProperty(prop, `${path}.properties.${key}`); return [key, required.has(key) ? value : value.optional()]; })); const objectSchema = z.object(shape); return schema.additionalProperties === false ? objectSchema.strict() : objectSchema; }; +const formatZodIssues = (issues = []) => issues.map((issue) => { + const path = issue.path?.length ? issue.path.join(".") : "arguments"; + return `${path}: ${issue.message}`; +}).join("; "); + +const toolSchemas = new Map(DATA_TOOLS.map((tool) => [tool.id, zodObjectFromJsonSchema(tool.inputSchema, `tool ${tool.id}`)])); + const errorResult = (message, details = undefined) => ({ isError: true, content: [{ type: "text", text: details ? `${message} ${details}` : message }], @@ -56,17 +107,19 @@ export const createTrailgenicMcpServer = () => { protocolVersion: PRIMARY_MCP_PROTOCOL_VERSION }); + server.validateToolInput = async (_tool, args) => args; + for (const tool of DATA_TOOLS) { const handler = TOOL_HANDLERS.get(tool.id); server.registerTool(tool.id, { title: tool.title, description: tool.description, - inputSchema: zodObjectFromJsonSchema(tool.inputSchema), + inputSchema: toolSchemas.get(tool.id), annotations }, async (args = {}) => { - const validate = validators.get(tool.id); - if (!validate(args)) { - return errorResult("Invalid tool arguments.", ajv.errorsText(validate.errors)); + const parsed = toolSchemas.get(tool.id).safeParse(args); + if (!parsed.success) { + return errorResult("Invalid tool arguments.", formatZodIssues(parsed.error.issues)); } if (tool.id.startsWith("tg.conditioning.") && (args.start_date || args.end_date)) { return conditioningDateError(tool.id); diff --git a/lib/registry.js b/lib/registry.js index 97a4da3..6c01b39 100644 --- a/lib/registry.js +++ b/lib/registry.js @@ -10,7 +10,7 @@ export const ENTITY = { }; export const BUILD = { - version: "1.4.1", + version: "1.4.2", released: "2026-07-14" }; diff --git a/package-lock.json b/package-lock.json index 879e377..1ad5194 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,16 +1,15 @@ { "name": "trailgenic-workers", - "version": "1.4.1", + "version": "1.4.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "trailgenic-workers", - "version": "1.4.1", + "version": "1.4.2", "dependencies": { "@modelcontextprotocol/sdk": "1.29.0", "agents": "0.17.4", - "ajv": "^8.17.1", "zod": "^4.4.3" }, "devDependencies": { diff --git a/package.json b/package.json index 123b549..c07366f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "trailgenic-workers", - "version": "1.4.1", + "version": "1.4.2", "private": true, "type": "module", "scripts": { @@ -14,7 +14,6 @@ "dependencies": { "@modelcontextprotocol/sdk": "1.29.0", "agents": "0.17.4", - "ajv": "^8.17.1", "zod": "^4.4.3" }, "devDependencies": { diff --git a/tests/registry.test.mjs b/tests/registry.test.mjs index 5d57334..5a07060 100644 --- a/tests/registry.test.mjs +++ b/tests/registry.test.mjs @@ -4,7 +4,7 @@ import { DATA_TOOLS, SUPPORTED_MCP_PROTOCOL_VERSIONS, BUILD, mcpTools } from '.. import { resourceInventory } from '../lib/resources.js'; test('all 19 tools are preserved and annotated', () => { - assert.equal(BUILD.version, '1.4.1'); + assert.equal(BUILD.version, '1.4.2'); assert.deepEqual(SUPPORTED_MCP_PROTOCOL_VERSIONS, ['2025-11-25','2025-06-18']); assert.equal(DATA_TOOLS.length, 19); for (const tool of mcpTools()) assert.deepEqual(tool.annotations, { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }); diff --git a/tests/worker.vitest.mjs b/tests/worker.vitest.mjs index 2740723..1ca1aef 100644 --- a/tests/worker.vitest.mjs +++ b/tests/worker.vitest.mjs @@ -1,4 +1,5 @@ import { describe, it, expect } from 'vitest'; +import mcpServerSource from '../lib/mcp-server.js?raw'; import worker from '../tool-registry/worker.js'; import { DATASET_LIST, PHYSIOLOGY_MODULES } from '../lib/datasets.js'; import { DATA_TOOLS, datasetCatalog } from '../lib/registry.js'; @@ -130,19 +131,37 @@ describe('mcp transport through official SDK in workerd', () => { }); it('successfully calls every registered tool and rejects invalid arguments before handlers', async () => { + expect(Object.keys(toolArgs).sort()).toEqual(DATA_TOOLS.map((tool) => tool.id).sort()); for (const tool of DATA_TOOLS) { const call = await post({ jsonrpc: '2.0', id: 60, method: 'tools/call', params: { name: tool.id, arguments: toolArgs[tool.id] } }, { Accept: 'application/json' }); const result = (await call.json()).result; expect(result?.isError, tool.id).not.toBe(true); expect(result?.structuredContent, tool.id).toBeDefined(); } + }); + + it('rejects extra properties with a corrective Zod validation result', async () => { const extra = await post({ jsonrpc: '2.0', id: 61, method: 'tools/call', params: { name: 'tg.datasets.index.get', arguments: { unknown: true } } }, { Accept: 'application/json' }); - const extraResult = (await extra.json()).result; - expect(extraResult.isError).toBe(true); + const result = (await extra.json()).result; + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('Invalid tool arguments.'); + expect(result.content[0].text).toMatch(/unrecognized|unknown/i); + }); + + it('rejects missing required arguments with a corrective Zod validation result', async () => { const missing = await post({ jsonrpc: '2.0', id: 62, method: 'tools/call', params: { name: 'tg.longevity.bioAge.compute', arguments: {} } }, { Accept: 'application/json' }); - expect((await missing.json()).result.isError).toBe(true); + const result = (await missing.json()).result; + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('Invalid tool arguments.'); + expect(result.content[0].text).toContain('age'); + }); + + it('rejects out-of-range numerics with a corrective Zod validation result', async () => { const outOfRange = await post({ jsonrpc: '2.0', id: 63, method: 'tools/call', params: { name: 'tg.longevity.bioAge.compute', arguments: { ...toolArgs['tg.longevity.bioAge.compute'], age: 101 } } }, { Accept: 'application/json' }); - expect((await outOfRange.json()).result.isError).toBe(true); + const result = (await outOfRange.json()).result; + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('Invalid tool arguments.'); + expect(result.content[0].text).toContain('age'); }); it('invalid gear category returns corrective isError', async () => { @@ -189,6 +208,12 @@ describe('mcp transport through official SDK in workerd', () => { }); }); + + it('does not import Ajv in Worker runtime code', async () => { + expect(mcpServerSource).not.toMatch(/from ["']ajv["']/i); + expect(mcpServerSource).not.toContain('new Ajv'); + }); + describe('rest dataset routes and machine documents', () => { const get = (path) => worker.fetch(new Request(`https://mcp.trailgenic.com${path}`), {});