From 47638c7a035e86a793078a44bc1a9abd267d97b4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dominik=20V=C3=ADt?= Date: Sun, 12 Jul 2026 23:13:32 +0200 Subject: [PATCH] feat(mcp): expose Effect platform server --- apps/backend/src/BackendApp.ts | 19 +- apps/backend/src/mcp/cloudflare-http.test.ts | 171 +++++++ apps/backend/src/mcp/cloudflare-http.ts | 210 +++++++++ apps/backend/src/mcp/platform-tools.test.ts | 209 +++++++++ apps/backend/src/mcp/platform-tools.ts | 245 ++++++++++ apps/backend/src/mcp/protocol.test.ts | 148 ------ apps/backend/src/mcp/protocol.ts | 196 -------- apps/backend/src/routes/mcp.test.ts | 197 ++++++++ apps/backend/src/routes/mcp.ts | 431 +++++++++++------- docs/security/backend-threat-model.md | 6 +- .../security/endpoint-authorization-matrix.md | 2 +- 11 files changed, 1302 insertions(+), 532 deletions(-) create mode 100644 apps/backend/src/mcp/cloudflare-http.test.ts create mode 100644 apps/backend/src/mcp/cloudflare-http.ts create mode 100644 apps/backend/src/mcp/platform-tools.test.ts create mode 100644 apps/backend/src/mcp/platform-tools.ts delete mode 100644 apps/backend/src/mcp/protocol.test.ts delete mode 100644 apps/backend/src/mcp/protocol.ts create mode 100644 apps/backend/src/routes/mcp.test.ts diff --git a/apps/backend/src/BackendApp.ts b/apps/backend/src/BackendApp.ts index 75e929805..078623931 100644 --- a/apps/backend/src/BackendApp.ts +++ b/apps/backend/src/BackendApp.ts @@ -941,6 +941,7 @@ export const buildBackendFetch = < const FeatureServicesLayer = layers.features.services(graph); const ExtensionServicesLayer = layers.rpcExtension.services(graph); const RpcGroup = RpcGroups.merge(layers.features.group, layers.rpcExtension.group); + const McpRpcGroup = RpcGroups.merge(layers.features.group); const RpcRouteDependencies = Layer.mergeAll( RpcSerialization.layerNdjson, @@ -1059,15 +1060,17 @@ export const buildBackendFetch = < ) : Layer.empty; - // MCP endpoint (`POST /api/mcp`, stateless streamable HTTP). Its request-scoped - // requirements — `ApiKeyService` (secret-key auth) + `PaywallWorkspaceService` - // + `AiChatService` (the shared workspace tools' context) + `Db` (key - // validation) — are satisfied via `provideRequest` like the AI chat route. - // `AuthSession` is provided in-handler from the validated secret key. Unlike - // the AI chat route, MCP needs no JWT namespace (it authenticates with v1 - // secret keys), so it registers unconditionally. - const McpRoutesLayer = McpRouteLayer.pipe( + // Effect MCP endpoint (`POST /api/mcp`, stateless streamable HTTP). Registration + // captures the complete customer RPC handler graph; request-scoped domain + // services remain available to focused workspace tools. The route validates a + // bearer user/project API key and provides the resulting `AuthSession` around + // every call, so the existing service authorization remains authoritative. + const McpRoutesLayer = McpRouteLayer(McpRpcGroup).pipe( HttpRouter.provideRequest(Layer.mergeAll(DomainServicesLayer, SupportServicesLayer)), + Layer.provide(RpcHandlersLayer), + Layer.provide(FeatureServicesLayer), + Layer.provide(DomainServicesLayer), + Layer.provide(SupportServicesLayer), ); const RoutesLayer = Layer.mergeAll( diff --git a/apps/backend/src/mcp/cloudflare-http.test.ts b/apps/backend/src/mcp/cloudflare-http.test.ts new file mode 100644 index 000000000..3aefb7748 --- /dev/null +++ b/apps/backend/src/mcp/cloudflare-http.test.ts @@ -0,0 +1,171 @@ +import { Context, Effect } from "effect"; +import { McpSchema, McpServer } from "effect/unstable/ai"; +import { describe, expect, it } from "vite-plus/test"; + +import { + handleStatelessMcpMessage, + JsonRpcErrorCode, + parseJsonRpcMessage, + SUPPORTED_PROTOCOL_VERSIONS, + type JsonRpcMessage, + type JsonRpcResponse, +} from "./cloudflare-http.ts"; + +const serverInfo = { name: "test", version: "1.0.0" } as const; + +const makeServer = () => + Effect.runPromise( + Effect.scoped( + Effect.gen(function* () { + const server = yield* McpServer.McpServer.make; + yield* server.addTool({ + tool: new McpSchema.Tool({ + name: "echo", + description: "Echo input", + inputSchema: { type: "object" }, + }), + annotations: Context.empty(), + handle: (input) => + Effect.succeed( + new McpSchema.CallToolResult({ + content: [{ type: "text", text: JSON.stringify(input) }], + structuredContent: input, + isError: false, + }), + ), + }); + yield* server.addResource({ + resource: new McpSchema.Resource({ + uri: "voidhash://test", + name: "Test resource", + mimeType: "text/plain", + }), + annotations: Context.empty(), + handle: Effect.succeed({ + contents: [{ uri: "voidhash://test", mimeType: "text/plain", text: "ready" }], + }), + }); + yield* server.addResourceTemplate({ + template: new McpSchema.ResourceTemplate({ + uriTemplate: "voidhash://widgets/{id}", + name: "Widget", + mimeType: "application/json", + }), + routerPath: "voidhash:://widgets/:0", + completions: {}, + annotations: Context.empty(), + handle: (uri, params) => + Effect.succeed({ + contents: [ + { uri, mimeType: "application/json", text: JSON.stringify({ id: params[0] }) }, + ], + }), + }); + return server; + }), + ), + ); + +const msg = ( + method: string, + params?: Record, + id: string | number = 1, +): JsonRpcMessage => ({ jsonrpc: "2.0", method, params, id }); + +const resultOf = (response: JsonRpcResponse | null): unknown => + response !== null && "result" in response ? response.result : undefined; + +describe("parseJsonRpcMessage", () => { + it("accepts a request and rejects batches", () => { + expect(parseJsonRpcMessage({ jsonrpc: "2.0", method: "ping", id: 1 }).ok).toBe(true); + expect(parseJsonRpcMessage([{ jsonrpc: "2.0", method: "ping", id: 1 }]).ok).toBe(false); + }); +}); + +describe("Cloudflare stateless MCP transport", () => { + it("initializes and calls tools without retaining or requiring an MCP session id", async () => { + const server = await makeServer(); + const initialized = await Effect.runPromise( + handleStatelessMcpMessage( + server, + msg("initialize", { + protocolVersion: SUPPORTED_PROTOCOL_VERSIONS[0], + capabilities: {}, + clientInfo: { name: "test", version: "1" }, + }), + serverInfo, + ), + ); + expect(resultOf(initialized)).toMatchObject({ + protocolVersion: SUPPORTED_PROTOCOL_VERSIONS[0], + capabilities: { tools: {}, resources: {} }, + }); + + const called = await Effect.runPromise( + handleStatelessMcpMessage( + server, + msg("tools/call", { name: "echo", arguments: { value: 42 } }, 2), + serverInfo, + ), + ); + expect(resultOf(called)).toMatchObject({ + structuredContent: { value: 42 }, + isError: false, + }); + }); + + it("discovers Effect MCP tools and resources", async () => { + const server = await makeServer(); + const tools = await Effect.runPromise( + handleStatelessMcpMessage(server, msg("tools/list"), serverInfo), + ); + expect(resultOf(tools)).toMatchObject({ tools: [{ name: "echo" }] }); + + const resources = await Effect.runPromise( + handleStatelessMcpMessage(server, msg("resources/list"), serverInfo), + ); + expect(resultOf(resources)).toMatchObject({ resources: [{ uri: "voidhash://test" }] }); + + const templates = await Effect.runPromise( + handleStatelessMcpMessage(server, msg("resources/templates/list"), serverInfo), + ); + expect(resultOf(templates)).toMatchObject({ + resourceTemplates: [{ uriTemplate: "voidhash://widgets/{id}" }], + }); + + const read = await Effect.runPromise( + handleStatelessMcpMessage( + server, + msg("resources/read", { uri: "voidhash://test" }), + serverInfo, + ), + ); + expect(resultOf(read)).toMatchObject({ contents: [{ text: "ready" }] }); + + const templated = await Effect.runPromise( + handleStatelessMcpMessage( + server, + msg("resources/read", { uri: "voidhash://widgets/42" }), + serverInfo, + ), + ); + expect(resultOf(templated)).toMatchObject({ contents: [{ text: '{"id":"42"}' }] }); + }); + + it("returns protocol errors for invalid params and unknown methods", async () => { + const server = await makeServer(); + const invalid = await Effect.runPromise( + handleStatelessMcpMessage(server, msg("tools/call", {}), serverInfo), + ); + expect(invalid && "error" in invalid ? invalid.error.code : null).toBe( + JsonRpcErrorCode.InvalidParams, + ); + + const unknown = await Effect.runPromise( + handleStatelessMcpMessage(server, msg("unknown/method"), serverInfo), + ); + expect(unknown && "error" in unknown ? unknown.error.code : null).toBe( + JsonRpcErrorCode.MethodNotFound, + ); + }); +}); diff --git a/apps/backend/src/mcp/cloudflare-http.ts b/apps/backend/src/mcp/cloudflare-http.ts new file mode 100644 index 000000000..785da6dc5 --- /dev/null +++ b/apps/backend/src/mcp/cloudflare-http.ts @@ -0,0 +1,210 @@ +import { Cause, Effect } from "effect"; +import { McpSchema, McpServer } from "effect/unstable/ai"; + +/** MCP protocol versions supported by Effect MCP, newest first. */ +export const SUPPORTED_PROTOCOL_VERSIONS = [ + "2025-06-18", + "2025-03-26", + "2024-11-05", + "2024-10-07", +] as const; + +const LATEST_PROTOCOL_VERSION = SUPPORTED_PROTOCOL_VERSIONS[0]; + +/** Standard JSON-RPC 2.0 error codes emitted by the stateless adapter. */ +export const JsonRpcErrorCode = { + ParseError: -32700, + InvalidRequest: -32600, + MethodNotFound: -32601, + InvalidParams: -32602, + InternalError: -32603, +} as const; + +export type JsonRpcId = string | number | null; + +/** A validated JSON-RPC request or notification. */ +export interface JsonRpcMessage { + readonly jsonrpc: "2.0"; + readonly method: string; + readonly id?: JsonRpcId; + readonly params?: Record; +} + +/** A JSON-RPC response returned over stateless streamable HTTP. */ +export type JsonRpcResponse = + | { readonly jsonrpc: "2.0"; readonly id: JsonRpcId; readonly result: unknown } + | { + readonly jsonrpc: "2.0"; + readonly id: JsonRpcId; + readonly error: { readonly code: number; readonly message: string; readonly data?: unknown }; + }; + +const success = (id: JsonRpcId, result: unknown): JsonRpcResponse => ({ + jsonrpc: "2.0", + id, + result, +}); + +const failure = ( + id: JsonRpcId, + code: number, + message: string, + data?: unknown, +): JsonRpcResponse => ({ + jsonrpc: "2.0", + id, + error: data === undefined ? { code, message } : { code, message, data }, +}); + +/** Validates a parsed value as one non-batched JSON-RPC 2.0 message. */ +export const parseJsonRpcMessage = ( + value: unknown, +): + | { readonly ok: true; readonly message: JsonRpcMessage } + | { readonly ok: false; readonly reason: string } => { + if (value === null || typeof value !== "object" || Array.isArray(value)) { + return { ok: false, reason: "Expected a single JSON-RPC 2.0 request object" }; + } + const record = value as Record; + if (record.jsonrpc !== "2.0") { + return { ok: false, reason: 'Missing or invalid "jsonrpc": expected "2.0"' }; + } + if (typeof record.method !== "string") { + return { ok: false, reason: 'Missing or invalid "method"' }; + } + const id = record.id; + if (id !== undefined && id !== null && typeof id !== "string" && typeof id !== "number") { + return { ok: false, reason: 'Invalid "id": expected string, number, or null' }; + } + const params = + record.params !== undefined && typeof record.params === "object" && record.params !== null + ? (record.params as Record) + : undefined; + return { + ok: true, + message: { jsonrpc: "2.0", method: record.method, id: id as JsonRpcId, params }, + }; +}; + +const negotiateProtocolVersion = (requested: unknown): string => + typeof requested === "string" && + (SUPPORTED_PROTOCOL_VERSIONS as ReadonlyArray).includes(requested) + ? requested + : LATEST_PROTOCOL_VERSION; + +const cleanFailure = (cause: Cause.Cause): string => + Cause.prettyErrors(cause)[0]?.message ?? "Internal error"; + +const statelessClient = McpSchema.McpServerClient.of({ + clientId: 0, + initializePayload: { + protocolVersion: LATEST_PROTOCOL_VERSION, + capabilities: {}, + clientInfo: { name: "stateless-http", version: "1" }, + }, + getClient: Effect.die(new Error("Stateless MCP does not support server-initiated requests")), +}); + +/** + * Dispatches one MCP message through Effect MCP's registry without retaining a + * transport session. Cloudflare Workers may route consecutive HTTP requests to + * different isolates, so client capabilities are deliberately not stored in + * isolate memory and every request remains independently executable. + */ +export const handleStatelessMcpMessage = ( + server: McpServer.McpServer["Service"], + message: JsonRpcMessage, + serverInfo: { readonly name: string; readonly version: string }, +): Effect.Effect => { + const id = message.id ?? null; + + switch (message.method) { + case "initialize": { + const protocolVersion = negotiateProtocolVersion(message.params?.protocolVersion); + return Effect.succeed( + success(id, { + protocolVersion, + capabilities: { + ...(server.tools.length > 0 ? { tools: { listChanged: false } } : {}), + ...(server.resources.length > 0 || server.resourceTemplates.length > 0 + ? { resources: { subscribe: false, listChanged: false } } + : {}), + }, + serverInfo, + instructions: + "Use the focused paywall tools for authoring. Use platform_describe then platform_call for the rest of the typed platform API.", + }), + ); + } + + case "notifications/initialized": + return Effect.succeed(null); + + case "ping": + return Effect.succeed(success(id, {})); + + case "tools/list": + return Effect.succeed(success(id, { tools: server.tools.map(({ tool }) => tool) })); + + case "tools/call": { + const name = message.params?.name; + if (typeof name !== "string" || name.length === 0) { + return Effect.succeed( + failure(id, JsonRpcErrorCode.InvalidParams, 'tools/call requires a string "name"'), + ); + } + const args = message.params?.arguments; + if ( + args !== undefined && + (args === null || typeof args !== "object" || Array.isArray(args)) + ) { + return Effect.succeed( + failure(id, JsonRpcErrorCode.InvalidParams, 'tools/call "arguments" must be an object'), + ); + } + return server.callTool({ name, arguments: (args ?? {}) as Record }).pipe( + Effect.provideService(McpSchema.McpServerClient, statelessClient), + Effect.map((result) => success(id, result)), + Effect.catchCause((cause) => + Effect.succeed(failure(id, JsonRpcErrorCode.InvalidParams, cleanFailure(cause))), + ), + ); + } + + case "resources/list": + return Effect.succeed( + success(id, { resources: server.resources.map(({ resource }) => resource) }), + ); + + case "resources/templates/list": + return Effect.succeed( + success(id, { + resourceTemplates: server.resourceTemplates.map(({ template }) => template), + }), + ); + + case "resources/read": { + const uri = message.params?.uri; + if (typeof uri !== "string" || uri.length === 0) { + return Effect.succeed( + failure(id, JsonRpcErrorCode.InvalidParams, 'resources/read requires a string "uri"'), + ); + } + return server.findResource(uri).pipe( + Effect.provideService(McpSchema.McpServerClient, statelessClient), + Effect.map((result) => success(id, result)), + Effect.catchCause((cause) => + Effect.succeed(failure(id, JsonRpcErrorCode.InvalidParams, cleanFailure(cause))), + ), + ); + } + + default: + if (message.method.startsWith("notifications/") && message.id === undefined) { + return Effect.succeed(null); + } + return Effect.succeed( + failure(id, JsonRpcErrorCode.MethodNotFound, `Method not found: ${message.method}`), + ); + } +}; diff --git a/apps/backend/src/mcp/platform-tools.test.ts b/apps/backend/src/mcp/platform-tools.test.ts new file mode 100644 index 000000000..7135566b9 --- /dev/null +++ b/apps/backend/src/mcp/platform-tools.test.ts @@ -0,0 +1,209 @@ +import { AuthSession } from "@voidhash/core/domain/auth/Auth"; +import { AuthMiddleware, RpcGroups } from "@voidhash/rpc"; +import { Effect, Schema } from "effect"; +import { McpServer } from "effect/unstable/ai"; +import { Rpc, RpcGroup } from "effect/unstable/rpc"; +import { describe, expect, it } from "vite-plus/test"; + +import { handleStatelessMcpMessage, type JsonRpcResponse } from "./cloudflare-http.ts"; +import { makePlatformOperations, registerPlatformTools } from "./platform-tools.ts"; + +const TestRpcs = RpcGroup.make( + Rpc.make("ListWidgets", { + payload: { projectId: Schema.String }, + success: Schema.Array(Schema.Struct({ id: Schema.String })), + }), + Rpc.make("DeleteWidget", { + payload: { id: Schema.String }, + success: Schema.Void, + }), + Rpc.make("CurrentWidget", { + success: Schema.Struct({ projectId: Schema.String }), + }), +).middleware(AuthMiddleware); + +const TestHandlers = TestRpcs.toLayer({ + ListWidgets: ({ projectId }) => + AuthSession.use((session) => + Effect.succeed([{ id: `${session.projects[0]?.id ?? "none"}:${projectId}` }]), + ), + DeleteWidget: () => Effect.void, + CurrentWidget: () => + AuthSession.use((session) => Effect.succeed({ projectId: session.projects[0]?.id ?? "none" })), +}); + +const session = AuthSession.of({ + cookie: null, + method: "secret-key", + name: "Test key", + organizations: [], + person: null, + projects: [ + { + id: "project-from-auth", + logo: null, + name: "Test", + organizationId: "org-1", + permissions: ["project:all"], + slug: "test", + }, + ], + user: null, +}); + +const makePlatformServer = () => + Effect.runPromise( + Effect.scoped( + Effect.gen(function* () { + const server = yield* McpServer.McpServer.make; + const operations = yield* makePlatformOperations(TestRpcs); + yield* registerPlatformTools(server, operations); + return { server, operations }; + }).pipe(Effect.provide(TestHandlers)), + ), + ); + +const resultOf = (response: JsonRpcResponse | null): Record => + (response !== null && "result" in response ? response.result : {}) as Record; + +describe("platform MCP gateway", () => { + it("builds a unique, JSON-schema-described operation for every composed platform RPC", async () => { + const handlers = Object.fromEntries( + Array.from(RpcGroups.requests.keys(), (tag) => [tag, () => Effect.void]), + ); + const operations = await Effect.runPromise( + Effect.scoped( + makePlatformOperations(RpcGroups).pipe( + Effect.provide(RpcGroups.toLayer(handlers as never)), + ), + ), + ); + + expect(operations).toHaveLength(RpcGroups.requests.size); + expect(new Set(operations.map(({ name }) => name)).size).toBe(RpcGroups.requests.size); + expect( + operations + .filter(({ inputSchema }) => typeof inputSchema !== "object" || inputSchema === null) + .map(({ name }) => name), + ).toEqual([]); + }); + + it("derives stable operation names, input schemas, and safety annotations from RPCs", async () => { + const { operations } = await makePlatformServer(); + expect(operations.map(({ name }) => name)).toEqual([ + "list_widgets", + "delete_widget", + "current_widget", + ]); + expect(operations[0]?.inputSchema).toMatchObject({ + type: "object", + properties: { projectId: { type: "string" } }, + }); + expect(operations[0]?.annotations).toMatchObject({ + readOnlyHint: true, + destructiveHint: false, + }); + expect(operations[1]?.annotations.destructiveHint).toBe(true); + }); + + it("describes and executes a typed RPC through Effect MCP with the request session", async () => { + const { server } = await makePlatformServer(); + const described = await Effect.runPromise( + handleStatelessMcpMessage( + server, + { + jsonrpc: "2.0", + id: 1, + method: "tools/call", + params: { + name: "platform_describe", + arguments: { operation: "list_widgets" }, + }, + }, + { name: "test", version: "1" }, + ).pipe(Effect.provideService(AuthSession, session)), + ); + expect(resultOf(described).structuredContent).toMatchObject({ + operations: [{ name: "list_widgets", rpc: "ListWidgets" }], + total: 1, + }); + + const called = await Effect.runPromise( + handleStatelessMcpMessage( + server, + { + jsonrpc: "2.0", + id: 2, + method: "tools/call", + params: { + name: "platform_call", + arguments: { + operation: "list_widgets", + input: { projectId: "project-from-input" }, + }, + }, + }, + { name: "test", version: "1" }, + ).pipe(Effect.provideService(AuthSession, session)), + ); + expect(resultOf(called).structuredContent).toEqual([ + { id: "project-from-auth:project-from-input" }, + ]); + + const calledWithoutInput = await Effect.runPromise( + handleStatelessMcpMessage( + server, + { + jsonrpc: "2.0", + id: 3, + method: "tools/call", + params: { + name: "platform_call", + arguments: { operation: "current_widget" }, + }, + }, + { name: "test", version: "1" }, + ).pipe(Effect.provideService(AuthSession, session)), + ); + expect(resultOf(calledWithoutInput).structuredContent).toEqual({ + projectId: "project-from-auth", + }); + }); + + it("folds schema validation failures into an MCP tool error", async () => { + const { server } = await makePlatformServer(); + const response = await Effect.runPromise( + handleStatelessMcpMessage( + server, + { + jsonrpc: "2.0", + id: 1, + method: "tools/call", + params: { + name: "platform_call", + arguments: { operation: "list_widgets", input: {} }, + }, + }, + { name: "test", version: "1" }, + ).pipe(Effect.provideService(AuthSession, session)), + ); + expect(resultOf(response)).toMatchObject({ isError: true }); + }); + + it("folds malformed gateway arguments into MCP tool errors", async () => { + const { server } = await makePlatformServer(); + const response = await Effect.runPromise( + handleStatelessMcpMessage( + server, + { + jsonrpc: "2.0", + id: 1, + method: "tools/call", + params: { name: "platform_describe", arguments: { query: 42 } }, + }, + { name: "test", version: "1" }, + ), + ); + expect(resultOf(response)).toMatchObject({ isError: true }); + }); +}); diff --git a/apps/backend/src/mcp/platform-tools.ts b/apps/backend/src/mcp/platform-tools.ts new file mode 100644 index 000000000..b5797f2c4 --- /dev/null +++ b/apps/backend/src/mcp/platform-tools.ts @@ -0,0 +1,245 @@ +import { AuthSession } from "@voidhash/core/domain/auth/Auth"; +import { Cause, Context, Effect, Schema } from "effect"; +import { McpSchema, McpServer, Tool as AiTool } from "effect/unstable/ai"; +import type { Headers } from "effect/unstable/http/Headers"; +import * as Rpc from "effect/unstable/rpc/Rpc"; +import type { RpcGroup } from "effect/unstable/rpc/RpcGroup"; +import { RequestId } from "effect/unstable/rpc/RpcMessage"; + +const toSnakeCase = (value: string): string => + value + .replace(/([a-z0-9])([A-Z])/g, "$1_$2") + .replace(/([A-Z])([A-Z][a-z])/g, "$1_$2") + .toLowerCase(); + +const readPrefixes = ["current", "get", "list", "query", "validate"]; +const destructivePrefixes = ["archive", "delete", "remove", "revoke"]; + +const operationAnnotations = (name: string) => { + const readOnly = readPrefixes.some((prefix) => name.startsWith(prefix)); + const destructive = destructivePrefixes.some((prefix) => name.startsWith(prefix)); + return { + readOnlyHint: readOnly, + destructiveHint: destructive, + idempotentHint: readOnly, + openWorldHint: false, + }; +}; + +const cleanFailure = (cause: Cause.Cause): string => { + const errors = Cause.prettyErrors(cause); + return errors[0]?.message ?? "Platform operation failed"; +}; + +const toolResult = (value: unknown, isError = false) => { + const structuredContent = value === undefined ? null : value; + return new McpSchema.CallToolResult({ + content: [{ type: "text", text: JSON.stringify(structuredContent, null, 2) }], + structuredContent, + isError, + }); +}; + +/** A project/user-authorized operation exposed through the compact platform MCP gateway. */ +export interface PlatformOperation { + readonly name: string; + readonly rpc: string; + readonly inputSchema: Record; + readonly annotations: ReturnType; + readonly call: (input: unknown) => Effect.Effect; +} + +/** Public catalog entry returned by `platform_describe` and the operations resource. */ +export interface PlatformOperationDescriptor { + readonly name: string; + readonly rpc: string; + readonly inputSchema: Record; + readonly annotations: ReturnType; +} + +/** + * Builds one callable MCP operation for every RPC in the supplied platform + * group. The RPC handlers remain the single business-logic implementation; + * MCP only decodes their input, supplies the authenticated session, and encodes + * their result. + */ +export const makePlatformOperations = ( + group: RpcGroup, +): Effect.Effect, never, Rpc.ToHandler> => + Effect.gen(function* () { + const operations: PlatformOperation[] = []; + const names = new Set(); + + for (const rpc of group.requests.values() as Iterable) { + const name = toSnakeCase(rpc._tag); + if (names.has(name)) { + return yield* Effect.die(new Error(`Duplicate MCP platform operation: ${name}`)); + } + names.add(name); + + const handler = yield* group.accessHandler(rpc._tag as Rpcs["_tag"]); + const decode = Schema.decodeUnknownEffect(rpc.payloadSchema); + const encode = Schema.encodeUnknownEffect(rpc.successSchema); + const inputSchema = AiTool.getJsonSchemaFromSchema(rpc.payloadSchema) as Record< + string, + unknown + >; + + operations.push({ + name, + rpc: rpc._tag, + inputSchema, + annotations: operationAnnotations(name), + call: (input) => + decode(input).pipe( + Effect.flatMap( + (payload) => + handler(payload as never, { + client: new Rpc.ServerClient(0), + requestId: RequestId(0n), + headers: {} as Headers, + }) as Effect.Effect, + ), + Effect.flatMap(encode), + Effect.map((result) => toolResult(result)), + Effect.catchCause((cause) => + Effect.succeed(toolResult({ error: cleanFailure(cause) }, true)), + ), + ) as Effect.Effect, + }); + } + + // Iterating RpcGroup's runtime map erases its concrete handler union. Each + // handler above still comes from this exact group, so restore that aggregate + // requirement for callers that provide the group's handler layer. + return operations; + }) as unknown as Effect.Effect, never, Rpc.ToHandler>; + +/** Returns the serializable operation catalog advertised to agents. */ +export const platformOperationDescriptors = ( + operations: ReadonlyArray, +): ReadonlyArray => + operations.map(({ name, rpc, inputSchema, annotations }) => ({ + name, + rpc, + inputSchema, + annotations, + })); + +const operationEnumSchema = (operations: ReadonlyArray) => ({ + type: "string", + enum: operations.map((operation) => operation.name), +}); + +/** Registers the compact discovery/call gateway for every platform RPC. */ +export const registerPlatformTools = ( + server: McpServer.McpServer["Service"], + operations: ReadonlyArray, +): Effect.Effect => { + const byName = new Map(operations.map((operation) => [operation.name, operation])); + const descriptors = platformOperationDescriptors(operations); + + return Effect.gen(function* () { + yield* server.addTool({ + tool: new McpSchema.Tool({ + name: "platform_describe", + title: "Describe Platform Operations", + description: + "Discover typed platform operations before calling them. Pass an exact operation name for its full input JSON Schema, or a query to search operation/RPC names.", + inputSchema: { + type: "object", + properties: { + operation: operationEnumSchema(operations), + query: { type: "string", description: "Case-insensitive operation-name search." }, + }, + additionalProperties: false, + }, + annotations: { + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: false, + }, + }), + annotations: Context.empty(), + handle: (input: unknown) => { + if (input === null || typeof input !== "object" || Array.isArray(input)) { + return Effect.succeed( + toolResult({ error: "platform_describe input must be an object" }, true), + ); + } + const { operation, query: rawQuery } = input as Record; + if (operation !== undefined && typeof operation !== "string") { + return Effect.succeed(toolResult({ error: "operation must be a string" }, true)); + } + if (rawQuery !== undefined && typeof rawQuery !== "string") { + return Effect.succeed(toolResult({ error: "query must be a string" }, true)); + } + const query = operation ?? rawQuery?.trim().toLowerCase(); + const matches = + query === undefined || query.length === 0 + ? descriptors + : descriptors.filter( + (descriptor) => + descriptor.name === operation || + descriptor.name.includes(query) || + descriptor.rpc.toLowerCase().includes(query), + ); + return Effect.succeed(toolResult({ operations: matches, total: matches.length })); + }, + }); + + yield* server.addTool({ + tool: new McpSchema.Tool({ + name: "platform_call", + title: "Call Platform Operation", + description: + "Call any typed platform operation. Use platform_describe first to get the operation's exact input schema. Authorization is enforced by the same platform services used by the application.", + inputSchema: { + type: "object", + properties: { + operation: operationEnumSchema(operations), + input: { + description: + "Input matching platform_describe's schema. Omit for operations whose schema is void.", + }, + }, + required: ["operation"], + additionalProperties: false, + }, + annotations: { + readOnlyHint: false, + destructiveHint: true, + idempotentHint: false, + openWorldHint: false, + }, + }), + annotations: Context.empty(), + // Effect MCP's low-level registry only exposes McpServerClient in this + // signature. The stateless route supplies AuthSession around each call, + // which is the additional requirement carried by operation.call. + handle: ((input: unknown) => { + if (input === null || typeof input !== "object" || Array.isArray(input)) { + return Effect.succeed( + toolResult({ error: "platform_call input must be an object" }, true), + ); + } + const { operation: operationName, input: operationInput } = input as Record< + string, + unknown + >; + if (typeof operationName !== "string" || operationName.length === 0) { + return Effect.succeed( + toolResult({ error: "operation must be a non-empty string" }, true), + ); + } + const operation = byName.get(operationName); + return operation === undefined + ? Effect.succeed( + toolResult({ error: `Unknown platform operation: ${operationName}` }, true), + ) + : operation.call(operationInput); + }) as never, + }); + }); +}; diff --git a/apps/backend/src/mcp/protocol.test.ts b/apps/backend/src/mcp/protocol.test.ts deleted file mode 100644 index a1f970891..000000000 --- a/apps/backend/src/mcp/protocol.test.ts +++ /dev/null @@ -1,148 +0,0 @@ -/** - * Unit tests for the hand-rolled MCP JSON-RPC handler. The tool executor is - * mocked (a context-free `callTool`), so these cover method dispatch, protocol - * negotiation, the tools/list shape, and the tool-error → `isError` mapping - * without a worker or the workspace service. - */ -import { Effect } from "effect"; -import { describe, expect, it } from "vite-plus/test"; - -import { - handleMcpMessage, - parseJsonRpcMessage, - JsonRpcErrorCode, - SUPPORTED_PROTOCOL_VERSIONS, - type CallTool, - type JsonRpcMessage, - type JsonRpcResponse, -} from "./protocol.ts"; -import type { WorkspaceToolResult } from "../ai/workspace-tools.ts"; - -/** A canned tool executor: echoes the name/args, or fails as an `isError` result. */ -const cannedCallTool = - (result: WorkspaceToolResult): CallTool => - () => - Effect.succeed(result); - -/** Run the handler with a canned executor and no real context (protocol-only). */ -const run = ( - message: JsonRpcMessage, - callTool: CallTool = cannedCallTool({ output: "ok", isError: false }), -): Promise => - Effect.runPromise(handleMcpMessage(message, callTool) as Effect.Effect); - -const msg = (method: string, params?: Record, id: string | number = 1): JsonRpcMessage => ({ - jsonrpc: "2.0", - method, - id, - params, -}); - -describe("parseJsonRpcMessage", () => { - it("accepts a valid request", () => { - const parsed = parseJsonRpcMessage({ jsonrpc: "2.0", method: "ping", id: 1 }); - expect(parsed.ok).toBe(true); - }); - - it("rejects a missing jsonrpc version", () => { - const parsed = parseJsonRpcMessage({ method: "ping", id: 1 }); - expect(parsed.ok).toBe(false); - }); - - it("rejects a missing method", () => { - const parsed = parseJsonRpcMessage({ jsonrpc: "2.0", id: 1 }); - expect(parsed.ok).toBe(false); - }); - - it("rejects a batch (array)", () => { - const parsed = parseJsonRpcMessage([{ jsonrpc: "2.0", method: "ping", id: 1 }]); - expect(parsed.ok).toBe(false); - }); -}); - -describe("initialize", () => { - it("negotiates the requested supported version and advertises tools", async () => { - for (const version of SUPPORTED_PROTOCOL_VERSIONS) { - const response = await run(msg("initialize", { protocolVersion: version })); - expect(response && "result" in response).toBe(true); - const result = (response as { result: Record }).result; - expect(result.protocolVersion).toBe(version); - expect(result.capabilities).toEqual({ tools: {} }); - expect((result.serverInfo as { name: string }).name).toBe("voidhash-paywall-workspace"); - } - }); - - it("falls back to the latest version for an unsupported request", async () => { - const response = await run(msg("initialize", { protocolVersion: "1999-01-01" })); - const result = (response as { result: Record }).result; - expect(result.protocolVersion).toBe(SUPPORTED_PROTOCOL_VERSIONS[0]); - }); -}); - -describe("notifications/initialized", () => { - it("is accepted with no response (route → 202)", async () => { - const response = await run({ jsonrpc: "2.0", method: "notifications/initialized" }); - expect(response).toBeNull(); - }); -}); - -describe("ping", () => { - it("returns an empty result", async () => { - const response = await run(msg("ping")); - expect((response as { result: unknown }).result).toEqual({}); - }); -}); - -describe("tools/list", () => { - it("returns the tool descriptors with JSON Schema inputs", async () => { - const response = await run(msg("tools/list")); - const result = (response as { result: { tools: Array> } }).result; - expect(result.tools.length).toBe(8); - const listPaywalls = result.tools[0]; - expect(listPaywalls.name).toBe("list_paywalls"); - expect((listPaywalls.inputSchema as { type: string }).type).toBe("object"); - }); -}); - -describe("tools/call", () => { - it("maps a successful tool run to text content (isError false)", async () => { - const response = await run( - msg("tools/call", { name: "read_file", arguments: { path: "/x" } }), - cannedCallTool({ output: "FILE", isError: false }), - ); - const result = (response as { result: Record }).result; - expect(result.isError).toBe(false); - expect(result.content).toEqual([{ type: "text", text: "FILE" }]); - }); - - it("maps a tool failure to isError content, NOT a JSON-RPC error", async () => { - const response = await run( - msg("tools/call", { name: "apply_paywall", arguments: {} }), - cannedCallTool({ output: "apply_paywall rejected: bad", isError: true }), - ); - expect(response && "result" in response).toBe(true); - const result = (response as { result: Record }).result; - expect(result.isError).toBe(true); - expect(result.content).toEqual([{ type: "text", text: "apply_paywall rejected: bad" }]); - }); - - it("rejects a missing tool name with InvalidParams", async () => { - const response = await run(msg("tools/call", { arguments: {} })); - const error = (response as { error: { code: number } }).error; - expect(error.code).toBe(JsonRpcErrorCode.InvalidParams); - }); -}); - -describe("unknown method", () => { - it("returns method-not-found", async () => { - const response = await run(msg("resources/list")); - const error = (response as { error: { code: number; message: string } }).error; - expect(error.code).toBe(JsonRpcErrorCode.MethodNotFound); - expect(error.message).toContain("resources/list"); - }); - - it("accepts an unknown notification silently", async () => { - const response = await run({ jsonrpc: "2.0", method: "notifications/cancelled" }); - expect(response).toBeNull(); - }); -}); diff --git a/apps/backend/src/mcp/protocol.ts b/apps/backend/src/mcp/protocol.ts deleted file mode 100644 index 01ba52838..000000000 --- a/apps/backend/src/mcp/protocol.ts +++ /dev/null @@ -1,196 +0,0 @@ -/** - * Minimal, hand-rolled MCP JSON-RPC 2.0 handler for the STATELESS streamable-HTTP - * transport. No SDK dependency: a stateless server answers each `POST /api/mcp` - * with a single JSON response (or 202 for a notification), which is spec-compliant - * and exactly what Claude Code's streamable-HTTP client accepts. - * - * This module is transport- and auth-free: {@link handleMcpMessage} takes an - * already-parsed JSON-RPC message plus a `callTool` executor (the route supplies - * one bound to the authenticated project scope) and returns either a JSON-RPC - * response object to serialize, or `null` for an accepted notification (the route - * maps that to HTTP 202). Method dispatch, protocol-version negotiation, and the - * error taxonomy live here so they can be unit-tested without a worker. - * - * Implemented methods: `initialize`, `notifications/initialized`, `tools/list`, - * `tools/call`, `ping`. Unknown methods → JSON-RPC method-not-found. Tool errors - * are `isError: true` content, never JSON-RPC errors (per MCP). Resources are not - * offered this increment (tools cover the workflow). - */ -import { Effect } from "effect"; - -import { mcpToolDescriptors } from "./tool-manifest.ts"; -import * as WorkspaceTools from "../ai/workspace-tools.ts"; - -/** Protocol versions we speak, newest first (used to pick the negotiated version). */ -export const SUPPORTED_PROTOCOL_VERSIONS = ["2025-06-18", "2025-03-26"] as const; -const LATEST_PROTOCOL_VERSION = SUPPORTED_PROTOCOL_VERSIONS[0]; - -/** Server identity advertised in the `initialize` result. */ -const SERVER_INFO = { name: "voidhash-paywall-workspace", version: "1.0.0" } as const; - -/** Standard JSON-RPC 2.0 error codes we emit. */ -export const JsonRpcErrorCode = { - ParseError: -32700, - InvalidRequest: -32600, - MethodNotFound: -32601, - InvalidParams: -32602, - InternalError: -32603, -} as const; - -/** A JSON-RPC id (string or number, or null on a pre-id parse error). */ -export type JsonRpcId = string | number | null; - -/** A parsed JSON-RPC request/notification (validated by {@link parseJsonRpcMessage}). */ -export interface JsonRpcMessage { - readonly jsonrpc: "2.0"; - readonly method: string; - readonly id?: JsonRpcId; - readonly params?: Record; -} - -/** A JSON-RPC success/error response object to serialize back to the client. */ -export type JsonRpcResponse = - | { jsonrpc: "2.0"; id: JsonRpcId; result: unknown } - | { jsonrpc: "2.0"; id: JsonRpcId; error: { code: number; message: string; data?: unknown } }; - -/** Executes a validated tool call against the authenticated scope. */ -export type CallTool = ( - name: string, - args: unknown, -) => Effect.Effect; - -const success = (id: JsonRpcId, result: unknown): JsonRpcResponse => ({ - jsonrpc: "2.0", - id, - result, -}); - -const failure = ( - id: JsonRpcId, - code: number, - message: string, - data?: unknown, -): JsonRpcResponse => ({ jsonrpc: "2.0", id, error: data === undefined ? { code, message } : { code, message, data } }); - -/** - * Validate an already-JSON-parsed value as a JSON-RPC 2.0 message. Returns the - * narrowed message or an `InvalidRequest` reason (missing `jsonrpc`/`method`). - * A notification is a message with no `id`. - */ -export const parseJsonRpcMessage = ( - value: unknown, -): { ok: true; message: JsonRpcMessage } | { ok: false; reason: string } => { - if (value === null || typeof value !== "object" || Array.isArray(value)) { - // Batches (arrays) are not supported by this stateless single-response server. - return { ok: false, reason: "Expected a single JSON-RPC 2.0 request object" }; - } - const record = value as Record; - if (record.jsonrpc !== "2.0") { - return { ok: false, reason: 'Missing or invalid "jsonrpc": expected "2.0"' }; - } - if (typeof record.method !== "string") { - return { ok: false, reason: 'Missing or invalid "method"' }; - } - const id = record.id; - if (id !== undefined && id !== null && typeof id !== "string" && typeof id !== "number") { - return { ok: false, reason: 'Invalid "id": expected string, number, or null' }; - } - const params = - record.params !== undefined && typeof record.params === "object" && record.params !== null - ? (record.params as Record) - : undefined; - return { - ok: true, - message: { jsonrpc: "2.0", method: record.method, id: id as JsonRpcId, params }, - }; -}; - -/** Negotiate a protocol version: echo a supported one, else offer our latest. */ -const negotiateProtocolVersion = (requested: unknown): string => - typeof requested === "string" && - (SUPPORTED_PROTOCOL_VERSIONS as ReadonlyArray).includes(requested) - ? requested - : LATEST_PROTOCOL_VERSION; - -/** The `initialize` result: negotiated version, tool capability, server identity. */ -const initializeResult = (params: Record | undefined) => ({ - protocolVersion: negotiateProtocolVersion(params?.protocolVersion), - capabilities: { tools: {} }, - serverInfo: SERVER_INFO, -}); - -/** The `tools/list` result: the advertised tool descriptors. */ -const toolsListResult = () => ({ tools: mcpToolDescriptors() }); - -/** - * Handle a `tools/call`: read `name` + `arguments`, run the executor, and shape - * the result as MCP content. A tool failure is `isError: true` content (never a - * JSON-RPC error). A missing/invalid `name` is `InvalidParams` (a protocol - * error, not a tool error). The executor never fails — the shared core folds - * workspace failures into `{ isError }` — so this always resolves to a response. - */ -const handleToolsCall = ( - id: JsonRpcId, - params: Record | undefined, - callTool: CallTool, -): Effect.Effect => { - const name = params?.name; - if (typeof name !== "string" || name.length === 0) { - return Effect.succeed( - failure(id, JsonRpcErrorCode.InvalidParams, 'tools/call requires a string "name"'), - ); - } - const args = params?.arguments ?? {}; - return callTool(name, args).pipe( - Effect.map((result) => - success(id, { - content: [{ type: "text", text: result.output }], - isError: result.isError, - }), - ), - ); -}; - -/** - * Dispatch one parsed JSON-RPC message. Returns a {@link JsonRpcResponse} to - * serialize, or `null` for an accepted notification (`notifications/*`, id-less) - * — the route answers those with HTTP 202 and an empty body. - * - * `callTool` runs a tool against the authenticated project scope; it (and this - * effect) require the workspace/chat/auth context, provided by the route. - */ -export const handleMcpMessage = ( - message: JsonRpcMessage, - callTool: CallTool, -): Effect.Effect => { - const id: JsonRpcId = message.id ?? null; - - switch (message.method) { - case "initialize": - return Effect.succeed(success(id, initializeResult(message.params))); - - case "notifications/initialized": - // Accepted notification: no response body (the route sends 202). - return Effect.succeed(null); - - case "ping": - // MCP ping → empty result. - return Effect.succeed(success(id, {})); - - case "tools/list": - return Effect.succeed(success(id, toolsListResult())); - - case "tools/call": - return handleToolsCall(id, message.params, callTool); - - default: { - // Any other `notifications/*` is an id-less message we accept silently. - if (message.method.startsWith("notifications/") && message.id === undefined) { - return Effect.succeed(null); - } - return Effect.succeed( - failure(id, JsonRpcErrorCode.MethodNotFound, `Method not found: ${message.method}`), - ); - } - } -}; diff --git a/apps/backend/src/routes/mcp.test.ts b/apps/backend/src/routes/mcp.test.ts new file mode 100644 index 000000000..a95795165 --- /dev/null +++ b/apps/backend/src/routes/mcp.test.ts @@ -0,0 +1,197 @@ +import { ApiKeyNotFoundError } from "@voidhash/core/domain/apiKey/ApiKey"; +import { AuthSession } from "@voidhash/core/domain/auth/Auth"; +import { ApiKeyService, LocalUserSessionService } from "@voidhash/core/services"; +import { Db } from "@voidhash/db"; +import { AuthMiddleware } from "@voidhash/rpc"; +import { Effect, Layer, Schema } from "effect"; +import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"; +import { Rpc, RpcGroup } from "effect/unstable/rpc"; +import { describe, expect, it } from "vite-plus/test"; + +import { McpRouteLayer } from "./mcp.ts"; + +const TestRpcs = RpcGroup.make( + Rpc.make("ListWidgets", { + payload: { projectId: Schema.String }, + success: Schema.Array(Schema.Struct({ id: Schema.String })), + }), +).middleware(AuthMiddleware); + +const TestHandlers = TestRpcs.toLayer({ + ListWidgets: ({ projectId }) => + AuthSession.use((session) => + Effect.succeed([{ id: `${session.projects[0]?.id ?? "none"}:${projectId}` }]), + ), +}); + +const TestApiKeys = Layer.succeed(ApiKeyService, { + validateUserApiKey: (token: string) => + token === "valid-user-key" + ? Effect.succeed({ user: { id: "user-from-key" } } as never) + : Effect.fail(new ApiKeyNotFoundError({})), + validateSecretKey: (token: string) => + token === "valid-project-key" + ? Effect.succeed({ + project: { + id: "project-from-key", + name: "MCP test", + organizationId: "org-test", + slug: "mcp-test", + }, + } as never) + : Effect.fail(new ApiKeyNotFoundError({})), +} as unknown as ApiKeyService["Service"]); + +const TestLocalSessions = Layer.succeed(LocalUserSessionService, { + loadUserAccess: () => Effect.succeed({} as never), + toUserSession: () => ({ + cookie: null, + method: "user", + name: "MCP user", + organizations: [], + person: null, + projects: [ + { + id: "project-from-user", + logo: null, + name: "MCP user project", + organizationId: "org-test", + permissions: ["project:all"], + slug: "mcp-user-project", + }, + ], + user: { + createdAt: new Date(0), + email: "mcp@example.test", + emailVerified: true, + id: "user-from-key", + image: null, + name: "MCP user", + role: null, + updatedAt: new Date(0), + workosUserId: null, + }, + }), +} as unknown as LocalUserSessionService["Service"]); + +interface McpRequest { + readonly body: unknown; + readonly token?: string; +} + +const requestFrom = ({ body, token }: McpRequest) => { + const headers = new Headers({ "content-type": "application/json" }); + if (token !== undefined) { + headers.set("authorization", `Bearer ${token}`); + } + return HttpServerRequest.fromWeb( + new Request("http://localhost/api/mcp", { + method: "POST", + headers, + body: JSON.stringify(body), + }), + ); +}; + +const serve = (requests: ReadonlyArray) => + Effect.gen(function* () { + const handler = yield* HttpRouter.toHttpEffect( + McpRouteLayer(TestRpcs).pipe( + Layer.provide(TestHandlers), + Layer.provide(TestApiKeys), + Layer.provide(TestLocalSessions), + ), + ); + const responses: Response[] = []; + for (const request of requests) { + const response = yield* handler.pipe( + Effect.provideService(HttpServerRequest.HttpServerRequest, requestFrom(request)), + ); + responses.push(HttpServerResponse.toWeb(response)); + } + return responses; + }).pipe(Effect.provideService(Db, {} as Db["Service"]), Effect.scoped, Effect.runPromise); + +describe("POST /api/mcp", () => { + it("rejects missing and invalid bearer credentials", async () => { + const [missing, invalid] = await serve([ + { body: { jsonrpc: "2.0", id: 1, method: "ping" } }, + { body: { jsonrpc: "2.0", id: 1, method: "ping" }, token: "invalid" }, + ]); + expect(missing.status).toBe(401); + expect(missing.headers.get("www-authenticate")).toContain("Bearer"); + expect(invalid.status).toBe(401); + }); + + it("initializes and calls a platform operation without isolate-local session state", async () => { + const [initialized, called] = await serve([ + { + token: "valid-project-key", + body: { + jsonrpc: "2.0", + id: 1, + method: "initialize", + params: { + protocolVersion: "2025-06-18", + capabilities: {}, + clientInfo: { name: "test", version: "1" }, + }, + }, + }, + { + token: "valid-project-key", + body: { + jsonrpc: "2.0", + id: 2, + method: "tools/call", + params: { + name: "platform_call", + arguments: { + operation: "list_widgets", + input: { projectId: "project-from-input" }, + }, + }, + }, + }, + ]); + expect(initialized.status).toBe(200); + expect(initialized.headers.get("mcp-session-id")).toBeNull(); + expect(await initialized.json()).toMatchObject({ + result: { capabilities: { tools: {}, resources: {} } }, + }); + + expect(called.status).toBe(200); + expect(await called.json()).toMatchObject({ + result: { + isError: false, + structuredContent: [{ id: "project-from-key:project-from-input" }], + }, + }); + }); + + it("accepts user API keys and materializes their normal project access", async () => { + const [called] = await serve([ + { + token: "valid-user-key", + body: { + jsonrpc: "2.0", + id: 1, + method: "tools/call", + params: { + name: "platform_call", + arguments: { + operation: "list_widgets", + input: { projectId: "project-from-input" }, + }, + }, + }, + }, + ]); + expect(await called.json()).toMatchObject({ + result: { + isError: false, + structuredContent: [{ id: "project-from-user:project-from-input" }], + }, + }); + }); +}); diff --git a/apps/backend/src/routes/mcp.ts b/apps/backend/src/routes/mcp.ts index e02966a2a..30cc0abe4 100644 --- a/apps/backend/src/routes/mcp.ts +++ b/apps/backend/src/routes/mcp.ts @@ -1,80 +1,53 @@ /** - * Model Context Protocol endpoint — `POST /api/mcp` (streamable HTTP, STATELESS). + * Authenticated, stateless streamable-HTTP MCP endpoint for Cloudflare Workers. * - * Exposes the paywall workspace to MCP clients (Claude Code, the voidhash CLI) - * as JSON-RPC 2.0 `tools/*`: the stateless, document-first workspace tools - * (`list_paywalls`, `get_paywall`, `get_components`, `read_component`, - * `edit_paywall`, `write_component`, `rename_component`, `delete_component`) - * from the shared tool core (`ai/workspace-tools.ts`). - * - * **Auth (v1 API keys → service authz).** The endpoint authenticates with - * `Authorization: Bearer `, validated by the SAME - * {@link ApiKeyService.validateSecretKey} the v1 API's `x-secret-key` path uses. - * A voidhash secret key is already PROJECT-scoped, so the key alone determines - * the workspace scope — MCP tools take no `projectId` argument. From the - * validated `{ project }` we construct the exact {@link SecretKeySession} the v1 - * middleware builds for a secret key (a real {@link AuthSession} with that one - * project), and provide it for the request. {@link PaywallWorkspaceService} then - * runs its normal `PaywallService.getPaywalls(projectId)` project-membership - * check against that session — no fake super-session, no authz bypass; the same - * seam every v1 secret-key handler already uses. - * - * **Stateless transport.** Each POST is answered with a single JSON response (or - * 202 for a notification). No SSE stream, no session ids, no server-initiated - * messages — spec-compliant for a stateless streamable-HTTP server and what - * Claude Code's client accepts. `GET`/`DELETE` on the endpoint → 405 (there is - * no stream to open and no session to terminate). Malformed JSON → 400; missing - * or invalid bearer → 401 with a `WWW-Authenticate` header. - * - * Resources are not offered this increment (tools cover the workflow) — see the - * architecture doc §3.6 follow-ups. + * Effect MCP owns the tool/resource registry, schemas, and result model. The + * HTTP adapter is intentionally stateless because isolate-local MCP session + * maps cannot provide affinity across Cloudflare Worker requests. */ -import { ApiKeyService, PaywallWorkspaceService } from "@voidhash/core/services"; import { AuthSession } from "@voidhash/core/domain/auth/Auth"; +import { ApiKeyService, LocalUserSessionService } from "@voidhash/core/services"; import type { SecretKeySession } from "@voidhash/rpc"; -import { Cause, Effect, Layer, Result } from "effect"; -import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"; +import { Cause, Context, Effect, Layer, Result } from "effect"; +import { McpSchema, McpServer } from "effect/unstable/ai"; import * as HttpHeaders from "effect/unstable/http/Headers"; +import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"; +import type { Rpc } from "effect/unstable/rpc"; +import type { RpcGroup } from "effect/unstable/rpc/RpcGroup"; +import * as WorkspaceTools from "../ai/workspace-tools.ts"; import { - handleMcpMessage, - parseJsonRpcMessage, + handleStatelessMcpMessage, JsonRpcErrorCode, - type CallTool, - type JsonRpcResponse, -} from "../mcp/protocol.ts"; -import { findMcpTool } from "../mcp/tool-manifest.ts"; -import type { WorkspaceToolScope } from "../ai/workspace-tools.ts"; + parseJsonRpcMessage, + SUPPORTED_PROTOCOL_VERSIONS, +} from "../mcp/cloudflare-http.ts"; +import { + makePlatformOperations, + type PlatformOperation, + platformOperationDescriptors, + registerPlatformTools, +} from "../mcp/platform-tools.ts"; +import { MCP_TOOLS } from "../mcp/tool-manifest.ts"; -/** `WWW-Authenticate` challenge returned on any 401 (bearer scheme). */ const WWW_AUTHENTICATE = 'Bearer realm="voidhash-mcp"'; +const SERVER_INFO = { name: "voidhash", version: "1.0.0" } as const; -/** A bare JSON-RPC error response (used for pre-dispatch failures with a null id). */ const jsonRpcErrorResponse = (status: number, code: number, message: string) => HttpServerResponse.json({ jsonrpc: "2.0", id: null, error: { code, message } }, { status }); -/** Extract a `Bearer ` credential from the `authorization` header. */ const bearerToken = (headers: HttpHeaders.Headers): string | undefined => { const raw = HttpHeaders.get(headers, "authorization"); const value = raw._tag === "Some" ? raw.value : undefined; - if (value === undefined) { - return undefined; - } - const match = /^Bearer\s+(.+)$/i.exec(value.trim()); - return match ? match[1].trim() : undefined; + const match = value === undefined ? null : /^Bearer\s+(.+)$/i.exec(value.trim()); + return match?.[1]?.trim(); }; -/** - * Construct the {@link SecretKeySession} for a validated project — byte-for-byte - * the shape the v1 `authenticateSecretKey` middleware builds — so the workspace - * service's project-membership authz sees a genuine single-project secret-key - * session. - */ const secretKeySessionForProject = (project: { - id: string; - name: string; - organizationId: string; - slug: string; + readonly id: string; + readonly name: string; + readonly organizationId: string; + readonly slug: string; }): SecretKeySession => ({ cookie: null, method: "secret-key", @@ -94,132 +67,236 @@ const secretKeySessionForProject = (project: { user: null, }); -/** - * The dispatcher passed to {@link handleMcpMessage}: look up the tool by name, - * run it against the authenticated `scope` — an unknown tool folds to an - * `isError` tool result (MCP maps a bad tool name to a tool error, not a - * JSON-RPC error, so a client retry loop can recover). - */ -const makeCallTool = - (scope: WorkspaceToolScope): CallTool => - (name, args) => { - const tool = findMcpTool(name); - if (tool === undefined) { - return Effect.succeed({ output: `Unknown tool: ${name}`, isError: true }); +const authenticateBearer = ( + token: string, + apiKeys: ApiKeyService["Service"], + localSessions: LocalUserSessionService["Service"], +) => + Effect.gen(function* () { + const userKey = yield* Effect.result(apiKeys.validateUserApiKey(token)); + if (Result.isSuccess(userKey)) { + const access = yield* localSessions.loadUserAccess(userKey.success.user.id); + return localSessions.toUserSession(userKey.success.user, access, null, null); } - return tool.dispatch(scope, args); - }; - -/** Handle a single stateless `POST /api/mcp` JSON-RPC message. */ -const handlePost = Effect.gen(function* () { - const request = yield* HttpServerRequest.HttpServerRequest; - - // 1. Auth: require a Bearer secret key, validate it via the shared service. - const token = bearerToken(request.headers); - if (token === undefined) { - return HttpServerResponse.setHeader( - yield* jsonRpcErrorResponse( - 401, - JsonRpcErrorCode.InvalidRequest, - "Missing bearer token", - ), - "www-authenticate", - WWW_AUTHENTICATE, - ); - } - - const apiKeys = yield* ApiKeyService; - const validated = yield* Effect.result(apiKeys.validateSecretKey(token)); - if (Result.isFailure(validated)) { - return HttpServerResponse.setHeader( - yield* jsonRpcErrorResponse( - 401, - JsonRpcErrorCode.InvalidRequest, - "Invalid or expired API key", - ), - "www-authenticate", - WWW_AUTHENTICATE, - ); - } - const record = validated.success; - const session = secretKeySessionForProject({ - id: record.project.id, - name: record.project.name, - organizationId: record.project.organizationId, - slug: record.project.slug, + + const projectKey = yield* Effect.result(apiKeys.validateSecretKey(token)); + if (Result.isFailure(projectKey)) { + return undefined; + } + return secretKeySessionForProject(projectKey.success.project); }); - // MCP is stateless and document-first — every tool reads/edits the LIVE - // document directly, so the scope is just the authenticated project. - const scope: WorkspaceToolScope = { projectId: record.project.id }; - - // 2. Parse the JSON body → JSON-RPC message. - const rawBody = yield* request.text; - const parsedJson = yield* Effect.result( - Effect.try({ - try: () => JSON.parse(rawBody) as unknown, - catch: () => new Error("Invalid JSON"), - }), + +const workspaceToolResult = (result: WorkspaceTools.WorkspaceToolResult) => + new McpSchema.CallToolResult({ + content: [{ type: "text", text: result.output }], + structuredContent: { output: result.output }, + isError: result.isError, + }); + +const registerWorkspaceTools = (server: McpServer.McpServer["Service"]): Effect.Effect => + Effect.forEach( + MCP_TOOLS, + (tool) => + server.addTool({ + tool: new McpSchema.Tool({ + ...tool.descriptor, + annotations: { + readOnlyHint: [ + "list_paywalls", + "get_paywall", + "get_components", + "read_component", + ].includes(tool.descriptor.name), + destructiveHint: tool.descriptor.name === "delete_component", + idempotentHint: tool.descriptor.name !== "edit_paywall", + openWorldHint: false, + }, + }), + annotations: Context.empty(), + // The low-level Effect MCP registry types handlers as McpServerClient- + // only. AuthSession and workspace services are supplied by this route's + // authenticated request context. + handle: ((args: unknown) => + Effect.gen(function* () { + const session = yield* AuthSession; + const projectId = session.projects[0]?.id; + if (projectId === undefined) { + return workspaceToolResult({ + output: "This tool requires access to a project.", + isError: true, + }); + } + return workspaceToolResult(yield* tool.dispatch({ projectId }, args)); + })) as never, + }), + { discard: true }, ); - if (Result.isFailure(parsedJson)) { - return yield* jsonRpcErrorResponse( - 400, - JsonRpcErrorCode.ParseError, - "Parse error: request body is not valid JSON", - ); - } - - const parsed = parseJsonRpcMessage(parsedJson.success); - if (!parsed.ok) { - return yield* jsonRpcErrorResponse(400, JsonRpcErrorCode.InvalidRequest, parsed.reason); - } - - // 3. Dispatch the message against the authenticated project scope. The tool - // effects are AuthSession-bound; provide the constructed secret-key session. - const response: JsonRpcResponse | null = yield* handleMcpMessage( - parsed.message, - makeCallTool(scope), - ).pipe(Effect.provideService(AuthSession, session)); - - // A notification (no response) is answered with 202 + empty body. - if (response === null) { - return HttpServerResponse.empty({ status: 202 }); - } - return yield* HttpServerResponse.json(response); -}); -/** `GET`/`DELETE` on the stateless endpoint: no stream, no session → 405. */ -const methodNotAllowed = HttpServerResponse.json( - { jsonrpc: "2.0", id: null, error: { code: JsonRpcErrorCode.InvalidRequest, message: "Method Not Allowed" } }, - { status: 405, headers: { allow: "POST" } }, -); - -const registerMcpRoute = Effect.gen(function* () { - const router = yield* HttpRouter.HttpRouter; - yield* router.add( - "POST", - "/api/mcp", - handlePost.pipe( - Effect.catchCause((cause) => +const registerResources = ( + server: McpServer.McpServer["Service"], + operations: ReadonlyArray, +): Effect.Effect => + Effect.gen(function* () { + const catalogUri = "voidhash://platform/operations"; + yield* server.addResource({ + resource: new McpSchema.Resource({ + uri: catalogUri, + name: "Platform operations", + description: + "Every typed platform operation accepted by platform_call, including its exact input JSON Schema.", + mimeType: "application/json", + }), + annotations: Context.empty(), + handle: Effect.succeed({ + contents: [ + { + uri: catalogUri, + mimeType: "application/json", + text: JSON.stringify({ operations: platformOperationDescriptors(operations) }, null, 2), + }, + ], + }), + }); + + yield* server.addResourceTemplate({ + template: new McpSchema.ResourceTemplate({ + uriTemplate: "voidhash://paywalls/{slug}", + name: "Live paywall document", + description: "A live paywall document tree addressed by its project-local slug.", + mimeType: "application/json", + }), + routerPath: "voidhash:://paywalls/:0", + completions: {}, + annotations: Context.empty(), + // Like tool calls, template reads resolve auth/workspace services from the + // current request rather than capturing one user's session at startup. + handle: ((uri: string, params: Array) => Effect.gen(function* () { - yield* Effect.logError(`MCP request error: ${Cause.pretty(cause)}`); + const session = yield* AuthSession; + const projectId = session.projects[0]?.id; + if (projectId === undefined) { + return yield* Effect.die(new Error("This resource requires access to a project")); + } + const result = yield* WorkspaceTools.getPaywall({ projectId }, { slug: params[0] }); + if (result.isError) { + return yield* Effect.die(new Error(result.output)); + } + return { contents: [{ uri, mimeType: "application/json", text: result.output }] }; + })) as never, + }); + }); + +/** Creates the Effect MCP route layer for all RPCs in the composed platform. */ +export const McpRouteLayer = (group: RpcGroup) => + Layer.effectDiscard( + Effect.gen(function* () { + const router = yield* HttpRouter.HttpRouter; + const server = yield* McpServer.McpServer; + const apiKeys = yield* ApiKeyService; + const localSessions = yield* LocalUserSessionService; + const operations = yield* makePlatformOperations(group); + + yield* registerWorkspaceTools(server); + yield* registerPlatformTools(server, operations); + yield* registerResources(server, operations); + + const handlePost = Effect.gen(function* () { + const request = yield* HttpServerRequest.HttpServerRequest; + const token = bearerToken(request.headers); + if (token === undefined) { + return HttpServerResponse.setHeader( + yield* jsonRpcErrorResponse( + 401, + JsonRpcErrorCode.InvalidRequest, + "Missing bearer token", + ), + "www-authenticate", + WWW_AUTHENTICATE, + ); + } + + const authenticated = yield* Effect.result( + authenticateBearer(token, apiKeys, localSessions), + ); + if (Result.isFailure(authenticated) || authenticated.success === undefined) { + return HttpServerResponse.setHeader( + yield* jsonRpcErrorResponse( + 401, + JsonRpcErrorCode.InvalidRequest, + "Invalid or expired API key", + ), + "www-authenticate", + WWW_AUTHENTICATE, + ); + } + + const rawBody = yield* request.text; + const parsedJson = yield* Effect.result( + Effect.try({ + try: () => JSON.parse(rawBody) as unknown, + catch: () => new Error("Invalid JSON"), + }), + ); + if (Result.isFailure(parsedJson)) { return yield* jsonRpcErrorResponse( - 500, - JsonRpcErrorCode.InternalError, - "Internal error", + 400, + JsonRpcErrorCode.ParseError, + "Parse error: request body is not valid JSON", ); - }), - ), - ), - ); - yield* router.add("GET", "/api/mcp", methodNotAllowed); - yield* router.add("DELETE", "/api/mcp", methodNotAllowed); -}); + } -/** - * Registers `POST /api/mcp` (+ 405 on GET/DELETE). The request-scoped - * requirements — {@link ApiKeyService}, {@link PaywallWorkspaceService}, `Db` - * (for key validation) — are satisfied via `HttpRouter.provideRequest` by the - * caller (`BackendApp`), mirroring the AI chat and webhook routes. `AuthSession` - * is provided in-handler from the validated secret key. - */ -export const McpRouteLayer = Layer.effectDiscard(registerMcpRoute); + const parsed = parseJsonRpcMessage(parsedJson.success); + if (!parsed.ok) { + return yield* jsonRpcErrorResponse(400, JsonRpcErrorCode.InvalidRequest, parsed.reason); + } + + const response = yield* handleStatelessMcpMessage(server, parsed.message, SERVER_INFO).pipe( + Effect.provideService(AuthSession, authenticated.success), + ); + if (response === null) { + return HttpServerResponse.empty({ status: 202 }); + } + + const protocolVersion = + typeof parsed.message.params?.protocolVersion === "string" && + (SUPPORTED_PROTOCOL_VERSIONS as ReadonlyArray).includes( + parsed.message.params.protocolVersion, + ) + ? parsed.message.params.protocolVersion + : SUPPORTED_PROTOCOL_VERSIONS[0]; + return HttpServerResponse.setHeader( + yield* HttpServerResponse.json(response), + "mcp-protocol-version", + protocolVersion, + ); + }); + + const methodNotAllowed = HttpServerResponse.json( + { + jsonrpc: "2.0", + id: null, + error: { code: JsonRpcErrorCode.InvalidRequest, message: "Method Not Allowed" }, + }, + { status: 405, headers: { allow: "POST" } }, + ); + + yield* router.add( + "POST", + "/api/mcp", + handlePost.pipe( + Effect.catchCause((cause) => + Effect.gen(function* () { + yield* Effect.logError(`MCP request error: ${Cause.pretty(cause)}`); + return yield* jsonRpcErrorResponse( + 500, + JsonRpcErrorCode.InternalError, + "Internal error", + ); + }), + ), + ), + ); + yield* router.add("GET", "/api/mcp", methodNotAllowed); + yield* router.add("DELETE", "/api/mcp", methodNotAllowed); + }), + ).pipe(Layer.provide(McpServer.McpServer.layer)); diff --git a/docs/security/backend-threat-model.md b/docs/security/backend-threat-model.md index d4263682e..5956964a8 100644 --- a/docs/security/backend-threat-model.md +++ b/docs/security/backend-threat-model.md @@ -73,8 +73,10 @@ Current controls: is loaded from server-side membership state. - Project secret keys establish a session containing only their project. Publishable keys establish an SDK identity with no management permissions. -- MCP accepts only a Bearer project secret key and constructs the same - project-scoped session used by the HTTP API. +- MCP accepts a Bearer user API key or project secret key. User keys materialize + normal membership access; project keys construct the same single-project + session used by the HTTP API. Every operation still runs through the existing + service authorization checks. - Authentication failures collapse to non-authenticated or generic internal errors rather than returning stored credential details. diff --git a/docs/security/endpoint-authorization-matrix.md b/docs/security/endpoint-authorization-matrix.md index 494cb91bb..4e344e62a 100644 --- a/docs/security/endpoint-authorization-matrix.md +++ b/docs/security/endpoint-authorization-matrix.md @@ -100,7 +100,7 @@ database-backed cross-tenant case. “Gap” is a publication blocker. | Surface | Principal or capability | Authorization/authenticity boundary | Status | | --- | --- | --- | --- | | `/rpc` | User API key, project secret key, or WorkOS session | Shared auth middleware creates the session consumed by every RPC. | Covered by RPC smoke and service evidence above | -| `/api/mcp` | Bearer project secret key | Key lookup creates a single-project session; workspace services re-check the requested paywall/project. | Route/protocol tests plus integrated cross-project workspace evidence | +| `/api/mcp` | Bearer user API key or project secret key | Effect MCP exposes focused authoring tools plus every composed platform RPC through the typed operation gateway. User keys materialize the user's normal access; project keys create a single-project session. The existing services re-check project/organization/entity ownership for every call. | Stateless route/protocol tests, complete RPC-catalog contract tests, and the integrated authorization evidence above | | `/api/ai/chat` | Authenticated user/session token | Token verifier and project-scoped chat/workspace services. | Route/tool tests plus integrated chat/workspace evidence | | `/i/v1/capture`, `/i/v1/batch` | Publishable project token | Token resolves the project; processing rejects route/project mismatch and reserved events. | Integrated | | Stripe webhook | Provider signature over exact raw body and timestamp | Configuration lookup is tied to the route ID; ledger IDs deduplicate. | Integrated |