| 1 | import { ProviderInstanceId, ThreadId } from "@t3tools/contracts"; |
| 2 | import * as Clock from "effect/Clock"; |
| 3 | import * as Context from "effect/Context"; |
| 4 | import * as Crypto from "effect/Crypto"; |
| 5 | import * as Effect from "effect/Effect"; |
| 6 | import * as Layer from "effect/Layer"; |
| 7 | import * as SynchronizedRef from "effect/SynchronizedRef"; |
| 8 | import { HttpServer } from "effect/http"; |
| 9 | import * as NetAddress from "effect/net/NetAddress"; |
| 10 | |
| 11 | import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; |
| 12 | import * as McpInvocationContext from "./McpInvocationContext.ts"; |
| 13 | import * as McpProviderSession from "@t3tools/provider-core/server/mcpSession"; |
| 14 | |
| 15 | export 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 | |
| 27 | export interface McpIssuedCredential { |
| 28 | readonly config: McpProviderSession.McpProviderSessionConfig; |
| 29 | } |
| 30 | |
| 31 | export 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 | |
| 47 | export 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. */ |
| 53 | interface CredentialRecord { |
| 54 | readonly tokenHash: string; |
| 55 | readonly scope: McpInvocationContext.McpThreadInvocationScope; |
| 56 | readonly lastAliveAt: number; |
| 57 | } |
| 58 | |
| 59 | interface RegistryState { |
| 60 | readonly records: ReadonlyMap<string, CredentialRecord>; |
| 61 | } |
| 62 | |
| 63 | export 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 | */ |
| 82 | const DEFAULT_LIVENESS_WINDOW_MS = 24 * 60 * 60 * 1_000; |
| 83 | |
| 84 | const bytesToHex = (bytes: Uint8Array): string => |
| 85 | Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join(""); |
| 86 | |
| 87 | const 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. |
| 91 | const getHttpMcpEndpointHost = (address: NetAddress.IpAddress): string => |
| 92 | NetAddress.isUnspecified(address) |
| 93 | ? "127.0.0.1" |
| 94 | : NetAddress.formatUrlHostString(NetAddress.formatIp(address)); |
| 95 | |
| 96 | const 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 | |
| 221 | let activeMcpSessionRegistry: McpSessionRegistryShape | undefined; |
| 222 | |
| 223 | const 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 | |
| 239 | export const layer = Layer.effect(McpSessionRegistry, make); |
| 240 | |
| 241 | export 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 | */ |
| 254 | export const touchActiveMcpThread = (threadId: ThreadId): Effect.Effect<void> => |
| 255 | activeMcpSessionRegistry ? activeMcpSessionRegistry.touch(threadId) : Effect.void; |
| 256 | |
| 257 | const revokeActiveMcpThread = (threadId: ThreadId): Effect.Effect<void> => |
| 258 | activeMcpSessionRegistry ? activeMcpSessionRegistry.revokeThread(threadId) : Effect.void; |
| 259 | |
| 260 | const revokeAllActiveMcpCredentials = (): Effect.Effect<void> => |
| 261 | activeMcpSessionRegistry ? activeMcpSessionRegistry.revokeAll : Effect.void; |
| 262 | |
| 263 | /** Exposed for tests. */ |
| 264 | export const __testing = { |
| 265 | make: makeWithOptions, |
| 266 | }; |