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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/mcp-oauth-discovery.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@cloudhal/api": patch
"@cloudhal/agent-runtime": patch
---

MCP OAuth discovery (RFC-0044 §1, phase 1 of #144): with `mcp.oauth` set, the 401 of `/mcp` carries a `WWW-Authenticate` challenge naming the RFC 9728 protected resource metadata and the scopes to request, the metadata is served at both well-known forms, and a token principal calling a tool without a scope its member's role holds gets a `403 insufficient_scope` step-up challenge. Deployments do not set the option yet: the authorization server is phase 2.
2 changes: 1 addition & 1 deletion docs/adr/0004-mcp-server.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,6 @@ MCP spec 2026-07-28 removed sessions and `initialize`; every request is self-des

## Addendum (2026-10-22): what was built instead
Three parts of the Decision were not built. The stateless server, `createMcpHandler`, `outputSchema`/`structuredContent`, honest annotations, the deterministic `tools/list` and the layered approval (elicitation with a `confirmToken` fallback) are as decided.
- **No OAuth provider.** `@cloudflare/workers-oauth-provider` is not used. `/mcp` is mounted in the Hono API, which resolves the caller before the MCP handler sees the request: a session cookie for a person, or an agent key (Better Auth API key) as `x-api-key` or `Authorization: Bearer`. See [ADR-0011](0011-agent-layer.md) §5–6, which deferred OAuth 2.1.
- **No OAuth provider.** `@cloudflare/workers-oauth-provider` is not used. `/mcp` is mounted in the Hono API, which resolves the caller before the MCP handler sees the request: a session cookie for a person, or an agent key (Better Auth API key) as `x-api-key` or `Authorization: Bearer`. See [ADR-0011](0011-agent-layer.md) §5–6, which deferred OAuth 2.1. *Superseded (2026-11-08):* [RFC-0044](../rfcs/0044-mcp-oauth-authorization.md) brings OAuth 2.1 in three phases. Phase 1 (discovery, `packages/api/src/mcp-oauth.ts`) ships the RFC 9728 metadata, the `WWW-Authenticate` challenge on the 401 and the `insufficient_scope` step-up behind `McpMountOptions.oauth`; the authorization server (Better Auth's `mcp()` plugin, not `@cloudflare/workers-oauth-provider`) and client registration follow with their own ADR. Agent keys keep working.
- **No server-minted handles.** Tools take the contract ids (`snap_…` and the other prefixed ids) and the node's `rev`; every call is re-authorized by the policy engine, and approval state is an HMAC-signed `requestState` bound to the tool, the arguments and the principal (ADR-0011 §4), not a stored handle.
- **More than four scopes.** The scope list grew with the features (content, preview, assets, analytics, comments, schedules and releases, `:own` variants, person-only scopes). The single source is `KEY_SCOPES` and `PERSON_SCOPES` in `packages/agent-runtime/src/policy.ts`; roles are in `src/roles.ts` ([ADR-0018](0018-roles-and-invites.md)). Each tool's scopes are in the [tool reference](../../packages/mcp-server/README.md#tool-reference).
5 changes: 5 additions & 0 deletions packages/agent-runtime/src/policy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -257,6 +257,11 @@ export interface Principal {
elevation?: { from: AgentMode; until: string } | undefined;
/** The API key an agent authenticated with (lets it ask for an elevation of that key). */
keyId?: string | undefined;
/**
* RFC-0044 §5: the OAuth grant an agent's access token belongs to (a connection approved on the
* consent screen). Only such principals get the `insufficient_scope` step-up challenge.
*/
grantId?: string | undefined;
/**
* RFC-0008 §11.2: the key is an automation's key (`purpose: "automation"` in its metadata). It
* works only inside an open run of that automation, except `begin_run` and `get_mode`.
Expand Down
37 changes: 4 additions & 33 deletions packages/api/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,13 @@ import {
type Principal,
} from "@cloudhal/agent-runtime";
import { ANALYTICS_OFF } from "@cloudhal/analytics/service";
import { createCloudhalMcpHandler } from "@cloudhal/mcp-server/http";
import { schedulesAvailable } from "@cloudhal/mcp-server/schedules";
import { defineTools, toolPolicies } from "@cloudhal/mcp-server/tools";
import { type Context, Hono } from "hono";
import { createBuildHub } from "./build.ts";
import type { ApiDeps, Env } from "./context.ts";
import { errorHandler, unauthorized } from "./errors.ts";
import { errorHandler } from "./errors.ts";
import { mountMcp } from "./mcp-mount.ts";
import { PLAYGROUND_PRINCIPAL, playgroundGuard } from "./playground.ts";
import { activity, deploymentActivity } from "./routes/activity.ts";
import { mountAdmin } from "./routes/admin-mount.ts";
Expand Down Expand Up @@ -222,37 +222,8 @@ export function createApi(deps: ApiDeps) {

app.get("/api/me", meHandler(membership));

if (deps.mcp) {
const mcp = createCloudhalMcpHandler({
store: deps.store,
build,
...deps.mcp,
previewSecret: deps.mcp.previewSecret ?? deps.preview?.secret,
now: deps.now,
ownership,
analytics: deps.analytics,
comments: deps.comments,
schedules,
});
app.all("/mcp", async (c) => {
// DNS-rebinding / cross-site protection: browsers send Origin; it must be this origin.
const origin = c.req.header("origin");
if (origin && origin !== new URL(c.req.url).origin)
return c.json(
{ error: "forbidden", message: "cross-origin MCP requests are not allowed" },
403,
);
const principal = c.get("principal");
if (!principal) throw unauthorized();
// A signed-in person without a membership has no scopes; refuse rather than list tools.
if (deps.staff && principal.actor.kind === "human" && !principal.person)
return c.json(
{ error: "not_a_member", message: "Your access was removed. Ask an administrator." },
403,
);
return mcp.fetch(c.req.raw, principal);
});
}
// RFC-0044 §1: with `mcp.oauth`, the route also serves OAuth discovery.
if (deps.mcp) mountMcp(app, { ...deps, mcp: deps.mcp }, { build, ownership, schedules });

if (deps.agentKeys) app.route("/api/agent-keys", agentKeys(deps.agentKeys));

Expand Down
6 changes: 6 additions & 0 deletions packages/api/src/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import type { Context } from "hono";
import type { AgentKeyService, StaffService } from "./auth.ts";
import type { BuildHub } from "./build.ts";
import type { MailSender } from "./mail.ts";
import type { McpOauthOptions } from "./mcp-oauth.ts";
import type { PlaygroundOptions } from "./playground.ts";
import type { AiDeps } from "./routes/ai.ts";
import type { UploadSecret } from "./uploads.ts";
Expand All @@ -42,6 +43,11 @@ export interface McpMountOptions {
readonly media?: MediaToolOptions | undefined;
/** A browser for `render_page` (RFC-0021 §4). */
readonly render?: RenderDeps | undefined;
/**
* RFC-0044 §1: OAuth discovery for remote clients (the `WWW-Authenticate` challenge, the RFC 9728
* documents, the `insufficient_scope` step-up). Absent: `/mcp` answers a bare 401, as before.
*/
readonly oauth?: McpOauthOptions | undefined;
}

export interface ApiDeps {
Expand Down
67 changes: 67 additions & 0 deletions packages/api/src/mcp-mount.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
/**
* `/mcp` (ADR-0011): the API resolves the caller, then hands the request and its `Principal` to
* the stateless MCP handler. With `McpMountOptions.oauth` (RFC-0044 §1) the route also speaks
* OAuth discovery: the challenge on 401, the protected resource metadata, the step-up 403.
*/
import type { OwnershipResolver } from "@cloudhal/agent-runtime";
import { createCloudhalMcpHandler } from "@cloudhal/mcp-server/http";
import type { Hono } from "hono";
import type { BuildHub } from "./build.ts";
import type { ApiDeps, Env } from "./context.ts";
import { unauthorized } from "./errors.ts";
import {
mountProtectedResourceMetadata,
stepUpChallenge,
unauthorizedChallenge,
} from "./mcp-oauth.ts";

export interface McpMountParts {
readonly build: BuildHub;
readonly ownership: OwnershipResolver;
readonly schedules: ApiDeps["schedules"];
}

export function mountMcp(
app: Hono<Env>,
deps: ApiDeps & { readonly mcp: NonNullable<ApiDeps["mcp"]> },
parts: McpMountParts,
): void {
const mcp = createCloudhalMcpHandler({
store: deps.store,
build: parts.build,
...deps.mcp,
previewSecret: deps.mcp.previewSecret ?? deps.preview?.secret,
now: deps.now,
ownership: parts.ownership,
analytics: deps.analytics,
comments: deps.comments,
schedules: parts.schedules,
});
const oauth = deps.mcp.oauth;
if (oauth) mountProtectedResourceMetadata(app, oauth);
app.all("/mcp", async (c) => {
// DNS-rebinding / cross-site protection: browsers send Origin; it must be this origin.
const origin = c.req.header("origin");
if (origin && origin !== new URL(c.req.url).origin)
return c.json(
{ error: "forbidden", message: "cross-origin MCP requests are not allowed" },
403,
);
const principal = c.get("principal");
if (!principal) {
if (oauth) return unauthorizedChallenge(oauth, c.req.raw);
throw unauthorized();
}
// A signed-in person without a membership has no scopes; refuse rather than list tools.
if (deps.staff && principal.actor.kind === "human" && !principal.person)
return c.json(
{ error: "not_a_member", message: "Your access was removed. Ask an administrator." },
403,
);
if (oauth) {
const stepUp = await stepUpChallenge(c.req.raw, principal, c.get("policy")(), oauth);
if (stepUp) return stepUp;
}
return mcp.fetch(c.req.raw, principal);
});
}
200 changes: 200 additions & 0 deletions packages/api/src/mcp-oauth.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,200 @@
/**
* RFC-0044 §1, Phase 1: what an OAuth MCP client needs to find the deployment's authorization
* server. The MCP authorization spec (2026-07-28) makes an MCP server a resource server that
* "MUST implement OAuth 2.0 Protected Resource Metadata" (RFC 9728) and name that document in the
* `WWW-Authenticate` challenge of its 401; a call a token cannot make for want of a scope the
* member's role holds answers `403 insufficient_scope` with the scopes to ask for (step-up).
*
* Nothing here verifies a token: that is Phase 2 (the authorization server). Until then a
* deployment that sets `McpMountOptions.oauth` advertises an issuer whose metadata does not
* exist, so clients fail at "authorization server metadata", a diagnosable error (RFC-0044 §9).
*/
import {
holds,
isKeyScope,
type PolicyEngine,
type Principal,
roleScopes,
type Scope,
scopesForMode,
} from "@cloudhal/agent-runtime";
import type { Context, Hono } from "hono";
import type { Env } from "./context.ts";

export interface McpOauthOptions {
/**
* The authorization server's issuer identifier (RFC 8414): Better Auth's base path on the
* deployment's public origin, e.g. `https://studio.example.com/api/auth`.
*/
readonly issuer: string;
/**
* The canonical URL of this MCP server (RFC 8707 §2, RFC 9728 `resource`): the public origin
* plus `/mcp`, no trailing slash, e.g. `https://studio.example.com/mcp`. Tokens are bound to it.
*/
readonly resource: string;
/**
* `scopes_supported`: the minimal set for basic use, which clients request when the challenge
* names none. Defaults to what a new agent key gets (`scopesForMode("edits")`).
*/
readonly scopesSupported?: readonly Scope[] | undefined;
/** `resource_name`, shown by some clients (RFC 9728 §2). */
readonly resourceName?: string | undefined;
/** `resource_documentation` (RFC 9728 §2). */
readonly documentation?: string | undefined;
}

/** RFC 9728 §3: the well-known path; the path-aware form appends the resource's path. */
export const PROTECTED_RESOURCE_PATH = "/.well-known/oauth-protected-resource";

/** The resource identifier as served: no trailing slash (MCP "Canonical Server URI"). */
export function canonicalResource(resource: string): string {
const url = new URL(resource);
url.hash = "";
return url.href.replace(/\/$/, "");
}

/** The URL of the path-aware metadata document, named in every challenge. */
export function resourceMetadataUrl(oauth: McpOauthOptions): string {
const url = new URL(canonicalResource(oauth.resource));
return `${url.origin}${PROTECTED_RESOURCE_PATH}${url.pathname}`;
}

/** The scopes the challenge and `scopes_supported` name (RFC-0044 §1). */
export function supportedScopes(oauth: McpOauthOptions): readonly Scope[] {
return oauth.scopesSupported ?? scopesForMode("edits");
}

/** The RFC 9728 document. */
export function protectedResourceMetadata(oauth: McpOauthOptions): Record<string, unknown> {
return {
resource: canonicalResource(oauth.resource),
authorization_servers: [oauth.issuer],
scopes_supported: [...supportedScopes(oauth)],
bearer_methods_supported: ["header"],
...(oauth.resourceName ? { resource_name: oauth.resourceName } : {}),
...(oauth.documentation ? { resource_documentation: oauth.documentation } : {}),
};
}

/**
* Serves the document at both well-known forms (`ROUTE_SCOPES` declares them `public`): the
* path-aware one clients try first, and the root one the spec makes them fall back to.
*/
export function mountProtectedResourceMetadata(app: Hono<Env>, oauth: McpOauthOptions): void {
const body = JSON.stringify(protectedResourceMetadata(oauth));
const handler = (c: Context<Env>) =>
c.body(body, 200, {
"content-type": "application/json",
// Static per deployment; RFC 9728 §3 and the hardening guide want it cacheable.
"cache-control": "public, max-age=3600",
});
app.get(
`${PROTECTED_RESOURCE_PATH}${new URL(canonicalResource(oauth.resource)).pathname}`,
handler,
);
app.get(PROTECTED_RESOURCE_PATH, handler);
}

export interface ChallengeOptions {
/** RFC 6750 §3.1 error code; absent on a first, credential-less request. */
readonly error?: "invalid_token" | "insufficient_scope" | undefined;
readonly description?: string | undefined;
/** The scopes to ask for (RFC 6750 `scope`, space-separated). */
readonly scope?: readonly string[] | undefined;
}

/** The `WWW-Authenticate: Bearer …` value (RFC 6750 §3, RFC 9728 §5.1). */
export function challengeHeader(oauth: McpOauthOptions, options: ChallengeOptions = {}): string {
const quote = (v: string) => `"${v.replace(/["\\]/g, "")}"`;
const params: string[] = [];
if (options.error) params.push(`error=${quote(options.error)}`);
if (options.description) params.push(`error_description=${quote(options.description)}`);
params.push(`resource_metadata=${quote(resourceMetadataUrl(oauth))}`);
const scope = options.scope ?? supportedScopes(oauth);
if (scope.length) params.push(`scope=${quote(scope.join(" "))}`);
return `Bearer ${params.join(", ")}`;
}

/** True when the request presented a bearer credential (RFC 6750 §2.1) that did not resolve. */
export function presentedBearer(request: Request): boolean {
return /^bearer\s+\S/i.test(request.headers.get("authorization") ?? "");
}

/** The 401 of `/mcp` with the challenge; the JSON body is `unauthorized()`'s. */
export function unauthorizedChallenge(oauth: McpOauthOptions, request: Request): Response {
return Response.json(
{ error: "unauthorized", message: "sign in required" },
{
status: 401,
headers: {
"www-authenticate": challengeHeader(
oauth,
presentedBearer(request) ? { error: "invalid_token" } : {},
),
},
},
);
}

/** The one JSON-RPC message a stateless request carries, when it is a tool call. */
async function toolCallOf(
request: Request,
): Promise<{ name: string; args: Record<string, unknown> } | undefined> {
if (request.method !== "POST") return undefined;
if (!/^application\/json\b/i.test(request.headers.get("content-type") ?? "")) return undefined;
let message: unknown;
try {
message = JSON.parse(await request.clone().text());
} catch {
return undefined;
}
if (!message || typeof message !== "object" || Array.isArray(message)) return undefined;
const { method, params } = message as { method?: unknown; params?: unknown };
if (method !== "tools/call" || !params || typeof params !== "object") return undefined;
const { name, arguments: args } = params as { name?: unknown; arguments?: unknown };
if (typeof name !== "string") return undefined;
return { name, args: args && typeof args === "object" ? (args as Record<string, unknown>) : {} };
}

/**
* RFC-0044 §1 step-up: a token principal (`grantId`) calling a tool whose scopes the token lacks
* but the member's role holds gets `403 insufficient_scope` naming every missing scope at once,
* so the client re-authorizes with the union (MCP "Scope Challenge Handling"). Every other denial
* (role, mode, `:own`, approvals) stays a JSON-RPC result from the handler, and a key principal
* is never challenged: a key cannot re-authorize.
*/
export async function stepUpChallenge(
request: Request,
principal: Principal,
policy: PolicyEngine,
oauth: McpOauthOptions,
): Promise<Response | undefined> {
if (!principal.grantId || !principal.person) return undefined;
const call = await toolCallOf(request);
if (!call) return undefined;
const tool = policy.tool(call.name);
if (!tool) return undefined;
const decision = policy.evaluate(principal, call.name, { args: call.args });
if (decision.kind !== "deny" || decision.code !== "missing_scope") return undefined;
const missing = tool.scopes.filter((s) => !holds(principal.scopes, s));
const role = roleScopes(principal.person.role);
// Only scopes a token can carry and the role holds: anything else would make the client loop.
if (!missing.length || !missing.every((s) => isKeyScope(s) && holds(role, s))) return undefined;
return Response.json(
{
error: "insufficient_scope",
message: `this connection needs ${missing.join(", ")}; authorize it again to add them`,
details: { scope: missing },
},
{
status: 403,
headers: {
"www-authenticate": challengeHeader(oauth, {
error: "insufficient_scope",
scope: missing,
description: `${call.name} needs ${missing.join(" ")}`,
}),
},
},
);
}
4 changes: 4 additions & 0 deletions packages/api/src/playground.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,10 @@ const DENY: readonly (readonly [string, string, PlaygroundBlock])[] = [
["ALL", "/api/agent-keys", "keys"],
["ALL", "/api/agent-keys/*", "keys"],
["ALL", "/mcp", "mcp"],
// RFC-0044 decision 10: a playground has one fixed principal and no authorization server, so
// its MCP never speaks OAuth discovery (the documents are only mounted with `mcp.oauth`).
["GET", "/.well-known/oauth-protected-resource", "mcp"],
["GET", "/.well-known/oauth-protected-resource/mcp", "mcp"],
["ALL", `${S}/approvals`, "agent"],
["ALL", `${S}/approvals/*`, "agent"],
["ALL", `${S}/ai/*`, "agent"],
Expand Down
4 changes: 4 additions & 0 deletions packages/api/src/scopes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,10 @@ export const ROUTE_SCOPES: readonly (readonly [Method, string, RouteRequirement]
["GET", "/api/auth/*", "public"],
["POST", "/api/auth/*", "public"],
["ALL", "/mcp", "signed-in"],
// RFC-0044 §1: OAuth discovery (served only with `mcp.oauth`; outside `/api`, so `routeGuard`
// never sees them, but every mounted route is declared).
["GET", "/.well-known/oauth-protected-resource", "public"],
["GET", "/.well-known/oauth-protected-resource/mcp", "public"],
["GET", "/api/me", "signed-in"],

// operator (deployment token)
Expand Down
Loading
Loading