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
81 changes: 67 additions & 14 deletions lib/mcp-server.js
Original file line number Diff line number Diff line change
@@ -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 }],
Expand All @@ -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);
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.1",
version: "1.4.2",
released: "2026-07-14"
};

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

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

3 changes: 1 addition & 2 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "trailgenic-workers",
"version": "1.4.1",
"version": "1.4.2",
"private": true,
"type": "module",
"scripts": {
Expand All @@ -14,7 +14,6 @@
"dependencies": {
"@modelcontextprotocol/sdk": "1.29.0",
"agents": "0.17.4",
"ajv": "^8.17.1",
"zod": "^4.4.3"
},
"devDependencies": {
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.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 });
Expand Down
33 changes: 29 additions & 4 deletions tests/worker.vitest.mjs
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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}`), {});

Expand Down