authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-07-27 19:57:22+02:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-07-27 19:57:22+02:00
log0e19c103ef07b6a2c143839d2d2a9cf223915d1e
tree9c40175790e649bf3a026237b40a0c05b58ce0d0
parent32843c2551bd5832d436ef46bff5ab1198cdc39c
signature Signed by PGP key B5690EEEBB952194

Keep MCP credentials alive across provider turns (#4659)


9 files changed, 119 insertions(+), 31 deletions(-)

apps/server/src/mcp/McpHttpServer.test.ts-1
......@@ -23,7 +23,6 @@ const invocation = {
2323 providerInstanceId: ProviderInstanceId.make("codex"),
2424 capabilities: new Set(["preview"] as const),
2525 issuedAt: 1,
26 expiresAt: Number.MAX_SAFE_INTEGER,
2726};
2827const client = McpSchema.McpServerClient.of({
2928 clientId: 1,
apps/server/src/mcp/McpHttpServer.ts+9-1
......@@ -74,7 +74,15 @@ const makeMcpAuthMiddleware = McpSessionRegistry.McpSessionRegistry.pipe(
7474 ? authorization.slice("Bearer ".length).trim()
7575 : "";
7676 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 }
7886 return yield* httpEffect.pipe(
7987 Effect.provideService(McpInvocationContext.McpInvocationContext, invocation),
8088 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
1717 providerInstanceId: ProviderInstanceId.make("codex"),
1818 capabilities: new Set(),
1919 issuedAt: 1,
20 expiresAt: 2,
2120 };
2221
2322 return Effect.gen(function* () {
apps/server/src/mcp/McpInvocationContext.ts-1
......@@ -16,7 +16,6 @@ export interface McpInvocationScope {
1616 readonly providerInstanceId: ProviderInstanceId;
1717 readonly capabilities: ReadonlySet<McpCapability>;
1818 readonly issuedAt: number;
19 readonly expiresAt: number;
2019}
2120
2221export class McpInvocationContext extends Context.Service<
apps/server/src/mcp/McpSessionRegistry.test.ts+42-3
......@@ -23,8 +23,7 @@ const makeRegistry = (now: () => number, httpServer = fakeHttpServer) =>
2323 McpSessionRegistry.__testing
2424 .make({
2525 now,
26 idleTimeoutMs: 100,
27 maximumLifetimeMs: 1_000,
26 livenessWindowMs: 100,
2827 })
2928 .pipe(
3029 Effect.provideService(HttpServer.HttpServer, httpServer),
......@@ -75,7 +74,7 @@ it.effect("builds MCP endpoints from the bound server host", () =>
7574 }),
7675);
7776
78it.effect("expires credentials after inactivity", () =>
77it.effect("expires credentials once their session stops showing signs of life", () =>
7978 Effect.gen(function* () {
8079 let timestamp = 1_000;
8180 const registry = yield* makeRegistry(() => timestamp);
......@@ -88,3 +87,43 @@ it.effect("expires credentials after inactivity", () =>
8887 expect(yield* registry.resolve(token)).toBeUndefined();
8988 }),
9089);
90
91it.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
113it.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 {
1818
1919export interface McpIssuedCredential {
2020 readonly config: McpProviderSession.McpProviderSessionConfig;
21 readonly expiresAt: number;
2221}
2322
2423export interface McpSessionRegistryShape {
......@@ -26,6 +25,12 @@ export interface McpSessionRegistryShape {
2625 readonly resolve: (
2726 rawToken: string,
2827 ) => 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>;
2934 readonly revokeProviderSession: (providerSessionId: string) => Effect.Effect<void>;
3035 readonly revokeThread: (threadId: ThreadId) => Effect.Effect<void>;
3136 readonly revokeAll: Effect.Effect<void>;
......@@ -39,7 +44,7 @@ export class McpSessionRegistry extends Context.Service<
3944interface CredentialRecord {
4045 readonly tokenHash: string;
4146 readonly scope: McpInvocationContext.McpInvocationScope;
42 readonly lastUsedAt: number;
47 readonly lastAliveAt: number;
4348}
4449
4550interface RegistryState {
......@@ -47,13 +52,25 @@ interface RegistryState {
4752}
4853
4954export interface McpSessionRegistryOptions {
50 readonly idleTimeoutMs?: number;
51 readonly maximumLifetimeMs?: number;
55 readonly livenessWindowMs?: number;
5256 readonly now?: () => number;
5357}
5458
55const DEFAULT_IDLE_TIMEOUT_MS = 30 * 60 * 1_000;
56const 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 */
73const DEFAULT_LIVENESS_WINDOW_MS = 24 * 60 * 60 * 1_000;
5774
5875const bytesToHex = (bytes: Uint8Array): string =>
5976 Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("");
......@@ -80,8 +97,7 @@ const makeWithOptions = Effect.fn("McpSessionRegistry.make")(function* (
8097 const httpServer = yield* HttpServer.HttpServer;
8198 const state = yield* SynchronizedRef.make<RegistryState>({ records: new Map() });
8299 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;
85101 const endpoint =
86102 httpServer.address._tag === "TcpAddress"
87103 ? `http://${getHttpMcpEndpointHost(httpServer.address.hostname)}:${httpServer.address.port}/mcp`
......@@ -92,11 +108,10 @@ const makeWithOptions = Effect.fn("McpSessionRegistry.make")(function* (
92108 .digest("SHA-256", new TextEncoder().encode(token))
93109 .pipe(Effect.map(bytesToHex), Effect.orDie);
94110
95 const pruneExpired = (records: ReadonlyMap<string, CredentialRecord>, timestamp: number) => {
111 const pruneDead = (records: ReadonlyMap<string, CredentialRecord>, timestamp: number) => {
96112 const next = new Map(
97113 Array.from(records).filter(
98 ([, record]) =>
99 timestamp <= record.scope.expiresAt && timestamp - record.lastUsedAt <= idleTimeoutMs,
114 ([, record]) => timestamp - record.lastAliveAt <= livenessWindowMs,
100115 ),
101116 );
102117 return next.size === records.size ? records : next;
......@@ -108,7 +123,6 @@ const makeWithOptions = Effect.fn("McpSessionRegistry.make")(function* (
108123 const providerSessionId = yield* crypto.randomUUIDv4.pipe(Effect.orDie);
109124 const rawToken = yield* crypto.randomBytes(32).pipe(Effect.map(tokenFromBytes), Effect.orDie);
110125 const tokenHash = yield* hashToken(rawToken);
111 const expiresAt = issuedAt + maximumLifetimeMs;
112126 const scope: McpInvocationContext.McpInvocationScope = {
113127 environmentId,
114128 threadId: ThreadId.make(request.threadId),
......@@ -116,11 +130,10 @@ const makeWithOptions = Effect.fn("McpSessionRegistry.make")(function* (
116130 providerInstanceId: ProviderInstanceId.make(request.providerInstanceId),
117131 capabilities: new Set(["preview"]),
118132 issuedAt,
119 expiresAt,
120133 };
121134 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 });
124137 return { records: next };
125138 });
126139 return {
......@@ -132,7 +145,6 @@ const makeWithOptions = Effect.fn("McpSessionRegistry.make")(function* (
132145 endpoint,
133146 authorizationHeader: `Bearer ${rawToken}`,
134147 },
135 expiresAt,
136148 };
137149 },
138150 );
......@@ -143,16 +155,32 @@ const makeWithOptions = Effect.fn("McpSessionRegistry.make")(function* (
143155 const tokenHash = yield* hashToken(rawToken);
144156 const timestamp = yield* currentTimeMillis;
145157 return yield* SynchronizedRef.modify(state, ({ records }) => {
146 const current = pruneExpired(records, timestamp);
158 const current = pruneDead(records, timestamp);
147159 const record = current.get(tokenHash);
148160 if (!record) return [undefined, { records: current }] as const;
149161 const next = new Map(current);
150 next.set(tokenHash, { ...record, lastUsedAt: timestamp });
162 next.set(tokenHash, { ...record, lastAliveAt: timestamp });
151163 return [record.scope, { records: next }] as const;
152164 });
153165 },
154166 );
155167
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
156184 const revokeWhere = (predicate: (record: CredentialRecord) => boolean) =>
157185 SynchronizedRef.update(state, ({ records }) => ({
158186 records: new Map(Array.from(records).filter(([, record]) => !predicate(record))),
......@@ -161,6 +189,7 @@ const makeWithOptions = Effect.fn("McpSessionRegistry.make")(function* (
161189 return McpSessionRegistry.of({
162190 issue,
163191 resolve,
192 touch,
164193 revokeProviderSession: Effect.fn("McpSessionRegistry.revokeProviderSession")(
165194 function* (providerSessionId) {
166195 yield* revokeWhere((record) => record.scope.providerSessionId === providerSessionId);
......@@ -202,6 +231,13 @@ export const issueActiveMcpCredential = (
202231 .pipe(Effect.andThen(activeMcpSessionRegistry.issue(request)))
203232 : Effect.sync((): McpIssuedCredential | undefined => undefined);
204233
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 */
238export const touchActiveMcpThread = (threadId: ThreadId): Effect.Effect<void> =>
239 activeMcpSessionRegistry ? activeMcpSessionRegistry.touch(threadId) : Effect.void;
240
205241export const revokeActiveMcpThread = (threadId: ThreadId): Effect.Effect<void> =>
206242 activeMcpSessionRegistry ? activeMcpSessionRegistry.revokeThread(threadId) : Effect.void;
207243
apps/server/src/mcp/PreviewAutomationBroker.test.ts-1
......@@ -31,7 +31,6 @@ const scope = {
3131 providerInstanceId: ProviderInstanceId.make("codex"),
3232 capabilities: new Set(["preview"] as const),
3333 issuedAt: 1,
34 expiresAt: Number.MAX_SAFE_INTEGER,
3534};
3635
3736const makeHost = (overrides: Partial<PreviewAutomationHost> = {}): PreviewAutomationHost => ({
apps/server/src/mcp/PreviewAutomationBroker.ts+8-5
......@@ -22,7 +22,6 @@ import {
2222 type PreviewAutomationStreamEvent,
2323} from "@t3tools/contracts";
2424import * as Context from "effect/Context";
25import * as Clock from "effect/Clock";
2625import * as Crypto from "effect/Crypto";
2726import * as Deferred from "effect/Deferred";
2827import * as Effect from "effect/Effect";
......@@ -75,11 +74,18 @@ interface PendingRequest {
7574 readonly context: PreviewAutomationRequestErrorContext;
7675}
7776
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 */
7885interface HostAssignment {
7986 readonly clientId: ClientConnection["clientId"];
8087 readonly connectionId: ClientConnection["connectionId"];
8188 readonly queue: ClientConnection["queue"];
82 readonly expiresAt: number;
8389 readonly tabId?: PreviewTabId;
8490 readonly tabSequence?: number;
8591}
......@@ -422,13 +428,11 @@ export const make = Effect.gen(function* PreviewAutomationBrokerMake() {
422428 ): Effect.fn.Return<A, PreviewAutomationError> {
423429 const timeoutMs = input.timeoutMs ?? 15_000;
424430 const deferred = yield* Deferred.make<unknown, PreviewAutomationError>();
425 const now = yield* Clock.currentTimeMillis;
426431 const route = yield* SynchronizedRef.modify(state, (current) => {
427432 const assignments = new Map(
428433 Array.from(current.assignments).filter(([, assignment]) => {
429434 const connection = current.clients.get(assignment.clientId);
430435 return (
431 assignment.expiresAt > now &&
432436 connection?.connectionId === assignment.connectionId &&
433437 connection.queue === assignment.queue
434438 );
......@@ -473,7 +477,6 @@ export const make = Effect.gen(function* PreviewAutomationBrokerMake() {
473477 clientId: connection.clientId,
474478 connectionId: connection.connectionId,
475479 queue: connection.queue,
476 expiresAt: input.scope.expiresAt,
477480 ...(canReuseAssignedTab && assigned.tabId !== undefined ? { tabId: assigned.tabId } : {}),
478481 ...(canReuseAssignedTab && assigned.tabSequence !== undefined
479482 ? { tabSequence: assigned.tabSequence }
apps/server/src/provider/Layers/ProviderService.ts+6
......@@ -679,6 +679,12 @@ const makeProviderService = Effect.fn("makeProviderService")(function* (
679679 "provider.kind": routed.adapter.provider,
680680 ...(input.modelSelection?.model ? { "provider.model": input.modelSelection.model } : {}),
681681 });
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);
682688 const turn = yield* routed.adapter.sendTurn(input);
683689 yield* directory.upsert({
684690 threadId: input.threadId,