1import { ProviderInstanceId, ThreadId } from "@t3tools/contracts";
2import * as Clock from "effect/Clock";
3import * as Context from "effect/Context";
4import * as Crypto from "effect/Crypto";
5import * as Effect from "effect/Effect";
6import * as Layer from "effect/Layer";
7import * as SynchronizedRef from "effect/SynchronizedRef";
8import { HttpServer } from "effect/http";
9import * as NetAddress from "effect/net/NetAddress";
10
11import * as ServerEnvironment from "../environment/ServerEnvironment.ts";
12import * as McpInvocationContext from "./McpInvocationContext.ts";
13import * as McpProviderSession from "@t3tools/provider-core/server/mcpSession";
14
15export interface McpCredentialRequest {
16 readonly threadId: ThreadId;
17 readonly providerInstanceId: ProviderInstanceId;
18 /**
19 * When false, the credential is minted without the "preview" capability so
20 * the user's choice to withhold agent browser access holds everywhere the
21 * token is honored (#7083). Defaults to full access.
22 */
23 readonly browserToolsAvailable?: boolean;
24 readonly capabilities?: ReadonlySet<McpInvocationContext.McpCapability>;
25}
26
27export interface McpIssuedCredential {
28 readonly config: McpProviderSession.McpProviderSessionConfig;
29}
30
31export interface McpSessionRegistryShape {
32 readonly issue: (request: McpCredentialRequest) => Effect.Effect<McpIssuedCredential>;
33 readonly resolve: (
34 rawToken: string,
35 ) => Effect.Effect<McpInvocationContext.McpThreadInvocationScope | undefined>;
36 /**
37 * Records a sign of life for every credential bound to `threadId`. Provider
38 * turns call this so that a session which is plainly alive keeps its
39 * credential even when it goes a long time without touching an MCP tool.
40 */
41 readonly touch: (threadId: ThreadId) => Effect.Effect<void>;
42 readonly revokeProviderSession: (providerSessionId: string) => Effect.Effect<void>;
43 readonly revokeThread: (threadId: ThreadId) => Effect.Effect<void>;
44 readonly revokeAll: Effect.Effect<void>;
45}
46
47export class McpSessionRegistry extends Context.Service<
48 McpSessionRegistry,
49 McpSessionRegistryShape
50>()("t3/mcp/McpSessionRegistry") {}
51
52/** Registry credentials always belong to a provider session, so their scope has a thread. */
53interface CredentialRecord {
54 readonly tokenHash: string;
55 readonly scope: McpInvocationContext.McpThreadInvocationScope;
56 readonly lastAliveAt: number;
57}
58
59interface RegistryState {
60 readonly records: ReadonlyMap<string, CredentialRecord>;
61}
62
63export interface McpSessionRegistryOptions {
64 readonly livenessWindowMs?: number;
65 readonly now?: () => number;
66}
67
68/**
69 * How long a credential outlives the last sign of life from its provider
70 * session.
71 *
72 * Liveness is refreshed both by MCP traffic and by `touch` on every provider
73 * turn, so a session that is still doing work never expires no matter how long
74 * it goes between browser tool calls. This window therefore only bounds
75 * credentials whose session died without a clean stop — the normal paths
76 * (`stopSession`, `stopAll`) revoke eagerly and do not wait for it.
77 *
78 * The bound matters because `/mcp` is mounted outside the environment auth
79 * stack and is reachable on whatever host the server binds to, so this token is
80 * the only thing guarding the `t3-code` toolkits on a remote-reachable server.
81 */
82const DEFAULT_LIVENESS_WINDOW_MS = 24 * 60 * 60 * 1_000;
83
84const bytesToHex = (bytes: Uint8Array): string =>
85 Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("");
86
87const tokenFromBytes = (bytes: Uint8Array): string => Buffer.from(bytes).toString("base64url");
88
89// A wildcard bind is reachable on loopback, which is where the provider
90// subprocesses run; anything else is announced as the address it bound.
91const getHttpMcpEndpointHost = (address: NetAddress.IpAddress): string =>
92 NetAddress.isUnspecified(address)
93 ? "127.0.0.1"
94 : NetAddress.formatUrlHostString(NetAddress.formatIp(address));
95
96const makeWithOptions = Effect.fn("McpSessionRegistry.make")(function* (
97 options: McpSessionRegistryOptions = {},
98) {
99 const crypto = yield* Crypto.Crypto;
100 const environment = yield* ServerEnvironment.ServerEnvironment;
101 const environmentId = yield* environment.getEnvironmentId;
102 const httpServer = yield* HttpServer.HttpServer;
103 const state = yield* SynchronizedRef.make<RegistryState>({ records: new Map() });
104 const currentTimeMillis = options.now ? Effect.sync(options.now) : Clock.currentTimeMillis;
105 const livenessWindowMs = options.livenessWindowMs ?? DEFAULT_LIVENESS_WINDOW_MS;
106 const endpoint = NetAddress.isInetAddress(httpServer.address)
107 ? `http://${getHttpMcpEndpointHost(httpServer.address.address)}:${httpServer.address.port}/mcp`
108 : "http://127.0.0.1/mcp";
109
110 const hashToken = (token: string) =>
111 crypto
112 .digest("SHA-256", new TextEncoder().encode(token))
113 .pipe(Effect.map(bytesToHex), Effect.orDie);
114
115 const pruneDead = (records: ReadonlyMap<string, CredentialRecord>, timestamp: number) => {
116 const next = new Map(
117 Array.from(records).filter(
118 ([, record]) => timestamp - record.lastAliveAt <= livenessWindowMs,
119 ),
120 );
121 return next.size === records.size ? records : next;
122 };
123
124 const issue: McpSessionRegistryShape["issue"] = Effect.fn("McpSessionRegistry.issue")(
125 function* (request) {
126 const issuedAt = yield* currentTimeMillis;
127 const providerSessionId = yield* crypto.randomUUIDv4().pipe(Effect.orDie);
128 const rawToken = yield* crypto.randomBytes(32).pipe(Effect.map(tokenFromBytes), Effect.orDie);
129 const tokenHash = yield* hashToken(rawToken);
130 const browserToolsAvailable = request.browserToolsAvailable ?? true;
131 const scope: McpInvocationContext.McpThreadInvocationScope = {
132 environmentId,
133 requestNamespace: providerSessionId,
134 thread: {
135 threadId: ThreadId.make(request.threadId),
136 providerSessionId,
137 providerInstanceId: ProviderInstanceId.make(request.providerInstanceId),
138 },
139 client: undefined,
140 capabilities: new Set<McpInvocationContext.McpCapability>([
141 "orchestration",
142 "worktree",
143 "pull-requests",
144 ...(request.capabilities ?? (browserToolsAvailable ? (["preview"] as const) : [])),
145 ]),
146 issuedAt,
147 };
148 yield* SynchronizedRef.update(state, ({ records }) => {
149 const next = new Map(pruneDead(records, issuedAt));
150 next.set(tokenHash, { tokenHash, scope, lastAliveAt: issuedAt });
151 return { records: next };
152 });
153 return {
154 config: {
155 environmentId,
156 threadId: scope.thread.threadId,
157 providerSessionId,
158 providerInstanceId: scope.thread.providerInstanceId,
159 endpoint,
160 authorizationHeader: `Bearer ${rawToken}`,
161 browserToolsAvailable: scope.capabilities.has("preview"),
162 capabilities: scope.capabilities,
163 },
164 };
165 },
166 );
167
168 const resolve: McpSessionRegistryShape["resolve"] = Effect.fn("McpSessionRegistry.resolve")(
169 function* (rawToken) {
170 if (rawToken.length === 0) return undefined;
171 const tokenHash = yield* hashToken(rawToken);
172 const timestamp = yield* currentTimeMillis;
173 return yield* SynchronizedRef.modify(state, ({ records }) => {
174 const current = pruneDead(records, timestamp);
175 const record = current.get(tokenHash);
176 if (!record) return [undefined, { records: current }] as const;
177 const next = new Map(current);
178 next.set(tokenHash, { ...record, lastAliveAt: timestamp });
179 return [record.scope, { records: next }] as const;
180 });
181 },
182 );
183
184 const touch: McpSessionRegistryShape["touch"] = Effect.fn("McpSessionRegistry.touch")(
185 function* (threadId) {
186 const timestamp = yield* currentTimeMillis;
187 yield* SynchronizedRef.update(state, ({ records }) => {
188 const current = pruneDead(records, timestamp);
189 const next = new Map(current);
190 for (const [tokenHash, record] of current) {
191 if (record.scope.thread.threadId === threadId) {
192 next.set(tokenHash, { ...record, lastAliveAt: timestamp });
193 }
194 }
195 return { records: next };
196 });
197 },
198 );
199
200 const revokeWhere = (predicate: (record: CredentialRecord) => boolean) =>
201 SynchronizedRef.update(state, ({ records }) => ({
202 records: new Map(Array.from(records).filter(([, record]) => !predicate(record))),
203 }));
204
205 return McpSessionRegistry.of({
206 issue,
207 resolve,
208 touch,
209 revokeProviderSession: Effect.fn("McpSessionRegistry.revokeProviderSession")(
210 function* (providerSessionId) {
211 yield* revokeWhere((record) => record.scope.thread.providerSessionId === providerSessionId);
212 },
213 ),
214 revokeThread: Effect.fn("McpSessionRegistry.revokeThread")(function* (threadId) {
215 yield* revokeWhere((record) => record.scope.thread.threadId === threadId);
216 }),
217 revokeAll: SynchronizedRef.set(state, { records: new Map() }),
218 });
219});
220
221let activeMcpSessionRegistry: McpSessionRegistryShape | undefined;
222
223const make = Effect.acquireRelease(
224 makeWithOptions().pipe(
225 Effect.tap((registry) =>
226 Effect.sync(() => {
227 activeMcpSessionRegistry = registry;
228 }),
229 ),
230 ),
231 (registry) =>
232 Effect.sync(() => {
233 if (activeMcpSessionRegistry === registry) {
234 activeMcpSessionRegistry = undefined;
235 }
236 }),
237);
238
239export const layer = Layer.effect(McpSessionRegistry, make);
240
241export const issueActiveMcpCredential = (
242 request: McpCredentialRequest,
243): Effect.Effect<McpIssuedCredential | undefined> =>
244 activeMcpSessionRegistry
245 ? activeMcpSessionRegistry
246 .revokeThread(request.threadId)
247 .pipe(Effect.andThen(activeMcpSessionRegistry.issue(request)))
248 : Effect.undefined;
249
250/**
251 * Refreshes the liveness of a thread's MCP credential. Called on every provider
252 * turn so an active session is never mistaken for an abandoned one.
253 */
254export const touchActiveMcpThread = (threadId: ThreadId): Effect.Effect<void> =>
255 activeMcpSessionRegistry ? activeMcpSessionRegistry.touch(threadId) : Effect.void;
256
257const revokeActiveMcpThread = (threadId: ThreadId): Effect.Effect<void> =>
258 activeMcpSessionRegistry ? activeMcpSessionRegistry.revokeThread(threadId) : Effect.void;
259
260const revokeAllActiveMcpCredentials = (): Effect.Effect<void> =>
261 activeMcpSessionRegistry ? activeMcpSessionRegistry.revokeAll : Effect.void;
262
263/** Exposed for tests. */
264export const __testing = {
265 make: makeWithOptions,
266};