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
23 changes: 23 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
name: CI
on:
pull_request:
branches: [main]
push:
branches: [main]
jobs:
verify:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version: '22'
cache: npm
- run: npm ci
- run: npm run lint
- run: npm run validate:json
- run: npm test
- run: npm run test:workers
- run: npm run validate:registry
- run: npm run wrangler:dry-run
- run: git diff --check
35 changes: 17 additions & 18 deletions .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
@@ -1,37 +1,36 @@
name: Deploy Tool Registry
on:
push:
branches:
- main
branches: [main]
workflow_dispatch:
concurrency:
group: deploy-tool-registry-${{ github.ref }}
cancel-in-progress: true
jobs:
deploy:
name: Deploy tool-registry Worker
name: Verify then deploy tool-registry Worker
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Deploy tool-registry
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version: '22'
cache: npm
- run: npm ci
- run: npm run lint
- run: npm run validate:json
- run: npm test
- run: npm run test:workers
- run: npm run validate:registry
- run: npm run wrangler:dry-run
- run: git diff --check
- name: Deploy tool-registry only
uses: cloudflare/wrangler-action@v4
with:
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
wranglerVersion: "4"
wranglerVersion: '4'
command: deploy --config tool-registry/wrangler.jsonc
verify:
name: Verify live MCP transport
runs-on: ubuntu-latest
needs: deploy
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: "22"
- name: Allow Cloudflare deployment propagation
run: sleep 25
- name: Run live acceptance harness
Expand Down
16 changes: 16 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -145,3 +145,19 @@ The permit poller is intentionally excluded from MCP CI/deploy workflows.
TrailGenic is created and operated by Mike Ye.

https://trailgenic.com

## MCP SDK, transport, and deployment safety

TrailGenic now uses the official `@modelcontextprotocol/sdk` `McpServer` and Cloudflare's stateless `createMcpHandler` from `agents/mcp` for the public MCP transport at `POST https://mcp.trailgenic.com/mcp`. Browser discovery remains JSON at `GET https://mcp.trailgenic.com/`; `GET /mcp` is intentionally `405 Allow: POST`, and `OPTIONS /mcp` remains `204`.

The server advertises and validates MCP protocol versions `2025-11-25` and `2025-06-18`; unsupported `MCP-Protocol-Version` headers are rejected instead of echoed. MCP POST accepts missing, empty, wildcard, JSON-only, or JSON-plus-event-stream `Accept` headers, normalizing incomplete accepted requests internally to `application/json, text/event-stream`. Event-stream-only or unsupported media types are rejected with `406`. MCP POST `Content-Type` must be `application/json` compatible or the request is rejected with `415`.

Browser `Origin` is validated only for the MCP transport. Default allowed origins are `https://trailgenic.com`, `https://www.trailgenic.com`, and `https://mcp.trailgenic.com`; deployments may override with `MCP_ALLOWED_ORIGINS`. Server-to-server clients without `Origin` remain accepted. Public read-only REST dataset routes keep their public CORS behavior.

All 19 existing tool names are preserved. Both gear tools remain: `tg.gear.intel.get` is the bounded query-oriented compatibility tool, while `tg.gear.getIntel` is the canonical full-dataset tool with optional exact-category filtering. Conditioning walking, rucking, and running keep optional date fields for backward compatibility, but date-range slicing returns an MCP `isError: true` tool result because public conditioning data is aggregate-only and contains no per-session rows.

MCP resources are generated from canonical dataset and physiology registries using stable URIs: `trailgenic://datasets/index`, `trailgenic://datasets/{dataset_id}`, and `trailgenic://physiology/{module_slug}`. Public data is deterministic and release-bundled. The mutable runtime fallback to GitHub `main` has been removed; missing bundled data is a server error and CI validation failure.

The permit poller and operational permit subscription infrastructure are separate from this MCP deployment. No Twilio data, phone numbers, permit subscriptions, raw telemetry, precise private location rows, credentials, secrets, or private user data are exposed through tools or resources.

CI runs `npm ci`, source checks, JSON validation, pure tests, Worker-runtime tests, registry/bundle/resource parity validation, Wrangler dry-run, and `git diff --check` before deployment. Push-to-main deployment verifies first, deploys only `tool-registry`, waits for propagation, and then runs `node scripts/live-acceptance.mjs`.
93 changes: 93 additions & 0 deletions lib/mcp-server.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
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 = {}) => {
let base;
if (schema.enum) 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();
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);
return base;
};

const zodShapeFromJsonSchema = (schema) => {
const required = new Set(schema.required ?? []);
return Object.fromEntries(Object.entries(schema.properties ?? {}).map(([key, prop]) => {
const value = zodForProperty(prop);
return [key, required.has(key) ? value : value.optional()];
}));
};

const errorResult = (message, details = undefined) => ({
isError: true,
content: [{ type: "text", text: details ? `${message} ${details}` : message }],
structuredContent: { error: message, details }
});

const successResult = (result) => ({
content: [{ type: "text", text: JSON.stringify(result) }],
structuredContent: result
});

const conditioningDateError = (toolId) => errorResult(
`${toolId} cannot perform date-range slicing because the public TrailGenic conditioning dataset is aggregate-only and contains no per-session rows. Omit start_date and end_date to receive the current aggregate dataset.`
);

export const createTrailgenicMcpServer = () => {
const server = new McpServer({ name: ENTITY.name, version: BUILD.version }, {
capabilities: { tools: {}, resources: {} },
instructions: "TrailGenic public read-only aggregate MCP server.",
protocolVersion: PRIMARY_MCP_PROTOCOL_VERSION
});

for (const tool of DATA_TOOLS) {
const handler = TOOL_HANDLERS.get(tool.id);
server.registerTool(tool.id, {
title: tool.title,
description: tool.description,
inputSchema: zodShapeFromJsonSchema(tool.inputSchema),
annotations
}, async (args = {}) => {
const validate = validators.get(tool.id);
if (!validate(args)) {
return errorResult("Invalid tool arguments.", ajv.errorsText(validate.errors));
}
if (tool.id.startsWith("tg.conditioning.") && (args.start_date || args.end_date)) {
return conditioningDateError(tool.id);
}
try {
return successResult(await handler(args));
} catch (error) {
return errorResult(error?.message ?? "Tool execution failed");
}
});
}

for (const resource of resourceInventory()) {
server.registerResource(resource.name, resource.uri, {
title: resource.title,
description: resource.description,
mimeType: resource.mimeType
}, async (uri) => {
const resourceUri = String(uri);
const data = readResource(resourceUri);
if (data === undefined) throw new Error(`Unknown TrailGenic resource: ${resourceUri}`);
return { contents: [{ uri: resourceUri, mimeType: "application/json", text: JSON.stringify(data) }] };
});
}

return server;
};
44 changes: 27 additions & 17 deletions lib/registry.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { DATASET_LIST, PHYSIOLOGY_MODULES } from "./datasets.js";
import { resourceInventory } from "./resources.js";

export const ENTITY = {
name: "TrailGenic",
Expand All @@ -9,12 +10,14 @@ export const ENTITY = {
};

export const BUILD = {
version: "1.3.0",
released: "2026-07-11"
version: "1.4.0",
released: "2026-07-14"
};

export const MCP_ORIGIN = "https://mcp.trailgenic.com";
export const MCP_TRANSPORT = `${MCP_ORIGIN}/mcp`;
export const SUPPORTED_MCP_PROTOCOL_VERSIONS = ["2025-11-25", "2025-06-18"];
export const PRIMARY_MCP_PROTOCOL_VERSION = SUPPORTED_MCP_PROTOCOL_VERSIONS[0];

const noArgsSchema = {
type: "object",
Expand Down Expand Up @@ -193,7 +196,7 @@ export const DATA_TOOLS = [
id: "tg.gear.intel.get",
title: "Get gear intelligence",
description:
"Return bounded gear intelligence records, optionally filtered by category.",
"Return bounded gear intelligence records, optionally filtered by category. This is the bounded, query-oriented compatibility gear tool.",
route: "/datasets/gear/intel",
inputSchema: {
type: "object",
Expand All @@ -208,7 +211,7 @@ export const DATA_TOOLS = [
id: "tg.gear.getIntel",
title: "Get TrailGenic Gear Intelligence dataset",
description:
"Returns the TrailGenic Gear Intelligence dataset — hiking gear scored through the TrailGenic longevity lens (fasted high-altitude performance, metabolic load, recovery impact, protocol fit). Optionally filter by gear category.",
"Returns the canonical full TrailGenic Gear Intelligence dataset — hiking gear scored through the TrailGenic longevity lens (fasted high-altitude performance, metabolic load, recovery impact, protocol fit). Optionally filter by exact gear category.",
route: "/datasets/gear/intel",
inputSchema: {
type: "object",
Expand Down Expand Up @@ -255,8 +258,8 @@ export const DATA_TOOLS = [
inputSchema: {
type: "object",
properties: {
start_date: { type: "string", description: "Optional date-range start (YYYY-MM-DD); dataset remains aggregate-only." },
end_date: { type: "string", description: "Optional date-range end (YYYY-MM-DD); dataset remains aggregate-only." }
start_date: { type: "string", description: "Optional backward-compatible field. Supplying it returns an error because public data is aggregate-only and cannot be date-sliced." },
end_date: { type: "string", description: "Optional backward-compatible field. Supplying it returns an error because public data is aggregate-only and cannot be date-sliced." }
},
additionalProperties: false
}
Expand All @@ -270,8 +273,8 @@ export const DATA_TOOLS = [
inputSchema: {
type: "object",
properties: {
start_date: { type: "string", description: "Optional date-range start (YYYY-MM-DD); dataset remains aggregate-only." },
end_date: { type: "string", description: "Optional date-range end (YYYY-MM-DD); dataset remains aggregate-only." }
start_date: { type: "string", description: "Optional backward-compatible field. Supplying it returns an error because public data is aggregate-only and cannot be date-sliced." },
end_date: { type: "string", description: "Optional backward-compatible field. Supplying it returns an error because public data is aggregate-only and cannot be date-sliced." }
},
additionalProperties: false
}
Expand All @@ -285,8 +288,8 @@ export const DATA_TOOLS = [
inputSchema: {
type: "object",
properties: {
start_date: { type: "string", description: "Optional date-range start (YYYY-MM-DD); dataset remains aggregate-only." },
end_date: { type: "string", description: "Optional date-range end (YYYY-MM-DD); dataset remains aggregate-only." }
start_date: { type: "string", description: "Optional backward-compatible field. Supplying it returns an error because public data is aggregate-only and cannot be date-sliced." },
end_date: { type: "string", description: "Optional backward-compatible field. Supplying it returns an error because public data is aggregate-only and cannot be date-sliced." }
},
additionalProperties: false
}
Expand Down Expand Up @@ -384,7 +387,8 @@ export const mcpTools = () => DATA_TOOLS.map((tool) => ({
id: tool.id,
title: tool.title,
description: tool.description,
inputSchema: tool.inputSchema
inputSchema: tool.inputSchema,
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }
}));

export const capabilitiesDocument = () => ({
Expand All @@ -399,14 +403,17 @@ export const capabilitiesDocument = () => ({
auth: { type: "none" }
},
datasets: datasetCatalog(),
tools: DATA_TOOLS.map((tool) => ({
tools: mcpTools().map((tool) => ({
id: tool.id,
name: tool.name,
title: tool.title,
description: tool.description,
endpoint: `${MCP_ORIGIN}${tool.route}`,
endpoint: `${MCP_ORIGIN}${DATA_TOOLS.find((entry) => entry.id === tool.id)?.route ?? "/mcp"}`,
mcp_method: "tools/call",
inputSchema: tool.inputSchema
inputSchema: tool.inputSchema,
annotations: tool.annotations
})),
resources: resourceInventory(),
content_links: CONTENT_LINKS,
deferred_tools: DEFERRED_DATA_TOOLS,
trust_signals: {
Expand All @@ -427,13 +434,16 @@ export const toolRegistryDocument = () => ({
transport: MCP_TRANSPORT,
auth: { type: "none" }
},
tools: DATA_TOOLS.map((tool) => ({
tools: mcpTools().map((tool) => ({
id: tool.id,
name: tool.name,
title: tool.title,
description: tool.description,
endpoint: `${MCP_ORIGIN}${tool.route}`,
inputSchema: tool.inputSchema
endpoint: `${MCP_ORIGIN}${DATA_TOOLS.find((entry) => entry.id === tool.id)?.route ?? "/mcp"}`,
inputSchema: tool.inputSchema,
annotations: tool.annotations
})),
resources: resourceInventory(),
content_links: CONTENT_LINKS,
deferred_tools: DEFERRED_DATA_TOOLS,
last_updated: BUILD.released
Expand Down
53 changes: 53 additions & 0 deletions lib/resources.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { DATASET_LIST, PHYSIOLOGY_MODULES } from "./datasets.js";
import { DATASET_JSON_BY_SOURCE_PATH } from "./queries.js";

export const datasetResourceUri = (datasetId) => `trailgenic://datasets/${datasetId}`;
export const physiologyResourceUri = (slug) => `trailgenic://physiology/${slug}`;

export const resourceInventory = () => {
const resources = [{
uri: "trailgenic://datasets/index",
name: "TrailGenic dataset index",
title: "TrailGenic dataset index",
description: "Generated public TrailGenic dataset catalog.",
mimeType: "application/json"
}];
for (const dataset of DATASET_LIST.filter((entry) => entry.enabled)) {
resources.push({
uri: datasetResourceUri(dataset.id),
name: dataset.id,
title: dataset.id,
description: dataset.description,
mimeType: "application/json"
});
}
for (const module of PHYSIOLOGY_MODULES) {
resources.push({
uri: physiologyResourceUri(module.slug),
name: module.slug,
title: module.title,
description: `TrailGenic physiology module: ${module.title}.`,
mimeType: "application/json"
});
}
return resources;
};

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 })) };
const datasetPrefix = "trailgenic://datasets/";
if (uri.startsWith(datasetPrefix)) {
const id = uri.slice(datasetPrefix.length);
const dataset = DATASET_LIST.find((entry) => entry.enabled && entry.id === id);
if (!dataset) return undefined;
return DATASET_JSON_BY_SOURCE_PATH.get(dataset.source_path);
}
const physiologyPrefix = "trailgenic://physiology/";
if (uri.startsWith(physiologyPrefix)) {
const slug = uri.slice(physiologyPrefix.length);
const module = PHYSIOLOGY_MODULES.find((entry) => entry.slug === slug);
if (!module) return undefined;
return DATASET_JSON_BY_SOURCE_PATH.get(module.source_path);
}
return undefined;
};
Loading