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
4 changes: 1 addition & 3 deletions lib/mcp-server.js
Original file line number Diff line number Diff line change
Expand Up @@ -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, {
Expand All @@ -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);
Expand Down
2 changes: 1 addition & 1 deletion lib/registry.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ export const ENTITY = {
};

export const BUILD = {
version: "1.4.2",
version: "1.4.3",
released: "2026-07-14"
};

Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "trailgenic-workers",
"version": "1.4.2",
"version": "1.4.3",
"private": true,
"type": "module",
"scripts": {
Expand Down
14 changes: 8 additions & 6 deletions scripts/live-acceptance.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down Expand Up @@ -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");

Expand Down
2 changes: 1 addition & 1 deletion scripts/validate-registry.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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}`);
Expand Down
6 changes: 3 additions & 3 deletions tests/registry.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
});

Expand Down
32 changes: 20 additions & 12 deletions tests/worker.vitest.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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'), {});
Expand Down Expand Up @@ -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));
Expand All @@ -144,48 +144,54 @@ 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');
});

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' });
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;
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 () => {
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;
expect(result.structuredContent).toBeDefined();
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}`);
Expand All @@ -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', () => {
Expand Down