| author | |
| committer | |
| log | 0e19c103ef07b6a2c143839d2d2a9cf223915d1e |
| tree | 9c40175790e649bf3a026237b40a0c05b58ce0d0 |
| parent | 32843c2551bd5832d436ef46bff5ab1198cdc39c |
| signature | Signed by PGP key B5690EEEBB952194 |
9 files changed, 119 insertions(+), 31 deletions(-)
apps/server/src/mcp/McpHttpServer.test.ts-1| ... | ... | @@ -23,7 +23,6 @@ const invocation = { |
| 23 | 23 | providerInstanceId: ProviderInstanceId.make("codex"), |
| 24 | 24 | capabilities: new Set(["preview"] as const), |
| 25 | 25 | issuedAt: 1, |
| 26 | expiresAt: Number.MAX_SAFE_INTEGER, | |
| 27 | 26 | }; |
| 28 | 27 | const client = McpSchema.McpServerClient.of({ |
| 29 | 28 | clientId: 1, |
apps/server/src/mcp/McpHttpServer.ts+9-1| ... | ... | @@ -74,7 +74,15 @@ const makeMcpAuthMiddleware = McpSessionRegistry.McpSessionRegistry.pipe( |
| 74 | 74 | ? authorization.slice("Bearer ".length).trim() |
| 75 | 75 | : ""; |
| 76 | 76 | const invocation = yield* registry.resolve(token); |
| 77 | if (!invocation) return unauthorized; | |
| 77 | if (!invocation) { | |
| 78 | // Without this the only symptom of a dead credential is the agent | |
| 79 | // quietly losing the whole `t3-code` toolkit for the rest of its | |
| 80 | // session, with nothing on the server to explain why. | |
| 81 | yield* Effect.logWarning("rejected MCP request with an unusable credential", { | |
| 82 | reason: token.length === 0 ? "missing_bearer_token" : "unknown_or_expired_token", | |
| 83 | }); | |
| 84 | return unauthorized; | |
| 85 | } | |
| 78 | 86 | return yield* httpEffect.pipe( |
| 79 | 87 | Effect.provideService(McpInvocationContext.McpInvocationContext, invocation), |
| 80 | 88 | Effect.map(normalizeMcpHttpResponse), |
apps/server/src/mcp/McpInvocationContext.test.ts-1| ... | ... | @@ -17,7 +17,6 @@ it.effect("reports the scoped credential context when preview capability is unav |
| 17 | 17 | providerInstanceId: ProviderInstanceId.make("codex"), |
| 18 | 18 | capabilities: new Set(), |
| 19 | 19 | issuedAt: 1, |
| 20 | expiresAt: 2, | |
| 21 | 20 | }; |
| 22 | 21 | |
| 23 | 22 | return Effect.gen(function* () { |
apps/server/src/mcp/McpInvocationContext.ts-1| ... | ... | @@ -16,7 +16,6 @@ export interface McpInvocationScope { |
| 16 | 16 | readonly providerInstanceId: ProviderInstanceId; |
| 17 | 17 | readonly capabilities: ReadonlySet<McpCapability>; |
| 18 | 18 | readonly issuedAt: number; |
| 19 | readonly expiresAt: number; | |
| 20 | 19 | } |
| 21 | 20 | |
| 22 | 21 | export class McpInvocationContext extends Context.Service< |
apps/server/src/mcp/McpSessionRegistry.test.ts+42-3| ... | ... | @@ -23,8 +23,7 @@ const makeRegistry = (now: () => number, httpServer = fakeHttpServer) => |
| 23 | 23 | McpSessionRegistry.__testing |
| 24 | 24 | .make({ |
| 25 | 25 | now, |
| 26 | idleTimeoutMs: 100, | |
| 27 | maximumLifetimeMs: 1_000, | |
| 26 | livenessWindowMs: 100, | |
| 28 | 27 | }) |
| 29 | 28 | .pipe( |
| 30 | 29 | Effect.provideService(HttpServer.HttpServer, httpServer), |
| ... | ... | @@ -75,7 +74,7 @@ it.effect("builds MCP endpoints from the bound server host", () => |
| 75 | 74 | }), |
| 76 | 75 | ); |
| 77 | 76 | |
| 78 | it.effect("expires credentials after inactivity", () => | |
| 77 | it.effect("expires credentials once their session stops showing signs of life", () => | |
| 79 | 78 | Effect.gen(function* () { |
| 80 | 79 | let timestamp = 1_000; |
| 81 | 80 | const registry = yield* makeRegistry(() => timestamp); |
| ... | ... | @@ -88,3 +87,43 @@ it.effect("expires credentials after inactivity", () => |
| 88 | 87 | expect(yield* registry.resolve(token)).toBeUndefined(); |
| 89 | 88 | }), |
| 90 | 89 | ); |
| 90 | ||
| 91 | it.effect("keeps a credential alive across turns that never touch an MCP tool", () => | |
| 92 | Effect.gen(function* () { | |
| 93 | let timestamp = 1_000; | |
| 94 | const registry = yield* makeRegistry(() => timestamp); | |
| 95 | const threadId = ThreadId.make("thread-3"); | |
| 96 | const issued = yield* registry.issue({ | |
| 97 | threadId, | |
| 98 | providerInstanceId: ProviderInstanceId.make("claude"), | |
| 99 | }); | |
| 100 | const token = issued.config.authorizationHeader.replace(/^Bearer\s+/, ""); | |
| 101 | ||
| 102 | // Well past the liveness window in total, but each turn reports in before | |
| 103 | // it lapses — this is the long-session case that used to lose the toolkit. | |
| 104 | for (let turn = 0; turn < 10; turn += 1) { | |
| 105 | timestamp += 99; | |
| 106 | yield* registry.touch(threadId); | |
| 107 | } | |
| 108 | ||
| 109 | expect((yield* registry.resolve(token))?.threadId).toBe(threadId); | |
| 110 | }), | |
| 111 | ); | |
| 112 | ||
| 113 | it.effect("does not keep credentials of other threads alive", () => | |
| 114 | Effect.gen(function* () { | |
| 115 | let timestamp = 1_000; | |
| 116 | const registry = yield* makeRegistry(() => timestamp); | |
| 117 | const issued = yield* registry.issue({ | |
| 118 | threadId: ThreadId.make("thread-4"), | |
| 119 | providerInstanceId: ProviderInstanceId.make("codex"), | |
| 120 | }); | |
| 121 | const token = issued.config.authorizationHeader.replace(/^Bearer\s+/, ""); | |
| 122 | ||
| 123 | timestamp += 99; | |
| 124 | yield* registry.touch(ThreadId.make("thread-unrelated")); | |
| 125 | timestamp += 2; | |
| 126 | ||
| 127 | expect(yield* registry.resolve(token)).toBeUndefined(); | |
| 128 | }), | |
| 129 | ); |
apps/server/src/mcp/McpSessionRegistry.ts+54-18| ... | ... | @@ -18,7 +18,6 @@ export interface McpCredentialRequest { |
| 18 | 18 | |
| 19 | 19 | export interface McpIssuedCredential { |
| 20 | 20 | readonly config: McpProviderSession.McpProviderSessionConfig; |
| 21 | readonly expiresAt: number; | |
| 22 | 21 | } |
| 23 | 22 | |
| 24 | 23 | export interface McpSessionRegistryShape { |
| ... | ... | @@ -26,6 +25,12 @@ export interface McpSessionRegistryShape { |
| 26 | 25 | readonly resolve: ( |
| 27 | 26 | rawToken: string, |
| 28 | 27 | ) => Effect.Effect<McpInvocationContext.McpInvocationScope | undefined>; |
| 28 | /** | |
| 29 | * Records a sign of life for every credential bound to `threadId`. Provider | |
| 30 | * turns call this so that a session which is plainly alive keeps its | |
| 31 | * credential even when it goes a long time without touching an MCP tool. | |
| 32 | */ | |
| 33 | readonly touch: (threadId: ThreadId) => Effect.Effect<void>; | |
| 29 | 34 | readonly revokeProviderSession: (providerSessionId: string) => Effect.Effect<void>; |
| 30 | 35 | readonly revokeThread: (threadId: ThreadId) => Effect.Effect<void>; |
| 31 | 36 | readonly revokeAll: Effect.Effect<void>; |
| ... | ... | @@ -39,7 +44,7 @@ export class McpSessionRegistry extends Context.Service< |
| 39 | 44 | interface CredentialRecord { |
| 40 | 45 | readonly tokenHash: string; |
| 41 | 46 | readonly scope: McpInvocationContext.McpInvocationScope; |
| 42 | readonly lastUsedAt: number; | |
| 47 | readonly lastAliveAt: number; | |
| 43 | 48 | } |
| 44 | 49 | |
| 45 | 50 | interface RegistryState { |
| ... | ... | @@ -47,13 +52,25 @@ interface RegistryState { |
| 47 | 52 | } |
| 48 | 53 | |
| 49 | 54 | export interface McpSessionRegistryOptions { |
| 50 | readonly idleTimeoutMs?: number; | |
| 51 | readonly maximumLifetimeMs?: number; | |
| 55 | readonly livenessWindowMs?: number; | |
| 52 | 56 | readonly now?: () => number; |
| 53 | 57 | } |
| 54 | 58 | |
| 55 | const DEFAULT_IDLE_TIMEOUT_MS = 30 * 60 * 1_000; | |
| 56 | const DEFAULT_MAXIMUM_LIFETIME_MS = 8 * 60 * 60 * 1_000; | |
| 59 | /** | |
| 60 | * How long a credential outlives the last sign of life from its provider | |
| 61 | * session. | |
| 62 | * | |
| 63 | * Liveness is refreshed both by MCP traffic and by `touch` on every provider | |
| 64 | * turn, so a session that is still doing work never expires no matter how long | |
| 65 | * it goes between browser tool calls. This window therefore only bounds | |
| 66 | * credentials whose session died without a clean stop — the normal paths | |
| 67 | * (`stopSession`, `stopAll`) revoke eagerly and do not wait for it. | |
| 68 | * | |
| 69 | * The bound matters because `/mcp` is mounted outside the environment auth | |
| 70 | * stack and is reachable on whatever host the server binds to, so this token is | |
| 71 | * the only thing guarding the preview toolkit on a remote-reachable server. | |
| 72 | */ | |
| 73 | const DEFAULT_LIVENESS_WINDOW_MS = 24 * 60 * 60 * 1_000; | |
| 57 | 74 | |
| 58 | 75 | const bytesToHex = (bytes: Uint8Array): string => |
| 59 | 76 | Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join(""); |
| ... | ... | @@ -80,8 +97,7 @@ const makeWithOptions = Effect.fn("McpSessionRegistry.make")(function* ( |
| 80 | 97 | const httpServer = yield* HttpServer.HttpServer; |
| 81 | 98 | const state = yield* SynchronizedRef.make<RegistryState>({ records: new Map() }); |
| 82 | 99 | const currentTimeMillis = options.now ? Effect.sync(options.now) : Clock.currentTimeMillis; |
| 83 | const idleTimeoutMs = options.idleTimeoutMs ?? DEFAULT_IDLE_TIMEOUT_MS; | |
| 84 | const maximumLifetimeMs = options.maximumLifetimeMs ?? DEFAULT_MAXIMUM_LIFETIME_MS; | |
| 100 | const livenessWindowMs = options.livenessWindowMs ?? DEFAULT_LIVENESS_WINDOW_MS; | |
| 85 | 101 | const endpoint = |
| 86 | 102 | httpServer.address._tag === "TcpAddress" |
| 87 | 103 | ? `http://${getHttpMcpEndpointHost(httpServer.address.hostname)}:${httpServer.address.port}/mcp` |
| ... | ... | @@ -92,11 +108,10 @@ const makeWithOptions = Effect.fn("McpSessionRegistry.make")(function* ( |
| 92 | 108 | .digest("SHA-256", new TextEncoder().encode(token)) |
| 93 | 109 | .pipe(Effect.map(bytesToHex), Effect.orDie); |
| 94 | 110 | |
| 95 | const pruneExpired = (records: ReadonlyMap<string, CredentialRecord>, timestamp: number) => { | |
| 111 | const pruneDead = (records: ReadonlyMap<string, CredentialRecord>, timestamp: number) => { | |
| 96 | 112 | const next = new Map( |
| 97 | 113 | Array.from(records).filter( |
| 98 | ([, record]) => | |
| 99 | timestamp <= record.scope.expiresAt && timestamp - record.lastUsedAt <= idleTimeoutMs, | |
| 114 | ([, record]) => timestamp - record.lastAliveAt <= livenessWindowMs, | |
| 100 | 115 | ), |
| 101 | 116 | ); |
| 102 | 117 | return next.size === records.size ? records : next; |
| ... | ... | @@ -108,7 +123,6 @@ const makeWithOptions = Effect.fn("McpSessionRegistry.make")(function* ( |
| 108 | 123 | const providerSessionId = yield* crypto.randomUUIDv4.pipe(Effect.orDie); |
| 109 | 124 | const rawToken = yield* crypto.randomBytes(32).pipe(Effect.map(tokenFromBytes), Effect.orDie); |
| 110 | 125 | const tokenHash = yield* hashToken(rawToken); |
| 111 | const expiresAt = issuedAt + maximumLifetimeMs; | |
| 112 | 126 | const scope: McpInvocationContext.McpInvocationScope = { |
| 113 | 127 | environmentId, |
| 114 | 128 | threadId: ThreadId.make(request.threadId), |
| ... | ... | @@ -116,11 +130,10 @@ const makeWithOptions = Effect.fn("McpSessionRegistry.make")(function* ( |
| 116 | 130 | providerInstanceId: ProviderInstanceId.make(request.providerInstanceId), |
| 117 | 131 | capabilities: new Set(["preview"]), |
| 118 | 132 | issuedAt, |
| 119 | expiresAt, | |
| 120 | 133 | }; |
| 121 | 134 | yield* SynchronizedRef.update(state, ({ records }) => { |
| 122 | const next = new Map(pruneExpired(records, issuedAt)); | |
| 123 | next.set(tokenHash, { tokenHash, scope, lastUsedAt: issuedAt }); | |
| 135 | const next = new Map(pruneDead(records, issuedAt)); | |
| 136 | next.set(tokenHash, { tokenHash, scope, lastAliveAt: issuedAt }); | |
| 124 | 137 | return { records: next }; |
| 125 | 138 | }); |
| 126 | 139 | return { |
| ... | ... | @@ -132,7 +145,6 @@ const makeWithOptions = Effect.fn("McpSessionRegistry.make")(function* ( |
| 132 | 145 | endpoint, |
| 133 | 146 | authorizationHeader: `Bearer ${rawToken}`, |
| 134 | 147 | }, |
| 135 | expiresAt, | |
| 136 | 148 | }; |
| 137 | 149 | }, |
| 138 | 150 | ); |
| ... | ... | @@ -143,16 +155,32 @@ const makeWithOptions = Effect.fn("McpSessionRegistry.make")(function* ( |
| 143 | 155 | const tokenHash = yield* hashToken(rawToken); |
| 144 | 156 | const timestamp = yield* currentTimeMillis; |
| 145 | 157 | return yield* SynchronizedRef.modify(state, ({ records }) => { |
| 146 | const current = pruneExpired(records, timestamp); | |
| 158 | const current = pruneDead(records, timestamp); | |
| 147 | 159 | const record = current.get(tokenHash); |
| 148 | 160 | if (!record) return [undefined, { records: current }] as const; |
| 149 | 161 | const next = new Map(current); |
| 150 | next.set(tokenHash, { ...record, lastUsedAt: timestamp }); | |
| 162 | next.set(tokenHash, { ...record, lastAliveAt: timestamp }); | |
| 151 | 163 | return [record.scope, { records: next }] as const; |
| 152 | 164 | }); |
| 153 | 165 | }, |
| 154 | 166 | ); |
| 155 | 167 | |
| 168 | const touch: McpSessionRegistryShape["touch"] = Effect.fn("McpSessionRegistry.touch")( | |
| 169 | function* (threadId) { | |
| 170 | const timestamp = yield* currentTimeMillis; | |
| 171 | yield* SynchronizedRef.update(state, ({ records }) => { | |
| 172 | const current = pruneDead(records, timestamp); | |
| 173 | const next = new Map(current); | |
| 174 | for (const [tokenHash, record] of current) { | |
| 175 | if (record.scope.threadId === threadId) { | |
| 176 | next.set(tokenHash, { ...record, lastAliveAt: timestamp }); | |
| 177 | } | |
| 178 | } | |
| 179 | return { records: next }; | |
| 180 | }); | |
| 181 | }, | |
| 182 | ); | |
| 183 | ||
| 156 | 184 | const revokeWhere = (predicate: (record: CredentialRecord) => boolean) => |
| 157 | 185 | SynchronizedRef.update(state, ({ records }) => ({ |
| 158 | 186 | records: new Map(Array.from(records).filter(([, record]) => !predicate(record))), |
| ... | ... | @@ -161,6 +189,7 @@ const makeWithOptions = Effect.fn("McpSessionRegistry.make")(function* ( |
| 161 | 189 | return McpSessionRegistry.of({ |
| 162 | 190 | issue, |
| 163 | 191 | resolve, |
| 192 | touch, | |
| 164 | 193 | revokeProviderSession: Effect.fn("McpSessionRegistry.revokeProviderSession")( |
| 165 | 194 | function* (providerSessionId) { |
| 166 | 195 | yield* revokeWhere((record) => record.scope.providerSessionId === providerSessionId); |
| ... | ... | @@ -202,6 +231,13 @@ export const issueActiveMcpCredential = ( |
| 202 | 231 | .pipe(Effect.andThen(activeMcpSessionRegistry.issue(request))) |
| 203 | 232 | : Effect.sync((): McpIssuedCredential | undefined => undefined); |
| 204 | 233 | |
| 234 | /** | |
| 235 | * Refreshes the liveness of a thread's MCP credential. Called on every provider | |
| 236 | * turn so an active session is never mistaken for an abandoned one. | |
| 237 | */ | |
| 238 | export const touchActiveMcpThread = (threadId: ThreadId): Effect.Effect<void> => | |
| 239 | activeMcpSessionRegistry ? activeMcpSessionRegistry.touch(threadId) : Effect.void; | |
| 240 | ||
| 205 | 241 | export const revokeActiveMcpThread = (threadId: ThreadId): Effect.Effect<void> => |
| 206 | 242 | activeMcpSessionRegistry ? activeMcpSessionRegistry.revokeThread(threadId) : Effect.void; |
| 207 | 243 |
apps/server/src/mcp/PreviewAutomationBroker.test.ts-1| ... | ... | @@ -31,7 +31,6 @@ const scope = { |
| 31 | 31 | providerInstanceId: ProviderInstanceId.make("codex"), |
| 32 | 32 | capabilities: new Set(["preview"] as const), |
| 33 | 33 | issuedAt: 1, |
| 34 | expiresAt: Number.MAX_SAFE_INTEGER, | |
| 35 | 34 | }; |
| 36 | 35 | |
| 37 | 36 | const makeHost = (overrides: Partial<PreviewAutomationHost> = {}): PreviewAutomationHost => ({ |
apps/server/src/mcp/PreviewAutomationBroker.ts+8-5| ... | ... | @@ -22,7 +22,6 @@ import { |
| 22 | 22 | type PreviewAutomationStreamEvent, |
| 23 | 23 | } from "@t3tools/contracts"; |
| 24 | 24 | import * as Context from "effect/Context"; |
| 25 | import * as Clock from "effect/Clock"; | |
| 26 | 25 | import * as Crypto from "effect/Crypto"; |
| 27 | 26 | import * as Deferred from "effect/Deferred"; |
| 28 | 27 | import * as Effect from "effect/Effect"; |
| ... | ... | @@ -75,11 +74,18 @@ interface PendingRequest { |
| 75 | 74 | readonly context: PreviewAutomationRequestErrorContext; |
| 76 | 75 | } |
| 77 | 76 | |
| 77 | /** | |
| 78 | * A lease pinning one provider session to one desktop runtime. It lives exactly | |
| 79 | * as long as the connection it names: `connectionId`/`queue` identity is what | |
| 80 | * makes a lease valid, so a disconnected or replaced host is dropped on the next | |
| 81 | * lookup. The lease deliberately has no clock of its own — it used to inherit | |
| 82 | * the MCP credential's expiry, which coupled host stickiness to an unrelated | |
| 83 | * auth deadline and could migrate a live session to another runtime mid-flow. | |
| 84 | */ | |
| 78 | 85 | interface HostAssignment { |
| 79 | 86 | readonly clientId: ClientConnection["clientId"]; |
| 80 | 87 | readonly connectionId: ClientConnection["connectionId"]; |
| 81 | 88 | readonly queue: ClientConnection["queue"]; |
| 82 | readonly expiresAt: number; | |
| 83 | 89 | readonly tabId?: PreviewTabId; |
| 84 | 90 | readonly tabSequence?: number; |
| 85 | 91 | } |
| ... | ... | @@ -422,13 +428,11 @@ export const make = Effect.gen(function* PreviewAutomationBrokerMake() { |
| 422 | 428 | ): Effect.fn.Return<A, PreviewAutomationError> { |
| 423 | 429 | const timeoutMs = input.timeoutMs ?? 15_000; |
| 424 | 430 | const deferred = yield* Deferred.make<unknown, PreviewAutomationError>(); |
| 425 | const now = yield* Clock.currentTimeMillis; | |
| 426 | 431 | const route = yield* SynchronizedRef.modify(state, (current) => { |
| 427 | 432 | const assignments = new Map( |
| 428 | 433 | Array.from(current.assignments).filter(([, assignment]) => { |
| 429 | 434 | const connection = current.clients.get(assignment.clientId); |
| 430 | 435 | return ( |
| 431 | assignment.expiresAt > now && | |
| 432 | 436 | connection?.connectionId === assignment.connectionId && |
| 433 | 437 | connection.queue === assignment.queue |
| 434 | 438 | ); |
| ... | ... | @@ -473,7 +477,6 @@ export const make = Effect.gen(function* PreviewAutomationBrokerMake() { |
| 473 | 477 | clientId: connection.clientId, |
| 474 | 478 | connectionId: connection.connectionId, |
| 475 | 479 | queue: connection.queue, |
| 476 | expiresAt: input.scope.expiresAt, | |
| 477 | 480 | ...(canReuseAssignedTab && assigned.tabId !== undefined ? { tabId: assigned.tabId } : {}), |
| 478 | 481 | ...(canReuseAssignedTab && assigned.tabSequence !== undefined |
| 479 | 482 | ? { tabSequence: assigned.tabSequence } |
apps/server/src/provider/Layers/ProviderService.ts+6| ... | ... | @@ -679,6 +679,12 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( |
| 679 | 679 | "provider.kind": routed.adapter.provider, |
| 680 | 680 | ...(input.modelSelection?.model ? { "provider.model": input.modelSelection.model } : {}), |
| 681 | 681 | }); |
| 682 | // A turn is the clearest sign a session is still alive. The MCP | |
| 683 | // credential is minted once at session start and cannot be rotated into | |
| 684 | // an already-spawned agent process, so we keep the existing token valid | |
| 685 | // rather than issuing a new one: sessions that go a long time between | |
| 686 | // browser tool calls used to lose the toolkit outright. | |
| 687 | yield* McpSessionRegistry.touchActiveMcpThread(input.threadId); | |
| 682 | 688 | const turn = yield* routed.adapter.sendTurn(input); |
| 683 | 689 | yield* directory.upsert({ |
| 684 | 690 | threadId: input.threadId, |