From 82d7470a023f7cf7518a1dfe5d4f501be800db3b Mon Sep 17 00:00:00 2001 From: Pete Koomen Date: Thu, 23 Jul 2026 21:49:44 -0700 Subject: [PATCH] Expose the publish API as a remote MCP endpoint at /mcp MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude Code (or any MCP client) can now publish and manage Scratchwork projects with no CLI installed: `claude mcp add --transport http scratchwork /mcp`, authenticate via /mcp (the OAuth surface from the previous commit), and use tools backed by the same contract endpoints the CLI calls. - api-routes.ts: extract invokeEndpoint — the transport-independent endpoint pipeline (bearer requirement, project read gate with its existence mask, strict payload decode, registered handler). runRoute delegates to it, byte-for-byte behavior-preserving, so MCP tools inherit route policy instead of reimplementing it (invariant 4). - mcp/jsonrpc.ts + mcp/transport.ts: a deliberately stateless MCP streamable-HTTP transport — single JSON responses, no Mcp-Session-Id, every POST self-contained — because Lambda/Workers instances share no memory; hand-rolled over @effect/ai's session-oriented McpServer (rationale in the module header). Bearer-only via requireMcpUser after the standard cross-origin gate; the 401 carries the WWW-Authenticate resource-metadata pointer that starts a client's OAuth flow. - mcp/tools.ts: seven tools at CLI-operation altitude (publish, list_projects, project_info, share_project, unpublish_project, delete_project, whoami), each wrapping one contract endpoint; publish takes {path, content|contentBase64} files and feeds the existing shared bundle decode and publish caps. tools/list schemas are derived from the same Effect Schemas that validate calls. - Tests: framing/negotiation/statelessness suite plus a policy-parity matrix deriving every tool's expected outcome from API_POLICY; e2e gains a full spec-client loop (401 challenge → discovery → DCR → authorize/consent → token → refresh → stateless tool publishes with served-byte verification) on all three lanes — local Bun, miniflare, LocalStack — since serverless statelessness is the claim under test. - README: "Connect from Claude Code" section. Co-Authored-By: Claude Fable 5 --- README.md | 12 + e2e/src/suite.ts | 176 ++++++++++++++ server/core/src/api-routes.ts | 129 ++++++++--- server/core/src/app.ts | 5 + server/core/src/mcp/jsonrpc.ts | 44 ++++ server/core/src/mcp/tools.ts | 307 ++++++++++++++++++++++++ server/core/src/mcp/transport.ts | 222 ++++++++++++++++++ server/core/test/mcp.test.ts | 386 +++++++++++++++++++++++++++++++ 8 files changed, 1252 insertions(+), 29 deletions(-) create mode 100644 server/core/src/mcp/jsonrpc.ts create mode 100644 server/core/src/mcp/tools.ts create mode 100644 server/core/src/mcp/transport.ts create mode 100644 server/core/test/mcp.test.ts diff --git a/README.md b/README.md index f2de94f..99b00bc 100644 --- a/README.md +++ b/README.md @@ -112,6 +112,18 @@ scratchwork share --role write bob@example.com scratchwork revoke alice@example.com ``` +## Connect from Claude Code (MCP) + +Every Scratchwork server is also a remote [MCP](https://modelcontextprotocol.io) server, so agents can publish and manage projects with no CLI installed. In Claude Code: + +```sh +claude mcp add --transport http scratchwork https://app.your-domain.example/mcp +``` + +Then run `/mcp` inside Claude Code to authenticate — a browser opens, you sign in with the server's regular login, approve the connection, and the agent gets `publish`, `list_projects`, `project_info`, `share_project`, `unpublish_project`, `delete_project`, and `whoami` tools. The publish tool takes file contents directly (the agent reads your local files), and responses carry the live URL. + +The endpoint speaks stateless streamable HTTP with MCP-spec OAuth 2.1 (discovery, dynamic client registration, PKCE), so any conforming MCP client works the same way. If you are already logged in with the CLI, the stored bearer token from `~/.scratchwork/auth.json` also works: `claude mcp add --transport http scratchwork /mcp --header "Authorization: Bearer "`. + Deployments live as projects under `deploy/`, one per domain, each deployable with one command: ```sh diff --git a/e2e/src/suite.ts b/e2e/src/suite.ts index 89f535a..0706523 100644 --- a/e2e/src/suite.ts +++ b/e2e/src/suite.ts @@ -234,5 +234,181 @@ export function publishLoopSuite( publicSite.remove(); } }, 60_000); + + // ----------------------------------------------------------------------- + // MCP: the remote endpoint, driven exactly as a spec MCP client would — + // discovery from the 401 challenge, dynamic registration, the browser + // authorize + consent leg, the token exchange, then stateless JSON-RPC + // tool calls. Runs on every lane because statelessness across serverless + // instances is precisely the claim under test. + // ----------------------------------------------------------------------- + + let ownerMcpToken = ""; + + test("mcp: discovery, registration, and the OAuth loop mint a working access token", async () => { + // 1. An unauthenticated /mcp call carries the resource-metadata pointer. + const challenge = await rawFetch(`${context.appUrl}/mcp`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ jsonrpc: "2.0", id: 0, method: "initialize" }), + }); + expect(challenge.status).toBe(401); + const metadataUrl = /resource_metadata="([^"]+)"/.exec(challenge.headers.get("www-authenticate") ?? "")?.[1]; + expect(metadataUrl).toBeDefined(); + + // 2. Protected-resource metadata → authorization-server metadata. + const resourceMeta = await (await rawFetch(metadataUrl!)).json() as { + resource: string; authorization_servers: string[]; + }; + expect(resourceMeta.resource).toBe(`${context.appUrl}/mcp`); + const asMeta = await (await rawFetch( + `${resourceMeta.authorization_servers[0]}/.well-known/oauth-authorization-server`, + )).json() as { authorization_endpoint: string; token_endpoint: string; registration_endpoint: string }; + + // 3. Dynamic client registration. + const redirectUri = "http://127.0.0.1:39877/callback"; + const registerResponse = await rawFetch(asMeta.registration_endpoint, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + redirect_uris: [redirectUri], + client_name: "e2e MCP client", + token_endpoint_auth_method: "none", + }), + }); + expect(registerResponse.status).toBe(201); + const registration = await registerResponse.json() as { client_id: string }; + + // 4. Authorize: a browser session (auto-approving hermetic provider) + // lands on the consent page; approval redirects to the loopback with a + // one-time code. + const verifier = "e2e-mcp-verifier-e2e-mcp-verifier-e2e-mcp-v1"; + const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier)); + const challengeS256 = Buffer.from(digest).toString("base64url"); + provider.user = OWNER; + const mcpBrowser = new Browser(); + const authorizeUrl = new URL(asMeta.authorization_endpoint); + authorizeUrl.searchParams.set("client_id", registration.client_id); + authorizeUrl.searchParams.set("redirect_uri", redirectUri); + authorizeUrl.searchParams.set("response_type", "code"); + authorizeUrl.searchParams.set("code_challenge", challengeS256); + authorizeUrl.searchParams.set("code_challenge_method", "S256"); + authorizeUrl.searchParams.set("state", "e2e-mcp-state"); + authorizeUrl.searchParams.set("resource", `${context.appUrl}/mcp`); + const consentPage = await mcpBrowser.get(authorizeUrl.toString()); + expect(consentPage.status).toBe(200); + const txn = /name="txn" value="([^"]+)"/.exec(await consentPage.response.text())?.[1]; + expect(txn).toBeDefined(); + + const approved = await mcpBrowser.request(`${context.appUrl}/oauth/consent`, { + method: "POST", + headers: { "content-type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ txn: txn!, decision: "approve" }).toString(), + }); + expect(approved.status).toBe(302); + const callback = new URL(approved.headers.get("location") ?? ""); + expect(`${callback.origin}${callback.pathname}`).toBe(redirectUri); + expect(callback.searchParams.get("state")).toBe("e2e-mcp-state"); + const code = callback.searchParams.get("code"); + expect(code).toBeDefined(); + + // 5. Token exchange, then a refresh-grant round trip. + const tokenResponse = await rawFetch(asMeta.token_endpoint, { + method: "POST", + headers: { "content-type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ + grant_type: "authorization_code", + code: code!, + code_verifier: verifier, + redirect_uri: redirectUri, + client_id: registration.client_id, + resource: `${context.appUrl}/mcp`, + }).toString(), + }); + expect(tokenResponse.status).toBe(200); + const tokens = await tokenResponse.json() as { access_token: string; refresh_token: string; token_type: string }; + expect(tokens.token_type).toBe("Bearer"); + + const refreshed = await rawFetch(asMeta.token_endpoint, { + method: "POST", + headers: { "content-type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ + grant_type: "refresh_token", + refresh_token: tokens.refresh_token, + client_id: registration.client_id, + }).toString(), + }); + expect(refreshed.status).toBe(200); + ownerMcpToken = ((await refreshed.json()) as { access_token: string }).access_token; + }, 120_000); + + /** One stateless JSON-RPC call to /mcp. */ + const mcpCall = async (token: string, body: unknown): Promise<{ response: Response; body: any }> => { + const response = await rawFetch(`${context.appUrl}/mcp`, { + method: "POST", + headers: { authorization: `Bearer ${token}`, "content-type": "application/json" }, + body: JSON.stringify(body), + }); + return { response, body: response.status === 202 ? null : await response.json() }; + }; + + test("mcp: stateless tool calls publish and serve content on this lane", async () => { + expect(ownerMcpToken).not.toBe(""); + + // initialize never issues a session id — every later POST stands alone. + const initialized = await mcpCall(ownerMcpToken, { + jsonrpc: "2.0", id: 1, method: "initialize", + params: { protocolVersion: "2025-06-18", capabilities: {}, clientInfo: { name: "e2e", version: "0" } }, + }); + expect(initialized.response.headers.get("mcp-session-id")).toBeNull(); + expect(initialized.body.result.protocolVersion).toBe("2025-06-18"); + const note = await mcpCall(ownerMcpToken, { jsonrpc: "2.0", method: "notifications/initialized" }); + expect(note.response.status).toBe(202); + + const listing = await mcpCall(ownerMcpToken, { jsonrpc: "2.0", id: 2, method: "tools/list" }); + const toolNames = (listing.body.result.tools as Array<{ name: string }>).map((tool) => tool.name); + expect(toolNames).toContain("publish"); + expect(toolNames).toContain("share_project"); + + // Publish text + binary through the tool; verify the served bytes. + const binary = Buffer.from([0x00, 0x01, 0xfe, 0xff, 0x89, 0x50]); + const published = await mcpCall(ownerMcpToken, { + jsonrpc: "2.0", id: 3, method: "tools/call", + params: { + name: "publish", + arguments: { + project: `${project}-mcp`, + isPublic: true, + files: [ + { path: "index.html", content: "

mcp-marker

" }, + { path: "assets/blob.bin", contentBase64: binary.toString("base64") }, + ], + }, + }, + }); + expect(published.body.result.isError).toBeUndefined(); + expect(published.body.result.structuredContent.project).toBe(`${project}-mcp`); + + const page = await rawFetch(`${context.contentUrl}/${project}-mcp/`); + expect(page.status).toBe(200); + expect(await page.text()).toContain("mcp-marker"); + const blob = await rawFetch(`${context.contentUrl}/${project}-mcp/assets/blob.bin`); + expect(new Uint8Array(await blob.arrayBuffer())).toEqual(new Uint8Array(binary)); + + // Share + unpublish smoke through the tools. + const shared = await mcpCall(ownerMcpToken, { + jsonrpc: "2.0", id: 4, method: "tools/call", + params: { name: "share_project", arguments: { project: `${project}-mcp`, role: "read", add: [VIEWER.email] } }, + }); + expect(shared.body.result.isError).toBeUndefined(); + const unpublished = await mcpCall(ownerMcpToken, { + jsonrpc: "2.0", id: 5, method: "tools/call", + params: { name: "unpublish_project", arguments: { project: `${project}-mcp` } }, + }); + expect(unpublished.body.result.structuredContent.project.isPublic).toBe(false); + + const who = await mcpCall(ownerMcpToken, { jsonrpc: "2.0", id: 6, method: "tools/call", params: { name: "whoami", arguments: {} } }); + expect(who.body.result.structuredContent.user.email).toBe(OWNER.email); + }, 120_000); }); } diff --git a/server/core/src/api-routes.ts b/server/core/src/api-routes.ts index 554dcf6..ee30942 100644 --- a/server/core/src/api-routes.ts +++ b/server/core/src/api-routes.ts @@ -77,10 +77,10 @@ import { import { StorageError } from "./storage.ts"; /** Failures any API handler may raise. */ -type RouteError = HttpError | AuthError | SiteStoreError | StorageError; +export type RouteError = HttpError | AuthError | SiteStoreError | StorageError; /** Services available to every API handler. */ -type RouteServices = ServerConfig | SiteStore | Auth | PrimitiveDb; +export type RouteServices = ServerConfig | SiteStore | Auth | PrimitiveDb; /** What the policy middleware hands a handler: the request, its parsed URL, * the principal the declared auth mode resolved, for project routes the @@ -102,7 +102,7 @@ export interface ApiContext)(result).pipe( + Effect.mapError((cause) => new HttpError({ status: 500, message: "Could not encode the response", cause })), + ); + return jsonResponse(body, 200); + }); +} + +/** The transport-independent input to one endpoint invocation. `args` + * produces the endpoint's raw (parsed-JSON) arguments and is run only for + * endpoints that declare a payload — the HTTP dispatcher passes the + * size-capped body read, alternate transports pass their already-parsed + * arguments. */ +export interface EndpointInvocation { + readonly request: HttpServerRequest.HttpServerRequest; + readonly url: URL; + readonly user: AuthUser | null; + readonly project: string | null; + readonly args: Effect.Effect; +} + +/** + * Invokes one contract endpoint through its registered policy pipeline from + * an alternate transport (the MCP tools). This is the same code path the HTTP + * dispatcher runs after principal resolution — bearer requirement, project + * read gate with its existence mask, strict argument decode, registered + * handler — so no transport can reach a handler with weaker checks than the + * route declares. Transport-level body size caps stay with each transport; + * content-level caps (publish and share validation) run inside the handlers + * either way. + */ +export function invokeEndpoint( + name: Name, + input: EndpointInvocation, +): Effect.Effect, RouteError, RouteServices> { + const route = ROUTES.find((candidate) => candidate.name === name); + if (route == null) { + // Unreachable: ROUTES is derived from the same contract Name is keyed by. + return Effect.fail(new HttpError({ status: 500, message: `Unregistered endpoint: ${name}` })); + } + return invokeRegisteredRoute(route, input) as Effect.Effect, RouteError, RouteServices>; +} + +/** The shared endpoint pipeline: declared bearer requirement, project read + * gate (missing and forbidden both masked), strict payload decode, handler. */ +function invokeRegisteredRoute( + route: RegisteredRoute, + input: EndpointInvocation, +): Effect.Effect { + return Effect.gen(function* () { + const config = yield* ServerConfig; + if (route.auth === "bearer" && input.user == null) { + return yield* Effect.fail(new AuthError({ status: 401, message: "Authentication required" })); + } + let site: LoadedSite | null = null; - if (project != null) { + if (input.project != null) { const siteStore = yield* SiteStore; - site = yield* requireReadableSite(yield* siteStore.loadProject(project), user, config); + site = yield* requireReadableSite(yield* siteStore.loadProject(input.project), input.user, config); } const payload = Option.isSome(route.endpoint.payloadSchema) && route.payloadLimit != null - ? yield* readEndpointPayload(request, route.endpoint.payloadSchema.value, route.payloadLimit) + ? yield* decodeEndpointArgs(route.endpoint.payloadSchema.value, yield* input.args) : undefined; - const result = yield* route.handler({ request, url, user, site, payload: payload as never }); - const body = yield* Schema.encodeUnknown(route.endpoint.successSchema as Schema.Schema)(result).pipe( - Effect.mapError((cause) => new HttpError({ status: 500, message: "Could not encode the response", cause })), - ); - return jsonResponse(body, 200); + return yield* route.handler({ + request: input.request, + url: input.url, + user: input.user, + site, + payload: payload as never, + }); }); } -/** Reads, size-limits, parses, and strictly decodes one request body through - * the contract payload schema. Decoding is deliberately strict — every - * problem is reported and unknown fields are errors — so protocol drift - * surfaces as a clear 400 instead of being silently dropped. */ -function readEndpointPayload( +/** Reads, size-limits, and parses one JSON request body. */ +export function readJsonBody( request: HttpServerRequest.HttpServerRequest, - schema: Schema.Schema.Any, limit: PayloadLimit, ): Effect.Effect { return Effect.gen(function* () { @@ -502,20 +563,30 @@ function readEndpointPayload( if (new TextEncoder().encode(text).byteLength > limit.maxBytes) { return yield* Effect.fail(new HttpError({ status: 413, message: limit.message })); } - const parsed = yield* Schema.decodeUnknown(Schema.parseJson())(text).pipe( + return yield* Schema.decodeUnknown(Schema.parseJson())(text).pipe( Effect.mapError(() => new HttpError({ status: 400, message: "Invalid JSON body" })), ); - return yield* Schema.decodeUnknown(schema as Schema.Schema)(parsed, { - errors: "all", - onExcessProperty: "error", - }).pipe( - Effect.mapError((error) => - new HttpError({ status: 400, message: ParseResult.TreeFormatter.formatErrorSync(error) }), - ), - ); }); } +/** Strictly decodes already-parsed endpoint arguments through the contract + * payload schema. Decoding is deliberately strict — every problem is reported + * and unknown fields are errors — so protocol drift surfaces as a clear 400 + * instead of being silently dropped. */ +function decodeEndpointArgs( + schema: Schema.Schema.Any, + parsed: unknown, +): Effect.Effect { + return Schema.decodeUnknown(schema as Schema.Schema)(parsed, { + errors: "all", + onExcessProperty: "error", + }).pipe( + Effect.mapError((error) => + new HttpError({ status: 400, message: ParseResult.TreeFormatter.formatErrorSync(error) }), + ), + ); +} + /** Matches a registry path pattern against a request pathname. The decoded * :project segment is validated so malformed names become a 404 instead of * reaching the store as backend keys. Returns null on no match. */ diff --git a/server/core/src/app.ts b/server/core/src/app.ts index 7432e49..a243924 100644 --- a/server/core/src/app.ts +++ b/server/core/src/app.ts @@ -23,6 +23,7 @@ import { ServerConfig, type ServerConfigShape } from "./config.ts"; import { PrimitiveDb } from "./db.ts"; import { projectAccessCookie, projectAccessCookieValues } from "./cookies.ts"; import { dispatchMcpOauthRoute } from "./mcp-oauth-routes.ts"; +import { dispatchMcpRoute } from "./mcp/transport.ts"; import { acceptsHtmlPage, errorPageResponse, errorResponse } from "./error-pages.ts"; import { appBaseUrl, @@ -104,6 +105,10 @@ function handleRequest(request: HttpServerRequest.HttpServerRequest): AppEffect return yield* issueProjectAccess(request, url); } + if (url.pathname === "/mcp") { + return yield* dispatchMcpRoute(request, url); + } + const mcpOauthResponse = yield* dispatchMcpOauthRoute(request, url); if (mcpOauthResponse != null) return mcpOauthResponse; diff --git a/server/core/src/mcp/jsonrpc.ts b/server/core/src/mcp/jsonrpc.ts new file mode 100644 index 0000000..e7b90b1 --- /dev/null +++ b/server/core/src/mcp/jsonrpc.ts @@ -0,0 +1,44 @@ +/** + * JSON-RPC 2.0 framing for the /mcp endpoint: the request envelope schema and + * the response/error builders. Only the stateless subset MCP needs lives here + * — single requests and notifications; batches are deliberately rejected by + * the transport (the 2025-06-18 MCP revision removed them). + */ +import * as Schema from "effect/Schema"; + +/** JSON-RPC 2.0 error codes the transport emits. */ +export const PARSE_ERROR = -32700; +export const INVALID_REQUEST = -32600; +export const METHOD_NOT_FOUND = -32601; +export const INVALID_PARAMS = -32602; +export const INTERNAL_ERROR = -32603; + +/** A JSON-RPC request id. Null is legal on the wire (and required on the + * error response to an unparsable request). */ +export const JsonRpcIdSchema = Schema.Union(Schema.String, Schema.Number, Schema.Null); + +/** The decoded request id. */ +export type JsonRpcId = typeof JsonRpcIdSchema.Type; + +/** One JSON-RPC 2.0 request or notification (no `id` member). Decoded + * tolerantly — this is the one wire shape where unknown members are expected + * (`_meta`, future protocol fields) rather than protocol drift. */ +export const JsonRpcRequestSchema = Schema.Struct({ + jsonrpc: Schema.Literal("2.0"), + id: Schema.optional(JsonRpcIdSchema), + method: Schema.String, + params: Schema.optional(Schema.Unknown), +}); + +/** The decoded request envelope. */ +export type JsonRpcRequest = typeof JsonRpcRequestSchema.Type; + +/** Builds a JSON-RPC success response. */ +export function jsonRpcResult(id: JsonRpcId, result: unknown): unknown { + return { jsonrpc: "2.0", id, result }; +} + +/** Builds a JSON-RPC error response. */ +export function jsonRpcError(id: JsonRpcId, code: number, message: string): unknown { + return { jsonrpc: "2.0", id, error: { code, message } }; +} diff --git a/server/core/src/mcp/tools.ts b/server/core/src/mcp/tools.ts new file mode 100644 index 0000000..e3a56cc --- /dev/null +++ b/server/core/src/mcp/tools.ts @@ -0,0 +1,307 @@ +/** + * The MCP tool registry: every tool wraps exactly one contract endpoint at + * CLI-operation altitude and executes it through invokeEndpoint, so a tool's + * security policy IS the endpoint's API_POLICY entry — auth, minimum role, + * masking, and strict decoding are inherited, never reimplemented (invariant + * 4). Tool argument schemas are Effect Schemas; the JSON Schema advertised by + * tools/list is derived from the same object that validates arguments, so the + * two cannot drift. + * + * Deliberately deferred tools: `resolve` (agents rarely hold only a content + * URL; trivial to add through this registry later) and a clone/bundle tool + * (base64 bundles in tool results are token-hostile — revisit with MCP + * resources). + */ +import type * as HttpServerRequest from "@effect/platform/HttpServerRequest"; +import * as Data from "effect/Data"; +import * as Effect from "effect/Effect"; +import * as Encoding from "effect/Encoding"; +import * as JSONSchema from "effect/JSONSchema"; +import * as ParseResult from "effect/ParseResult"; +import * as Schema from "effect/Schema"; +import { + MeResponseSchema, + OkResponseSchema, + ProjectIdentifierSchema, + ProjectResponseSchema, + ProjectsListResponseSchema, + PublishResponseSchema, + ShareResponseSchema, + type ScratchworkEndpointName, +} from "@scratchwork/shared/publish/api"; +import { PUBLISH_BUNDLE_VERSION } from "@scratchwork/shared/publish/bundle"; +import { decodedBase64ByteLength } from "@scratchwork/shared/encoding/base64"; +import { isSafeSitePath } from "@scratchwork/shared/site/paths"; +import { invokeEndpoint, type RouteError, type RouteServices } from "../api-routes.ts"; +import type { AuthUser } from "../auth.ts"; +import { HttpError } from "../http.ts"; + +/** What the transport hands every tool execution: the authenticated /mcp + * principal plus the request/url the endpoint handlers derive origins from. */ +export interface McpToolContext { + readonly request: HttpServerRequest.HttpServerRequest; + readonly url: URL; + readonly user: AuthUser; +} + +/** One registered MCP tool. `endpoint` types the backing contract endpoint, + * so a tool over a nonexistent endpoint cannot compile; `execute` receives + * the tool's decoded arguments. */ +interface McpToolDef { + readonly name: string; + readonly title: string; + readonly description: string; + readonly endpoint: ScratchworkEndpointName; + readonly annotations: { + readonly readOnlyHint?: boolean; + readonly destructiveHint?: boolean; + readonly idempotentHint?: boolean; + }; + readonly argsSchema: Schema.Schema.Any; + readonly outputSchema: Schema.Schema.Any; + readonly execute: (args: never, context: McpToolContext) => Effect.Effect; +} + +/** One file in a publish tool call: text content for the common case, base64 + * for binary assets — exactly one of the two. */ +const McpPublishFileSchema = Schema.Struct({ + path: Schema.String.pipe( + Schema.filter((path) => isSafeSitePath(path) || "Invalid site path"), + Schema.annotations({ description: "Site-relative file path, like \"index.html\" or \"notes/day-1.md\"" }), + ), + content: Schema.optional(Schema.String.pipe( + Schema.annotations({ description: "UTF-8 text content of the file" }), + )), + contentBase64: Schema.optional(Schema.String.pipe( + Schema.filter((content) => decodedBase64ByteLength(content) != null || "Invalid base64 content"), + Schema.annotations({ description: "Base64-encoded binary content of the file" }), + )), +}).pipe( + Schema.filter((file) => + (file.content == null) !== (file.contentBase64 == null) || + "Provide exactly one of content or contentBase64", + ), +); + +/** Arguments of the publish tool. */ +const PublishArgsSchema = Schema.Struct({ + files: Schema.Array(McpPublishFileSchema).pipe( + Schema.minItems(1), + Schema.annotations({ + title: "files", + description: "Every file of the site — a publish replaces the project's whole file set", + }), + ), + project: Schema.optional(ProjectIdentifierSchema.pipe( + Schema.annotations({ description: "Project name. Omit on first publish to let the server assign one; pass an existing name to update it" }), + )), + openPath: Schema.optional(Schema.String.pipe( + Schema.annotations({ description: "Path the project's URL should open, like \"/\" or \"/report.html\"" }), + )), + isPublic: Schema.optional(Schema.Boolean.pipe( + Schema.annotations({ description: "true publishes publicly, false makes the project private; omitted keeps an existing project's setting (new projects start private)" }), + )), +}); + +/** A bare `{ project }` argument object. */ +const ProjectArgsSchema = Schema.Struct({ + project: ProjectIdentifierSchema.pipe(Schema.annotations({ description: "The project name" })), +}); + +/** Arguments of the share tool. */ +const ShareArgsSchema = Schema.Struct({ + project: ProjectIdentifierSchema.pipe(Schema.annotations({ description: "The project name" })), + role: Schema.optional(Schema.Literal("read", "write", "admin").pipe( + Schema.annotations({ description: "Role granted to the added targets (default read)" }), + )), + add: Schema.optional(Schema.Array(Schema.String).pipe( + Schema.annotations({ description: "Email addresses or @domain groups to grant access" }), + )), + remove: Schema.optional(Schema.Array(Schema.String).pipe( + Schema.annotations({ description: "Email addresses or @domain groups to revoke" }), + )), +}); + +/** No-argument tools. The explicit jsonSchema annotation pins the derived + * shape to a plain object schema (an unannotated empty struct derives to + * "object or array", which MCP clients reject as a tool inputSchema). */ +const EmptyArgsSchema = Schema.Struct({}).annotations({ + jsonSchema: { type: "object", properties: {}, additionalProperties: false }, +}); + +/** Runs one contract endpoint from a tool execution. */ +function invoke( + name: Name, + context: McpToolContext, + input: { readonly project?: string; readonly payload?: unknown }, +) { + return invokeEndpoint(name, { + request: context.request, + url: context.url, + user: context.user, + project: input.project ?? null, + args: Effect.succeed(input.payload), + }); +} + +/** Builds the publish endpoint's body from tool-call files: text content is + * base64-encoded here, then the whole bundle re-runs the shared + * PublishBundleSchema decode and the server's publish caps unchanged. */ +function publishPayload(args: typeof PublishArgsSchema.Type): unknown { + return { + bundle: { + version: PUBLISH_BUNDLE_VERSION, + files: args.files.map((file) => ({ + path: file.path, + contentBase64: file.contentBase64 ?? Encoding.encodeBase64(new TextEncoder().encode(file.content ?? "")), + })), + }, + ...(args.project == null ? {} : { project: args.project }), + ...(args.openPath == null ? {} : { openPath: args.openPath }), + ...(args.isPublic == null ? {} : { isPublic: args.isPublic }), + }; +} + +/** Every tool the /mcp endpoint serves. */ +export const MCP_TOOLS: ReadonlyArray = [ + { + name: "publish", + title: "Publish a project", + description: + "Publish (or update) a Scratchwork project from a set of files and return its URL. " + + "Send the complete file set — a publish replaces the project's previous contents. " + + "HTML files are served as pages; Markdown files render through the Scratchwork viewer.", + endpoint: "publish", + annotations: {}, + argsSchema: PublishArgsSchema, + outputSchema: PublishResponseSchema, + execute: (args: typeof PublishArgsSchema.Type, context) => + invoke("publish", context, { payload: publishPayload(args) }), + }, + { + name: "list_projects", + title: "List projects", + description: "List every Scratchwork project the signed-in account can read, with URLs and metadata.", + endpoint: "projects-list", + annotations: { readOnlyHint: true }, + argsSchema: EmptyArgsSchema, + outputSchema: ProjectsListResponseSchema, + execute: (_args, context) => invoke("projects-list", context, {}), + }, + { + name: "project_info", + title: "Get project info", + description: "Fetch one project's summary: visibility, owner, URL, file count, and timestamps.", + endpoint: "project-info", + annotations: { readOnlyHint: true }, + argsSchema: ProjectArgsSchema, + outputSchema: ProjectResponseSchema, + execute: (args: typeof ProjectArgsSchema.Type, context) => + invoke("project-info", context, { project: args.project }), + }, + { + name: "share_project", + title: "Share a project", + description: + "Grant or revoke access to a project: add email addresses or @domain groups at a role " + + "(read, write, or admin), or remove existing grants. Requires admin access to the project.", + endpoint: "project-share", + annotations: {}, + argsSchema: ShareArgsSchema, + outputSchema: ShareResponseSchema, + execute: (args: typeof ShareArgsSchema.Type, context) => + invoke("project-share", context, { + project: args.project, + payload: { + ...(args.role == null ? {} : { role: args.role }), + ...(args.add == null ? {} : { add: args.add }), + ...(args.remove == null ? {} : { remove: args.remove }), + }, + }), + }, + { + name: "unpublish_project", + title: "Unpublish a project", + description: "Make a project private: its content stays intact but stops being publicly reachable. Requires admin access.", + endpoint: "project-unpublish", + annotations: { destructiveHint: true, idempotentHint: true }, + argsSchema: ProjectArgsSchema, + outputSchema: ProjectResponseSchema, + execute: (args: typeof ProjectArgsSchema.Type, context) => + invoke("project-unpublish", context, { project: args.project }), + }, + { + name: "delete_project", + title: "Delete a project", + description: "Permanently delete a project and all of its content. Only the project owner can delete it.", + endpoint: "project-delete", + annotations: { destructiveHint: true }, + argsSchema: ProjectArgsSchema, + outputSchema: OkResponseSchema, + execute: (args: typeof ProjectArgsSchema.Type, context) => + invoke("project-delete", context, { project: args.project }), + }, + { + name: "whoami", + title: "Show the signed-in account", + description: "Report which Scratchwork account this connection is authenticated as.", + endpoint: "me", + annotations: { readOnlyHint: true }, + argsSchema: EmptyArgsSchema, + outputSchema: MeResponseSchema, + execute: (_args, context) => invoke("me", context, {}), + }, +] as ReadonlyArray; + +/** The tools/list wire entries, with JSON Schemas derived from the same + * Effect Schemas that validate calls. */ +export const MCP_TOOL_LISTING: ReadonlyArray = MCP_TOOLS.map((tool) => ({ + name: tool.name, + title: tool.title, + description: tool.description, + inputSchema: toJsonSchema(tool.argsSchema), + outputSchema: toJsonSchema(tool.outputSchema), + annotations: tool.annotations, +})); + +/** Raised when a tools/call names an unknown tool or sends invalid arguments + * — the transport maps it to JSON-RPC -32602. */ +export class McpToolArgsError extends Data.TaggedError("McpToolArgsError")<{ + readonly message: string; +}> {} + +/** Executes one tools/call: strict argument decode through the tool's schema, + * the endpoint invocation, then the result encoded through the shared success + * schema as the tool's structuredContent. */ +export function callMcpTool( + name: string, + args: unknown, + context: McpToolContext, +): Effect.Effect { + return Effect.gen(function* () { + const tool = MCP_TOOLS.find((candidate) => candidate.name === name); + if (tool == null) { + return yield* Effect.fail(new McpToolArgsError({ message: `Unknown tool: ${name}` })); + } + const decoded = yield* Schema.decodeUnknown(tool.argsSchema as Schema.Schema)(args ?? {}, { + errors: "all", + onExcessProperty: "error", + }).pipe( + Effect.mapError((error) => new McpToolArgsError({ message: ParseResult.TreeFormatter.formatErrorSync(error) })), + ); + const result = yield* tool.execute(decoded as never, context); + return yield* Schema.encodeUnknown(tool.outputSchema as Schema.Schema)(result).pipe( + Effect.mapError((cause) => new HttpError({ status: 500, message: "Could not encode the tool result", cause })), + ); + }); +} + +/** Derives the advertised JSON Schema from an Effect Schema, dropping the + * `$schema` marker MCP clients do not expect. */ +function toJsonSchema(schema: Schema.Schema.Any): unknown { + const { $schema: _dropped, ...rest } = JSONSchema.make(schema as Schema.Schema) as unknown as { + readonly $schema?: string; + readonly [key: string]: unknown; + }; + return rest; +} diff --git a/server/core/src/mcp/transport.ts b/server/core/src/mcp/transport.ts new file mode 100644 index 0000000..2f95bc0 --- /dev/null +++ b/server/core/src/mcp/transport.ts @@ -0,0 +1,222 @@ +/** + * The /mcp endpoint: a deliberately stateless MCP streamable-HTTP transport. + * Every response is a single application/json message (the spec's permitted + * alternative to an SSE stream), no Mcp-Session-Id is ever issued, and every + * POST is self-contained — which is exactly what the Lambda and Workers + * deploy targets require, where consecutive calls can land on different + * instances. Clients on both supported protocol revisions run statelessly + * against a server that omits the session header. + * + * Hand-rolled rather than delegated to @effect/ai's McpServer (invariant 1 + * judgment call): that layer keeps per-session state in memory and routes + * through @effect/platform HttpRouter, neither of which fits this server's + * stateless serverless targets or its policy-registry dispatch; the stateless + * tools-only subset implemented here is five methods over Effect Schemas. + * + * Security posture: bearer-only (never cookies) via the requireMcpUser + * chokepoint, after the same cross-origin rejection every API route applies — + * non-browser MCP clients send no Origin header and pass; a browser page on a + * foreign origin is rejected, which also covers the spec's DNS-rebinding + * guidance. Tool authorization is the endpoint policy registry, reached only + * through invokeEndpoint (invariant 4). + */ +import * as HttpServerRequest from "@effect/platform/HttpServerRequest"; +import * as HttpServerResponse from "@effect/platform/HttpServerResponse"; +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; +import { readJsonBody, type RouteError, type RouteServices } from "../api-routes.ts"; +import { requireMcpUser } from "../auth.ts"; +import { ServerConfig } from "../config.ts"; +import { appBaseUrl, HttpError, jsonResponse, rejectCrossOriginApiRequest, securityHeaders } from "../http.ts"; +import { mcpResourceUrl, mcpUnauthorizedResponse } from "../mcp-oauth-routes.ts"; +import { + INTERNAL_ERROR, + INVALID_PARAMS, + INVALID_REQUEST, + jsonRpcError, + jsonRpcResult, + JsonRpcRequestSchema, + METHOD_NOT_FOUND, + PARSE_ERROR, + type JsonRpcId, +} from "./jsonrpc.ts"; +import { callMcpTool, MCP_TOOL_LISTING, type McpToolContext } from "./tools.ts"; + +/** Protocol revisions this transport accepts, newest first. */ +const SUPPORTED_PROTOCOL_VERSIONS = ["2025-06-18", "2025-03-26"] as const; +const LATEST_PROTOCOL_VERSION = SUPPORTED_PROTOCOL_VERSIONS[0]; + +/** Identifies the MCP surface itself (not the package release): bump when the + * advertised tool surface changes materially. Deploy targets cannot read + * package.json at runtime, so this is a deliberate constant. */ +const MCP_SERVER_INFO = { name: "scratchwork", version: "1.0.0" } as const; + +/** Guidance MCP clients surface to their model alongside the tool list. */ +const MCP_INSTRUCTIONS = + "Scratchwork publishes static HTML and Markdown as shareable pages. " + + "Use the publish tool with the complete file set of a project; the response carries the live URL. " + + "Projects start private — pass isPublic true to publish publicly, and share_project to grant specific people access."; + +/** The /mcp request body cap: the publish endpoint's cap plus headroom for + * the JSON-RPC envelope (base64 file arguments match the HTTP encoding, so + * the same content fits both transports). The authoritative content limits — + * file count and byte caps — run inside the publish handler regardless. */ +export const MAX_MCP_BODY_BYTES = 34 * 1024 * 1024; +const MCP_BODY_LIMIT = { maxBytes: MAX_MCP_BODY_BYTES, message: "Request body is too large" }; + +/** initialize params: only the protocol version matters to a stateless server. */ +const InitializeParamsSchema = Schema.Struct({ + protocolVersion: Schema.optional(Schema.String), +}); + +/** tools/call params. */ +const ToolCallParamsSchema = Schema.Struct({ + name: Schema.String, + arguments: Schema.optional(Schema.Unknown), +}); + +/** Handles every request to /mcp. */ +export function dispatchMcpRoute( + request: HttpServerRequest.HttpServerRequest, + url: URL, +): Effect.Effect { + return Effect.gen(function* () { + const config = yield* ServerConfig; + yield* rejectCrossOriginApiRequest(request, appBaseUrl(request, config)); + + // No server-initiated stream is offered and no session exists to delete. + if (request.method !== "POST") { + return yield* Effect.fail(new HttpError({ status: 405, message: "Method not allowed" })); + } + + const user = yield* requireMcpUser(request, mcpResourceUrl(request, config), config.auth).pipe( + Effect.orElseSucceed(() => null), + ); + if (user == null) return mcpUnauthorizedResponse(request, config); + + // An explicit unsupported protocol version header is a client bug worth a + // hard error; an absent header reads as the older revision, per spec. + const versionHeader = request.headers["mcp-protocol-version"]; + if (versionHeader != null && !(SUPPORTED_PROTOCOL_VERSIONS as readonly string[]).includes(versionHeader)) { + return jsonResponse({ error: `Unsupported MCP-Protocol-Version: ${versionHeader}` }, 400); + } + + // An oversized body stays an HTTP 413; only a JSON parse failure becomes + // the JSON-RPC parse error. + const body = yield* readJsonBody(request, MCP_BODY_LIMIT).pipe( + Effect.catchTag("HttpError", (error) => + error.status === 400 ? Effect.succeed(PARSE_FAILED) : Effect.fail(error)), + ); + if (body === PARSE_FAILED) { + return rpcResponse(jsonRpcError(null, PARSE_ERROR, "Request body is not valid JSON")); + } + if (Array.isArray(body)) { + return rpcResponse(jsonRpcError(null, INVALID_REQUEST, "JSON-RPC batching is not supported")); + } + const message = yield* Schema.decodeUnknown(JsonRpcRequestSchema)(body).pipe( + Effect.orElseSucceed(() => null), + ); + if (message == null) { + return rpcResponse(jsonRpcError(null, INVALID_REQUEST, "Malformed JSON-RPC request")); + } + + // Notifications (no id) are acknowledged and never answered. + if (message.id === undefined) { + return HttpServerResponse.empty({ status: 202, headers: securityHeaders() }); + } + + return yield* handleRpc(message.id, message.method, message.params, { request, url, user }); + }); +} + +/** Sentinel distinguishing an unparsable body from any legal JSON value. */ +const PARSE_FAILED: unique symbol = Symbol("mcp-parse-failed"); + +/** Dispatches one JSON-RPC method. */ +function handleRpc( + id: JsonRpcId, + method: string, + params: unknown, + context: McpToolContext, +): Effect.Effect { + return Effect.gen(function* () { + switch (method) { + case "initialize": { + const requested = yield* Schema.decodeUnknown(InitializeParamsSchema)(params ?? {}).pipe( + Effect.orElseSucceed(() => ({ protocolVersion: undefined })), + ); + const negotiated = requested.protocolVersion != null && + (SUPPORTED_PROTOCOL_VERSIONS as readonly string[]).includes(requested.protocolVersion) + ? requested.protocolVersion + : LATEST_PROTOCOL_VERSION; + // Deliberately no Mcp-Session-Id header: its absence is what tells + // clients this server is stateless. + return rpcResponse(jsonRpcResult(id, { + protocolVersion: negotiated, + capabilities: { tools: { listChanged: false } }, + serverInfo: MCP_SERVER_INFO, + instructions: MCP_INSTRUCTIONS, + })); + } + case "ping": + return rpcResponse(jsonRpcResult(id, {})); + case "tools/list": + return rpcResponse(jsonRpcResult(id, { tools: MCP_TOOL_LISTING })); + case "tools/call": { + const call = yield* Schema.decodeUnknown(ToolCallParamsSchema)(params ?? {}).pipe( + Effect.orElseSucceed(() => null), + ); + if (call == null) { + return rpcResponse(jsonRpcError(id, INVALID_PARAMS, "tools/call requires a tool name")); + } + return yield* runToolCall(id, call.name, call.arguments, context); + } + default: + return rpcResponse(jsonRpcError(id, METHOD_NOT_FOUND, `Unknown method: ${method}`)); + } + }); +} + +/** Executes one tool call. Domain failures — denied roles, masked projects, + * validation inside the endpoint — are successful JSON-RPC responses carrying + * `isError: true`, exactly the message the HTTP route would emit, so the + * masking semantics of the policy registry survive the transport unchanged. + * Unknown tools and argument-shape failures are protocol errors (-32602). */ +function runToolCall( + id: JsonRpcId, + name: string, + args: unknown, + context: McpToolContext, +): Effect.Effect { + return callMcpTool(name, args, context).pipe( + Effect.map((structured) => + rpcResponse(jsonRpcResult(id, { + content: [{ type: "text", text: JSON.stringify(structured) }], + structuredContent: structured, + }))), + Effect.catchTags({ + McpToolArgsError: (error) => + Effect.succeed(rpcResponse(jsonRpcError(id, INVALID_PARAMS, error.message))), + HttpError: (error) => Effect.succeed(toolErrorResponse(id, error.status, error.message)), + AuthError: (error) => Effect.succeed(toolErrorResponse(id, error.status, error.message)), + SiteStoreError: (error) => Effect.succeed(toolErrorResponse(id, error.status, error.message)), + StorageError: () => Effect.succeed(toolErrorResponse(id, 500, "Storage operation failed")), + }), + Effect.catchAllDefect(() => + Effect.succeed(rpcResponse(jsonRpcError(id, INTERNAL_ERROR, "Internal error")))), + ); +} + +/** A tool-level failure as the spec shapes it: a successful tools/call result + * with isError set, carrying the same status and message as the HTTP route. */ +function toolErrorResponse(id: JsonRpcId, status: number, message: string): HttpServerResponse.HttpServerResponse { + return rpcResponse(jsonRpcResult(id, { + content: [{ type: "text", text: `${status}: ${message}` }], + isError: true, + })); +} + +/** Every JSON-RPC message rides an HTTP 200 with the standard API headers. */ +function rpcResponse(body: unknown): HttpServerResponse.HttpServerResponse { + return jsonResponse(body, 200); +} diff --git a/server/core/test/mcp.test.ts b/server/core/test/mcp.test.ts new file mode 100644 index 0000000..6d65845 --- /dev/null +++ b/server/core/test/mcp.test.ts @@ -0,0 +1,386 @@ +/* + * Tests of the /mcp endpoint: JSON-RPC framing, protocol-version negotiation, + * statelessness, per-tool round trips through real publishes, and the + * policy-parity matrix — every tool derives its expected outcome from the + * API_POLICY entry of its backing endpoint, so the MCP surface provably + * enforces the same authorization the HTTP routes do (invariant 4). The OAuth + * flow that mints mcp-access tokens is covered in mcp-oauth.test.ts; here + * bearers are minted directly. + */ +import { describe, expect, test } from "bun:test"; +import * as Effect from "effect/Effect"; +import { API_ROUTES } from "../src/api-routes"; +import { createMcpAccessToken, createSessionToken, type AuthUser } from "../src/auth"; +import type { AuthConfig } from "../src/config"; +import { MCP_TOOLS } from "../src/mcp/tools"; +import { roleAtLeast, type ProjectRole } from "../src/site-store"; +import { appHandler, bundle, json } from "./helpers"; + +/** Must match the appHandler defaults in helpers.ts so minted bearers verify. */ +const authConfig: AuthConfig = { + mode: "oauth", + clientId: "test-client-id", + clientSecret: "test-client-secret", + sessionSecret: "test-session-secret-test-session-secret", + allowedUsers: "public", + sessionTtlSeconds: 60, +}; + +const BASE = "https://scratch.test"; +const AUD = `${BASE}/mcp`; + +const users = { + owner: { id: "owner-1", email: "owner@example.com" }, + admin: { id: "admin-1", email: "admin@example.com" }, + writer: { id: "writer-1", email: "writer@example.com" }, + reader: { id: "reader-1", email: "reader@example.com" }, + stranger: { id: "stranger-1", email: "stranger@example.com" }, +} satisfies Record; + +type Handler = Awaited>; + +/** Mints the MCP access token the OAuth flow would issue this user. */ +async function mcpBearer(user: AuthUser): Promise { + return Effect.runPromise(createMcpAccessToken({ user, clientId: "mcp-test-client" }, AUD, authConfig)); +} + +/** One JSON-RPC POST to /mcp. */ +async function rpc( + handler: Handler, + body: unknown, + options: { token?: string; headers?: Record } = {}, +): Promise { + return handler(new Request(`${BASE}/mcp`, { + method: "POST", + headers: { + "content-type": "application/json", + ...(options.token == null ? {} : { authorization: `Bearer ${options.token}` }), + ...options.headers, + }, + body: typeof body === "string" ? body : JSON.stringify(body), + })); +} + +/** Calls one tool and returns the JSON-RPC response body. */ +async function callTool( + handler: Handler, + token: string, + name: string, + args: unknown, +): Promise<{ result?: { content?: Array<{ text: string }>; structuredContent?: unknown; isError?: boolean }; error?: { code: number; message: string } }> { + const response = await rpc(handler, { + jsonrpc: "2.0", + id: 1, + method: "tools/call", + params: { name, arguments: args }, + }, { token }); + expect(response.status).toBe(200); + return await json(response) as never; +} + +/** A server with one private and one public project plus the standard grants. */ +async function fixture(): Promise { + const handler = await appHandler({}); + const token = await mcpBearer(users.owner); + for (const [project, isPublic] of [["site", false], ["pub", true]] as const) { + const published = await callTool(handler, token, "publish", { + files: [{ path: "index.html", content: `

${project}

` }], + project, + isPublic, + }); + if (published.result?.isError) throw new Error(`fixture publish failed: ${JSON.stringify(published)}`); + for (const [role, user] of [["read", users.reader], ["write", users.writer], ["admin", users.admin]] as const) { + const shared = await callTool(handler, token, "share_project", { project, role, add: [user.email] }); + if (shared.result?.isError) throw new Error(`fixture share failed: ${JSON.stringify(shared)}`); + } + } + return handler; +} + +describe("mcp transport: framing and negotiation", () => { + test("initialize negotiates both supported revisions and never issues a session id", async () => { + const handler = await appHandler({}); + const token = await mcpBearer(users.owner); + for (const requested of ["2025-06-18", "2025-03-26"]) { + const response = await rpc(handler, { + jsonrpc: "2.0", + id: 0, + method: "initialize", + params: { protocolVersion: requested, capabilities: {}, clientInfo: { name: "test", version: "0" } }, + }, { token }); + expect(response.status).toBe(200); + expect(response.headers.get("mcp-session-id")).toBeNull(); + const body = await json(response) as { result: { protocolVersion: string; serverInfo: { name: string } } }; + expect(body.result.protocolVersion).toBe(requested); + expect(body.result.serverInfo.name).toBe("scratchwork"); + } + // An unknown requested version answers with the latest supported one. + const unknown = await rpc(handler, { + jsonrpc: "2.0", id: 0, method: "initialize", params: { protocolVersion: "1999-01-01" }, + }, { token }); + expect(((await json(unknown)) as { result: { protocolVersion: string } }).result.protocolVersion).toBe("2025-06-18"); + }); + + test("tools/call works without a prior initialize — the transport is stateless", async () => { + const handler = await appHandler({}); + const token = await mcpBearer(users.owner); + const body = await callTool(handler, token, "whoami", {}); + expect(body.result?.structuredContent).toEqual({ authenticated: true, user: users.owner }); + }); + + test("an unsupported MCP-Protocol-Version header is a 400; absent is accepted", async () => { + const handler = await appHandler({}); + const token = await mcpBearer(users.owner); + const bad = await rpc(handler, { jsonrpc: "2.0", id: 1, method: "ping" }, { + token, + headers: { "mcp-protocol-version": "2024-01-01" }, + }); + expect(bad.status).toBe(400); + const good = await rpc(handler, { jsonrpc: "2.0", id: 1, method: "ping" }, { + token, + headers: { "mcp-protocol-version": "2025-03-26" }, + }); + expect(good.status).toBe(200); + }); + + test("framing errors map to the JSON-RPC error codes", async () => { + const handler = await appHandler({}); + const token = await mcpBearer(users.owner); + const cases: Array<{ body: unknown; code: number }> = [ + { body: "not json", code: -32700 }, + { body: [{ jsonrpc: "2.0", id: 1, method: "ping" }], code: -32600 }, + { body: { jsonrpc: "1.0", id: 1, method: "ping" }, code: -32600 }, + { body: { jsonrpc: "2.0", id: 1 }, code: -32600 }, + { body: { jsonrpc: "2.0", id: 1, method: "resources/list" }, code: -32601 }, + { body: { jsonrpc: "2.0", id: 1, method: "tools/call", params: {} }, code: -32602 }, + { body: { jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "no-such-tool" } }, code: -32602 }, + ]; + for (const item of cases) { + const response = await rpc(handler, item.body, { token }); + const body = await json(response) as { error?: { code: number } }; + expect({ case: item.body, code: body.error?.code }).toEqual({ case: item.body, code: item.code }); + } + }); + + test("notifications are acknowledged with an empty 202", async () => { + const handler = await appHandler({}); + const token = await mcpBearer(users.owner); + const response = await rpc(handler, { jsonrpc: "2.0", method: "notifications/initialized" }, { token }); + expect(response.status).toBe(202); + expect(await response.text()).toBe(""); + }); + + test("non-POST methods are 405", async () => { + const handler = await appHandler({}); + const token = await mcpBearer(users.owner); + for (const method of ["GET", "DELETE", "PUT"]) { + const response = await handler(new Request(`${BASE}/mcp`, { + method, + headers: { authorization: `Bearer ${token}` }, + })); + expect({ method, status: response.status }).toEqual({ method, status: 405 }); + } + }); + + test("unauthenticated requests get the 401 that starts the OAuth flow", async () => { + const handler = await appHandler({}); + const credentialCases: Array> = [{}, { authorization: "Bearer garbage.token" }]; + for (const headers of credentialCases) { + const response = await rpc(handler, { jsonrpc: "2.0", id: 1, method: "initialize" }, { headers }); + expect(response.status).toBe(401); + expect(response.headers.get("www-authenticate")).toBe( + `Bearer resource_metadata="${BASE}/.well-known/oauth-protected-resource/mcp", error="invalid_token"`, + ); + } + }); + + test("a session bearer is accepted; a wrong-audience mcp token is not", async () => { + const handler = await appHandler({}); + const session = await Effect.runPromise(createSessionToken(users.owner, authConfig)); + const viaSession = await rpc(handler, { jsonrpc: "2.0", id: 1, method: "ping" }, { token: session }); + expect(viaSession.status).toBe(200); + const foreign = await Effect.runPromise( + createMcpAccessToken({ user: users.owner, clientId: "c" }, "https://other.example/mcp", authConfig), + ); + const viaForeign = await rpc(handler, { jsonrpc: "2.0", id: 1, method: "ping" }, { token: foreign }); + expect(viaForeign.status).toBe(401); + }); + + test("cross-origin browser calls are rejected even with a valid bearer", async () => { + const handler = await appHandler({}); + const token = await mcpBearer(users.owner); + const originCases: Array> = [{ origin: "https://evil.example" }, { "sec-fetch-site": "cross-site" }]; + for (const headers of originCases) { + const response = await rpc(handler, { jsonrpc: "2.0", id: 1, method: "ping" }, { token, headers }); + expect(response.status).toBe(403); + } + }); + + test("oversized bodies stay an HTTP 413, not a JSON-RPC parse error", async () => { + const handler = await appHandler({}); + const token = await mcpBearer(users.owner); + const response = await rpc(handler, `{"pad":"${"x".repeat(35 * 1024 * 1024)}"}`, { token }); + expect(response.status).toBe(413); + }); + + test("tools/list advertises every registered tool with derived object schemas", async () => { + const handler = await appHandler({}); + const token = await mcpBearer(users.owner); + const response = await rpc(handler, { jsonrpc: "2.0", id: 1, method: "tools/list" }, { token }); + const body = await json(response) as { + result: { tools: Array<{ name: string; inputSchema: { type: string; $schema?: string } }> }; + }; + expect(body.result.tools.map((tool) => tool.name)).toEqual(MCP_TOOLS.map((tool) => tool.name)); + for (const tool of body.result.tools) { + expect({ name: tool.name, type: tool.inputSchema.type }).toEqual({ name: tool.name, type: "object" }); + expect(tool.inputSchema.$schema).toBeUndefined(); + } + }); +}); + +describe("mcp tools: round trips", () => { + test("publish serves mixed text and binary files at the returned URL", async () => { + const handler = await appHandler({}); + const token = await mcpBearer(users.owner); + const binary = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x01]); + const published = await callTool(handler, token, "publish", { + files: [ + { path: "index.html", content: "

hello mcp

" }, + { path: "img/dot.png", contentBase64: binary.toString("base64") }, + ], + project: "mcp-pub", + isPublic: true, + openPath: "/", + }); + expect(published.result?.isError).toBeUndefined(); + const structured = published.result?.structuredContent as { project: string; url: string; isPublic: boolean }; + expect(structured.project).toBe("mcp-pub"); + expect(structured.isPublic).toBe(true); + // The text mirror carries the same JSON for clients that ignore structuredContent. + expect(JSON.parse(published.result?.content?.[0]?.text ?? "")).toEqual(structured); + + const page = await handler(new Request(`${BASE}/mcp-pub/`)); + expect(page.status).toBe(200); + expect(await page.text()).toContain("hello mcp"); + const image = await handler(new Request(`${BASE}/mcp-pub/img/dot.png`)); + expect(new Uint8Array(await image.arrayBuffer())).toEqual(new Uint8Array(binary)); + }); + + test("list, info, share, unpublish, delete, whoami round-trip", async () => { + const handler = await fixture(); + const token = await mcpBearer(users.owner); + + const listed = await callTool(handler, token, "list_projects", {}); + const projects = (listed.result?.structuredContent as { projects: Array<{ project: string }> }).projects; + expect(projects.map((p) => p.project).sort()).toEqual(["pub", "site"]); + + const info = await callTool(handler, token, "project_info", { project: "site" }); + const summary = (info.result?.structuredContent as { project: { isPublic: boolean; permissions?: unknown } }).project; + expect(summary.isPublic).toBe(false); + expect(summary.permissions).toBeDefined(); // owner sees grants + + const shared = await callTool(handler, token, "share_project", { + project: "site", + role: "read", + add: ["friend@example.com"], + }); + expect(shared.result?.isError).toBeUndefined(); + + const unpublished = await callTool(handler, token, "unpublish_project", { project: "pub" }); + expect((unpublished.result?.structuredContent as { project: { isPublic: boolean } }).project.isPublic).toBe(false); + + const deleted = await callTool(handler, token, "delete_project", { project: "site" }); + expect(deleted.result?.structuredContent).toEqual({ ok: true }); + const gone = await callTool(handler, token, "project_info", { project: "site" }); + expect(gone.result?.isError).toBe(true); + + const who = await callTool(handler, token, "whoami", {}); + expect((who.result?.structuredContent as { user: { email: string } }).user.email).toBe(users.owner.email); + }); + + test("publish argument validation is strict", async () => { + const handler = await appHandler({}); + const token = await mcpBearer(users.owner); + const cases: Array> = [ + { files: [] }, + { files: [{ path: "a.html" }] }, + { files: [{ path: "a.html", content: "x", contentBase64: "eA==" }] }, + { files: [{ path: "../evil.html", content: "x" }] }, + { files: [{ path: "a.html", contentBase64: "not base64!!!" }] }, + { files: [{ path: "a.html", content: "x" }], unexpected: true }, + ]; + for (const args of cases) { + const body = await callTool(handler, token, "publish", args); + expect({ args, code: body.error?.code }).toEqual({ args, code: -32602 }); + } + }); +}); + +describe("mcp tools: policy parity with the route registry", () => { + /** Every tool's backing endpoint exists in the route registry. */ + test("every tool names a registered endpoint, and tool names are unique", () => { + const routeNames = new Set(API_ROUTES.map((route) => route.name)); + for (const tool of MCP_TOOLS) { + expect({ tool: tool.name, registered: routeNames.has(tool.endpoint) }).toEqual({ tool: tool.name, registered: true }); + } + expect(new Set(MCP_TOOLS.map((tool) => tool.name)).size).toBe(MCP_TOOLS.length); + }); + + /** Success-shaped arguments per tool, parameterized by project. */ + const TOOL_ARGS: Record unknown> = { + publish: (project) => ({ files: [{ path: "index.html", content: "update" }], project }), + list_projects: () => ({}), + project_info: (project) => ({ project }), + share_project: (project) => ({ project, role: "read", add: ["friend@example.com"] }), + unpublish_project: (project) => ({ project }), + delete_project: (project) => ({ project }), + whoami: () => ({}), + }; + + const CREDENTIALS: ReadonlyArray<{ name: string; user: AuthUser; role: ProjectRole }> = [ + { name: "stranger", user: users.stranger, role: "none" }, + { name: "reader", user: users.reader, role: "read" }, + { name: "writer", user: users.writer, role: "write" }, + { name: "admin", user: users.admin, role: "admin" }, + { name: "owner", user: users.owner, role: "owner" }, + ]; + + test("every tool has matrix arguments", () => { + expect(Object.keys(TOOL_ARGS).sort()).toEqual(MCP_TOOLS.map((tool) => tool.name).sort()); + }); + + for (const tool of MCP_TOOLS) { + for (const subject of [{ project: "site", isPublic: false }, { project: "pub", isPublic: true }] as const) { + test(`${tool.name} on ${subject.isPublic ? "public" : "private"} project enforces ${tool.endpoint}'s policy`, async () => { + const route = API_ROUTES.find((candidate) => candidate.name === tool.endpoint)!; + // Mutating cells get a fresh server so no cell poisons another. + const shared = route.mutation ? null : await fixture(); + for (const credential of CREDENTIALS) { + const handler = shared ?? await fixture(); + const body = await callTool(handler, await mcpBearer(credential.user), tool.name, TOOL_ARGS[tool.name]!(subject.project)); + const label = `${tool.name} × ${credential.name} × ${subject.project}`; + const isError = body.result?.isError === true; + const text = body.result?.content?.[0]?.text ?? ""; + + if (route.minimumRole == null) { + expect({ label, isError }).toEqual({ label, isError: false }); + continue; + } + const role = subject.isPublic && !roleAtLeast(credential.role, "read") ? "read" : credential.role; + if (roleAtLeast(role, route.minimumRole)) { + expect({ label, isError, text }).toEqual({ label, isError: false, text }); + } else if (!roleAtLeast(role, "read")) { + // Below read, existence is masked with the same message the HTTP + // route sends: publish reads as a name collision, everything else + // as a missing project. + const masked = tool.name === "publish" ? text.includes("already taken") : text.includes("Project not found"); + expect({ label, isError, masked }).toEqual({ label, isError: true, masked: true }); + } else { + expect({ label, isError }).toEqual({ label, isError: true }); + expect(text.includes("Project not found")).toBe(false); // denied, never masked as missing + } + } + }, 30_000); + } + } +});