@@ -11,12 +11,25 @@ import { registerPlatformCatalogTools } from "./tools/platformTools.js";
1111import { registerShowServersTool } from "./tools/showServers.js" ;
1212
1313interface 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+
1829export 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+
91194function uiSupportsResourceMime (
92195 clientCapabilities : ClientCapabilities | undefined
93196) : boolean {
0 commit comments