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 = {...@@ -23,7 +23,6 @@ const invocation = {
23 providerInstanceId: ProviderInstanceId.make("codex"),23 providerInstanceId: ProviderInstanceId.make("codex"),
24 capabilities: new Set(["preview"] as const),24 capabilities: new Set(["preview"] as const),
25 issuedAt: 1,25 issuedAt: 1,
26 expiresAt: Number.MAX_SAFE_INTEGER,
27};26};
28const client = McpSchema.McpServerClient.of({27const client = McpSchema.McpServerClient.of({
29 clientId: 1,28 clientId: 1,
apps/server/src/mcp/McpHttpServer.ts+9-1
...@@ -74,7 +74,15 @@ const makeMcpAuthMiddleware = McpSessionRegistry.McpSessionRegistry.pipe(...@@ -74,7 +74,15 @@ const makeMcpAuthMiddleware = McpSessionRegistry.McpSessionRegistry.pipe(
74 ? authorization.slice("Bearer ".length).trim()74 ? authorization.slice("Bearer ".length).trim()
75 : "";75 : "";
76 const invocation = yield* registry.resolve(token);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 return yield* httpEffect.pipe(86 return yield* httpEffect.pipe(
79 Effect.provideService(McpInvocationContext.McpInvocationContext, invocation),87 Effect.provideService(McpInvocationContext.McpInvocationContext, invocation),
80 Effect.map(normalizeMcpHttpResponse),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,7 +17,6 @@ it.effect("reports the scoped credential context when preview capability is unav
17 providerInstanceId: ProviderInstanceId.make("codex"),17 providerInstanceId: ProviderInstanceId.make("codex"),
18 capabilities: new Set(),18 capabilities: new Set(),
19 issuedAt: 1,19 issuedAt: 1,
20 expiresAt: 2,
21 };20 };
2221
23 return Effect.gen(function* () {22 return Effect.gen(function* () {
apps/server/src/mcp/McpInvocationContext.ts-1
...@@ -16,7 +16,6 @@ export interface McpInvocationScope {...@@ -16,7 +16,6 @@ export interface McpInvocationScope {
16 readonly providerInstanceId: ProviderInstanceId;16 readonly providerInstanceId: ProviderInstanceId;
17 readonly capabilities: ReadonlySet<McpCapability>;17 readonly capabilities: ReadonlySet<McpCapability>;
18 readonly issuedAt: number;18 readonly issuedAt: number;
19 readonly expiresAt: number;
20}19}
2120
22export class McpInvocationContext extends Context.Service<21export 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,8 +23,7 @@ const makeRegistry = (now: () => number, httpServer = fakeHttpServer) =>
23 McpSessionRegistry.__testing23 McpSessionRegistry.__testing
24 .make({24 .make({
25 now,25 now,
26 idleTimeoutMs: 100,26 livenessWindowMs: 100,
27 maximumLifetimeMs: 1_000,
28 })27 })
29 .pipe(28 .pipe(
30 Effect.provideService(HttpServer.HttpServer, httpServer),29 Effect.provideService(HttpServer.HttpServer, httpServer),
...@@ -75,7 +74,7 @@ it.effect("builds MCP endpoints from the bound server host", () =>...@@ -75,7 +74,7 @@ it.effect("builds MCP endpoints from the bound server host", () =>
75 }),74 }),
76);75);
7776
78it.effect("expires credentials after inactivity", () =>77it.effect("expires credentials once their session stops showing signs of life", () =>
79 Effect.gen(function* () {78 Effect.gen(function* () {
80 let timestamp = 1_000;79 let timestamp = 1_000;
81 const registry = yield* makeRegistry(() => timestamp);80 const registry = yield* makeRegistry(() => timestamp);
...@@ -88,3 +87,43 @@ it.effect("expires credentials after inactivity", () =>...@@ -88,3 +87,43 @@ it.effect("expires credentials after inactivity", () =>
88 expect(yield* registry.resolve(token)).toBeUndefined();87 expect(yield* registry.resolve(token)).toBeUndefined();
89 }),88 }),
90);89);
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 {...@@ -18,7 +18,6 @@ export interface McpCredentialRequest {
1818
19export interface McpIssuedCredential {19export interface McpIssuedCredential {
20 readonly config: McpProviderSession.McpProviderSessionConfig;20 readonly config: McpProviderSession.McpProviderSessionConfig;
21 readonly expiresAt: number;
22}21}
2322
24export interface McpSessionRegistryShape {23export interface McpSessionRegistryShape {
...@@ -26,6 +25,12 @@ export interface McpSessionRegistryShape {...@@ -26,6 +25,12 @@ export interface McpSessionRegistryShape {
26 readonly resolve: (25 readonly resolve: (
27 rawToken: string,26 rawToken: string,
28 ) => Effect.Effect<McpInvocationContext.McpInvocationScope | undefined>;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 readonly revokeProviderSession: (providerSessionId: string) => Effect.Effect<void>;34 readonly revokeProviderSession: (providerSessionId: string) => Effect.Effect<void>;
30 readonly revokeThread: (threadId: ThreadId) => Effect.Effect<void>;35 readonly revokeThread: (threadId: ThreadId) => Effect.Effect<void>;
31 readonly revokeAll: Effect.Effect<void>;36 readonly revokeAll: Effect.Effect<void>;
...@@ -39,7 +44,7 @@ export class McpSessionRegistry extends Context.Service<...@@ -39,7 +44,7 @@ export class McpSessionRegistry extends Context.Service<
39interface CredentialRecord {44interface CredentialRecord {
40 readonly tokenHash: string;45 readonly tokenHash: string;
41 readonly scope: McpInvocationContext.McpInvocationScope;46 readonly scope: McpInvocationContext.McpInvocationScope;
42 readonly lastUsedAt: number;47 readonly lastAliveAt: number;
43}48}
4449
45interface RegistryState {50interface RegistryState {
...@@ -47,13 +52,25 @@ interface RegistryState {...@@ -47,13 +52,25 @@ interface RegistryState {
47}52}
4853
49export interface McpSessionRegistryOptions {54export interface McpSessionRegistryOptions {
50 readonly idleTimeoutMs?: number;55 readonly livenessWindowMs?: number;
51 readonly maximumLifetimeMs?: number;
52 readonly now?: () => number;56 readonly now?: () => number;
53}57}
5458
55const DEFAULT_IDLE_TIMEOUT_MS = 30 * 60 * 1_000;59/**
56const DEFAULT_MAXIMUM_LIFETIME_MS = 8 * 60 * 60 * 1_000;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
58const bytesToHex = (bytes: Uint8Array): string =>75const bytesToHex = (bytes: Uint8Array): string =>
59 Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("");76 Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("");
...@@ -80,8 +97,7 @@ const makeWithOptions = Effect.fn("McpSessionRegistry.make")(function* (...@@ -80,8 +97,7 @@ const makeWithOptions = Effect.fn("McpSessionRegistry.make")(function* (
80 const httpServer = yield* HttpServer.HttpServer;97 const httpServer = yield* HttpServer.HttpServer;
81 const state = yield* SynchronizedRef.make<RegistryState>({ records: new Map() });98 const state = yield* SynchronizedRef.make<RegistryState>({ records: new Map() });
82 const currentTimeMillis = options.now ? Effect.sync(options.now) : Clock.currentTimeMillis;99 const currentTimeMillis = options.now ? Effect.sync(options.now) : Clock.currentTimeMillis;
83 const idleTimeoutMs = options.idleTimeoutMs ?? DEFAULT_IDLE_TIMEOUT_MS;100 const livenessWindowMs = options.livenessWindowMs ?? DEFAULT_LIVENESS_WINDOW_MS;
84 const maximumLifetimeMs = options.maximumLifetimeMs ?? DEFAULT_MAXIMUM_LIFETIME_MS;
85 const endpoint =101 const endpoint =
86 httpServer.address._tag === "TcpAddress"102 httpServer.address._tag === "TcpAddress"
87 ? `http://${getHttpMcpEndpointHost(httpServer.address.hostname)}:${httpServer.address.port}/mcp`103 ? `http://${getHttpMcpEndpointHost(httpServer.address.hostname)}:${httpServer.address.port}/mcp`
...@@ -92,11 +108,10 @@ const makeWithOptions = Effect.fn("McpSessionRegistry.make")(function* (...@@ -92,11 +108,10 @@ const makeWithOptions = Effect.fn("McpSessionRegistry.make")(function* (
92 .digest("SHA-256", new TextEncoder().encode(token))108 .digest("SHA-256", new TextEncoder().encode(token))
93 .pipe(Effect.map(bytesToHex), Effect.orDie);109 .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) => {
96 const next = new Map(112 const next = new Map(
97 Array.from(records).filter(113 Array.from(records).filter(
98 ([, record]) =>114 ([, record]) => timestamp - record.lastAliveAt <= livenessWindowMs,
99 timestamp <= record.scope.expiresAt && timestamp - record.lastUsedAt <= idleTimeoutMs,
100 ),115 ),
101 );116 );
102 return next.size === records.size ? records : next;117 return next.size === records.size ? records : next;
...@@ -108,7 +123,6 @@ const makeWithOptions = Effect.fn("McpSessionRegistry.make")(function* (...@@ -108,7 +123,6 @@ const makeWithOptions = Effect.fn("McpSessionRegistry.make")(function* (
108 const providerSessionId = yield* crypto.randomUUIDv4.pipe(Effect.orDie);123 const providerSessionId = yield* crypto.randomUUIDv4.pipe(Effect.orDie);
109 const rawToken = yield* crypto.randomBytes(32).pipe(Effect.map(tokenFromBytes), Effect.orDie);124 const rawToken = yield* crypto.randomBytes(32).pipe(Effect.map(tokenFromBytes), Effect.orDie);
110 const tokenHash = yield* hashToken(rawToken);125 const tokenHash = yield* hashToken(rawToken);
111 const expiresAt = issuedAt + maximumLifetimeMs;
112 const scope: McpInvocationContext.McpInvocationScope = {126 const scope: McpInvocationContext.McpInvocationScope = {
113 environmentId,127 environmentId,
114 threadId: ThreadId.make(request.threadId),128 threadId: ThreadId.make(request.threadId),
...@@ -116,11 +130,10 @@ const makeWithOptions = Effect.fn("McpSessionRegistry.make")(function* (...@@ -116,11 +130,10 @@ const makeWithOptions = Effect.fn("McpSessionRegistry.make")(function* (
116 providerInstanceId: ProviderInstanceId.make(request.providerInstanceId),130 providerInstanceId: ProviderInstanceId.make(request.providerInstanceId),
117 capabilities: new Set(["preview"]),131 capabilities: new Set(["preview"]),
118 issuedAt,132 issuedAt,
119 expiresAt,
120 };133 };
121 yield* SynchronizedRef.update(state, ({ records }) => {134 yield* SynchronizedRef.update(state, ({ records }) => {
122 const next = new Map(pruneExpired(records, issuedAt));135 const next = new Map(pruneDead(records, issuedAt));
123 next.set(tokenHash, { tokenHash, scope, lastUsedAt: issuedAt });136 next.set(tokenHash, { tokenHash, scope, lastAliveAt: issuedAt });
124 return { records: next };137 return { records: next };
125 });138 });
126 return {139 return {
...@@ -132,7 +145,6 @@ const makeWithOptions = Effect.fn("McpSessionRegistry.make")(function* (...@@ -132,7 +145,6 @@ const makeWithOptions = Effect.fn("McpSessionRegistry.make")(function* (
132 endpoint,145 endpoint,
133 authorizationHeader: `Bearer ${rawToken}`,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,16 +155,32 @@ const makeWithOptions = Effect.fn("McpSessionRegistry.make")(function* (
143 const tokenHash = yield* hashToken(rawToken);155 const tokenHash = yield* hashToken(rawToken);
144 const timestamp = yield* currentTimeMillis;156 const timestamp = yield* currentTimeMillis;
145 return yield* SynchronizedRef.modify(state, ({ records }) => {157 return yield* SynchronizedRef.modify(state, ({ records }) => {
146 const current = pruneExpired(records, timestamp);158 const current = pruneDead(records, timestamp);
147 const record = current.get(tokenHash);159 const record = current.get(tokenHash);
148 if (!record) return [undefined, { records: current }] as const;160 if (!record) return [undefined, { records: current }] as const;
149 const next = new Map(current);161 const next = new Map(current);
150 next.set(tokenHash, { ...record, lastUsedAt: timestamp });162 next.set(tokenHash, { ...record, lastAliveAt: timestamp });
151 return [record.scope, { records: next }] as const;163 return [record.scope, { records: next }] as const;
152 });164 });
153 },165 },
154 );166 );
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
156 const revokeWhere = (predicate: (record: CredentialRecord) => boolean) =>184 const revokeWhere = (predicate: (record: CredentialRecord) => boolean) =>
157 SynchronizedRef.update(state, ({ records }) => ({185 SynchronizedRef.update(state, ({ records }) => ({
158 records: new Map(Array.from(records).filter(([, record]) => !predicate(record))),186 records: new Map(Array.from(records).filter(([, record]) => !predicate(record))),
...@@ -161,6 +189,7 @@ const makeWithOptions = Effect.fn("McpSessionRegistry.make")(function* (...@@ -161,6 +189,7 @@ const makeWithOptions = Effect.fn("McpSessionRegistry.make")(function* (
161 return McpSessionRegistry.of({189 return McpSessionRegistry.of({
162 issue,190 issue,
163 resolve,191 resolve,
192 touch,
164 revokeProviderSession: Effect.fn("McpSessionRegistry.revokeProviderSession")(193 revokeProviderSession: Effect.fn("McpSessionRegistry.revokeProviderSession")(
165 function* (providerSessionId) {194 function* (providerSessionId) {
166 yield* revokeWhere((record) => record.scope.providerSessionId === providerSessionId);195 yield* revokeWhere((record) => record.scope.providerSessionId === providerSessionId);
...@@ -202,6 +231,13 @@ export const issueActiveMcpCredential = (...@@ -202,6 +231,13 @@ export const issueActiveMcpCredential = (
202 .pipe(Effect.andThen(activeMcpSessionRegistry.issue(request)))231 .pipe(Effect.andThen(activeMcpSessionRegistry.issue(request)))
203 : Effect.sync((): McpIssuedCredential | undefined => undefined);232 : 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
205export const revokeActiveMcpThread = (threadId: ThreadId): Effect.Effect<void> =>241export const revokeActiveMcpThread = (threadId: ThreadId): Effect.Effect<void> =>
206 activeMcpSessionRegistry ? activeMcpSessionRegistry.revokeThread(threadId) : Effect.void;242 activeMcpSessionRegistry ? activeMcpSessionRegistry.revokeThread(threadId) : Effect.void;
207243
apps/server/src/mcp/PreviewAutomationBroker.test.ts-1
...@@ -31,7 +31,6 @@ const scope = {...@@ -31,7 +31,6 @@ const scope = {
31 providerInstanceId: ProviderInstanceId.make("codex"),31 providerInstanceId: ProviderInstanceId.make("codex"),
32 capabilities: new Set(["preview"] as const),32 capabilities: new Set(["preview"] as const),
33 issuedAt: 1,33 issuedAt: 1,
34 expiresAt: Number.MAX_SAFE_INTEGER,
35};34};
3635
37const makeHost = (overrides: Partial<PreviewAutomationHost> = {}): PreviewAutomationHost => ({36const makeHost = (overrides: Partial<PreviewAutomationHost> = {}): PreviewAutomationHost => ({
apps/server/src/mcp/PreviewAutomationBroker.ts+8-5
...@@ -22,7 +22,6 @@ import {...@@ -22,7 +22,6 @@ import {
22 type PreviewAutomationStreamEvent,22 type PreviewAutomationStreamEvent,
23} from "@t3tools/contracts";23} from "@t3tools/contracts";
24import * as Context from "effect/Context";24import * as Context from "effect/Context";
25import * as Clock from "effect/Clock";
26import * as Crypto from "effect/Crypto";25import * as Crypto from "effect/Crypto";
27import * as Deferred from "effect/Deferred";26import * as Deferred from "effect/Deferred";
28import * as Effect from "effect/Effect";27import * as Effect from "effect/Effect";
...@@ -75,11 +74,18 @@ interface PendingRequest {...@@ -75,11 +74,18 @@ interface PendingRequest {
75 readonly context: PreviewAutomationRequestErrorContext;74 readonly context: PreviewAutomationRequestErrorContext;
76}75}
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 */
78interface HostAssignment {85interface HostAssignment {
79 readonly clientId: ClientConnection["clientId"];86 readonly clientId: ClientConnection["clientId"];
80 readonly connectionId: ClientConnection["connectionId"];87 readonly connectionId: ClientConnection["connectionId"];
81 readonly queue: ClientConnection["queue"];88 readonly queue: ClientConnection["queue"];
82 readonly expiresAt: number;
83 readonly tabId?: PreviewTabId;89 readonly tabId?: PreviewTabId;
84 readonly tabSequence?: number;90 readonly tabSequence?: number;
85}91}
...@@ -422,13 +428,11 @@ export const make = Effect.gen(function* PreviewAutomationBrokerMake() {...@@ -422,13 +428,11 @@ export const make = Effect.gen(function* PreviewAutomationBrokerMake() {
422 ): Effect.fn.Return<A, PreviewAutomationError> {428 ): Effect.fn.Return<A, PreviewAutomationError> {
423 const timeoutMs = input.timeoutMs ?? 15_000;429 const timeoutMs = input.timeoutMs ?? 15_000;
424 const deferred = yield* Deferred.make<unknown, PreviewAutomationError>();430 const deferred = yield* Deferred.make<unknown, PreviewAutomationError>();
425 const now = yield* Clock.currentTimeMillis;
426 const route = yield* SynchronizedRef.modify(state, (current) => {431 const route = yield* SynchronizedRef.modify(state, (current) => {
427 const assignments = new Map(432 const assignments = new Map(
428 Array.from(current.assignments).filter(([, assignment]) => {433 Array.from(current.assignments).filter(([, assignment]) => {
429 const connection = current.clients.get(assignment.clientId);434 const connection = current.clients.get(assignment.clientId);
430 return (435 return (
431 assignment.expiresAt > now &&
432 connection?.connectionId === assignment.connectionId &&436 connection?.connectionId === assignment.connectionId &&
433 connection.queue === assignment.queue437 connection.queue === assignment.queue
434 );438 );
...@@ -473,7 +477,6 @@ export const make = Effect.gen(function* PreviewAutomationBrokerMake() {...@@ -473,7 +477,6 @@ export const make = Effect.gen(function* PreviewAutomationBrokerMake() {
473 clientId: connection.clientId,477 clientId: connection.clientId,
474 connectionId: connection.connectionId,478 connectionId: connection.connectionId,
475 queue: connection.queue,479 queue: connection.queue,
476 expiresAt: input.scope.expiresAt,
477 ...(canReuseAssignedTab && assigned.tabId !== undefined ? { tabId: assigned.tabId } : {}),480 ...(canReuseAssignedTab && assigned.tabId !== undefined ? { tabId: assigned.tabId } : {}),
478 ...(canReuseAssignedTab && assigned.tabSequence !== undefined481 ...(canReuseAssignedTab && assigned.tabSequence !== undefined
479 ? { tabSequence: assigned.tabSequence }482 ? { tabSequence: assigned.tabSequence }
apps/server/src/provider/Layers/ProviderService.ts+6
...@@ -679,6 +679,12 @@ const makeProviderService = Effect.fn("makeProviderService")(function* (...@@ -679,6 +679,12 @@ const makeProviderService = Effect.fn("makeProviderService")(function* (
679 "provider.kind": routed.adapter.provider,679 "provider.kind": routed.adapter.provider,
680 ...(input.modelSelection?.model ? { "provider.model": input.modelSelection.model } : {}),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 const turn = yield* routed.adapter.sendTurn(input);688 const turn = yield* routed.adapter.sendTurn(input);
683 yield* directory.upsert({689 yield* directory.upsert({
684 threadId: input.threadId,690 threadId: input.threadId,