Skip to content
Draft
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
21 changes: 21 additions & 0 deletions pnpm-lock.yaml

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

2 changes: 2 additions & 0 deletions workers/mcp-services/README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
# mpp-services-mcp

Read-only Cloudflare Worker MCP server for the MPP service discovery catalog.
The official `@modelcontextprotocol/server` SDK handles protocol validation and
Streamable HTTP transport; this package owns the discovery tools and catalog.

Production endpoint:

Expand Down
3 changes: 3 additions & 0 deletions workers/mcp-services/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,9 @@
"test": "vitest --run",
"check": "pnpm gen:types && pnpm check:types && pnpm test"
},
"dependencies": {
"@modelcontextprotocol/server": "^2.0.0"
},
"devDependencies": {
"@types/node": "^25.9.1",
"typescript": "^6.0.3",
Expand Down
23 changes: 20 additions & 3 deletions workers/mcp-services/src/health.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { LATEST_PROTOCOL_VERSION } from "@modelcontextprotocol/server";
import { workerMetrics } from "../../../src/lib/worker-metrics.js";
import type { WorkerEnv } from "./types.js";

Expand Down Expand Up @@ -121,7 +122,11 @@ async function assertHead(endpoint: string): Promise<void> {
}

async function assertInitialize(endpoint: string): Promise<void> {
const result = await rpc(endpoint, "initialize");
const result = await rpc(endpoint, "initialize", {
protocolVersion: LATEST_PROTOCOL_VERSION,
capabilities: {},
clientInfo: { name: "mpp-discovery-health", version: "1.0.0" },
});
if (stringValue(object(result.serverInfo).name) !== "mpp-services-mcp") {
throw new Error("initialize serverInfo mismatch");
}
Expand Down Expand Up @@ -205,7 +210,7 @@ async function rpc(
const body = await fetchJson(endpoint, {
method: "POST",
headers: {
accept: "application/json",
accept: "application/json, text/event-stream",
"content-type": "application/json",
},
body: JSON.stringify({ jsonrpc: "2.0", id: 1, method, params }),
Expand All @@ -226,7 +231,19 @@ async function fetchJson(
if (response.status !== 200) {
throw new Error(`expected 200, received ${response.status}`);
}
return object(await response.json());
return object(await jsonRpcBody(response));
}

async function jsonRpcBody(response: Response): Promise<unknown> {
if (response.headers.get("content-type")?.includes("application/json")) {
return response.json();
}
const data = (await response.text())
.split("\n")
.filter((line) => line.startsWith("data:"))
.at(-1);
if (!data) throw new Error("MCP response contained no JSON-RPC message");
return JSON.parse(data.slice("data:".length));
}

async function fetchWithTimeout(
Expand Down
56 changes: 35 additions & 21 deletions workers/mcp-services/src/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,22 +63,13 @@ describe("worker routes", () => {

it("handles MCP JSON-RPC at /mcp/services", async () => {
const response = await worker.fetch(
new Request("https://worker.example.com/mcp/services", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
jsonrpc: "2.0",
id: 1,
method: "initialize",
params: {},
}),
}),
initializeRequest(),
envWithCatalog(),
testContext(),
);

expect(response.status).toBe(200);
const body = (await response.json()) as {
const body = (await jsonRpcBody(response)) as {
result: { serverInfo: { name: string }; instructions: string };
};
expect(body.result.serverInfo.name).toBe("mpp-services-mcp");
Expand All @@ -99,16 +90,7 @@ describe("worker routes", () => {
const log = vi.spyOn(console, "log").mockImplementation(() => {});

const response = await worker.fetch(
new Request("https://worker.example.com/mcp/services", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
jsonrpc: "2.0",
id: 1,
method: "initialize",
params: {},
}),
}),
initializeRequest(),
envWithCatalog(),
testContext(),
);
Expand Down Expand Up @@ -194,6 +176,38 @@ function envWithCatalog(): WorkerEnv {
} as WorkerEnv;
}

function initializeRequest(): Request {
return new Request("https://worker.example.com/mcp/services", {
method: "POST",
headers: {
accept: "application/json, text/event-stream",
"content-type": "application/json",
},
body: JSON.stringify({
jsonrpc: "2.0",
id: 1,
method: "initialize",
params: {
protocolVersion: "2025-06-18",
capabilities: {},
clientInfo: { name: "mpp-worker-test", version: "1.0.0" },
},
}),
});
}

async function jsonRpcBody(response: Response): Promise<unknown> {
if (response.headers.get("content-type")?.includes("application/json")) {
return response.json();
}
const data = (await response.text())
.split("\n")
.filter((line) => line.startsWith("data:"))
.at(-1);
if (!data) throw new Error("MCP response contained no JSON-RPC message");
return JSON.parse(data.slice("data:".length));
}

function testContext(): ExecutionContext {
return {
waitUntil() {},
Expand Down
132 changes: 120 additions & 12 deletions workers/mcp-services/src/mcp.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,7 @@ describe("mcp handler", () => {
expect(tool.outputSchema).toEqual(
expect.objectContaining({ type: "object" }),
);
expect(tool.execution).toEqual({ taskSupport: "forbidden" });
}
expect(
tools.find((tool) => tool.name === "get_openapi")?.inputSchema,
Expand All @@ -113,6 +114,98 @@ describe("mcp handler", () => {
);
});

it("serves 2026-era requests through the SDK handler", async () => {
const response = await handleMcp(
new Request("https://example.com/mcp", {
method: "POST",
headers: {
accept: "application/json",
"content-type": "application/json",
"mcp-method": "tools/list",
"mcp-protocol-version": "2026-07-28",
},
body: JSON.stringify({
jsonrpc: "2.0",
id: 1,
method: "tools/list",
params: {
_meta: {
"io.modelcontextprotocol/protocolVersion": "2026-07-28",
"io.modelcontextprotocol/clientInfo": {
name: "mpp-worker-test",
version: "1.0.0",
},
"io.modelcontextprotocol/clientCapabilities": {},
},
},
}),
}),
envWithCatalog(),
testContext(),
);

expect(response.status).toBe(200);
expect(response.headers.get("content-type")).toContain("application/json");
expect(response.headers.get("access-control-allow-origin")).toBe("*");
const body = (await response.json()) as {
result: { tools: Array<{ name: string }> };
};
expect(body.result.tools).toHaveLength(11);
});

it("delegates HTTP and JSON-RPC validation to the SDK", async () => {
const missingAccept = await handleMcp(
new Request("https://example.com/mcp", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
jsonrpc: "2.0",
id: 1,
method: "tools/list",
params: {},
}),
}),
envWithCatalog(),
testContext(),
);
expect(missingAccept.status).toBe(406);
expect(await missingAccept.json()).toEqual(
expect.objectContaining({
error: expect.objectContaining({ code: -32000 }),
}),
);

const malformedJson = await handleMcp(
new Request("https://example.com/mcp", {
method: "POST",
headers: {
accept: "application/json, text/event-stream",
"content-type": "application/json",
},
body: "{",
}),
envWithCatalog(),
testContext(),
);
expect(malformedJson.status).toBe(400);
expect(await malformedJson.json()).toEqual(
expect.objectContaining({
error: expect.objectContaining({ code: -32700 }),
}),
);

const notification = await handleMcp(
legacyRequest({
jsonrpc: "2.0",
method: "notifications/initialized",
}),
envWithCatalog(),
testContext(),
);
expect(notification.status).toBe(202);
expect(await notification.text()).toBe("");
});

it("searches endpoint-level payment offers with matching and ranking metadata", async () => {
const body = await callTool("search_offers", {
query: "inbox",
Expand Down Expand Up @@ -332,12 +425,7 @@ describe("mcp handler", () => {
]) {
const body = await callTool("search_services", args);
expect(body.result.isError).toBe(true);
expect(body.result.structuredContent).toEqual(
expect.objectContaining({
success: false,
error: expect.stringContaining("Allowed values:"),
}),
);
expect(body.result.content[0]?.text).toContain("Input validation error");
}
});

Expand Down Expand Up @@ -653,20 +741,17 @@ async function callTool(name: string, args: Record<string, unknown>) {

async function mcp(method: string, params: Record<string, unknown>, env: Env) {
const response = await handleMcp(
new Request("https://example.com/mcp", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ jsonrpc: "2.0", id: 1, method, params }),
}),
legacyRequest({ jsonrpc: "2.0", id: 1, method, params }),
env,
testContext(),
);
return response.json() as Promise<{
return jsonRpcBody(response) as Promise<{
result: {
tools?: Array<{
name?: string;
inputSchema?: unknown;
outputSchema?: unknown;
execution?: unknown;
}>;
content: Array<{ type: string; text: string }>;
isError?: boolean;
Expand All @@ -688,6 +773,29 @@ async function mcp(method: string, params: Record<string, unknown>, env: Env) {
}>;
}

function legacyRequest(body: unknown): Request {
return new Request("https://example.com/mcp", {
method: "POST",
headers: {
accept: "application/json, text/event-stream",
"content-type": "application/json",
},
body: JSON.stringify(body),
});
}

async function jsonRpcBody(response: Response): Promise<unknown> {
if (response.headers.get("content-type")?.includes("application/json")) {
return response.json();
}
const data = (await response.text())
.split("\n")
.filter((line) => line.startsWith("data:"))
.at(-1);
if (!data) throw new Error("MCP response contained no JSON-RPC message");
return JSON.parse(data.slice("data:".length));
}

function envWithCatalog(): Env {
return envWithCatalogFor(services);
}
Expand Down
Loading
Loading