Skip to content

Commit 06ea06f

Browse files
chelojimenezclaude
andauthored
feat(mcp): guest + anonymous access to the platform MCP server (#2675)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 8e174e5 commit 06ea06f

15 files changed

Lines changed: 2349 additions & 168 deletions

File tree

mcp/src/auth.ts

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,16 @@ export interface VerifiedToken {
1111
payload: JWTPayload;
1212
}
1313

14+
/**
15+
* Guest token issuer. Mirrors the inspector's
16+
* `server/services/guest-token-keypair.ts` and the backend's
17+
* `convex/lib/guestJwt.ts` (`GUEST_ISSUER`). Guest tokens are RS256, carry
18+
* `{ iss, sub, iat, exp }` with NO `aud` claim, and must NOT carry a
19+
* `purpose` claim (promotion-proof tokens must not double as session
20+
* bearers — see `verifyGuestBearerToken` in the backend).
21+
*/
22+
export const GUEST_ISSUER = "https://api.mcpjam.com/guest";
23+
1424
export type VerifyResult =
1525
| { ok: true; verified: VerifiedToken }
1626
| { ok: false; response: Response };
@@ -87,11 +97,24 @@ export interface VerifyConfig {
8797
clientId: string;
8898
/** Custom AuthKit domain (`env.AUTHKIT_DOMAIN`), added to the issuer set. */
8999
authkitDomain: string | undefined;
100+
/**
101+
* Guest verification. When set, a token whose `iss` equals `guest.issuer`
102+
* is verified against `guest.jwksUrl` (the JWKS the platform publishes for
103+
* guest tokens) with NO audience pin. When undefined, guest tokens are
104+
* rejected (the guest issuer is absent from the AuthKit allow-list).
105+
*/
106+
guest?: { issuer: string; jwksUrl: string };
90107
/**
91108
* Issuer → key/JWKS resolver. `null` rejects the issuer. Injectable for
92109
* tests; production derives it from the allow-list above (remote JWKS).
93110
*/
94111
resolveKey?: (issuer: string) => KeyResolver | null;
112+
/**
113+
* Guest key resolver, parallel to `resolveKey`. Injectable for tests so the
114+
* guest branch doesn't need a remote JWKS; production resolves the remote
115+
* JWKS at `guest.jwksUrl`.
116+
*/
117+
resolveGuestKey?: (issuer: string) => KeyResolver | null;
95118
}
96119

97120
function defaultResolveKey(
@@ -152,6 +175,16 @@ export async function verifyBearerToken(
152175
return { ok: false, response: invalidTokenResponse(origin) };
153176
}
154177

178+
// Guest branch: a guest token (`iss === guest.issuer`) is verified against
179+
// the guest JWKS with NO audience pin (guest tokens carry no `aud`). Checked
180+
// before the AuthKit path because the guest issuer is intentionally absent
181+
// from `authkitIssuerJwks`. The trust decision is still `jwtVerify` —
182+
// signature + issuer pin + RS256 + exp — plus an explicit `purpose`
183+
// rejection mirroring the backend's `verifyGuestBearerToken`.
184+
if (config.guest && issuer === config.guest.issuer) {
185+
return verifyGuestToken(token, issuer, config, origin);
186+
}
187+
155188
const resolveKey =
156189
config.resolveKey ?? defaultResolveKey(config.clientId, config.authkitDomain);
157190
const key = resolveKey(issuer);
@@ -176,6 +209,47 @@ export async function verifyBearerToken(
176209
}
177210
}
178211

212+
async function verifyGuestToken(
213+
token: string,
214+
issuer: string,
215+
config: VerifyConfig,
216+
origin: string,
217+
): Promise<VerifyResult> {
218+
const guest = config.guest;
219+
if (!guest) {
220+
return { ok: false, response: invalidTokenResponse(origin) };
221+
}
222+
const key = config.resolveGuestKey
223+
? config.resolveGuestKey(issuer)
224+
: remoteJwks(guest.jwksUrl);
225+
if (!key) {
226+
return { ok: false, response: invalidTokenResponse(origin) };
227+
}
228+
const getKey: JWTVerifyGetKey =
229+
typeof key === "function" ? (key as JWTVerifyGetKey) : async () => key;
230+
231+
try {
232+
const { payload } = await jwtVerify(token, getKey, {
233+
issuer: guest.issuer,
234+
// No `audience`: guest tokens carry no `aud` claim.
235+
algorithms: ["RS256"],
236+
clockTolerance: 5,
237+
});
238+
// Mirror the backend's `verifyGuestBearerToken`: session bearers never
239+
// carry a `purpose` (promotion-proof tokens do), and must have a non-empty
240+
// string `sub`.
241+
if (payload.purpose !== undefined) {
242+
return { ok: false, response: invalidTokenResponse(origin) };
243+
}
244+
if (typeof payload.sub !== "string" || payload.sub.length === 0) {
245+
return { ok: false, response: invalidTokenResponse(origin) };
246+
}
247+
return { ok: true, verified: { token, payload } };
248+
} catch {
249+
return { ok: false, response: invalidTokenResponse(origin) };
250+
}
251+
}
252+
179253
export const OAUTH_DISCOVERY_HEADERS = {
180254
"access-control-allow-origin": "*",
181255
"access-control-expose-headers": "WWW-Authenticate",

mcp/src/env.d.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
// Secrets live outside `wrangler.jsonc` vars (set via `wrangler secret put`),
2+
// so `wrangler types` does not emit them into `worker-configuration.d.ts`.
3+
// Declaration-merge them onto the global `Env` here so they survive type
4+
// regeneration.
5+
interface Env {
6+
/**
7+
* Shared secret presented to the inspector guest-mint route as
8+
* `x-inspector-service-token` (matches the inspector's
9+
* `INSPECTOR_SERVICE_TOKEN`). Set with `wrangler secret put
10+
* MCPJAM_INSPECTOR_SERVICE_TOKEN --env <env>`.
11+
*/
12+
MCPJAM_INSPECTOR_SERVICE_TOKEN?: string;
13+
14+
/**
15+
* Killswitch toggle (runtime var / dashboard secret, not in wrangler.jsonc).
16+
* When "true", the worker is AuthKit-only: guest tokens are rejected and
17+
* anonymous (tokenless) /mcp connections get the normal 401 → OAuth
18+
* challenge.
19+
*/
20+
MCPJAM_NONPROD_LOCKDOWN?: string;
21+
}

mcp/src/index.ts

Lines changed: 55 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
import { McpJamMcpServer } from "./server.js";
22
import {
3+
GUEST_ISSUER,
34
OAUTH_DISCOVERY_HEADERS,
45
normalizeIssuer,
56
verifyBearerToken,
7+
type VerifyConfig,
68
} from "./auth.js";
79

810
export { McpJamMcpServer };
@@ -115,20 +117,63 @@ export default {
115117
return McpJamMcpServer.serve("/mcp").fetch(request, env, ctx);
116118
}
117119

118-
const result = await verifyBearerToken(
119-
request,
120-
{ clientId, authkitDomain: env.AUTHKIT_DOMAIN },
121-
origin,
122-
);
123-
if (!result.ok) return result.response;
120+
// Killswitch: when locked down, the server is AuthKit-only — guest
121+
// tokens are not accepted and anonymous (tokenless) connections are
122+
// refused with the normal 401 → OAuth challenge.
123+
const lockedDown = env.MCPJAM_NONPROD_LOCKDOWN === "true";
124+
125+
// Guest verification is enabled only when a guest JWKS URL is configured
126+
// (and not locked down). Absent it, guest tokens fall through to the
127+
// AuthKit allow-list and are rejected.
128+
const guest: VerifyConfig["guest"] =
129+
!lockedDown && env.MCPJAM_GUEST_JWKS_URL
130+
? { issuer: GUEST_ISSUER, jwksUrl: env.MCPJAM_GUEST_JWKS_URL }
131+
: undefined;
132+
133+
let props: { bearerToken?: string; claims?: unknown; clientIp?: string };
134+
if (request.headers.has("authorization")) {
135+
// A bearer was presented → it must verify (AuthKit or guest). A
136+
// present-but-invalid token still 401s; we never downgrade it to an
137+
// anonymous guest.
138+
const result = await verifyBearerToken(
139+
request,
140+
{ clientId, authkitDomain: env.AUTHKIT_DOMAIN, guest },
141+
origin,
142+
);
143+
if (!result.ok) return result.response;
144+
props = {
145+
bearerToken: result.verified.token,
146+
claims: result.verified.payload,
147+
};
148+
} else if (lockedDown) {
149+
// Tokenless + locked down → preserve the 401 → OAuth challenge.
150+
const result = await verifyBearerToken(
151+
request,
152+
{ clientId, authkitDomain: env.AUTHKIT_DOMAIN },
153+
origin,
154+
);
155+
// verifyBearerToken returns the 401 missing-token response here.
156+
if (!result.ok) return result.response;
157+
props = {
158+
bearerToken: result.verified.token,
159+
claims: result.verified.payload,
160+
};
161+
} else {
162+
// Tokenless anonymous session: NO mint here. The Durable Object mints
163+
// a guest token lazily on first platform-tool execution. Capture the
164+
// edge-provided client IP (trustworthy here — set by Cloudflare on the
165+
// inbound hit) so the DO can forward it to the rate-limited mint route.
166+
props = {
167+
bearerToken: undefined,
168+
claims: undefined,
169+
clientIp: request.headers.get("cf-connecting-ip") ?? undefined,
170+
};
171+
}
124172

125173
const authedCtx: ExecutionContext<Record<string, unknown>> = {
126174
waitUntil: (promise: Promise<unknown>) => ctx.waitUntil(promise),
127175
passThroughOnException: () => ctx.passThroughOnException(),
128-
props: {
129-
bearerToken: result.verified.token,
130-
claims: result.verified.payload,
131-
},
176+
props,
132177
};
133178

134179
return McpJamMcpServer.serve("/mcp").fetch(request, env, authedCtx);

mcp/src/server.ts

Lines changed: 106 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,12 +11,25 @@ import { registerPlatformCatalogTools } from "./tools/platformTools.js";
1111
import { registerShowServersTool } from "./tools/showServers.js";
1212

1313
interface McpProps extends Record<string, unknown> {
14-
bearerToken: string;
15-
claims: JWTPayload;
14+
// Optional: an anonymous (tokenless) session carries neither. The bearer is
15+
// then minted lazily on first platform-tool execution (see getBearerToken).
16+
bearerToken?: string;
17+
claims?: JWTPayload;
18+
// Real client IP for the anonymous connection (set by the edge from
19+
// cf-connecting-ip), forwarded to the mint route so it can rate-limit per
20+
// client rather than per worker.
21+
clientIp?: string;
1622
}
1723

24+
// Re-mint a minted guest token this far before its expiry. A guest token is
25+
// long-lived (~24h) but a long-running anonymous session must not start
26+
// failing every tool call the moment it lapses — refresh ahead of the edge.
27+
const GUEST_TOKEN_REFRESH_SLACK_MS = 60_000;
28+
1829
export class McpJamMcpServer extends McpAgent<Env, unknown, McpProps> {
1930
private sessionToolRegistrar?: SessionToolRegistrar;
31+
private mintedGuest?: { token: string; expiresAt: number };
32+
private mintInFlight?: Promise<string | undefined>;
2033

2134
server = new McpServer({
2235
name: "MCPJam MCP",
@@ -27,8 +40,43 @@ export class McpJamMcpServer extends McpAgent<Env, unknown, McpProps> {
2740
return this.env as Required<Env>;
2841
}
2942

43+
/** Synchronous view: the verified/minted token if one already exists. */
3044
get bearerToken(): string | undefined {
31-
return this.props?.bearerToken;
45+
return this.props?.bearerToken ?? this.mintedGuest?.token;
46+
}
47+
48+
/**
49+
* The bearer to authenticate Platform API calls with. For an authed session
50+
* it's the verified token. For an anonymous session it's a guest token
51+
* minted lazily on first call (NOT at connect/list_tools — listing tools
52+
* needs no Platform API, so an anonymous preflight must not create a guest
53+
* session) and re-minted before it expires, so a long-lived session never
54+
* starts 401ing on a lapsed guest token. Concurrent calls share one mint; a
55+
* mint failure surfaces as a tool error (caller checks for undefined) and is
56+
* retried next call.
57+
*/
58+
async getBearerToken(): Promise<string | undefined> {
59+
if (this.props?.bearerToken) return this.props.bearerToken;
60+
61+
const cached = this.mintedGuest;
62+
if (cached && cached.expiresAt - Date.now() > GUEST_TOKEN_REFRESH_SLACK_MS) {
63+
return cached.token;
64+
}
65+
66+
// Absent or within the refresh window → (re)mint once, shared across
67+
// concurrent callers.
68+
if (!this.mintInFlight) {
69+
this.mintInFlight = mintGuestToken(this.env, this.props?.clientIp)
70+
.then((minted) => {
71+
this.mintedGuest = minted; // undefined on failure → cache untouched
72+
return minted?.token;
73+
})
74+
.catch(() => undefined)
75+
.finally(() => {
76+
this.mintInFlight = undefined; // allow refresh/retry next call
77+
});
78+
}
79+
return this.mintInFlight;
3280
}
3381

3482
async init(): Promise<void> {
@@ -88,6 +136,61 @@ export class McpJamMcpServer extends McpAgent<Env, unknown, McpProps> {
88136
}
89137
}
90138

139+
/**
140+
* Mint a fresh guest token via the inspector's service-token-gated route
141+
* (`MCPJAM_GUEST_MINT_URL`). The route mints through the same Convex authority
142+
* that publishes the guest JWKS the worker verifies against, so the token is
143+
* accepted on the way back in. The client IP is forwarded in a custom header
144+
* (cf-connecting-ip would be overwritten by Cloudflare on the worker→inspector
145+
* hop) so the route can rate-limit per client. Returns the token and its
146+
* expiry (ms epoch) so the session can refresh before it lapses; returns
147+
* undefined on any failure (the caller turns that into a tool error).
148+
*/
149+
async function mintGuestToken(
150+
env: Env,
151+
clientIp: string | undefined
152+
): Promise<{ token: string; expiresAt: number } | undefined> {
153+
const url = env.MCPJAM_GUEST_MINT_URL;
154+
const serviceToken = env.MCPJAM_INSPECTOR_SERVICE_TOKEN;
155+
if (!url || !serviceToken) return undefined;
156+
try {
157+
const headers: Record<string, string> = {
158+
"content-type": "application/json",
159+
"x-inspector-service-token": serviceToken,
160+
};
161+
if (clientIp) headers["x-mcpjam-client-ip"] = clientIp;
162+
const response = await fetch(url, {
163+
method: "POST",
164+
headers,
165+
body: "{}",
166+
signal: AbortSignal.timeout(10_000),
167+
});
168+
if (!response.ok) return undefined;
169+
const data = (await response.json()) as {
170+
token?: unknown;
171+
expiresAt?: unknown;
172+
};
173+
if (typeof data.token !== "string") return undefined;
174+
return { token: data.token, expiresAt: normalizeExpiry(data.expiresAt) };
175+
} catch {
176+
return undefined;
177+
}
178+
}
179+
180+
/**
181+
* Normalize a mint `expiresAt` to ms epoch. The mint contract is ms (matches
182+
* `issueGuestToken`), but tolerate a seconds value defensively. A missing or
183+
* non-positive value falls back to a short TTL so the next call re-mints
184+
* rather than caching a never-expiring token forever.
185+
*/
186+
function normalizeExpiry(raw: unknown): number {
187+
if (typeof raw !== "number" || !Number.isFinite(raw) || raw <= 0) {
188+
return Date.now() + GUEST_TOKEN_REFRESH_SLACK_MS;
189+
}
190+
// Seconds-epoch timestamps are < 1e12; ms-epoch are ~1.7e12 today.
191+
return raw < 1e12 ? raw * 1000 : raw;
192+
}
193+
91194
function uiSupportsResourceMime(
92195
clientCapabilities: ClientCapabilities | undefined
93196
): boolean {

mcp/src/tools/platformTools.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -155,7 +155,10 @@ export async function runPlatformOperation<TInput, TOutput extends object>(
155155
input: TInput,
156156
transformPayload?: (payload: TOutput) => object
157157
) {
158-
const token = agent.bearerToken;
158+
// Resolve the bearer: the verified token for an authed session, or a
159+
// lazily-minted guest token for an anonymous one. Minting happens here (on
160+
// first tool execution), never at connect/list_tools.
161+
const token = await agent.getBearerToken();
159162
if (!token) {
160163
return toolError("No bearer token on the request.");
161164
}

0 commit comments

Comments
 (0)