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
9 changes: 6 additions & 3 deletions lib/mcp-server.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) => ({
Expand Down Expand Up @@ -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);
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.0",
version: "1.4.1",
released: "2026-07-14"
};

Expand Down
3 changes: 2 additions & 1 deletion lib/resources.js
Original file line number Diff line number Diff line change
@@ -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}`;
Expand Down Expand Up @@ -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);
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.0",
"version": "1.4.1",
"private": true,
"type": "module",
"scripts": {
Expand Down
17 changes: 13 additions & 4 deletions scripts/live-acceptance.mjs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { BUILD } from "../lib/registry.js";
const BASE = process.env.BASE || "https://mcp.trailgenic.com";

let nextId = 1;
Expand Down Expand Up @@ -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"));
Expand Down Expand Up @@ -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");
Expand All @@ -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.");
1 change: 1 addition & 0 deletions scripts/validate-registry.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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}`);
Expand Down
2 changes: 1 addition & 1 deletion tests/registry.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
Expand Down
133 changes: 128 additions & 5 deletions tests/worker.vitest.mjs
Original file line number Diff line number Diff line change
@@ -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 () => {
Expand All @@ -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);
Expand Down Expand Up @@ -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;
Expand All @@ -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 () => {
Expand Down Expand Up @@ -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());
});
});
Loading