Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions apps/server/src/mcp/McpSessionRegistry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,3 +88,45 @@ it.effect("expires credentials after inactivity", () =>
expect(yield* registry.resolve(token)).toBeUndefined();
}),
);

it.effect("expires a never-used credential once it hits the maximum lifetime", () =>
Effect.gen(function* () {
let timestamp = 1_000;
const registry = yield* makeRegistry(() => timestamp);
const issued = yield* registry.issue({
threadId: ThreadId.make("thread-3"),
providerInstanceId: ProviderInstanceId.make("claude"),
});
const token = issued.config.authorizationHeader.replace(/^Bearer\s+/, "");
// Stay within the idle window but past the maximum lifetime.
timestamp += 1_001;
expect(yield* registry.resolve(token)).toBeUndefined();
}),
);

it.effect(
"transparently renews a credential's expiry on every use, so an actively-used session outlives the maximum lifetime",
() =>
Effect.gen(function* () {
let timestamp = 1_000;
const registry = yield* makeRegistry(() => timestamp);
const threadId = ThreadId.make("thread-4");
const issued = yield* registry.issue({
threadId,
providerInstanceId: ProviderInstanceId.make("claude"),
});
const token = issued.config.authorizationHeader.replace(/^Bearer\s+/, "");

// Repeatedly use the credential at an interval shorter than both the
// idle timeout and the maximum lifetime, well past the point where the
// original mint-time expiry (issuedAt + maximumLifetimeMs = 2_000)
// would have killed it without renewal.
for (let i = 0; i < 20; i++) {
timestamp += 90;
const resolved = yield* registry.resolve(token);
expect(resolved?.threadId).toBe(threadId);
}

expect(timestamp).toBeGreaterThan(issued.expiresAt);
}),
);
31 changes: 28 additions & 3 deletions apps/server/src/mcp/McpSessionRegistry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,23 @@ export interface McpSessionRegistryOptions {
}

const DEFAULT_IDLE_TIMEOUT_MS = 30 * 60 * 1_000;
const DEFAULT_MAXIMUM_LIFETIME_MS = 8 * 60 * 60 * 1_000;
// This bounds how long an MCP bearer credential stays valid *without any use*
// past its mint time before `resolve` refreshes it (see below) — it is not a
// hard cap on session length. Agent sessions routinely run far longer than a
// single workday (multi-hour coding sessions, overnight/scheduled runs), and
// the credential is minted once at provider-session start and handed to the
// agent process as a static header, so there is no way for the agent to pick
// up a rotated token later. A short absolute cap therefore silently kills the
// MCP connection out from under an otherwise-healthy session (observed
// 2026-07-03: "t3-code requires re-authorization (token expired)" mid-session).
// The credential is already tightly scoped (random 256-bit token, single
// thread + provider instance, held only in memory, and explicitly revoked on
// session stop/error/environment shutdown — see `revokeThread`/`revokeAll`),
// so a long backstop here doesn't meaningfully widen exposure; it's paired
// with `resolve` sliding the expiry forward on every authenticated use so
// active sessions never approach the cap, and the existing idle timeout still
// reclaims credentials for threads that truly go quiet.
const DEFAULT_MAXIMUM_LIFETIME_MS = 30 * 24 * 60 * 60 * 1_000;

const bytesToHex = (bytes: Uint8Array): string =>
Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("");
Expand Down Expand Up @@ -147,8 +163,17 @@ const makeWithOptions = Effect.fn("McpSessionRegistry.make")(function* (
const record = current.get(tokenHash);
if (!record) return [undefined, { records: current }] as const;
const next = new Map(current);
next.set(tokenHash, { ...record, lastUsedAt: timestamp });
return [record.scope, { records: next }] as const;
// Transparently re-issue: every authenticated use of a still-valid
// credential pushes its absolute expiry forward, so a session that
// keeps calling MCP tools never runs into the maximum-lifetime cap
// above. Only sessions that stop using MCP entirely fall back to
// that cap (or the idle timeout, whichever is hit first).
const renewedScope: McpInvocationContext.McpInvocationScope = {
...record.scope,
expiresAt: Math.max(record.scope.expiresAt, timestamp + maximumLifetimeMs),
};
next.set(tokenHash, { ...record, scope: renewedScope, lastUsedAt: timestamp });
return [renewedScope, { records: next }] as const;
});
},
);
Expand Down
Loading