diff --git a/lib/mcp-server.js b/lib/mcp-server.js index 7c1837b..46d2e2a 100644 --- a/lib/mcp-server.js +++ b/lib/mcp-server.js @@ -107,8 +107,6 @@ 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, { @@ -119,7 +117,7 @@ export const createTrailgenicMcpServer = () => { }, async (args = {}) => { const parsed = toolSchemas.get(tool.id).safeParse(args); if (!parsed.success) { - return errorResult("Invalid tool arguments.", formatZodIssues(parsed.error.issues)); + return errorResult(`Schema validation failed for ${tool.id}.`, 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 6c01b39..a9654fb 100644 --- a/lib/registry.js +++ b/lib/registry.js @@ -10,7 +10,7 @@ export const ENTITY = { }; export const BUILD = { - version: "1.4.2", + version: "1.4.3", released: "2026-07-14" }; diff --git a/package-lock.json b/package-lock.json index 1ad5194..490346c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "trailgenic-workers", - "version": "1.4.2", + "version": "1.4.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "trailgenic-workers", - "version": "1.4.2", + "version": "1.4.3", "dependencies": { "@modelcontextprotocol/sdk": "1.29.0", "agents": "0.17.4", diff --git a/package.json b/package.json index c07366f..1d6e016 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "trailgenic-workers", - "version": "1.4.2", + "version": "1.4.3", "private": true, "type": "module", "scripts": { diff --git a/scripts/live-acceptance.mjs b/scripts/live-acceptance.mjs index 198e8c8..863a6ec 100644 --- a/scripts/live-acceptance.mjs +++ b/scripts/live-acceptance.mjs @@ -111,7 +111,12 @@ assert(ping.response.ok && ping.json.result && Object.keys(ping.json.result).len const listed = await postMcp("tools/list"); const listIds = toolIdsFromList(listed.json.result.tools); -assert(listIds.length === 19, `tools/list should return 19 tools, got ${listIds.length}`); +const rootDiscovery = await getJson(`/?cache_bust=${Date.now()}`); +assert(rootDiscovery.response.headers.get("cache-control") === "no-cache", "root discovery should be served with Cache-Control: no-cache"); +assert(rootDiscovery.response.ok, "root browser discovery should return HTTP 200 JSON"); +assert(rootDiscovery.json.build_version === BUILD.version, `root discovery build_version should be ${BUILD.version}, got ${rootDiscovery.json.build_version}`); +const rootToolIds = [...rootDiscovery.json.tools].sort(); +assert(JSON.stringify(listIds) === JSON.stringify(rootToolIds), "tools/list ids should match root discovery tools"); assert(listIds.includes("tg.hiking.worldModel.get"), "tools/list should include tg.hiking.worldModel.get"); assert(listIds.includes("tg.longevity.bioAge.compute"), "tools/list should include tg.longevity.bioAge.compute"); assert(listIds.includes("tg.gear.getIntel"), "tools/list should include tg.gear.getIntel"); @@ -258,11 +263,8 @@ const badContentType = await mcpPostFetch({ }); assert(badContentType.status === 415, "non-JSON Content-Type should return 415"); -const rootDiscovery = await getJson(`/?cache_bust=${Date.now()}`); -assert(rootDiscovery.response.headers.get("cache-control") === "no-cache", "root discovery should be served with Cache-Control: no-cache"); -assert(rootDiscovery.response.ok, "root browser discovery should return HTTP 200 JSON"); -assert(rootDiscovery.json.build_version === BUILD.version, `root discovery build_version should be ${BUILD.version}, got ${rootDiscovery.json.build_version}`); -assert(Array.isArray(rootDiscovery.json.tools) && rootDiscovery.json.tools.length === 19, "root discovery should list 19 tools"); +assert(Array.isArray(rootDiscovery.json.tools), "root discovery should list tools"); +assert(JSON.stringify(rootToolIds) === JSON.stringify(listIds), "root discovery tool ids should match tools/list ids"); assert(Array.isArray(rootDiscovery.json.resources) && rootDiscovery.json.resources.length > 0, "root discovery should list generated resources"); assert(Array.isArray(rootDiscovery.json.supported_protocol_versions) && rootDiscovery.json.supported_protocol_versions.includes("2025-11-25"), "root discovery should advertise 2025-11-25"); diff --git a/scripts/validate-registry.mjs b/scripts/validate-registry.mjs index f34deb3..cbc67ef 100644 --- a/scripts/validate-registry.mjs +++ b/scripts/validate-registry.mjs @@ -3,7 +3,7 @@ import { DATASET_LIST, PHYSIOLOGY_MODULES } from '../lib/datasets.js'; import { DATA_TOOLS, mcpTools } from '../lib/registry.js'; import { DATASET_JSON_BY_SOURCE_PATH, TOOL_HANDLERS, datasetSourcePaths } from '../lib/queries.js'; import { readResource, resourceInventory } from '../lib/resources.js'; -assert.equal(DATA_TOOLS.length, 19); +assert.deepEqual(mcpTools().map(t=>t.name).sort(), DATA_TOOLS.map(t=>t.id).sort()); assert.deepEqual(mcpTools().map(t=>t.name), DATA_TOOLS.map(t=>t.id)); for (const tool of DATA_TOOLS) assert.ok(TOOL_HANDLERS.has(tool.id), `missing handler ${tool.id}`); for (const handlerId of TOOL_HANDLERS.keys()) assert.ok(DATA_TOOLS.some((tool) => tool.id === handlerId), `missing tool definition ${handlerId}`); diff --git a/tests/registry.test.mjs b/tests/registry.test.mjs index 5a07060..aa89809 100644 --- a/tests/registry.test.mjs +++ b/tests/registry.test.mjs @@ -3,10 +3,10 @@ import assert from 'node:assert/strict'; import { DATA_TOOLS, SUPPORTED_MCP_PROTOCOL_VERSIONS, BUILD, mcpTools } from '../lib/registry.js'; import { resourceInventory } from '../lib/resources.js'; -test('all 19 tools are preserved and annotated', () => { - assert.equal(BUILD.version, '1.4.2'); +test('all registry tools are preserved and annotated', () => { + assert.equal(BUILD.version, '1.4.3'); assert.deepEqual(SUPPORTED_MCP_PROTOCOL_VERSIONS, ['2025-11-25','2025-06-18']); - assert.equal(DATA_TOOLS.length, 19); + assert.deepEqual(mcpTools().map((tool) => tool.name).sort(), DATA_TOOLS.map((tool) => tool.id).sort()); 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 1ca1aef..69cf4a7 100644 --- a/tests/worker.vitest.mjs +++ b/tests/worker.vitest.mjs @@ -50,7 +50,7 @@ describe('worker http behavior', () => { it('serves root discovery', async () => { const res = await worker.fetch(new Request('https://mcp.trailgenic.com/'), {}); expect(res.status).toBe(200); - expect((await res.json()).tools.length).toBe(19); + expect((await res.json()).tools.sort()).toEqual(DATA_TOOLS.map((tool) => tool.id).sort()); }); it('keeps GET and OPTIONS /mcp contract', async () => { const get = await worker.fetch(new Request('https://mcp.trailgenic.com/mcp'), {}); @@ -120,10 +120,10 @@ describe('mcp transport through official SDK in workerd', () => { expect(negotiated).not.toBe('2031-01-01'); }); - it('lists semantically equivalent schemas for exactly 19 tools', async () => { + it('lists semantically equivalent schemas for every registry tool', async () => { const list = await post({ jsonrpc: '2.0', id: 50, method: 'tools/list', params: {} }, { Accept: 'application/json' }); const tools = (await list.json()).result.tools; - expect(tools.length).toBe(19); + expect(tools.map((tool) => tool.name).sort()).toEqual(DATA_TOOLS.map((tool) => tool.id).sort()); const byName = new Map(tools.map((tool) => [tool.name, tool])); for (const tool of DATA_TOOLS) { expect(deepSort(normalizeSchema(byName.get(tool.id).inputSchema))).toEqual(deepSort(tool.inputSchema)); @@ -144,15 +144,13 @@ describe('mcp transport through official SDK in workerd', () => { 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 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); + expect(result.content[0].text).toMatch(/unknown|tg\.datasets\.index\.get/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' }); 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'); }); @@ -160,10 +158,18 @@ describe('mcp transport through official SDK in workerd', () => { 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' }); 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('rejects invalid enum values with a meaningful schema validation result', async () => { + const invalidEnum = await post({ jsonrpc: '2.0', id: 65, method: 'tools/call', params: { name: 'tg.physiology.adaptation.get', arguments: { module: 'not-a-module' } } }, { Accept: 'application/json' }); + expect(invalidEnum.status).toBe(200); + const result = (await invalidEnum.json()).result; + expect(result.isError).toBe(true); + expect(result.content[0].text).toMatch(/module|tg\.physiology\.adaptation\.get/i); + }); + it('invalid gear category returns corrective isError', async () => { const res = await post({ jsonrpc: '2.0', id: 64, method: 'tools/call', params: { name: 'tg.gear.getIntel', arguments: { category: 'Invalid' } } }, { Accept: 'application/json' }); const result = (await res.json()).result; @@ -171,10 +177,10 @@ describe('mcp transport through official SDK in workerd', () => { expect(result.content[0].text).toContain('expected one of'); }); - it('lists exactly 19 tools and calls one end to end', async () => { + it('lists every registry tool and calls one end to end', async () => { const list = await post({ jsonrpc: '2.0', id: 5, method: 'tools/list', params: {} }, { Accept: 'application/json' }); const tools = (await list.json()).result.tools; - expect(tools.length).toBe(19); + expect(tools.map((tool) => tool.name).sort()).toEqual(DATA_TOOLS.map((tool) => tool.id).sort()); const call = await post({ jsonrpc: '2.0', id: 6, method: 'tools/call', params: { name: 'tg.datasets.index.get', arguments: {} } }, { Accept: 'application/json' }); const result = (await call.json()).result; @@ -182,10 +188,10 @@ describe('mcp transport through official SDK in workerd', () => { expect(result.isError).not.toBe(true); }); - it('serves all 35 resources through the SDK with full REST parity', async () => { + it('serves every registry resource through the SDK with full REST parity', async () => { const list = await post({ jsonrpc: '2.0', id: 7, method: 'resources/list', params: {} }, { Accept: 'application/json' }); const resources = (await list.json()).result.resources; - expect(resources.length).toBe(35); + expect(resources.map((resource) => resource.uri).sort()).toEqual(resourceInventory().map((resource) => resource.uri).sort()); const routesByUri = new Map([['trailgenic://datasets/index', '/datasets/index']]); for (const dataset of DATASET_LIST.filter((entry) => entry.enabled)) routesByUri.set(`trailgenic://datasets/${dataset.id}`, dataset.endpoint); for (const module of PHYSIOLOGY_MODULES) routesByUri.set(`trailgenic://physiology/${module.slug}`, `/datasets/physiology-adaptation/${module.slug}`); @@ -209,9 +215,11 @@ describe('mcp transport through official SDK in workerd', () => { }); - it('does not import Ajv in Worker runtime code', async () => { + it('does not import Ajv or override SDK-private validation in Worker runtime code', async () => { expect(mcpServerSource).not.toMatch(/from ["']ajv["']/i); expect(mcpServerSource).not.toContain('new Ajv'); + expect(mcpServerSource).not.toMatch(/server\.[A-Za-z_$][\w$]*\s*=/); + expect(mcpServerSource).not.toMatch(/\._[A-Za-z_$][\w$]*\s*=/); }); describe('rest dataset routes and machine documents', () => {