diff --git a/lib/mcp-server.js b/lib/mcp-server.js index d444299..535280c 100644 --- a/lib/mcp-server.js +++ b/lib/mcp-server.js @@ -20,15 +20,18 @@ const zodForProperty = (schema = {}) => { 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.description) base = base.describe(schema.description); return base; }; -const zodShapeFromJsonSchema = (schema) => { +const zodObjectFromJsonSchema = (schema) => { const required = new Set(schema.required ?? []); - return Object.fromEntries(Object.entries(schema.properties ?? {}).map(([key, prop]) => { + const shape = Object.fromEntries(Object.entries(schema.properties ?? {}).map(([key, prop]) => { const value = zodForProperty(prop); return [key, required.has(key) ? value : value.optional()]; })); + const objectSchema = z.object(shape); + return schema.additionalProperties === false ? objectSchema.strict() : objectSchema; }; const errorResult = (message, details = undefined) => ({ @@ -58,7 +61,7 @@ export const createTrailgenicMcpServer = () => { server.registerTool(tool.id, { title: tool.title, description: tool.description, - inputSchema: zodShapeFromJsonSchema(tool.inputSchema), + inputSchema: zodObjectFromJsonSchema(tool.inputSchema), annotations }, async (args = {}) => { const validate = validators.get(tool.id); diff --git a/lib/registry.js b/lib/registry.js index 210be66..97a4da3 100644 --- a/lib/registry.js +++ b/lib/registry.js @@ -10,7 +10,7 @@ export const ENTITY = { }; export const BUILD = { - version: "1.4.0", + version: "1.4.1", released: "2026-07-14" }; diff --git a/lib/resources.js b/lib/resources.js index 7be7874..a0180b8 100644 --- a/lib/resources.js +++ b/lib/resources.js @@ -1,4 +1,5 @@ import { DATASET_LIST, PHYSIOLOGY_MODULES } from "./datasets.js"; +import { datasetCatalog } from "./registry.js"; import { DATASET_JSON_BY_SOURCE_PATH } from "./queries.js"; export const datasetResourceUri = (datasetId) => `trailgenic://datasets/${datasetId}`; @@ -34,7 +35,7 @@ export const resourceInventory = () => { }; export const readResource = (uri) => { - if (uri === "trailgenic://datasets/index") return { dataset_catalog_version: "1.0", datasets: DATASET_LIST.filter((entry) => entry.enabled).map((entry) => ({ dataset_id: entry.id, dataset_family: entry.family, description: entry.description, endpoint: entry.endpoint, version: entry.version })) }; + if (uri === "trailgenic://datasets/index") return datasetCatalog(); const datasetPrefix = "trailgenic://datasets/"; if (uri.startsWith(datasetPrefix)) { const id = uri.slice(datasetPrefix.length); diff --git a/package-lock.json b/package-lock.json index 95c6edd..879e377 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "trailgenic-workers", - "version": "1.4.0", + "version": "1.4.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "trailgenic-workers", - "version": "1.4.0", + "version": "1.4.1", "dependencies": { "@modelcontextprotocol/sdk": "1.29.0", "agents": "0.17.4", diff --git a/package.json b/package.json index ad10a84..123b549 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "trailgenic-workers", - "version": "1.4.0", + "version": "1.4.1", "private": true, "type": "module", "scripts": { diff --git a/scripts/live-acceptance.mjs b/scripts/live-acceptance.mjs index b6fce53..eb4b4f6 100644 --- a/scripts/live-acceptance.mjs +++ b/scripts/live-acceptance.mjs @@ -1,3 +1,4 @@ +import { BUILD } from "../lib/registry.js"; const BASE = process.env.BASE || "https://mcp.trailgenic.com"; let nextId = 1; @@ -41,7 +42,7 @@ const init = await postMcp("initialize", initParams("2025-06-18")); assert(init.response.ok, "initialize should return HTTP 2xx"); assert(init.json.result, `initialize should return a result, got: ${JSON.stringify(init.json).slice(0, 200)}`); assert(init.json.result.serverInfo.name === "TrailGenic", "initialize serverInfo.name should be TrailGenic"); -assert(init.json.result.serverInfo.version === "1.4.0", `server version should be 1.4.0, got ${init.json.result.serverInfo.version}`); +assert(init.json.result.serverInfo.version === BUILD.version, `server version should be ${BUILD.version}, got ${init.json.result.serverInfo.version}`); assert(init.json.result.protocolVersion === "2025-06-18", "initialize should negotiate 2025-06-18"); const initPrimary = await postMcp("initialize", initParams("2025-11-25")); @@ -211,9 +212,10 @@ const badContentType = await fetch(`${BASE}/mcp`, { }); assert(badContentType.status === 415, "non-JSON Content-Type should return 415"); -const rootDiscovery = await getJson("/"); +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 === "1.4.0", `root discovery build_version should be 1.4.0, got ${rootDiscovery.json.build_version}`); +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.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"); @@ -228,6 +230,13 @@ const indexContents = indexResource.json.result?.contents?.[0]?.text; assert(indexContents, "resources/read for dataset index should return contents"); const indexParsed = JSON.parse(indexContents); const restIndex = await getJson("/datasets/index"); -assert((indexParsed.datasets?.length ?? 0) === (restIndex.json.datasets?.length ?? -1), "dataset-index resource should match REST dataset index"); +assert(JSON.stringify(indexParsed) === JSON.stringify(restIndex.json), "dataset-index resource should deep-equal REST dataset index"); + +const disallowedOriginPost = await fetch(`${BASE}/mcp`, { + method: "POST", + headers: { "content-type": "application/json", origin: "https://evil.example" }, + body: JSON.stringify({ jsonrpc: "2.0", id: nextId++, method: "ping" }) +}); +assert(disallowedOriginPost.status === 403, "disallowed-Origin POST should return 403"); console.log("TrailGenic live acceptance passed."); diff --git a/scripts/validate-registry.mjs b/scripts/validate-registry.mjs index 20ec767..f34deb3 100644 --- a/scripts/validate-registry.mjs +++ b/scripts/validate-registry.mjs @@ -6,6 +6,7 @@ import { readResource, resourceInventory } from '../lib/resources.js'; assert.equal(DATA_TOOLS.length, 19); 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}`); for (const dataset of DATASET_LIST.filter(d=>d.enabled)) assert.ok(DATASET_JSON_BY_SOURCE_PATH.has(dataset.source_path), `missing bundle ${dataset.source_path}`); for (const module of PHYSIOLOGY_MODULES) assert.ok(DATASET_JSON_BY_SOURCE_PATH.has(module.source_path), `missing module ${module.source_path}`); for (const [route, source] of datasetSourcePaths()) assert.ok(DATASET_JSON_BY_SOURCE_PATH.has(source), `missing route data ${route}`); diff --git a/tests/registry.test.mjs b/tests/registry.test.mjs index cfc5ef7..5d57334 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.0'); + assert.equal(BUILD.version, '1.4.1'); 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 aaff6f6..2740723 100644 --- a/tests/worker.vitest.mjs +++ b/tests/worker.vitest.mjs @@ -1,5 +1,49 @@ import { describe, it, expect } from 'vitest'; import worker from '../tool-registry/worker.js'; +import { DATASET_LIST, PHYSIOLOGY_MODULES } from '../lib/datasets.js'; +import { DATA_TOOLS, datasetCatalog } from '../lib/registry.js'; +import { TOOL_HANDLERS } from '../lib/queries.js'; +import { readResource, resourceInventory } from '../lib/resources.js'; + + +const normalizeSchema = (schema) => { + const clone = structuredClone(schema); + delete clone.$schema; + for (const prop of Object.values(clone.properties ?? {})) { + if (prop.type === 'integer' && prop.maximum === Number.MAX_SAFE_INTEGER) delete prop.maximum; + } + return clone; +}; + +const deepSort = (value) => { + if (Array.isArray(value)) return value.map(deepSort).sort((a, b) => JSON.stringify(a).localeCompare(JSON.stringify(b))); + if (value && typeof value === 'object') { + return Object.fromEntries(Object.entries(value).sort(([a], [b]) => a.localeCompare(b)).map(([k, v]) => [k, deepSort(v)])); + } + return value; +}; + +const toolArgs = { + 'tg.datasets.index.get': {}, + 'tg.ontology.get': {}, + 'tg.protocols.get': {}, + 'tg.physiology.adaptation.get': {}, + 'tg.hiking.worldModel.get': {}, + 'tg.physiology.hrDriftAdaptation.get': {}, + 'tg.nutrition.get': { limit: 1 }, + 'tg.hydration.get': { limit: 1 }, + 'tg.permits.dataset.get': {}, + 'tg.terrain.accessibleTrails.get': { limit: 1 }, + 'tg.evidence.validationSummits.get': { limit: 1 }, + 'tg.gear.intel.get': { limit: 1 }, + 'tg.gear.getIntel': {}, + 'tg.longevity.protocol.get': {}, + 'tg.longevity.foundationSessions.get': {}, + 'tg.conditioning.walking.get': {}, + 'tg.conditioning.rucking.get': {}, + 'tg.conditioning.running.get': {}, + 'tg.longevity.bioAge.compute': { age: 53, resting_hr: 59, distance_mi: 10.94, elevation_gain_ft: 4140, moving_time_min: 256, avg_hr: 122 } +}; describe('worker http behavior', () => { it('serves root discovery', async () => { @@ -14,6 +58,22 @@ describe('worker http behavior', () => { const opt = await worker.fetch(new Request('https://mcp.trailgenic.com/mcp', { method: 'OPTIONS' }), {}); expect(opt.status).toBe(204); }); + it('enforces origin-specific CORS on MCP preflight and early errors', async () => { + const allowed = 'https://trailgenic.com'; + const disallowed = await worker.fetch(new Request('https://mcp.trailgenic.com/mcp', { method: 'OPTIONS', headers: { Origin: 'https://evil.example' } }), {}); + expect(disallowed.status).toBe(403); + const opt = await worker.fetch(new Request('https://mcp.trailgenic.com/mcp', { method: 'OPTIONS', headers: { Origin: allowed } }), {}); + expect(opt.status).toBe(204); + expect(opt.headers.get('Access-Control-Allow-Origin')).toBe(allowed); + expect(opt.headers.get('Vary')).toContain('Origin'); + const body = JSON.stringify({ jsonrpc: '2.0', id: 99, method: 'ping' }); + for (const headers of [{ 'Content-Type': 'text/plain', Accept: 'application/json' }, { 'Content-Type': 'application/json', Accept: 'text/event-stream' }]) { + const res = await worker.fetch(new Request('https://mcp.trailgenic.com/mcp', { method: 'POST', body, headers: { Origin: allowed, ...headers } }), {}); + expect([415, 406]).toContain(res.status); + expect(res.headers.get('Access-Control-Allow-Origin')).toBe(allowed); + expect(res.headers.get('Vary')).toContain('Origin'); + } + }); it('rejects bad accept, content type, and origin', async () => { const body = JSON.stringify({ jsonrpc:'2.0', id:1, method:'ping' }); expect((await worker.fetch(new Request('https://mcp.trailgenic.com/mcp', { method:'POST', body, headers:{ 'Content-Type':'text/plain' } }), {})).status).toBe(415); @@ -59,6 +119,39 @@ 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 () => { + 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); + 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)); + } + }); + + it('successfully calls every registered tool and rejects invalid arguments before handlers', async () => { + 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(); + } + 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 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 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); + }); + + 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; + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('expected one of'); + }); + it('lists exactly 19 tools 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; @@ -70,13 +163,21 @@ describe('mcp transport through official SDK in workerd', () => { expect(result.isError).not.toBe(true); }); - it('serves resources through the SDK', async () => { + it('serves all 35 resources 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).toBeGreaterThan(0); - const read = await post({ jsonrpc: '2.0', id: 8, method: 'resources/read', - params: { uri: resources[0].uri } }, { Accept: 'application/json' }); - expect((await read.json()).result.contents).toBeDefined(); + expect(resources.length).toBe(35); + 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}`); + for (const resource of resourceInventory()) { + const read = await post({ jsonrpc: '2.0', id: 80, method: 'resources/read', params: { uri: resource.uri } }, { Accept: 'application/json' }); + const parsed = JSON.parse((await read.json()).result.contents[0].text); + const route = routesByUri.get(resource.uri); + expect(route, resource.uri).toBeDefined(); + const rest = await (await worker.fetch(new Request(`https://mcp.trailgenic.com${route}`), {})).json(); + expect(parsed, resource.uri).toEqual(rest); + } }); it('conditioning tools return isError for date arguments', async () => { @@ -110,5 +211,27 @@ describe('rest dataset routes and machine documents', () => { } const health = await (await get('/health')).json(); expect(health.uptime).toBeNull(); + expect(health.capabilities_status).toBe('not_checked'); + }); +}); + + +describe('document cache-control contracts', () => { + const get = (path) => worker.fetch(new Request(`https://mcp.trailgenic.com${path}`), {}); + it('serves generated machine documents with no-cache and preserves dataset caching', async () => { + for (const path of ['/', '/capabilities.json', '/.well-known/tool-registry.json', '/.well-known/ai-plugin.json', '/.well-known/openapi.json', '/health']) { + const res = await get(path); + expect(res.headers.get('Cache-Control'), path).toBe('no-cache'); + } + const dataset = await get('/datasets/hiking'); + expect(dataset.headers.get('Cache-Control')).toBe('public, max-age=3600'); + }); + + it('dataset index resource exactly equals canonical catalog', () => { + expect(readResource('trailgenic://datasets/index')).toEqual(datasetCatalog()); + }); + + it('handler and tool registries have reverse parity', () => { + expect([...TOOL_HANDLERS.keys()].sort()).toEqual(DATA_TOOLS.map((tool) => tool.id).sort()); }); }); diff --git a/tool-registry/worker.js b/tool-registry/worker.js index 566f10e..3804294 100644 --- a/tool-registry/worker.js +++ b/tool-registry/worker.js @@ -66,38 +66,40 @@ const normalizeMcpRequest = (request) => { }; const handleMcp = async (request, env) => { + const origin = request.headers.get("Origin"); + const mcpResponse = (response) => applyOriginCors(response, origin); + + if (origin && !allowedOrigins(env).includes(origin)) { + return mcpResponse(textResponse("Forbidden Origin", { status: 403, cacheControl: "no-cache" })); + } + if (request.method === "GET") { - return textResponse("Method Not Allowed", { status: 405, headers: { Allow: "POST" } }); + return mcpResponse(textResponse("Method Not Allowed", { status: 405, headers: { Allow: "POST" } })); } - if (request.method === "OPTIONS") return emptyResponse({ status: 204 }); + if (request.method === "OPTIONS") return mcpResponse(emptyResponse({ status: 204 })); if (request.method !== "POST") { - return textResponse("Method Not Allowed", { status: 405, headers: { Allow: "POST" } }); - } - - const origin = request.headers.get("Origin"); - if (origin && !allowedOrigins(env).includes(origin)) { - return textResponse("Forbidden Origin", { status: 403, cacheControl: "no-cache" }); + return mcpResponse(textResponse("Method Not Allowed", { status: 405, headers: { Allow: "POST" } })); } if (!isJsonContentType(request.headers.get("Content-Type"))) { - return textResponse("Unsupported Media Type", { status: 415, cacheControl: "no-cache" }); + return mcpResponse(textResponse("Unsupported Media Type", { status: 415, cacheControl: "no-cache" })); } const protocolVersion = request.headers.get("MCP-Protocol-Version"); if (protocolVersion && !SUPPORTED_MCP_PROTOCOL_VERSIONS.includes(protocolVersion)) { - return jsonResponse({ jsonrpc: "2.0", id: null, error: { code: -32000, message: `Unsupported MCP protocol version: ${protocolVersion}` } }, { status: 400, cacheControl: "no-cache" }); + return mcpResponse(jsonResponse({ jsonrpc: "2.0", id: null, error: { code: -32000, message: `Unsupported MCP protocol version: ${protocolVersion}` } }, { status: 400, cacheControl: "no-cache" })); } const acceptAction = acceptPolicy(request.headers.get("Accept")); if (acceptAction === "reject") { - return textResponse("Not Acceptable", { status: 406, cacheControl: "no-cache" }); + return mcpResponse(textResponse("Not Acceptable", { status: 406, cacheControl: "no-cache" })); } const internalRequest = acceptAction === "normalize" ? normalizeMcpRequest(request) : request; const server = createTrailgenicMcpServer(); const mcpHandler = createMcpHandler(server, { enableJsonResponse: true }); const response = await mcpHandler(internalRequest, env, {}); - return applyOriginCors(response, origin); + return mcpResponse(response); }; const rootDiscovery = () => ({ @@ -147,7 +149,7 @@ const health = () => ({ registry_status: "not_checked", plugin_status: "not_checked", openapi_status: "not_checked", - capabilities_status: "operational", + capabilities_status: "not_checked", uptime: null, uptime_note: "Uptime is observed via Cloudflare observability, not asserted in this endpoint.", region: "global", @@ -191,7 +193,15 @@ const openApiPaths = () => { } } }, - responses: { "200": { description: "JSON-RPC response" }, "202": { description: "Initialized notification accepted" } } + responses: { + "200": { description: "JSON-RPC response" }, + "202": { description: "Initialized notification accepted" }, + "400": { description: "Unsupported MCP protocol version or malformed JSON-RPC request" }, + "403": { description: "Forbidden Origin" }, + "405": { description: "Method Not Allowed; use POST" }, + "406": { description: "Not Acceptable; request must accept application/json or */*" }, + "415": { description: "Unsupported Media Type; Content-Type must be application/json" } + } } }, "/datasets/index": { @@ -265,7 +275,7 @@ export default { ["trailgenic.com", "www.trailgenic.com"].includes(url.hostname) && normalizedPath === "/.well-known/tool-registry.json" ) { - return jsonResponse(pointerRegistry()); + return jsonResponse(pointerRegistry(), { cacheControl: "no-cache" }); } if (normalizedPath === "/mcp") { @@ -273,11 +283,11 @@ export default { } if (normalizedPath === "/" || normalizedPath === "") { - return jsonResponse(rootDiscovery()); + return jsonResponse(rootDiscovery(), { cacheControl: "no-cache" }); } if (normalizedPath === "/capabilities.json") { - return jsonResponse(capabilitiesDocument()); + return jsonResponse(capabilitiesDocument(), { cacheControl: "no-cache" }); } if (normalizedPath === "/datasets/index") { @@ -310,15 +320,15 @@ export default { } if (normalizedPath === "/.well-known/tool-registry.json") { - return jsonResponse(toolRegistryDocument()); + return jsonResponse(toolRegistryDocument(), { cacheControl: "no-cache" }); } if (normalizedPath === "/.well-known/ai-plugin.json") { - return jsonResponse(pluginManifest()); + return jsonResponse(pluginManifest(), { cacheControl: "no-cache" }); } if (normalizedPath === "/.well-known/openapi.json") { - return jsonResponse(openApi()); + return jsonResponse(openApi(), { cacheControl: "no-cache" }); } if (CONTENT_LINKS.some((link) => link.url === `https://www.trailgenic.com${normalizedPath}`)) {