Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <server>/mcp --header "Authorization: Bearer <token>"`.

Deployments live as projects under `deploy/`, one per domain, each deployable with one command:

```sh
Expand Down
176 changes: 176 additions & 0 deletions e2e/src/suite.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: "<h1>mcp-marker</h1>" },
{ 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);
});
}
129 changes: 100 additions & 29 deletions server/core/src/api-routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -102,7 +102,7 @@ export interface ApiContext<Name extends ScratchworkEndpointName = ScratchworkEn
}

/** The size cap applied while reading a payload endpoint's request body. */
interface PayloadLimit {
export interface PayloadLimit {
readonly maxBytes: number;
/** The message of the 413 an oversized body receives. */
readonly message: string;
Expand Down Expand Up @@ -447,9 +447,9 @@ export function dispatchApiRoute(
});
}

/** Applies the declared policy in order — origin, principal, project gate,
* payload decodethen runs the handler and encodes its result through the
* endpoint's success schema. */
/** Applies the declared policy in order — origin, principal, then the shared
* endpoint pipelineand encodes the handler's result through the endpoint's
* success schema. */
function runRoute(
route: RegisteredRoute,
project: string | null,
Expand All @@ -467,31 +467,92 @@ function runRoute(
? yield* auth.currentUser(request)
: null;

const result = yield* invokeRegisteredRoute(route, {
request,
url,
user,
project,
args: route.payloadLimit == null
? Effect.succeed(undefined)
: readJsonBody(request, route.payloadLimit),
});
const body = yield* Schema.encodeUnknown(route.endpoint.successSchema as Schema.Schema<unknown, unknown>)(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<unknown, RouteError>;
}

/**
* 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 extends ScratchworkEndpointName>(
name: Name,
input: EndpointInvocation,
): Effect.Effect<EndpointSuccess<Name>, 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<EndpointSuccess<Name>, 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<unknown, RouteError, RouteServices> {
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<unknown, unknown>)(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<unknown, HttpError> {
return Effect.gen(function* () {
Expand All @@ -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<unknown, unknown>)(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<unknown, HttpError> {
return Schema.decodeUnknown(schema as Schema.Schema<unknown, unknown>)(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. */
Expand Down
Loading
Loading