| author | |
| committer | |
| log | 2c8be5893eb3754071162e344a8c8e8a67bef597 |
| tree | 147bd933ebff3d13738a697c43a0fedac8e4d706 |
| parent | 2f85686d9dca5ec98c892a2a26c321e09d746d71 |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>29 files changed, 2531 insertions(+), 67 deletions(-)
apps/server/src/auth/EnvironmentAuth.ts+161-2| ... | ... | @@ -2,6 +2,8 @@ import { |
| 2 | 2 | AuthAccessTokenType, |
| 3 | 3 | AuthAccessWriteScope, |
| 4 | 4 | AuthAdministrativeScopes, |
| 5 | AuthOrchestrationOperateScope, | |
| 6 | AuthOrchestrationReadScope, | |
| 5 | 7 | AuthStandardClientScopes, |
| 6 | 8 | type AuthAccessTokenResult, |
| 7 | 9 | type AuthBrowserSessionResult, |
| ... | ... | @@ -9,6 +11,7 @@ import { |
| 9 | 11 | type AuthClientSession, |
| 10 | 12 | type AuthCreatePairingCredentialInput, |
| 11 | 13 | type AuthEnvironmentScope, |
| 14 | type AuthMcpClientAccess, | |
| 12 | 15 | type AuthPairingLink, |
| 13 | 16 | type AuthPairingCredentialResult, |
| 14 | 17 | type AuthSessionId, |
| ... | ... | @@ -63,6 +66,31 @@ export interface IssuedBearerSession { |
| 63 | 66 | readonly expiresAt: DateTime.Utc; |
| 64 | 67 | } |
| 65 | 68 | |
| 69 | /** | |
| 70 | * Sessions an MCP client (an agent T3 Code did not launch) obtains through | |
| 71 | * OAuth. They are accepted only by `/mcp`, where every action is capped by the | |
| 72 | * access the user approved; the HTTP API and WebSocket reject them so an agent | |
| 73 | * token cannot reach the full RPC surface around that cap. | |
| 74 | * | |
| 75 | * A read-only grant holds `orchestration:read` alone. Any other grant also | |
| 76 | * holds `orchestration:operate` and carries its runtime-mode ceiling. | |
| 77 | */ | |
| 78 | const MCP_CLIENT_SUBJECT = "mcp-client"; | |
| 79 | const MCP_CLIENT_SESSION_TTL = Duration.days(30); | |
| 80 | ||
| 81 | export const mcpClientScopes = ( | |
| 82 | access: AuthMcpClientAccess, | |
| 83 | ): ReadonlyArray<AuthEnvironmentScope> => | |
| 84 | access === "read-only" | |
| 85 | ? [AuthOrchestrationReadScope] | |
| 86 | : [AuthOrchestrationReadScope, AuthOrchestrationOperateScope]; | |
| 87 | ||
| 88 | export interface McpClientSession { | |
| 89 | readonly sessionId: AuthSessionId; | |
| 90 | readonly label: string; | |
| 91 | readonly access: AuthMcpClientAccess; | |
| 92 | } | |
| 93 | ||
| 66 | 94 | export interface AuthenticatedSession { |
| 67 | 95 | readonly sessionId: AuthSessionId; |
| 68 | 96 | readonly subject: string; |
| ... | ... | @@ -406,6 +434,19 @@ export const serverAuthInvalidRequestReason = ( |
| 406 | 434 | ): "invalid_scope" | "scope_not_granted" => |
| 407 | 435 | error._tag === "ServerAuthInvalidScopeError" ? "invalid_scope" : "scope_not_granted"; |
| 408 | 436 | |
| 437 | export class ServerAuthMcpApprovalCodeError extends Schema.TaggedError<ServerAuthMcpApprovalCodeError>()( | |
| 438 | "ServerAuthMcpApprovalCodeError", | |
| 439 | { reason: Schema.Literals(["unknown_or_used", "not_a_pairing_code", "insufficient_scope"]) }, | |
| 440 | ) { | |
| 441 | override get message(): string { | |
| 442 | return this.reason === "insufficient_scope" | |
| 443 | ? "That pairing code cannot grant this access, and it is now used up. Create one with the standard scopes, or choose Read only with a new code." | |
| 444 | : this.reason === "not_a_pairing_code" | |
| 445 | ? "That is not a one-time pairing code." | |
| 446 | : "That pairing code is unknown, expired, or already used."; | |
| 447 | } | |
| 448 | } | |
| 449 | ||
| 409 | 450 | export class ServerAuthForbiddenOperationError extends Schema.TaggedError<ServerAuthForbiddenOperationError>()( |
| 410 | 451 | "ServerAuthForbiddenOperationError", |
| 411 | 452 | {}, |
| ... | ... | @@ -502,6 +543,32 @@ export class EnvironmentAuth extends Context.Service< |
| 502 | 543 | readonly issueStartupPairingUrl: ( |
| 503 | 544 | baseUrl: string, |
| 504 | 545 | ) => Effect.Effect<string, ServerAuthInternalError>; |
| 546 | /** Only bearer `mcp-client` sessions; never cookies or proof-bound tokens. */ | |
| 547 | readonly authenticateMcpClient: ( | |
| 548 | request: HttpServerRequest.HttpServerRequest, | |
| 549 | ) => Effect.Effect<McpClientSession, ServerAuthCredentialError | ServerAuthInternalError>; | |
| 550 | readonly issueMcpClientSession: (input: { | |
| 551 | readonly label: string; | |
| 552 | readonly access: AuthMcpClientAccess; | |
| 553 | readonly client: AuthClientMetadata; | |
| 554 | }) => Effect.Effect< | |
| 555 | { readonly token: string; readonly expiresAt: DateTime.DateTime }, | |
| 556 | ServerAuthInternalError | |
| 557 | >; | |
| 558 | /** | |
| 559 | * Spends a one-time pairing code as approval for an MCP client with the | |
| 560 | * given access; the code must hold every scope that access grants. | |
| 561 | * Proof-bound codes (T3 Connect) are refused without being spent, and | |
| 562 | * desktop bootstrap grants never qualify. | |
| 563 | */ | |
| 564 | readonly consumeMcpApprovalCode: ( | |
| 565 | code: string, | |
| 566 | access: AuthMcpClientAccess, | |
| 567 | ) => Effect.Effect<void, ServerAuthMcpApprovalCodeError | ServerAuthInternalError>; | |
| 568 | /** A browser cookie session only; a bearer header never counts as one. */ | |
| 569 | readonly authenticateBrowserSession: ( | |
| 570 | request: HttpServerRequest.HttpServerRequest, | |
| 571 | ) => Effect.Effect<AuthenticatedSession, ServerAuthCredentialError | ServerAuthInternalError>; | |
| 505 | 572 | } |
| 506 | 573 | >()("t3/auth/EnvironmentAuth") {} |
| 507 | 574 | |
| ... | ... | @@ -540,6 +607,15 @@ export function toBootstrapExchangeError( |
| 540 | 607 | }); |
| 541 | 608 | } |
| 542 | 609 | |
| 610 | const rejectMcpClientAudience = <S extends { readonly subject: string }>(session: S) => | |
| 611 | session.subject === MCP_CLIENT_SUBJECT | |
| 612 | ? Effect.fail( | |
| 613 | new ServerAuthInvalidCredentialError({ | |
| 614 | diagnostic: "MCP client sessions are only accepted by the MCP endpoint.", | |
| 615 | }), | |
| 616 | ).pipe(Effect.tap(() => Effect.logWarning("Rejected an MCP client session outside /mcp."))) | |
| 617 | : Effect.succeed(session); | |
| 618 | ||
| 543 | 619 | const mapSessionVerificationErrors = <A, R>( |
| 544 | 620 | effect: Effect.Effect<A, SessionStore.SessionCredentialError, R>, |
| 545 | 621 | ): Effect.Effect<A, ServerAuthInvalidCredentialError | ServerAuthInternalError, R> => |
| ... | ... | @@ -624,6 +700,8 @@ export const make = Effect.gen(function* () { |
| 624 | 700 | ) |
| 625 | 701 | : Effect.void, |
| 626 | 702 | ), |
| 703 | mapSessionVerificationErrors, | |
| 704 | Effect.flatMap(rejectMcpClientAudience), | |
| 627 | 705 | Effect.map((session) => ({ |
| 628 | 706 | sessionId: session.sessionId, |
| 629 | 707 | subject: session.subject, |
| ... | ... | @@ -632,7 +710,6 @@ export const make = Effect.gen(function* () { |
| 632 | 710 | ...(session.proofKeyThumbprint ? { proofKeyThumbprint: session.proofKeyThumbprint } : {}), |
| 633 | 711 | ...(session.expiresAt ? { expiresAt: session.expiresAt } : {}), |
| 634 | 712 | })), |
| 635 | mapSessionVerificationErrors, | |
| 636 | 713 | ); |
| 637 | 714 | |
| 638 | 715 | const authenticateRequest = ( |
| ... | ... | @@ -1079,6 +1156,8 @@ export const make = Effect.gen(function* () { |
| 1079 | 1156 | const websocketTicket = requestUrl.value.searchParams.get(WEBSOCKET_TICKET_QUERY_PARAM); |
| 1080 | 1157 | if (websocketTicket && websocketTicket.trim().length > 0) { |
| 1081 | 1158 | return yield* sessions.verifyWebSocketToken(websocketTicket).pipe( |
| 1159 | mapSessionVerificationErrors, | |
| 1160 | Effect.flatMap(rejectMcpClientAudience), | |
| 1082 | 1161 | Effect.map((session) => ({ |
| 1083 | 1162 | sessionId: session.sessionId, |
| 1084 | 1163 | subject: session.subject, |
| ... | ... | @@ -1086,7 +1165,6 @@ export const make = Effect.gen(function* () { |
| 1086 | 1165 | scopes: session.scopes, |
| 1087 | 1166 | ...(session.expiresAt ? { expiresAt: session.expiresAt } : {}), |
| 1088 | 1167 | })), |
| 1089 | mapSessionVerificationErrors, | |
| 1090 | 1168 | ); |
| 1091 | 1169 | } |
| 1092 | 1170 | } |
| ... | ... | @@ -1094,6 +1172,83 @@ export const make = Effect.gen(function* () { |
| 1094 | 1172 | return yield* authenticateRequest(request); |
| 1095 | 1173 | }); |
| 1096 | 1174 | |
| 1175 | const authenticateMcpClient: EnvironmentAuth["Service"]["authenticateMcpClient"] = (request) => { | |
| 1176 | const token = parseBearerToken(request); | |
| 1177 | if (token === null) return Effect.fail(new ServerAuthMissingCredentialError({})); | |
| 1178 | return sessions.verify(token).pipe( | |
| 1179 | mapSessionVerificationErrors, | |
| 1180 | Effect.flatMap((session) => | |
| 1181 | session.subject === MCP_CLIENT_SUBJECT && session.method === "bearer-access-token" | |
| 1182 | ? Effect.succeed({ | |
| 1183 | sessionId: session.sessionId, | |
| 1184 | label: session.client.label ?? "MCP client", | |
| 1185 | access: session.scopes.includes(AuthOrchestrationOperateScope) | |
| 1186 | ? (session.runtimeModeCeiling ?? "approval-required") | |
| 1187 | : "read-only", | |
| 1188 | } satisfies McpClientSession) | |
| 1189 | : Effect.fail( | |
| 1190 | new ServerAuthInvalidCredentialError({ | |
| 1191 | diagnostic: "Only MCP client sessions are accepted here.", | |
| 1192 | }), | |
| 1193 | ), | |
| 1194 | ), | |
| 1195 | Effect.withSpan("EnvironmentAuth.authenticateMcpClient"), | |
| 1196 | ); | |
| 1197 | }; | |
| 1198 | ||
| 1199 | const issueMcpClientSession: EnvironmentAuth["Service"]["issueMcpClientSession"] = (input) => | |
| 1200 | sessions | |
| 1201 | .issue({ | |
| 1202 | subject: MCP_CLIENT_SUBJECT, | |
| 1203 | method: "bearer-access-token", | |
| 1204 | scopes: mcpClientScopes(input.access), | |
| 1205 | ttl: MCP_CLIENT_SESSION_TTL, | |
| 1206 | ...(input.access === "read-only" ? {} : { runtimeModeCeiling: input.access }), | |
| 1207 | client: { ...input.client, label: input.label, deviceType: "bot" }, | |
| 1208 | }) | |
| 1209 | .pipe( | |
| 1210 | Effect.map((issued) => ({ token: issued.token, expiresAt: issued.expiresAt })), | |
| 1211 | Effect.mapError((cause) => new ServerAuthSessionTokenIssueError({ cause })), | |
| 1212 | Effect.withSpan("EnvironmentAuth.issueMcpClientSession"), | |
| 1213 | ); | |
| 1214 | ||
| 1215 | const authenticateBrowserSession: EnvironmentAuth["Service"]["authenticateBrowserSession"] = ( | |
| 1216 | request, | |
| 1217 | ) => { | |
| 1218 | const token = | |
| 1219 | request.cookies[sessions.cookieName] ?? | |
| 1220 | (sessions.legacyCookieName ? request.cookies[sessions.legacyCookieName] : undefined) ?? | |
| 1221 | (devAuth ? request.cookies[devAuth.cookieName] : undefined); | |
| 1222 | if (!token) return Effect.fail(new ServerAuthMissingCredentialError({})); | |
| 1223 | return authenticateToken(token).pipe( | |
| 1224 | Effect.filterOrFail( | |
| 1225 | (session) => session.method === "browser-session-cookie" && !session.proofKeyThumbprint, | |
| 1226 | () => new ServerAuthInvalidCredentialError({ diagnostic: "Not a browser session." }), | |
| 1227 | ), | |
| 1228 | Effect.withSpan("EnvironmentAuth.authenticateBrowserSession"), | |
| 1229 | ); | |
| 1230 | }; | |
| 1231 | ||
| 1232 | const consumeMcpApprovalCode: EnvironmentAuth["Service"]["consumeMcpApprovalCode"] = ( | |
| 1233 | code, | |
| 1234 | access, | |
| 1235 | ) => | |
| 1236 | // No proof key: a code bound to a T3 Connect client's key fails without being spent. | |
| 1237 | resolveBootstrapGrant(code.trim()).pipe( | |
| 1238 | Effect.catchTags({ | |
| 1239 | ServerAuthInvalidCredentialError: () => | |
| 1240 | Effect.fail(new ServerAuthMcpApprovalCodeError({ reason: "unknown_or_used" })), | |
| 1241 | }), | |
| 1242 | Effect.flatMap((grant) => | |
| 1243 | grant.method !== "one-time-token" && grant.method !== "reusable-dev-token" | |
| 1244 | ? Effect.fail(new ServerAuthMcpApprovalCodeError({ reason: "not_a_pairing_code" })) | |
| 1245 | : mcpClientScopes(access).every((scope) => grant.scopes.includes(scope)) | |
| 1246 | ? Effect.void | |
| 1247 | : Effect.fail(new ServerAuthMcpApprovalCodeError({ reason: "insufficient_scope" })), | |
| 1248 | ), | |
| 1249 | Effect.withSpan("EnvironmentAuth.consumeMcpApprovalCode"), | |
| 1250 | ); | |
| 1251 | ||
| 1097 | 1252 | return EnvironmentAuth.of({ |
| 1098 | 1253 | getDescriptor: () => |
| 1099 | 1254 | Effect.succeed(descriptor).pipe(Effect.withSpan("EnvironmentAuth.getDescriptor")), |
| ... | ... | @@ -1116,6 +1271,10 @@ export const make = Effect.gen(function* () { |
| 1116 | 1271 | authenticateWebSocketUpgrade, |
| 1117 | 1272 | issueWebSocketTicket, |
| 1118 | 1273 | issueStartupPairingUrl, |
| 1274 | authenticateMcpClient, | |
| 1275 | issueMcpClientSession, | |
| 1276 | consumeMcpApprovalCode, | |
| 1277 | authenticateBrowserSession, | |
| 1119 | 1278 | }); |
| 1120 | 1279 | }); |
| 1121 | 1280 |
apps/server/src/auth/McpOAuth.test.ts created+505| ... | ... | @@ -0,0 +1,505 @@ |
| 1 | import * as NodeServices from "@effect/platform-node/NodeServices"; | |
| 2 | import { | |
| 3 | AuthAdministrativeScopes, | |
| 4 | type AuthEnvironmentScope, | |
| 5 | EnvironmentHttpApi, | |
| 6 | } from "@t3tools/contracts"; | |
| 7 | import { expect, it } from "@effect/vitest"; | |
| 8 | import * as Context from "effect/Context"; | |
| 9 | import * as Crypto from "effect/Crypto"; | |
| 10 | import * as Effect from "effect/Effect"; | |
| 11 | import * as Layer from "effect/Layer"; | |
| 12 | import * as Etag from "effect/http/Etag"; | |
| 13 | import * as HttpPlatform from "effect/http/HttpPlatform"; | |
| 14 | import * as HttpRouter from "effect/http/HttpRouter"; | |
| 15 | import * as HttpServerRequest from "effect/http/HttpServerRequest"; | |
| 16 | import * as HttpApi from "effect/http-api/HttpApi"; | |
| 17 | import * as HttpApiBuilder from "effect/http-api/HttpApiBuilder"; | |
| 18 | import * as Schema from "effect/Schema"; | |
| 19 | ||
| 20 | import * as ServerConfig from "../config.ts"; | |
| 21 | import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; | |
| 22 | import * as Sqlite from "../persistence/Sqlite.ts"; | |
| 23 | import * as EnvironmentAuth from "./EnvironmentAuth.ts"; | |
| 24 | import * as McpOAuth from "./McpOAuth.ts"; | |
| 25 | import * as McpOAuthHttp from "./mcpOAuthHttp.ts"; | |
| 26 | import * as ServerSecretStore from "./ServerSecretStore.ts"; | |
| 27 | import * as AuthHttp from "./http.ts"; | |
| 28 | ||
| 29 | class AuthTestApi extends HttpApi.make("environment") | |
| 30 | .add(EnvironmentHttpApi.groups.auth) | |
| 31 | .add(EnvironmentHttpApi.groups.mcpOAuth) {} | |
| 32 | ||
| 33 | const layerConfig = ServerConfig.layerTest(process.cwd(), { prefix: "t3-mcp-oauth-test-" }); | |
| 34 | const layerEnvironmentAuth = EnvironmentAuth.layer.pipe( | |
| 35 | Layer.provide(Sqlite.layerMemory), | |
| 36 | Layer.provideMerge(ServerSecretStore.layer), | |
| 37 | Layer.provideMerge(ServerEnvironment.layerIdentity), | |
| 38 | Layer.provide(layerConfig), | |
| 39 | ); | |
| 40 | // Each router gets its own database; the capture hands that router's EnvironmentAuth to its test. | |
| 41 | const makeLayerRoutes = (capture: (auth: EnvironmentAuth.EnvironmentAuth["Service"]) => void) => | |
| 42 | Layer.mergeAll( | |
| 43 | Layer.effectDiscard( | |
| 44 | EnvironmentAuth.EnvironmentAuth.pipe(Effect.tap((auth) => Effect.sync(() => capture(auth)))), | |
| 45 | ), | |
| 46 | HttpApiBuilder.layer(AuthTestApi).pipe( | |
| 47 | Layer.provide(AuthHttp.layer), | |
| 48 | Layer.provide(McpOAuthHttp.layer.pipe(Layer.provide(McpOAuth.layer))), | |
| 49 | Layer.provide(AuthHttp.layerAuthenticatedAuth), | |
| 50 | ), | |
| 51 | ).pipe( | |
| 52 | Layer.provideMerge(layerEnvironmentAuth), | |
| 53 | Layer.provide(layerConfig), | |
| 54 | Layer.provideMerge( | |
| 55 | HttpPlatform.layer.pipe( | |
| 56 | Layer.provideMerge(NodeServices.layer), | |
| 57 | Layer.provideMerge(Etag.layerWeak), | |
| 58 | ), | |
| 59 | ), | |
| 60 | Layer.provide(NodeServices.layer), | |
| 61 | ); | |
| 62 | ||
| 63 | const encodeJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)); | |
| 64 | const ORIGIN = "https://box.example.ts.net"; | |
| 65 | const REDIRECT = "http://localhost/callback"; | |
| 66 | const verifier = "a".repeat(43) + "-verifier-for-tests"; | |
| 67 | /** base64url(SHA-256(verifier)), the S256 challenge for `verifier`. */ | |
| 68 | const challenge = "DeB41nTVkPwpbbYecrnqtVq7VXLezustdHAK4SWt13c"; | |
| 69 | ||
| 70 | /** Requests as they arrive behind an https proxy: plain http with the public Host. */ | |
| 71 | const at = (path: string, init?: RequestInit) => | |
| 72 | new Request(`http://127.0.0.1${path}`, { | |
| 73 | ...init, | |
| 74 | headers: { host: "box.example.ts.net", "x-forwarded-proto": "https", ...init?.headers }, | |
| 75 | }); | |
| 76 | const form = (body: Record<string, string>) => ({ | |
| 77 | method: "POST", | |
| 78 | headers: { "content-type": "application/x-www-form-urlencoded" }, | |
| 79 | body: new URLSearchParams(body).toString(), | |
| 80 | }); | |
| 81 | ||
| 82 | type Handler = (request: Request) => Effect.Effect<Response>; | |
| 83 | ||
| 84 | const withRoutes = <A, E>( | |
| 85 | use: (handler: Handler, auth: EnvironmentAuth.EnvironmentAuth["Service"]) => Effect.Effect<A, E>, | |
| 86 | ) => | |
| 87 | Effect.gen(function* () { | |
| 88 | const crypto = yield* Crypto.Crypto; | |
| 89 | const context = Context.make(Crypto.Crypto, crypto); | |
| 90 | let routerAuth: EnvironmentAuth.EnvironmentAuth["Service"] | undefined; | |
| 91 | const layerRoutes = makeLayerRoutes((auth) => { | |
| 92 | routerAuth = auth; | |
| 93 | }); | |
| 94 | return yield* Effect.acquireUseRelease( | |
| 95 | Effect.sync(() => HttpRouter.toWebHandler(layerRoutes, { disableLogger: true })), | |
| 96 | (web) => | |
| 97 | Effect.gen(function* () { | |
| 98 | const handler: Handler = (request) => Effect.promise(() => web.handler(request, context)); | |
| 99 | // The router builds its layer on first request; a metadata read warms it. | |
| 100 | yield* handler(at("/.well-known/oauth-authorization-server")); | |
| 101 | return yield* use(handler, routerAuth!); | |
| 102 | }), | |
| 103 | (web) => Effect.promise(() => web.dispose()), | |
| 104 | ); | |
| 105 | }).pipe(Effect.provide(NodeServices.layer)); | |
| 106 | ||
| 107 | const json = <A>(response: Response) => Effect.promise(() => response.json() as Promise<A>); | |
| 108 | ||
| 109 | const postJson = (path: string, body: unknown, cookie?: string) => | |
| 110 | at(path, { | |
| 111 | method: "POST", | |
| 112 | headers: { "content-type": "application/json", ...(cookie ? { cookie } : {}) }, | |
| 113 | body: encodeJson(body), | |
| 114 | }); | |
| 115 | ||
| 116 | /** What the approval page posts: the agent's request plus the user's choice. */ | |
| 117 | const decide = ( | |
| 118 | handler: Handler, | |
| 119 | authorization: Record<string, string>, | |
| 120 | decision: Record<string, string>, | |
| 121 | cookie?: string, | |
| 122 | ) => | |
| 123 | handler(postJson("/oauth/mcp/decision", { authorization, decision }, cookie)).pipe( | |
| 124 | Effect.flatMap((response) => | |
| 125 | json<{ redirectTo?: string; message?: string }>(response).pipe( | |
| 126 | Effect.map((payload) => ({ status: response.status, ...payload })), | |
| 127 | ), | |
| 128 | ), | |
| 129 | ); | |
| 130 | ||
| 131 | const register = (handler: Handler, redirect = REDIRECT) => | |
| 132 | handler( | |
| 133 | at("/oauth/mcp/register", { | |
| 134 | method: "POST", | |
| 135 | headers: { "content-type": "application/json" }, | |
| 136 | body: encodeJson({ | |
| 137 | client_name: "Claude Code", | |
| 138 | redirect_uris: [redirect], | |
| 139 | grant_types: ["authorization_code", "refresh_token"], | |
| 140 | token_endpoint_auth_method: "none", | |
| 141 | }), | |
| 142 | }), | |
| 143 | ); | |
| 144 | ||
| 145 | const registeredClientId = (handler: Handler) => | |
| 146 | register(handler).pipe( | |
| 147 | Effect.flatMap((response) => json<{ client_id: string }>(response)), | |
| 148 | Effect.map((body) => body.client_id), | |
| 149 | ); | |
| 150 | ||
| 151 | const authorizeParams = (clientId: string, redirect = "http://localhost:51234/callback") => ({ | |
| 152 | response_type: "code", | |
| 153 | client_id: clientId, | |
| 154 | redirect_uri: redirect, | |
| 155 | code_challenge: challenge, | |
| 156 | code_challenge_method: "S256", | |
| 157 | state: "state-1", | |
| 158 | resource: `${ORIGIN}/mcp`, | |
| 159 | }); | |
| 160 | ||
| 161 | it.live("derives discovery metadata from the origin the client reached", () => | |
| 162 | withRoutes((handler) => | |
| 163 | Effect.gen(function* () { | |
| 164 | const resource = yield* handler(at("/.well-known/oauth-protected-resource/mcp")); | |
| 165 | expect(yield* json<unknown>(resource)).toMatchObject({ | |
| 166 | resource: `${ORIGIN}/mcp`, | |
| 167 | authorization_servers: [ORIGIN], | |
| 168 | }); | |
| 169 | const server = yield* json<unknown>( | |
| 170 | yield* handler(at("/.well-known/oauth-authorization-server")), | |
| 171 | ); | |
| 172 | expect(server).toMatchObject({ | |
| 173 | issuer: ORIGIN, | |
| 174 | authorization_endpoint: `${ORIGIN}/oauth/mcp/authorize`, | |
| 175 | token_endpoint: `${ORIGIN}/oauth/mcp/token`, | |
| 176 | registration_endpoint: `${ORIGIN}/oauth/mcp/register`, | |
| 177 | code_challenge_methods_supported: ["S256"], | |
| 178 | }); | |
| 179 | }), | |
| 180 | ), | |
| 181 | ); | |
| 182 | ||
| 183 | it.live("registers only loopback clients and never redirects for an unverified client", () => | |
| 184 | withRoutes((handler) => | |
| 185 | Effect.gen(function* () { | |
| 186 | const remote = yield* register(handler, "https://attacker.example/callback"); | |
| 187 | expect(remote.status).toBe(400); | |
| 188 | expect(yield* json<unknown>(remote)).toMatchObject({ error: "invalid_redirect_uri" }); | |
| 189 | ||
| 190 | const registered = yield* register(handler); | |
| 191 | expect(registered.status).toBe(201); | |
| 192 | const { client_id: clientId } = (yield* json<unknown>(registered)) as { client_id: string }; | |
| 193 | ||
| 194 | const forged = yield* handler( | |
| 195 | at( | |
| 196 | `/oauth/mcp/authorize?${new URLSearchParams(authorizeParams(`${clientId.split(".")[0]}.forged`))}`, | |
| 197 | ), | |
| 198 | ); | |
| 199 | expect(forged.status).toBe(400); | |
| 200 | expect(forged.headers.get("location")).toBeNull(); | |
| 201 | ||
| 202 | const unregistered = yield* handler( | |
| 203 | at( | |
| 204 | `/oauth/mcp/authorize?${new URLSearchParams( | |
| 205 | authorizeParams(clientId, "http://localhost:51234/elsewhere"), | |
| 206 | )}`, | |
| 207 | ), | |
| 208 | ); | |
| 209 | expect(unregistered.status).toBe(400); | |
| 210 | expect(unregistered.headers.get("location")).toBeNull(); | |
| 211 | ||
| 212 | // A valid request is handed to the web app's approval page, query intact. | |
| 213 | const query = new URLSearchParams(authorizeParams(clientId)).toString(); | |
| 214 | const handoff = yield* handler(at(`/oauth/mcp/authorize?${query}`)); | |
| 215 | expect(handoff.status).toBe(302); | |
| 216 | expect(handoff.headers.get("location")).toBe(`/connect-agent?${query}`); | |
| 217 | const details = yield* handler( | |
| 218 | postJson("/oauth/mcp/approval", authorizeParams(clientId)), | |
| 219 | ).pipe(Effect.flatMap(json<Record<string, unknown>>)); | |
| 220 | expect(details).toEqual({ | |
| 221 | clientName: "Claude Code", | |
| 222 | redirectHost: "localhost:51234", | |
| 223 | environmentHost: "box.example.ts.net", | |
| 224 | }); | |
| 225 | ||
| 226 | // The approval endpoints re-check the request: a forged client gets a message, not a URL. | |
| 227 | const forgedDetails = yield* handler( | |
| 228 | postJson("/oauth/mcp/approval", authorizeParams(`${clientId.split(".")[0]}.forged`)), | |
| 229 | ); | |
| 230 | expect(forgedDetails.status).toBe(400); | |
| 231 | expect(yield* json<Record<string, unknown>>(forgedDetails)).not.toHaveProperty("redirectTo"); | |
| 232 | }), | |
| 233 | ), | |
| 234 | ); | |
| 235 | ||
| 236 | it.live("signs in with a pairing code and issues a token only /mcp accepts", () => | |
| 237 | withRoutes((handler, auth) => | |
| 238 | Effect.gen(function* () { | |
| 239 | const clientId = yield* registeredClientId(handler); | |
| 240 | const pairing = yield* auth.issuePairingCredential(); | |
| 241 | const params = authorizeParams(clientId); | |
| 242 | ||
| 243 | const wrongCode = yield* decide(handler, params, { | |
| 244 | _tag: "pairing-code", | |
| 245 | access: "auto", | |
| 246 | code: "nope", | |
| 247 | }); | |
| 248 | expect(wrongCode.status).toBe(400); | |
| 249 | expect(wrongCode.message).toContain("unknown, expired, or already used"); | |
| 250 | ||
| 251 | const approved = yield* decide(handler, params, { | |
| 252 | _tag: "pairing-code", | |
| 253 | access: "auto", | |
| 254 | code: pairing.credential, | |
| 255 | }); | |
| 256 | expect(approved.status).toBe(200); | |
| 257 | const callback = new URL(approved.redirectTo!); | |
| 258 | expect(callback.origin).toBe("http://localhost:51234"); | |
| 259 | expect(callback.searchParams.get("state")).toBe("state-1"); | |
| 260 | expect(callback.searchParams.get("iss")).toBe(ORIGIN); | |
| 261 | const code = callback.searchParams.get("code")!; | |
| 262 | ||
| 263 | const exchange = (codeVerifier: string) => | |
| 264 | handler( | |
| 265 | at( | |
| 266 | "/oauth/mcp/token", | |
| 267 | form({ | |
| 268 | grant_type: "authorization_code", | |
| 269 | code, | |
| 270 | redirect_uri: params.redirect_uri, | |
| 271 | client_id: clientId, | |
| 272 | code_verifier: codeVerifier, | |
| 273 | resource: `${ORIGIN}/mcp`, | |
| 274 | }), | |
| 275 | ), | |
| 276 | ); | |
| 277 | const tokenResponse = yield* exchange(verifier); | |
| 278 | expect(tokenResponse.status).toBe(200); | |
| 279 | const token = (yield* json<unknown>(tokenResponse)) as { | |
| 280 | access_token: string; | |
| 281 | scope: string; | |
| 282 | }; | |
| 283 | expect(token.scope).toBe("orchestration:read orchestration:operate"); | |
| 284 | ||
| 285 | // Codes are single use. | |
| 286 | expect((yield* exchange(verifier)).status).toBe(400); | |
| 287 | ||
| 288 | const client = yield* auth.authenticateMcpClient( | |
| 289 | HttpServerRequest.fromWeb( | |
| 290 | at("/mcp", { headers: { authorization: `Bearer ${token.access_token}` } }), | |
| 291 | ), | |
| 292 | ); | |
| 293 | expect(client).toMatchObject({ label: "Claude Code", access: "auto" }); | |
| 294 | ||
| 295 | // The same token is refused by the rest of the environment. | |
| 296 | const session = yield* handler( | |
| 297 | at("/api/auth/session", { headers: { authorization: `Bearer ${token.access_token}` } }), | |
| 298 | ); | |
| 299 | expect(yield* json<unknown>(session)).toMatchObject({ authenticated: false }); | |
| 300 | const ticket = yield* handler( | |
| 301 | at("/api/auth/websocket-ticket", { | |
| 302 | method: "POST", | |
| 303 | headers: { authorization: `Bearer ${token.access_token}` }, | |
| 304 | }), | |
| 305 | ); | |
| 306 | expect(ticket.status).toBe(401); | |
| 307 | }), | |
| 308 | ), | |
| 309 | ); | |
| 310 | ||
| 311 | it.live("rejects a wrong PKCE verifier and spends the code", () => | |
| 312 | withRoutes((handler, auth) => | |
| 313 | Effect.gen(function* () { | |
| 314 | const clientId = yield* registeredClientId(handler); | |
| 315 | const pairing = yield* auth.issuePairingCredential(); | |
| 316 | const params = authorizeParams(clientId); | |
| 317 | const approved = yield* decide(handler, params, { | |
| 318 | _tag: "pairing-code", | |
| 319 | access: "approval-required", | |
| 320 | code: pairing.credential, | |
| 321 | }); | |
| 322 | const code = new URL(approved.redirectTo!).searchParams.get("code")!; | |
| 323 | const exchange = (codeVerifier: string) => | |
| 324 | handler( | |
| 325 | at( | |
| 326 | "/oauth/mcp/token", | |
| 327 | form({ | |
| 328 | grant_type: "authorization_code", | |
| 329 | code, | |
| 330 | redirect_uri: params.redirect_uri, | |
| 331 | client_id: clientId, | |
| 332 | code_verifier: codeVerifier, | |
| 333 | }), | |
| 334 | ), | |
| 335 | ); | |
| 336 | const wrong = yield* exchange("b".repeat(64)); | |
| 337 | expect(wrong.status).toBe(400); | |
| 338 | expect(yield* json<unknown>(wrong)).toMatchObject({ error: "invalid_grant" }); | |
| 339 | expect((yield* exchange(verifier)).status).toBe(400); | |
| 340 | }), | |
| 341 | ), | |
| 342 | ); | |
| 343 | ||
| 344 | it.live( | |
| 345 | "denies and refuses codes bound to another client's key or without the scopes it grants", | |
| 346 | () => | |
| 347 | withRoutes((handler, auth) => | |
| 348 | Effect.gen(function* () { | |
| 349 | const clientId = yield* registeredClientId(handler); | |
| 350 | const params = authorizeParams(clientId); | |
| 351 | ||
| 352 | const denied = yield* decide(handler, params, { _tag: "deny" }); | |
| 353 | const deniedUrl = new URL(denied.redirectTo!); | |
| 354 | expect(deniedUrl.searchParams.get("error")).toBe("access_denied"); | |
| 355 | expect(deniedUrl.searchParams.get("state")).toBe("state-1"); | |
| 356 | ||
| 357 | const approveWith = (code: string) => | |
| 358 | decide(handler, params, { _tag: "pairing-code", access: "auto", code }); | |
| 359 | ||
| 360 | // A T3 Connect code is bound to a device key: refused, and still usable by its device. | |
| 361 | const bound = yield* auth.createPairingLink({ | |
| 362 | proofKeyThumbprint: "device-key-thumbprint", | |
| 363 | }); | |
| 364 | expect((yield* approveWith(bound.credential)).status).toBe(400); | |
| 365 | const stillValid = yield* auth | |
| 366 | .exchangeBootstrapCredentialForAccessToken( | |
| 367 | bound.credential, | |
| 368 | undefined, | |
| 369 | { deviceType: "mobile" }, | |
| 370 | { proofKeyThumbprint: "device-key-thumbprint" }, | |
| 371 | ) | |
| 372 | .pipe( | |
| 373 | Effect.as(true), | |
| 374 | Effect.orElseSucceed(() => false), | |
| 375 | ); | |
| 376 | expect(stillValid).toBe(true); | |
| 377 | ||
| 378 | const readOnly = yield* auth.issuePairingCredential({ scopes: ["orchestration:read"] }); | |
| 379 | const readOnlyResponse = yield* approveWith(readOnly.credential); | |
| 380 | expect(readOnlyResponse.status).toBe(400); | |
| 381 | expect(readOnlyResponse.message).toContain("cannot grant this access"); | |
| 382 | ||
| 383 | // A read-only code can approve read-only access. | |
| 384 | const readOnlyCode = yield* auth.issuePairingCredential({ scopes: ["orchestration:read"] }); | |
| 385 | const readOnlyApproval = yield* decide(handler, params, { | |
| 386 | _tag: "pairing-code", | |
| 387 | access: "read-only", | |
| 388 | code: readOnlyCode.credential, | |
| 389 | }); | |
| 390 | expect(readOnlyApproval.status).toBe(200); | |
| 391 | const readOnlyToken = yield* handler( | |
| 392 | at( | |
| 393 | "/oauth/mcp/token", | |
| 394 | form({ | |
| 395 | grant_type: "authorization_code", | |
| 396 | code: new URL(readOnlyApproval.redirectTo!).searchParams.get("code")!, | |
| 397 | redirect_uri: params.redirect_uri, | |
| 398 | client_id: clientId, | |
| 399 | code_verifier: verifier, | |
| 400 | resource: `${ORIGIN}/mcp`, | |
| 401 | }), | |
| 402 | ), | |
| 403 | ).pipe(Effect.flatMap(json<{ access_token: string; scope: string }>)); | |
| 404 | expect(readOnlyToken.scope).toBe("orchestration:read"); | |
| 405 | const readOnlyClient = yield* auth.authenticateMcpClient( | |
| 406 | HttpServerRequest.fromWeb( | |
| 407 | at("/mcp", { headers: { authorization: `Bearer ${readOnlyToken.access_token}` } }), | |
| 408 | ), | |
| 409 | ); | |
| 410 | expect(readOnlyClient.access).toBe("read-only"); | |
| 411 | }), | |
| 412 | ), | |
| 413 | ); | |
| 414 | ||
| 415 | it.live("one-click approves only access the browser session holds the scopes for", () => | |
| 416 | withRoutes((handler, auth) => | |
| 417 | Effect.gen(function* () { | |
| 418 | const clientId = yield* registeredClientId(handler); | |
| 419 | const params = authorizeParams(clientId); | |
| 420 | // Signs a browser in through the real route and returns its session cookie. | |
| 421 | const browserCookie = (scopes: ReadonlyArray<AuthEnvironmentScope>) => | |
| 422 | Effect.gen(function* () { | |
| 423 | const pairing = yield* auth.issuePairingCredential({ scopes }); | |
| 424 | const response = yield* handler( | |
| 425 | at("/api/auth/browser-session", { | |
| 426 | method: "POST", | |
| 427 | headers: { "content-type": "application/json" }, | |
| 428 | body: encodeJson({ credential: pairing.credential }), | |
| 429 | }), | |
| 430 | ); | |
| 431 | return response.headers.getSetCookie()[0]!.split(";", 1)[0]!; | |
| 432 | }); | |
| 433 | const details = (cookie: string) => | |
| 434 | handler(postJson("/oauth/mcp/approval", params, cookie)).pipe( | |
| 435 | Effect.flatMap(json<{ csrfToken?: string; oneClickAccess?: ReadonlyArray<string> }>), | |
| 436 | ); | |
| 437 | ||
| 438 | const admin = yield* browserCookie([...AuthAdministrativeScopes]); | |
| 439 | const adminDetails = yield* details(admin); | |
| 440 | expect(adminDetails.csrfToken).toEqual(expect.any(String)); | |
| 441 | expect(adminDetails.oneClickAccess).toEqual([ | |
| 442 | "read-only", | |
| 443 | "approval-required", | |
| 444 | "auto-accept-edits", | |
| 445 | "auto", | |
| 446 | "full-access", | |
| 447 | ]); | |
| 448 | const oneClick = yield* decide( | |
| 449 | handler, | |
| 450 | params, | |
| 451 | { _tag: "browser-session", access: "auto", csrfToken: adminDetails.csrfToken! }, | |
| 452 | admin, | |
| 453 | ); | |
| 454 | expect(new URL(oneClick.redirectTo!).searchParams.get("code")).toEqual(expect.any(String)); | |
| 455 | ||
| 456 | // access:write alone cannot hand an agent thread control it does not hold. | |
| 457 | const accessOnly = yield* browserCookie(["access:read", "access:write"]); | |
| 458 | expect((yield* details(accessOnly)).csrfToken).toBeUndefined(); | |
| 459 | const forged = yield* decide( | |
| 460 | handler, | |
| 461 | params, | |
| 462 | { _tag: "browser-session", access: "auto", csrfToken: "forged" }, | |
| 463 | accessOnly, | |
| 464 | ); | |
| 465 | expect(forged.status).toBe(400); | |
| 466 | expect(forged.message).toContain("Enter a pairing code instead"); | |
| 467 | ||
| 468 | // A session that can read but not operate threads may approve read-only access only. | |
| 469 | const reader = yield* browserCookie(["access:write", "orchestration:read"]); | |
| 470 | const readerDetails = yield* details(reader); | |
| 471 | expect(readerDetails.csrfToken).toEqual(expect.any(String)); | |
| 472 | // The page shows the pairing-code field for anything above read only. | |
| 473 | expect(readerDetails.oneClickAccess).toEqual(["read-only"]); | |
| 474 | const tooBroad = yield* decide( | |
| 475 | handler, | |
| 476 | params, | |
| 477 | { _tag: "browser-session", access: "auto", csrfToken: readerDetails.csrfToken! }, | |
| 478 | reader, | |
| 479 | ); | |
| 480 | expect(tooBroad.status).toBe(400); | |
| 481 | const readOnly = yield* decide( | |
| 482 | handler, | |
| 483 | params, | |
| 484 | { _tag: "browser-session", access: "read-only", csrfToken: readerDetails.csrfToken! }, | |
| 485 | reader, | |
| 486 | ); | |
| 487 | expect(new URL(readOnly.redirectTo!).searchParams.get("code")).toEqual(expect.any(String)); | |
| 488 | }), | |
| 489 | ), | |
| 490 | ); | |
| 491 | ||
| 492 | it("matches loopback redirects on everything but the port", () => { | |
| 493 | expect( | |
| 494 | McpOAuth.loopbackRedirectMatches("http://localhost/callback", "http://localhost:9/callback"), | |
| 495 | ).toBe(true); | |
| 496 | expect( | |
| 497 | McpOAuth.loopbackRedirectMatches("http://127.0.0.1:1/callback", "http://127.0.0.1:2/callback"), | |
| 498 | ).toBe(true); | |
| 499 | expect( | |
| 500 | McpOAuth.loopbackRedirectMatches("http://localhost/callback", "http://127.0.0.1/callback"), | |
| 501 | ).toBe(false); | |
| 502 | expect( | |
| 503 | McpOAuth.loopbackRedirectMatches("http://localhost/callback", "https://localhost/callback"), | |
| 504 | ).toBe(false); | |
| 505 | }); |
apps/server/src/auth/McpOAuth.ts created+603| ... | ... | @@ -0,0 +1,603 @@ |
| 1 | import { | |
| 2 | AuthAccessWriteScope, | |
| 3 | type AuthMcpApprovalDecision, | |
| 4 | type AuthMcpAuthorizationRequest, | |
| 5 | type AuthMcpAuthorizationServerMetadata, | |
| 6 | type AuthMcpClientRegistration, | |
| 7 | type AuthMcpProtectedResourceMetadata, | |
| 8 | AuthMcpRegistrationError, | |
| 9 | AuthMcpTokenError, | |
| 10 | type AuthMcpTokenRequest, | |
| 11 | type AuthMcpTokenResult, | |
| 12 | type AuthMcpClientAccess, | |
| 13 | type AuthEnvironmentScope, | |
| 14 | AuthOrchestrationOperateScope, | |
| 15 | AuthOrchestrationReadScope, | |
| 16 | } from "@t3tools/contracts"; | |
| 17 | import { encodeOAuthScope } from "@t3tools/shared/oauthScope"; | |
| 18 | import * as Clock from "effect/Clock"; | |
| 19 | import * as Context from "effect/Context"; | |
| 20 | import * as Crypto from "effect/Crypto"; | |
| 21 | import * as Effect from "effect/Effect"; | |
| 22 | import * as Layer from "effect/Layer"; | |
| 23 | import * as Option from "effect/Option"; | |
| 24 | import * as Ref from "effect/Ref"; | |
| 25 | import * as Schema from "effect/Schema"; | |
| 26 | import { HttpServerRequest } from "effect/http"; | |
| 27 | ||
| 28 | import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; | |
| 29 | import * as McpHttpServer from "../mcp/McpHttpServer.ts"; | |
| 30 | import type * as McpInvocationContext from "../mcp/McpInvocationContext.ts"; | |
| 31 | import * as EnvironmentAuth from "./EnvironmentAuth.ts"; | |
| 32 | import * as ServerSecretStore from "./ServerSecretStore.ts"; | |
| 33 | import { | |
| 34 | base64UrlDecodeUtf8, | |
| 35 | base64UrlEncode, | |
| 36 | deriveAuthClientMetadata, | |
| 37 | signPayload, | |
| 38 | timingSafeEqualBase64Url, | |
| 39 | } from "./utils.ts"; | |
| 40 | ||
| 41 | /** | |
| 42 | * The OAuth authorization server MCP clients (Claude Code, Codex, any agent | |
| 43 | * T3 Code did not launch) use to sign in to this environment's `/mcp`. | |
| 44 | * | |
| 45 | * Every URL is derived from the request's own origin, so the same server | |
| 46 | * answers correctly over loopback, Tailscale Serve and a T3 Connect tunnel. | |
| 47 | * Client registration is stateless: a client id is its signed metadata, so | |
| 48 | * an unauthenticated caller cannot grow server state. Only loopback redirect | |
| 49 | * URIs are accepted; an https redirect would let anyone mail the owner an | |
| 50 | * approval link that delivers the code to their own server. | |
| 51 | */ | |
| 52 | ||
| 53 | const SIGNING_SECRET_NAME = "mcp-oauth-signing-key"; | |
| 54 | const AUTHORIZATION_CODE_TTL_MS = 60_000; | |
| 55 | const MAX_CLIENT_NAME_LENGTH = 100; | |
| 56 | const MAX_REDIRECT_URIS = 5; | |
| 57 | const MAX_REDIRECT_URI_LENGTH = 512; | |
| 58 | const DEFAULT_CLIENT_NAME = "MCP client"; | |
| 59 | const LOOPBACK_HOSTNAMES = new Set(["localhost", "127.0.0.1", "[::1]"]); | |
| 60 | const CODE_CHALLENGE_PATTERN = /^[A-Za-z0-9_-]{43}$/; | |
| 61 | const CODE_VERIFIER_PATTERN = /^[A-Za-z0-9\-._~]{43,128}$/; | |
| 62 | ||
| 63 | const MCP_OAUTH_SCOPES = [AuthOrchestrationReadScope, AuthOrchestrationOperateScope]; | |
| 64 | ||
| 65 | export interface McpOAuthUrls { | |
| 66 | readonly issuer: string; | |
| 67 | readonly resource: string; | |
| 68 | } | |
| 69 | ||
| 70 | /** | |
| 71 | * Issuer and MCP resource for the origin this request reached: its Host, and | |
| 72 | * https when a proxy says so. A Host that is not a valid authority falls back | |
| 73 | * to localhost, which no client will have asked for. | |
| 74 | */ | |
| 75 | export const requestUrls = (request: HttpServerRequest.HttpServerRequest): McpOAuthUrls => { | |
| 76 | const origin = Option.match(HttpServerRequest.toURL(request), { | |
| 77 | onNone: () => "http://localhost", | |
| 78 | onSome: (url) => url.origin, | |
| 79 | }); | |
| 80 | return { issuer: origin, resource: `${origin}/mcp` }; | |
| 81 | }; | |
| 82 | ||
| 83 | export const protectedResourceMetadata = ( | |
| 84 | urls: McpOAuthUrls, | |
| 85 | ): AuthMcpProtectedResourceMetadata => ({ | |
| 86 | resource: urls.resource, | |
| 87 | authorization_servers: [urls.issuer], | |
| 88 | scopes_supported: MCP_OAUTH_SCOPES, | |
| 89 | bearer_methods_supported: ["header"], | |
| 90 | resource_name: "T3 Code", | |
| 91 | }); | |
| 92 | ||
| 93 | export const authorizationServerMetadata = ( | |
| 94 | urls: McpOAuthUrls, | |
| 95 | ): AuthMcpAuthorizationServerMetadata => ({ | |
| 96 | issuer: urls.issuer, | |
| 97 | authorization_endpoint: `${urls.issuer}/oauth/mcp/authorize`, | |
| 98 | token_endpoint: `${urls.issuer}/oauth/mcp/token`, | |
| 99 | registration_endpoint: `${urls.issuer}/oauth/mcp/register`, | |
| 100 | response_types_supported: ["code"], | |
| 101 | grant_types_supported: ["authorization_code"], | |
| 102 | code_challenge_methods_supported: ["S256"], | |
| 103 | token_endpoint_auth_methods_supported: ["none"], | |
| 104 | scopes_supported: MCP_OAUTH_SCOPES, | |
| 105 | authorization_response_iss_parameter_supported: true, | |
| 106 | }); | |
| 107 | ||
| 108 | const parseLoopbackRedirect = (value: string): URL | undefined => { | |
| 109 | if (value.length > MAX_REDIRECT_URI_LENGTH) return undefined; | |
| 110 | try { | |
| 111 | const url = new URL(value); | |
| 112 | return url.protocol === "http:" && | |
| 113 | LOOPBACK_HOSTNAMES.has(url.hostname) && | |
| 114 | url.username === "" && | |
| 115 | url.password === "" && | |
| 116 | url.hash === "" | |
| 117 | ? url | |
| 118 | : undefined; | |
| 119 | } catch { | |
| 120 | return undefined; | |
| 121 | } | |
| 122 | }; | |
| 123 | ||
| 124 | /** RFC 8252 §7.3: loopback redirects match on everything but the port. */ | |
| 125 | export const loopbackRedirectMatches = (registered: string, presented: string): boolean => { | |
| 126 | const left = parseLoopbackRedirect(registered); | |
| 127 | const right = parseLoopbackRedirect(presented); | |
| 128 | return ( | |
| 129 | left !== undefined && | |
| 130 | right !== undefined && | |
| 131 | left.hostname === right.hostname && | |
| 132 | left.pathname === right.pathname && | |
| 133 | left.search === right.search | |
| 134 | ); | |
| 135 | }; | |
| 136 | ||
| 137 | const sameResource = (urls: McpOAuthUrls, presented: string | undefined): boolean => { | |
| 138 | if (presented === undefined) return true; | |
| 139 | try { | |
| 140 | const url = new URL(presented); | |
| 141 | return url.hash === "" && `${url.origin}${url.pathname.replace(/\/+$/u, "")}` === urls.resource; | |
| 142 | } catch { | |
| 143 | return false; | |
| 144 | } | |
| 145 | }; | |
| 146 | ||
| 147 | const ClientIdPayload = Schema.Struct({ | |
| 148 | v: Schema.Literal(1), | |
| 149 | n: Schema.String, | |
| 150 | r: Schema.Array(Schema.String), | |
| 151 | }); | |
| 152 | const decodeClientIdPayload = Schema.decodeUnknownOption(Schema.fromJsonString(ClientIdPayload)); | |
| 153 | ||
| 154 | export interface McpOAuthClient { | |
| 155 | readonly clientId: string; | |
| 156 | readonly name: string; | |
| 157 | readonly redirectUris: ReadonlyArray<string>; | |
| 158 | } | |
| 159 | ||
| 160 | /** Problems the authorize page shows the user without redirecting anywhere. */ | |
| 161 | export class McpOAuthPageError extends Schema.TaggedError<McpOAuthPageError>()( | |
| 162 | "McpOAuthPageError", | |
| 163 | { description: Schema.String }, | |
| 164 | ) { | |
| 165 | override get message(): string { | |
| 166 | return this.description; | |
| 167 | } | |
| 168 | } | |
| 169 | ||
| 170 | /** Problems reported back to the client through its (validated) redirect URI. */ | |
| 171 | export class McpOAuthRedirectError extends Schema.TaggedError<McpOAuthRedirectError>()( | |
| 172 | "McpOAuthRedirectError", | |
| 173 | { | |
| 174 | error: Schema.Literals(["invalid_request", "unsupported_response_type", "invalid_target"]), | |
| 175 | description: Schema.String, | |
| 176 | redirectUri: Schema.String, | |
| 177 | state: Schema.optional(Schema.String), | |
| 178 | }, | |
| 179 | ) {} | |
| 180 | ||
| 181 | export interface AuthorizationRequest { | |
| 182 | readonly client: McpOAuthClient; | |
| 183 | readonly redirectUri: string; | |
| 184 | readonly codeChallenge: string; | |
| 185 | readonly state: string | undefined; | |
| 186 | readonly resource: string; | |
| 187 | readonly issuer: string; | |
| 188 | } | |
| 189 | ||
| 190 | interface PendingCode { | |
| 191 | readonly clientId: string; | |
| 192 | readonly clientName: string; | |
| 193 | readonly redirectUri: string; | |
| 194 | readonly codeChallenge: string; | |
| 195 | readonly resource: string; | |
| 196 | readonly access: AuthMcpClientAccess; | |
| 197 | readonly expiresAtMs: number; | |
| 198 | } | |
| 199 | ||
| 200 | export type ApprovalDecision = Exclude<AuthMcpApprovalDecision, { readonly _tag: "deny" }>; | |
| 201 | ||
| 202 | export class McpOAuth extends Context.Service< | |
| 203 | McpOAuth, | |
| 204 | { | |
| 205 | readonly register: ( | |
| 206 | input: AuthMcpClientRegistration, | |
| 207 | ) => Effect.Effect<McpOAuthClient, AuthMcpRegistrationError>; | |
| 208 | /** Validates an authorize request. Page errors must never redirect. */ | |
| 209 | readonly validateAuthorization: (input: { | |
| 210 | readonly urls: McpOAuthUrls; | |
| 211 | readonly request: AuthMcpAuthorizationRequest; | |
| 212 | }) => Effect.Effect<AuthorizationRequest, McpOAuthPageError | McpOAuthRedirectError>; | |
| 213 | /** | |
| 214 | * The signed-in owner on this origin, when their browser session may | |
| 215 | * approve at least read-only access. `approve` checks the chosen access | |
| 216 | * against the session's scopes. | |
| 217 | */ | |
| 218 | readonly approvingBrowserSession: ( | |
| 219 | request: HttpServerRequest.HttpServerRequest, | |
| 220 | authorization: AuthorizationRequest, | |
| 221 | ) => Effect.Effect< | |
| 222 | | { readonly csrfToken: string; readonly scopes: ReadonlyArray<AuthEnvironmentScope> } | |
| 223 | | undefined | |
| 224 | >; | |
| 225 | /** Approves and returns the URL to send the browser to. */ | |
| 226 | readonly approve: (input: { | |
| 227 | readonly request: HttpServerRequest.HttpServerRequest; | |
| 228 | readonly authorization: AuthorizationRequest; | |
| 229 | readonly decision: ApprovalDecision; | |
| 230 | }) => Effect.Effect<string, EnvironmentAuth.ServerAuthMcpApprovalCodeError | McpOAuthPageError>; | |
| 231 | readonly deny: (authorization: AuthorizationRequest) => string; | |
| 232 | readonly exchangeCode: (input: { | |
| 233 | readonly request: HttpServerRequest.HttpServerRequest; | |
| 234 | readonly urls: McpOAuthUrls; | |
| 235 | readonly token: AuthMcpTokenRequest; | |
| 236 | }) => Effect.Effect<AuthMcpTokenResult, AuthMcpTokenError>; | |
| 237 | } | |
| 238 | >()("t3/auth/McpOAuth") {} | |
| 239 | ||
| 240 | /** Appends OAuth response parameters, keeping any the client put in its redirect URI. */ | |
| 241 | const redirectWith = (redirectUri: string, params: Record<string, string | undefined>) => { | |
| 242 | const url = new URL(redirectUri); | |
| 243 | for (const [name, value] of Object.entries(params)) { | |
| 244 | if (value !== undefined) url.searchParams.set(name, value); | |
| 245 | } | |
| 246 | return url.toString(); | |
| 247 | }; | |
| 248 | ||
| 249 | export const redirectForError = (error: McpOAuthRedirectError, issuer: string) => | |
| 250 | redirectWith(error.redirectUri, { | |
| 251 | error: error.error, | |
| 252 | error_description: error.description, | |
| 253 | state: error.state, | |
| 254 | iss: issuer, | |
| 255 | }); | |
| 256 | ||
| 257 | const make = Effect.gen(function* () { | |
| 258 | const environmentAuth = yield* EnvironmentAuth.EnvironmentAuth; | |
| 259 | const secretStore = yield* ServerSecretStore.ServerSecretStore; | |
| 260 | const crypto = yield* Crypto.Crypto; | |
| 261 | const signingKey = yield* secretStore | |
| 262 | .getOrCreateRandom(SIGNING_SECRET_NAME, 32) | |
| 263 | .pipe(Effect.orDie); | |
| 264 | const codes = yield* Ref.make<ReadonlyMap<string, PendingCode>>(new Map()); | |
| 265 | ||
| 266 | const sign = (domain: string, payload: string) => signPayload(`${domain}.${payload}`, signingKey); | |
| 267 | ||
| 268 | const signClientId = (name: string, redirectUris: ReadonlyArray<string>) => { | |
| 269 | const body = base64UrlEncode(JSON.stringify({ v: 1, n: name, r: redirectUris })); | |
| 270 | return `${body}.${sign("mcp-client-id", body)}`; | |
| 271 | }; | |
| 272 | ||
| 273 | const parseClientId = (clientId: string): McpOAuthClient | undefined => { | |
| 274 | const [body, signature, extra] = clientId.split("."); | |
| 275 | if (!body || !signature || extra !== undefined) return undefined; | |
| 276 | if (!timingSafeEqualBase64Url(signature, sign("mcp-client-id", body))) return undefined; | |
| 277 | let json: string; | |
| 278 | try { | |
| 279 | json = base64UrlDecodeUtf8(body); | |
| 280 | } catch { | |
| 281 | return undefined; | |
| 282 | } | |
| 283 | return Option.getOrUndefined( | |
| 284 | decodeClientIdPayload(json).pipe( | |
| 285 | Option.map((payload) => ({ clientId, name: payload.n, redirectUris: payload.r })), | |
| 286 | ), | |
| 287 | ); | |
| 288 | }; | |
| 289 | ||
| 290 | const pkceVerifies = (verifier: string, challenge: string) => | |
| 291 | CODE_VERIFIER_PATTERN.test(verifier) | |
| 292 | ? crypto.digest("SHA-256", new TextEncoder().encode(verifier)).pipe( | |
| 293 | Effect.orDie, | |
| 294 | Effect.map((digest) => timingSafeEqualBase64Url(base64UrlEncode(digest), challenge)), | |
| 295 | ) | |
| 296 | : Effect.succeed(false); | |
| 297 | ||
| 298 | const csrfToken = (sessionId: string, authorization: AuthorizationRequest) => | |
| 299 | sign( | |
| 300 | "mcp-csrf", | |
| 301 | JSON.stringify([ | |
| 302 | sessionId, | |
| 303 | authorization.client.clientId, | |
| 304 | authorization.redirectUri, | |
| 305 | authorization.codeChallenge, | |
| 306 | ]), | |
| 307 | ); | |
| 308 | ||
| 309 | const register: McpOAuth["Service"]["register"] = (metadata) => | |
| 310 | Effect.gen(function* () { | |
| 311 | const rawName = metadata.client_name?.trim() ?? ""; | |
| 312 | const name = (rawName.length > 0 ? rawName : DEFAULT_CLIENT_NAME).slice( | |
| 313 | 0, | |
| 314 | MAX_CLIENT_NAME_LENGTH, | |
| 315 | ); | |
| 316 | const redirectUris = metadata.redirect_uris ?? []; | |
| 317 | if (redirectUris.length === 0 || redirectUris.length > MAX_REDIRECT_URIS) { | |
| 318 | return yield* new AuthMcpRegistrationError({ | |
| 319 | error: "invalid_redirect_uri", | |
| 320 | error_description: `Register between 1 and ${MAX_REDIRECT_URIS} redirect URIs.`, | |
| 321 | }); | |
| 322 | } | |
| 323 | if (!redirectUris.every((uri) => parseLoopbackRedirect(uri) !== undefined)) { | |
| 324 | return yield* new AuthMcpRegistrationError({ | |
| 325 | error: "invalid_redirect_uri", | |
| 326 | error_description: | |
| 327 | "Only http://localhost, 127.0.0.1 or [::1] redirect URIs are accepted.", | |
| 328 | }); | |
| 329 | } | |
| 330 | if ( | |
| 331 | metadata.token_endpoint_auth_method !== undefined && | |
| 332 | metadata.token_endpoint_auth_method !== "none" | |
| 333 | ) { | |
| 334 | return yield* new AuthMcpRegistrationError({ | |
| 335 | error: "invalid_client_metadata", | |
| 336 | error_description: "Only public clients (token_endpoint_auth_method none) are supported.", | |
| 337 | }); | |
| 338 | } | |
| 339 | // Requested grant types and scopes are ignored rather than rejected: RFC 7591 lets | |
| 340 | // the server answer with what it supports, and Claude Code asks for refresh_token. | |
| 341 | return { clientId: signClientId(name, redirectUris), name, redirectUris }; | |
| 342 | }); | |
| 343 | ||
| 344 | const validateAuthorization: McpOAuth["Service"]["validateAuthorization"] = ({ urls, request }) => | |
| 345 | Effect.gen(function* () { | |
| 346 | const client = parseClientId(request.client_id ?? ""); | |
| 347 | if (client === undefined) { | |
| 348 | return yield* new McpOAuthPageError({ | |
| 349 | description: "This sign-in link names an unknown app. Start the sign-in again from it.", | |
| 350 | }); | |
| 351 | } | |
| 352 | const redirectUri = request.redirect_uri; | |
| 353 | if ( | |
| 354 | redirectUri === undefined || | |
| 355 | parseLoopbackRedirect(redirectUri) === undefined || | |
| 356 | !client.redirectUris.some((registered) => loopbackRedirectMatches(registered, redirectUri)) | |
| 357 | ) { | |
| 358 | return yield* new McpOAuthPageError({ | |
| 359 | description: "This sign-in link sends you to an address the app did not register.", | |
| 360 | }); | |
| 361 | } | |
| 362 | const state = request.state; | |
| 363 | const fail = (error: McpOAuthRedirectError["error"], description: string) => | |
| 364 | new McpOAuthRedirectError({ | |
| 365 | error, | |
| 366 | description, | |
| 367 | redirectUri, | |
| 368 | ...(state === undefined ? {} : { state }), | |
| 369 | }); | |
| 370 | if (request.response_type !== "code") { | |
| 371 | return yield* fail("unsupported_response_type", "Only response_type=code is supported."); | |
| 372 | } | |
| 373 | const codeChallenge = request.code_challenge; | |
| 374 | if ( | |
| 375 | request.code_challenge_method !== "S256" || | |
| 376 | codeChallenge === undefined || | |
| 377 | !CODE_CHALLENGE_PATTERN.test(codeChallenge) | |
| 378 | ) { | |
| 379 | return yield* fail("invalid_request", "PKCE with code_challenge_method=S256 is required."); | |
| 380 | } | |
| 381 | if (!sameResource(urls, request.resource)) { | |
| 382 | return yield* fail( | |
| 383 | "invalid_target", | |
| 384 | `This server only issues tokens for ${urls.resource}.`, | |
| 385 | ); | |
| 386 | } | |
| 387 | return { | |
| 388 | client, | |
| 389 | redirectUri, | |
| 390 | codeChallenge, | |
| 391 | state, | |
| 392 | resource: urls.resource, | |
| 393 | issuer: urls.issuer, | |
| 394 | } satisfies AuthorizationRequest; | |
| 395 | }); | |
| 396 | ||
| 397 | const approvingBrowserSession: McpOAuth["Service"]["approvingBrowserSession"] = ( | |
| 398 | request, | |
| 399 | authorization, | |
| 400 | ) => | |
| 401 | environmentAuth.authenticateBrowserSession(request).pipe( | |
| 402 | Effect.map((session) => | |
| 403 | // Approving manages access, and a session may only hand out scopes it holds. | |
| 404 | session.scopes.includes(AuthAccessWriteScope) && | |
| 405 | session.scopes.includes(AuthOrchestrationReadScope) | |
| 406 | ? { csrfToken: csrfToken(session.sessionId, authorization), scopes: session.scopes } | |
| 407 | : undefined, | |
| 408 | ), | |
| 409 | Effect.orElseSucceed(() => undefined), | |
| 410 | ); | |
| 411 | ||
| 412 | const mintCode = (authorization: AuthorizationRequest, access: AuthMcpClientAccess) => | |
| 413 | Effect.gen(function* () { | |
| 414 | const now = yield* Clock.currentTimeMillis; | |
| 415 | const code = Buffer.from(yield* crypto.randomBytes(32).pipe(Effect.orDie)).toString( | |
| 416 | "base64url", | |
| 417 | ); | |
| 418 | yield* Ref.update(codes, (current) => { | |
| 419 | const next = new Map( | |
| 420 | Array.from(current).filter(([, pending]) => pending.expiresAtMs > now), | |
| 421 | ); | |
| 422 | next.set(code, { | |
| 423 | clientId: authorization.client.clientId, | |
| 424 | clientName: authorization.client.name, | |
| 425 | redirectUri: authorization.redirectUri, | |
| 426 | codeChallenge: authorization.codeChallenge, | |
| 427 | resource: authorization.resource, | |
| 428 | access, | |
| 429 | expiresAtMs: now + AUTHORIZATION_CODE_TTL_MS, | |
| 430 | }); | |
| 431 | return next; | |
| 432 | }); | |
| 433 | return code; | |
| 434 | }); | |
| 435 | ||
| 436 | const approve: McpOAuth["Service"]["approve"] = (input) => | |
| 437 | Effect.gen(function* () { | |
| 438 | const { decision } = input; | |
| 439 | if (decision._tag === "pairing-code") { | |
| 440 | yield* environmentAuth.consumeMcpApprovalCode(decision.code, decision.access).pipe( | |
| 441 | Effect.catchIf(EnvironmentAuth.isServerAuthInternalError, (cause) => | |
| 442 | Effect.logError("MCP approval code check failed.", { cause }).pipe( | |
| 443 | Effect.andThen( | |
| 444 | Effect.fail( | |
| 445 | new McpOAuthPageError({ | |
| 446 | description: "The code could not be checked. Try again.", | |
| 447 | }), | |
| 448 | ), | |
| 449 | ), | |
| 450 | ), | |
| 451 | ), | |
| 452 | ); | |
| 453 | } else { | |
| 454 | const session = yield* approvingBrowserSession(input.request, input.authorization); | |
| 455 | if ( | |
| 456 | session === undefined || | |
| 457 | !timingSafeEqualBase64Url(decision.csrfToken, session.csrfToken) || | |
| 458 | !EnvironmentAuth.mcpClientScopes(decision.access).every((scope) => | |
| 459 | session.scopes.includes(scope), | |
| 460 | ) | |
| 461 | ) { | |
| 462 | return yield* new McpOAuthPageError({ | |
| 463 | description: "Your session cannot approve this request. Enter a pairing code instead.", | |
| 464 | }); | |
| 465 | } | |
| 466 | } | |
| 467 | const code = yield* mintCode(input.authorization, decision.access); | |
| 468 | yield* Effect.logInfo("Approved an MCP client sign-in.", { | |
| 469 | client: input.authorization.client.name, | |
| 470 | access: decision.access, | |
| 471 | method: decision._tag, | |
| 472 | }); | |
| 473 | return redirectWith(input.authorization.redirectUri, { | |
| 474 | code, | |
| 475 | state: input.authorization.state, | |
| 476 | iss: input.authorization.issuer, | |
| 477 | }); | |
| 478 | }); | |
| 479 | ||
| 480 | const deny: McpOAuth["Service"]["deny"] = (authorization) => | |
| 481 | redirectWith(authorization.redirectUri, { | |
| 482 | error: "access_denied", | |
| 483 | state: authorization.state, | |
| 484 | iss: authorization.issuer, | |
| 485 | }); | |
| 486 | ||
| 487 | const exchangeCode: McpOAuth["Service"]["exchangeCode"] = ({ request, urls, token }) => | |
| 488 | Effect.gen(function* () { | |
| 489 | const fail = (error: AuthMcpTokenError["error"], description: string) => | |
| 490 | new AuthMcpTokenError({ error, error_description: description }); | |
| 491 | if (token.grant_type !== "authorization_code") { | |
| 492 | return yield* fail("unsupported_grant_type", "Only authorization_code is supported."); | |
| 493 | } | |
| 494 | const { code, redirect_uri: redirectUri, client_id: clientId } = token; | |
| 495 | const verifier = token.code_verifier; | |
| 496 | if (!code || !redirectUri || !clientId || !verifier) { | |
| 497 | return yield* fail( | |
| 498 | "invalid_request", | |
| 499 | "code, redirect_uri, client_id and code_verifier are required.", | |
| 500 | ); | |
| 501 | } | |
| 502 | if (parseClientId(clientId) === undefined) { | |
| 503 | return yield* fail("invalid_client", "The client is unknown."); | |
| 504 | } | |
| 505 | // A presented code is spent even when a later check fails (RFC 6749 §4.1.2). | |
| 506 | const pending = yield* Ref.modify(codes, (current) => { | |
| 507 | const found = current.get(code); | |
| 508 | if (found === undefined) return [undefined, current] as const; | |
| 509 | const next = new Map(current); | |
| 510 | next.delete(code); | |
| 511 | return [found, next] as const; | |
| 512 | }); | |
| 513 | const now = yield* Clock.currentTimeMillis; | |
| 514 | if ( | |
| 515 | pending === undefined || | |
| 516 | pending.expiresAtMs <= now || | |
| 517 | pending.clientId !== clientId || | |
| 518 | pending.redirectUri !== redirectUri || | |
| 519 | pending.resource !== urls.resource || | |
| 520 | !sameResource(urls, token.resource) || | |
| 521 | !(yield* pkceVerifies(verifier, pending.codeChallenge)) | |
| 522 | ) { | |
| 523 | return yield* fail("invalid_grant", "The authorization code is invalid or expired."); | |
| 524 | } | |
| 525 | const issued = yield* environmentAuth | |
| 526 | .issueMcpClientSession({ | |
| 527 | label: pending.clientName, | |
| 528 | access: pending.access, | |
| 529 | client: deriveAuthClientMetadata({ request }), | |
| 530 | }) | |
| 531 | .pipe( | |
| 532 | Effect.catch((cause) => | |
| 533 | Effect.logError("Could not issue an MCP client session.", { cause }).pipe( | |
| 534 | Effect.andThen( | |
| 535 | Effect.fail(fail("invalid_grant", "The session could not be issued.")), | |
| 536 | ), | |
| 537 | ), | |
| 538 | ), | |
| 539 | ); | |
| 540 | return { | |
| 541 | access_token: issued.token, | |
| 542 | token_type: "Bearer", | |
| 543 | expires_in: Math.max(0, Math.floor((issued.expiresAt.epochMilliseconds - now) / 1000)), | |
| 544 | scope: encodeOAuthScope(EnvironmentAuth.mcpClientScopes(pending.access)), | |
| 545 | }; | |
| 546 | }); | |
| 547 | ||
| 548 | return McpOAuth.of({ | |
| 549 | register, | |
| 550 | validateAuthorization, | |
| 551 | approvingBrowserSession, | |
| 552 | approve, | |
| 553 | deny, | |
| 554 | exchangeCode, | |
| 555 | }); | |
| 556 | }); | |
| 557 | ||
| 558 | export const layer = Layer.effect(McpOAuth, make); | |
| 559 | ||
| 560 | /** | |
| 561 | * Lets `/mcp` admit OAuth clients. Their scope has no calling thread, and | |
| 562 | * never carries preview or device access: those tools act on the caller's | |
| 563 | * own thread. | |
| 564 | */ | |
| 565 | export const layerMcpClientAuthenticator = Layer.effect( | |
| 566 | McpHttpServer.McpClientAuthenticator, | |
| 567 | Effect.gen(function* () { | |
| 568 | const environmentAuth = yield* EnvironmentAuth.EnvironmentAuth; | |
| 569 | const environment = yield* ServerEnvironment.ServerEnvironment; | |
| 570 | const environmentId = yield* environment.getEnvironmentId; | |
| 571 | return McpHttpServer.McpClientAuthenticator.of({ | |
| 572 | authenticate: (request) => | |
| 573 | environmentAuth.authenticateMcpClient(request).pipe( | |
| 574 | Effect.flatMap((client) => | |
| 575 | Clock.currentTimeMillis.pipe( | |
| 576 | Effect.map((issuedAt): McpInvocationContext.McpInvocationScope => ({ | |
| 577 | environmentId, | |
| 578 | requestNamespace: `client:${client.sessionId}`, | |
| 579 | thread: undefined, | |
| 580 | client: { | |
| 581 | sessionId: client.sessionId, | |
| 582 | label: client.label, | |
| 583 | access: client.access, | |
| 584 | }, | |
| 585 | capabilities: new Set<McpInvocationContext.McpCapability>([ | |
| 586 | "orchestration", | |
| 587 | "worktree", | |
| 588 | "pull-requests", | |
| 589 | ]), | |
| 590 | issuedAt, | |
| 591 | })), | |
| 592 | ), | |
| 593 | ), | |
| 594 | Effect.catch((error) => | |
| 595 | (EnvironmentAuth.isServerAuthCredentialError(error) | |
| 596 | ? Effect.void | |
| 597 | : Effect.logWarning("MCP client authentication failed.", { cause: error }) | |
| 598 | ).pipe(Effect.as(undefined)), | |
| 599 | ), | |
| 600 | ), | |
| 601 | }); | |
| 602 | }), | |
| 603 | ); |
apps/server/src/auth/SessionStore.test.ts+14| ... | ... | @@ -276,6 +276,20 @@ it.layer(NodeServices.layer)("SessionStore.layer", (it) => { |
| 276 | 276 | expect(verified.expiresAt?.toString()).toBe(issued.expiresAt.toString()); |
| 277 | 277 | }).pipe(Effect.provide(layerSessionStore())), |
| 278 | 278 | ); |
| 279 | it.effect("carries a runtime-mode ceiling only on sessions issued with one", () => | |
| 280 | Effect.gen(function* () { | |
| 281 | const sessions = yield* SessionStore.SessionStore; | |
| 282 | const capped = yield* sessions.issue({ | |
| 283 | subject: "mcp-client", | |
| 284 | method: "bearer-access-token", | |
| 285 | runtimeModeCeiling: "auto", | |
| 286 | }); | |
| 287 | const uncapped = yield* sessions.issue({ method: "bearer-access-token" }); | |
| 288 | ||
| 289 | expect((yield* sessions.verify(capped.token)).runtimeModeCeiling).toBe("auto"); | |
| 290 | expect((yield* sessions.verify(uncapped.token)).runtimeModeCeiling).toBeUndefined(); | |
| 291 | }).pipe(Effect.provide(layerSessionStore())), | |
| 292 | ); | |
| 279 | 293 | it.effect("rejects malformed session tokens", () => |
| 280 | 294 | Effect.gen(function* () { |
| 281 | 295 | const sessions = yield* SessionStore.SessionStore; |
apps/server/src/auth/SessionStore.ts+7| ... | ... | @@ -7,6 +7,7 @@ import { |
| 7 | 7 | type AuthClientSession, |
| 8 | 8 | type AuthEnvironmentScope, |
| 9 | 9 | type ClientSurface, |
| 10 | RuntimeMode, | |
| 10 | 11 | type ServerAuthSessionMethod, |
| 11 | 12 | } from "@t3tools/contracts"; |
| 12 | 13 | import * as Context from "effect/Context"; |
| ... | ... | @@ -58,6 +59,8 @@ export interface VerifiedSession { |
| 58 | 59 | readonly subject: string; |
| 59 | 60 | readonly scopes: ReadonlyArray<AuthEnvironmentScope>; |
| 60 | 61 | readonly proofKeyThumbprint?: string; |
| 62 | /** The most an MCP client approved through OAuth may hand to the threads it drives. */ | |
| 63 | readonly runtimeModeCeiling?: RuntimeMode; | |
| 61 | 64 | } |
| 62 | 65 | |
| 63 | 66 | export type SessionCredentialChange = |
| ... | ... | @@ -374,6 +377,7 @@ export class SessionStore extends Context.Service< |
| 374 | 377 | readonly scopes?: ReadonlyArray<AuthEnvironmentScope>; |
| 375 | 378 | readonly client?: AuthClientMetadata; |
| 376 | 379 | readonly proofKeyThumbprint?: string; |
| 380 | readonly runtimeModeCeiling?: RuntimeMode; | |
| 377 | 381 | /** |
| 378 | 382 | * Atomically revoke active sessions with the same subject and method |
| 379 | 383 | * before storing this session. |
| ... | ... | @@ -430,6 +434,7 @@ const SessionClaims = Schema.Struct({ |
| 430 | 434 | scopes: AuthEnvironmentScopes, |
| 431 | 435 | method: Schema.Literals(["browser-session-cookie", "bearer-access-token", "dpop-access-token"]), |
| 432 | 436 | jkt: Schema.optionalKey(Schema.String), |
| 437 | rtc: Schema.optionalKey(RuntimeMode), | |
| 433 | 438 | iat: Schema.Number, |
| 434 | 439 | exp: Schema.Number, |
| 435 | 440 | }); |
| ... | ... | @@ -665,6 +670,7 @@ export const make = Effect.gen(function* () { |
| 665 | 670 | scopes: input?.scopes ?? AuthStandardClientScopes, |
| 666 | 671 | method: input?.method ?? "browser-session-cookie", |
| 667 | 672 | ...(input?.proofKeyThumbprint ? { jkt: input.proofKeyThumbprint } : {}), |
| 673 | ...(input?.runtimeModeCeiling ? { rtc: input.runtimeModeCeiling } : {}), | |
| 668 | 674 | iat: issuedAt.epochMilliseconds, |
| 669 | 675 | exp: expiresAt.epochMilliseconds, |
| 670 | 676 | }; |
| ... | ... | @@ -839,6 +845,7 @@ export const make = Effect.gen(function* () { |
| 839 | 845 | subject: claims.sub, |
| 840 | 846 | scopes: claims.scopes, |
| 841 | 847 | ...(claims.jkt ? { proofKeyThumbprint: claims.jkt } : {}), |
| 848 | ...(claims.rtc ? { runtimeModeCeiling: claims.rtc } : {}), | |
| 842 | 849 | } satisfies VerifiedSession; |
| 843 | 850 | }, |
| 844 | 851 | ); |
apps/server/src/auth/mcpOAuthHtml.ts created+46| ... | ... | @@ -0,0 +1,46 @@ |
| 1 | /** | |
| 2 | * The page for a sign-in link that cannot be trusted (unknown client or | |
| 3 | * unregistered redirect). It is plain server HTML so it never depends on the | |
| 4 | * web app, and it must not redirect anywhere. | |
| 5 | */ | |
| 6 | const escapeHtml = (value: string) => | |
| 7 | value.replace( | |
| 8 | /[&<>"']/gu, | |
| 9 | (character) => | |
| 10 | ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[character]!, | |
| 11 | ); | |
| 12 | ||
| 13 | const STYLES = ` | |
| 14 | :root { color-scheme: light dark; font-family: ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; } | |
| 15 | body { margin: 0; min-height: 100vh; display: grid; place-items: center; padding: 32px 16px; background: #f6f7f9; color: #17191f; } | |
| 16 | main { width: min(100%, 440px); } | |
| 17 | h1 { margin: 0 0 8px; font-size: 20px; } | |
| 18 | p { margin: 0 0 12px; line-height: 1.5; color: #4b5060; } | |
| 19 | @media (prefers-color-scheme: dark) { | |
| 20 | body { background: #0f1115; color: #e6e8ee; } | |
| 21 | p { color: #a0a6b4; } | |
| 22 | } | |
| 23 | `; | |
| 24 | ||
| 25 | const shell = (title: string, body: string) => `<!doctype html> | |
| 26 | <html lang="en"> | |
| 27 | <head> | |
| 28 | <meta charset="utf-8" /> | |
| 29 | <meta name="viewport" content="width=device-width, initial-scale=1" /> | |
| 30 | <meta name="color-scheme" content="light dark" /> | |
| 31 | <title>${escapeHtml(title)}</title> | |
| 32 | <style>${STYLES}</style> | |
| 33 | </head> | |
| 34 | <body> | |
| 35 | <main>${body}</main> | |
| 36 | </body> | |
| 37 | </html>`; | |
| 38 | ||
| 39 | export function renderErrorPage(description: string): string { | |
| 40 | return shell( | |
| 41 | "Sign-in failed", | |
| 42 | `<h1>This sign-in cannot continue</h1> | |
| 43 | <p>${escapeHtml(description)}</p> | |
| 44 | <p>Close this page and start the sign-in again from your agent.</p>`, | |
| 45 | ); | |
| 46 | } |
apps/server/src/auth/mcpOAuthHttp.ts created+159| ... | ... | @@ -0,0 +1,159 @@ |
| 1 | import { | |
| 2 | AuthMcpApprovalError, | |
| 3 | type AuthMcpAuthorizationRequest, | |
| 4 | AuthMcpClientAccess, | |
| 5 | EnvironmentHttpApi, | |
| 6 | } from "@t3tools/contracts"; | |
| 7 | import * as Effect from "effect/Effect"; | |
| 8 | import * as Result from "effect/Result"; | |
| 9 | import { HttpServerRequest, HttpServerResponse } from "effect/http"; | |
| 10 | import * as HttpEffect from "effect/http/HttpEffect"; | |
| 11 | import * as HttpApiBuilder from "effect/http-api/HttpApiBuilder"; | |
| 12 | ||
| 13 | import * as EnvironmentAuth from "./EnvironmentAuth.ts"; | |
| 14 | import * as McpOAuth from "./McpOAuth.ts"; | |
| 15 | import { renderErrorPage } from "./mcpOAuthHtml.ts"; | |
| 16 | ||
| 17 | /** Where the approval page lives in the web app. */ | |
| 18 | const APPROVAL_PAGE_PATH = "/connect-agent"; | |
| 19 | ||
| 20 | const PAGE_HEADERS = { | |
| 21 | "content-security-policy": | |
| 22 | "default-src 'none'; style-src 'unsafe-inline'; frame-ancestors 'none'", | |
| 23 | "x-frame-options": "DENY", | |
| 24 | "cache-control": "no-store", | |
| 25 | "referrer-policy": "no-referrer", | |
| 26 | "x-content-type-options": "nosniff", | |
| 27 | }; | |
| 28 | ||
| 29 | /** OAuth responses carry codes, tokens and client ids, none of which may be cached. */ | |
| 30 | const noStore = HttpEffect.appendPreResponseHandler((_request, response) => | |
| 31 | Effect.succeed( | |
| 32 | HttpServerResponse.setHeaders(response, { "cache-control": "no-store", pragma: "no-cache" }), | |
| 33 | ), | |
| 34 | ); | |
| 35 | ||
| 36 | /** Issuer and resource for the origin this request reached. */ | |
| 37 | const requestUrls = Effect.map(HttpServerRequest.HttpServerRequest, McpOAuth.requestUrls); | |
| 38 | ||
| 39 | /** | |
| 40 | * Validates the request the approval page forwards. An unverified client or | |
| 41 | * redirect is reported, never redirected; any other problem goes back to the | |
| 42 | * agent through its (verified) redirect URI. | |
| 43 | */ | |
| 44 | const forwardedAuthorization = ( | |
| 45 | oauth: McpOAuth.McpOAuth["Service"], | |
| 46 | request: AuthMcpAuthorizationRequest, | |
| 47 | ) => | |
| 48 | Effect.gen(function* () { | |
| 49 | const urls = yield* requestUrls; | |
| 50 | return yield* oauth.validateAuthorization({ urls, request }).pipe( | |
| 51 | Effect.catchTags({ | |
| 52 | McpOAuthPageError: (error) => | |
| 53 | Effect.fail(new AuthMcpApprovalError({ message: error.description })), | |
| 54 | McpOAuthRedirectError: (error) => | |
| 55 | Effect.succeed({ redirectTo: McpOAuth.redirectForError(error, urls.issuer) }), | |
| 56 | }), | |
| 57 | ); | |
| 58 | }); | |
| 59 | ||
| 60 | export const layer = HttpApiBuilder.group( | |
| 61 | EnvironmentHttpApi, | |
| 62 | "mcpOAuth", | |
| 63 | Effect.fnUntraced(function* (handlers) { | |
| 64 | const oauth = yield* McpOAuth.McpOAuth; | |
| 65 | const protectedResource = () => | |
| 66 | requestUrls.pipe(Effect.map(McpOAuth.protectedResourceMetadata)); | |
| 67 | ||
| 68 | return ( | |
| 69 | handlers | |
| 70 | .handle("protectedResource", protectedResource) | |
| 71 | .handle("mcpProtectedResource", protectedResource) | |
| 72 | .handle("authorizationServer", () => | |
| 73 | requestUrls.pipe(Effect.map(McpOAuth.authorizationServerMetadata)), | |
| 74 | ) | |
| 75 | .handle("register", ({ payload }) => | |
| 76 | oauth.register(payload).pipe( | |
| 77 | Effect.map((client) => ({ | |
| 78 | client_id: client.clientId, | |
| 79 | client_name: client.name, | |
| 80 | redirect_uris: client.redirectUris, | |
| 81 | grant_types: ["authorization_code" as const], | |
| 82 | response_types: ["code" as const], | |
| 83 | token_endpoint_auth_method: "none" as const, | |
| 84 | })), | |
| 85 | Effect.tap(() => noStore), | |
| 86 | ), | |
| 87 | ) | |
| 88 | // The browser lands here from the agent. A valid request is handed to the | |
| 89 | // web app's approval page with its query intact. | |
| 90 | .handleRaw("authorize", ({ request }) => | |
| 91 | Effect.gen(function* () { | |
| 92 | const urls = yield* requestUrls; | |
| 93 | const url = new URL(request.url, urls.issuer); | |
| 94 | const authorization = yield* oauth | |
| 95 | .validateAuthorization({ urls, request: Object.fromEntries(url.searchParams) }) | |
| 96 | .pipe(Effect.result); | |
| 97 | const headers = { "cache-control": "no-store", "referrer-policy": "no-referrer" }; | |
| 98 | if (Result.isSuccess(authorization)) { | |
| 99 | return HttpServerResponse.redirect(`${APPROVAL_PAGE_PATH}${url.search}`, { headers }); | |
| 100 | } | |
| 101 | const error = authorization.failure; | |
| 102 | return error._tag === "McpOAuthPageError" | |
| 103 | ? HttpServerResponse.text(renderErrorPage(error.description), { | |
| 104 | status: 400, | |
| 105 | contentType: "text/html; charset=utf-8", | |
| 106 | headers: PAGE_HEADERS, | |
| 107 | }) | |
| 108 | : HttpServerResponse.redirect(McpOAuth.redirectForError(error, urls.issuer), { | |
| 109 | headers, | |
| 110 | }); | |
| 111 | }), | |
| 112 | ) | |
| 113 | // What the approval page shows, and whether one click may approve. | |
| 114 | .handle("approval", ({ payload, request }) => | |
| 115 | Effect.gen(function* () { | |
| 116 | const resolved = yield* forwardedAuthorization(oauth, payload); | |
| 117 | if ("redirectTo" in resolved) return resolved; | |
| 118 | const urls = yield* requestUrls; | |
| 119 | const session = yield* oauth.approvingBrowserSession(request, resolved); | |
| 120 | yield* noStore; | |
| 121 | return { | |
| 122 | clientName: resolved.client.name, | |
| 123 | redirectHost: new URL(resolved.redirectUri).host, | |
| 124 | environmentHost: new URL(urls.issuer).host, | |
| 125 | ...(session === undefined | |
| 126 | ? {} | |
| 127 | : { | |
| 128 | csrfToken: session.csrfToken, | |
| 129 | oneClickAccess: AuthMcpClientAccess.literals.filter((access) => | |
| 130 | EnvironmentAuth.mcpClientScopes(access).every((scope) => | |
| 131 | session.scopes.includes(scope), | |
| 132 | ), | |
| 133 | ), | |
| 134 | }), | |
| 135 | }; | |
| 136 | }), | |
| 137 | ) | |
| 138 | .handle("decision", ({ payload, request }) => | |
| 139 | Effect.gen(function* () { | |
| 140 | const resolved = yield* forwardedAuthorization(oauth, payload.authorization); | |
| 141 | if ("redirectTo" in resolved) return resolved; | |
| 142 | yield* noStore; | |
| 143 | const { decision } = payload; | |
| 144 | if (decision._tag === "deny") return { redirectTo: oauth.deny(resolved) }; | |
| 145 | return yield* oauth.approve({ request, authorization: resolved, decision }).pipe( | |
| 146 | Effect.map((redirectTo) => ({ redirectTo })), | |
| 147 | Effect.mapError((error) => new AuthMcpApprovalError({ message: error.message })), | |
| 148 | ); | |
| 149 | }), | |
| 150 | ) | |
| 151 | .handle("token", ({ payload, request }) => | |
| 152 | Effect.gen(function* () { | |
| 153 | yield* noStore; | |
| 154 | return yield* oauth.exchangeCode({ request, urls: yield* requestUrls, token: payload }); | |
| 155 | }), | |
| 156 | ) | |
| 157 | ); | |
| 158 | }), | |
| 159 | ); |
apps/server/src/mcp/McpHttpServer.test.ts+153-2| ... | ... | @@ -3,7 +3,13 @@ import * as ProjectionStore from "../orchestration-v2/ProjectionStore.ts"; |
| 3 | 3 | import { expect, it } from "@effect/vitest"; |
| 4 | 4 | import { NodeHttpServer } from "@effect/platform-node"; |
| 5 | 5 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 6 | import { EnvironmentId, PreviewTabId, ProviderInstanceId, ThreadId } from "@t3tools/contracts"; | |
| 6 | import { | |
| 7 | EnvironmentId, | |
| 8 | OrchestratorMcpFailure, | |
| 9 | PreviewTabId, | |
| 10 | ProviderInstanceId, | |
| 11 | ThreadId, | |
| 12 | } from "@t3tools/contracts"; | |
| 7 | 13 | import * as Deferred from "effect/Deferred"; |
| 8 | 14 | import * as Effect from "effect/Effect"; |
| 9 | 15 | import * as FileSystem from "effect/FileSystem"; |
| ... | ... | @@ -11,14 +17,16 @@ import * as Layer from "effect/Layer"; |
| 11 | 17 | import * as Path from "effect/Path"; |
| 12 | 18 | import * as Schema from "effect/Schema"; |
| 13 | 19 | import * as Stream from "effect/Stream"; |
| 14 | import { McpProtocol, McpSchema, McpServer } from "effect/ai"; | |
| 20 | import { McpProtocol, McpSchema, McpServer, Tool, Toolkit } from "effect/ai"; | |
| 15 | 21 | import { HttpBody, HttpClient, HttpRouter, HttpServerResponse } from "effect/http"; |
| 16 | 22 | |
| 17 | 23 | import * as ProjectService from "../project/ProjectService.ts"; |
| 18 | 24 | import * as ServerConfig from "../config.ts"; |
| 19 | 25 | import * as McpHttpServer from "./McpHttpServer.ts"; |
| 26 | import * as McpToolAccess from "./McpToolAccess.ts"; | |
| 20 | 27 | import * as McpToolAccessTestkit from "./McpToolAccess.testkit.ts"; |
| 21 | 28 | import * as McpInvocationContext from "./McpInvocationContext.ts"; |
| 29 | import * as McpSessionRegistry from "./McpSessionRegistry.ts"; | |
| 22 | 30 | import * as PreviewAutomationBroker from "./PreviewAutomationBroker.ts"; |
| 23 | 31 | |
| 24 | 32 | const environmentId = EnvironmentId.make("environment-mcp-test"); |
| ... | ... | @@ -356,6 +364,28 @@ it.effect.each([ |
| 356 | 364 | ).pipe(Effect.provide(layerTest)), |
| 357 | 365 | ); |
| 358 | 366 | |
| 367 | it.effect("refuses preview tools to a client outside a thread before they run", () => | |
| 368 | Effect.gen(function* () { | |
| 369 | const server = yield* McpServer.McpServer; | |
| 370 | const readOnly = { | |
| 371 | ...invocation, | |
| 372 | thread: undefined, | |
| 373 | requestNamespace: "client:session-1", | |
| 374 | client: { sessionId: "session-1", label: "Claude Code", access: "read-only" as const }, | |
| 375 | }; | |
| 376 | const click = yield* server | |
| 377 | .callTool({ name: "preview_click", arguments: { locator: "text=Send" } }) | |
| 378 | .pipe( | |
| 379 | Effect.provideService(McpInvocationContext.McpInvocationContext, readOnly), | |
| 380 | Effect.provideService(McpSchema.McpServerClient, client), | |
| 381 | ); | |
| 382 | expect(click.isError).toBe(true); | |
| 383 | expect(click.content).toEqual([ | |
| 384 | { type: "text", text: expect.stringContaining("needs an agent running inside T3 Code") }, | |
| 385 | ]); | |
| 386 | }).pipe(Effect.provide(layerTest)), | |
| 387 | ); | |
| 388 | ||
| 359 | 389 | it.effect("rejects non-boolean snapshot image options before selecting a browser host", () => |
| 360 | 390 | Effect.gen(function* () { |
| 361 | 391 | const server = yield* McpServer.McpServer; |
| ... | ... | @@ -916,3 +946,124 @@ it.effect("registers annotated tools and preserves authenticated request context |
| 916 | 946 | }), |
| 917 | 947 | ).pipe(Effect.provide(layerTest)), |
| 918 | 948 | ); |
| 949 | ||
| 950 | it.effect("admits provider and OAuth client credentials and points only clients at OAuth", () => | |
| 951 | Effect.scoped( | |
| 952 | Effect.gen(function* () { | |
| 953 | const providerToken = "providerTokenWithoutDots"; | |
| 954 | const clientToken = "client-payload.client-signature"; | |
| 955 | const providerScope: McpInvocationContext.McpInvocationScope = { | |
| 956 | environmentId, | |
| 957 | requestNamespace: "provider-session", | |
| 958 | thread: { | |
| 959 | threadId: ThreadId.make("thread-provider"), | |
| 960 | providerSessionId: "provider-session", | |
| 961 | providerInstanceId: ProviderInstanceId.make("codex"), | |
| 962 | }, | |
| 963 | client: undefined, | |
| 964 | capabilities: new Set(["orchestration"]), | |
| 965 | issuedAt: 1, | |
| 966 | }; | |
| 967 | const clientScope: McpInvocationContext.McpInvocationScope = { | |
| 968 | environmentId, | |
| 969 | requestNamespace: "client:session-1", | |
| 970 | thread: undefined, | |
| 971 | client: { sessionId: "session-1", label: "Claude Code", access: "auto" }, | |
| 972 | capabilities: new Set(["orchestration"]), | |
| 973 | issuedAt: 1, | |
| 974 | }; | |
| 975 | const seen: Array<McpInvocationContext.McpInvocationScope> = []; | |
| 976 | const ProbeToolkit = Toolkit.make( | |
| 977 | Tool.make("probe", { | |
| 978 | description: "Reports the caller.", | |
| 979 | success: Schema.Struct({ ok: Schema.Boolean }), | |
| 980 | failure: OrchestratorMcpFailure, | |
| 981 | failureMode: "return", | |
| 982 | dependencies: [McpInvocationContext.McpInvocationContext], | |
| 983 | }), | |
| 984 | ); | |
| 985 | const serverLayer = McpHttpServer.toolkitRegistration( | |
| 986 | ProbeToolkit, | |
| 987 | McpToolAccess.toLayer(ProbeToolkit, { | |
| 988 | probe: McpToolAccess.reads(() => | |
| 989 | McpInvocationContext.McpInvocationContext.pipe( | |
| 990 | Effect.tap((scope) => Effect.sync(() => seen.push(scope))), | |
| 991 | Effect.as({ ok: true }), | |
| 992 | ), | |
| 993 | ), | |
| 994 | }), | |
| 995 | ).pipe( | |
| 996 | Layer.provideMerge(McpHttpServer.layerMcpTransport), | |
| 997 | Layer.provide( | |
| 998 | Layer.mock(McpSessionRegistry.McpSessionRegistry)({ | |
| 999 | resolve: (token) => | |
| 1000 | Effect.succeed( | |
| 1001 | token === providerToken | |
| 1002 | ? (providerScope as McpInvocationContext.McpThreadInvocationScope) | |
| 1003 | : undefined, | |
| 1004 | ), | |
| 1005 | }), | |
| 1006 | ), | |
| 1007 | Layer.provide( | |
| 1008 | Layer.succeed(McpHttpServer.McpClientAuthenticator, { | |
| 1009 | authenticate: (request) => | |
| 1010 | Effect.succeed( | |
| 1011 | request.headers.authorization === `Bearer ${clientToken}` ? clientScope : undefined, | |
| 1012 | ), | |
| 1013 | }), | |
| 1014 | ), | |
| 1015 | ); | |
| 1016 | yield* HttpRouter.serve(serverLayer, { disableListenLog: true, disableLogger: true }).pipe( | |
| 1017 | Layer.build, | |
| 1018 | ); | |
| 1019 | const httpClient = yield* HttpClient.HttpClient; | |
| 1020 | const call = (token: string | undefined) => | |
| 1021 | Effect.gen(function* () { | |
| 1022 | const headers = { | |
| 1023 | accept: "application/json, text/event-stream", | |
| 1024 | ...(token === undefined ? {} : { authorization: `Bearer ${token}` }), | |
| 1025 | }; | |
| 1026 | const initialize = yield* httpClient.post("/mcp", { | |
| 1027 | headers, | |
| 1028 | body: HttpBody.text( | |
| 1029 | `{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"probe","version":"1.0.0"}}}`, | |
| 1030 | "application/json", | |
| 1031 | ), | |
| 1032 | }); | |
| 1033 | if (initialize.status !== 200) return initialize; | |
| 1034 | return yield* httpClient.post("/mcp", { | |
| 1035 | headers: { | |
| 1036 | ...headers, | |
| 1037 | "mcp-session-id": initialize.headers["mcp-session-id"]!, | |
| 1038 | "mcp-protocol-version": "2025-06-18", | |
| 1039 | }, | |
| 1040 | body: HttpBody.text( | |
| 1041 | `{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"probe","arguments":{}}}`, | |
| 1042 | "application/json", | |
| 1043 | ), | |
| 1044 | }); | |
| 1045 | }); | |
| 1046 | ||
| 1047 | expect((yield* call(providerToken)).status).toBe(200); | |
| 1048 | expect((yield* call(clientToken)).status).toBe(200); | |
| 1049 | expect(seen.map((scope) => scope.requestNamespace)).toEqual([ | |
| 1050 | "provider-session", | |
| 1051 | "client:session-1", | |
| 1052 | ]); | |
| 1053 | ||
| 1054 | const missing = yield* call(undefined); | |
| 1055 | expect(missing.status).toBe(401); | |
| 1056 | expect(missing.headers["www-authenticate"]).toMatch( | |
| 1057 | /^Bearer resource_metadata="http:\/\/[^"]+\/\.well-known\/oauth-protected-resource\/mcp"$/, | |
| 1058 | ); | |
| 1059 | ||
| 1060 | const expiredClient = yield* call("stale-payload.stale-signature"); | |
| 1061 | expect(expiredClient.headers["www-authenticate"]).toContain("resource_metadata="); | |
| 1062 | expect(expiredClient.headers["www-authenticate"]).toContain('error="invalid_token"'); | |
| 1063 | ||
| 1064 | const deadProvider = yield* call("deadProviderToken"); | |
| 1065 | expect(deadProvider.status).toBe(401); | |
| 1066 | expect(deadProvider.headers["www-authenticate"]).toBe('Bearer error="invalid_token"'); | |
| 1067 | }), | |
| 1068 | ).pipe(Effect.provide(NodeHttpServer.layerTest)), | |
| 1069 | ); |
apps/server/src/mcp/McpHttpServer.ts+93-40| ... | ... | @@ -58,19 +58,62 @@ import { |
| 58 | 58 | import * as HtmlHandlers from "./toolkits/html/handlers.ts"; |
| 59 | 59 | import { HtmlPreviewTool, HtmlPreviewToolkit, HtmlRenderToolkit } from "./toolkits/html/tools.ts"; |
| 60 | 60 | |
| 61 | const unauthorized = HttpServerResponse.jsonUnsafe( | |
| 62 | { | |
| 63 | error: "invalid_mcp_credential", | |
| 64 | message: "A valid provider-scoped MCP bearer credential is required.", | |
| 65 | }, | |
| 66 | { | |
| 67 | status: 401, | |
| 68 | headers: { | |
| 69 | "cache-control": "no-store", | |
| 70 | "www-authenticate": "Bearer", | |
| 61 | /** Where an MCP client discovers how to sign in (RFC 9728), at this request's own origin. */ | |
| 62 | const mcpResourceMetadataUrl = (request: HttpServerRequest.HttpServerRequest) => | |
| 63 | HttpServerRequest.toURL(request).pipe( | |
| 64 | Option.map((url) => `${url.origin}/.well-known/oauth-protected-resource/mcp`), | |
| 65 | ); | |
| 66 | ||
| 67 | /** | |
| 68 | * Agents T3 Code launched carry a registry token and must never be sent into | |
| 69 | * an OAuth flow when it dies: they cannot open a browser, and a sign-in | |
| 70 | * would mint a credential that outlives their session. Only a request that | |
| 71 | * does not look like a provider token is pointed at the OAuth metadata. | |
| 72 | */ | |
| 73 | const unauthorized = (input: { | |
| 74 | readonly request: HttpServerRequest.HttpServerRequest; | |
| 75 | readonly presentedToken: boolean; | |
| 76 | readonly offerOAuth: boolean; | |
| 77 | }) => { | |
| 78 | const metadataUrl = input.offerOAuth | |
| 79 | ? Option.getOrUndefined(mcpResourceMetadataUrl(input.request)) | |
| 80 | : undefined; | |
| 81 | const challenge = [ | |
| 82 | "Bearer", | |
| 83 | [ | |
| 84 | ...(metadataUrl === undefined ? [] : [`resource_metadata="${metadataUrl}"`]), | |
| 85 | ...(input.presentedToken ? ['error="invalid_token"'] : []), | |
| 86 | ].join(", "), | |
| 87 | ] | |
| 88 | .filter((part) => part.length > 0) | |
| 89 | .join(" "); | |
| 90 | return HttpServerResponse.jsonUnsafe( | |
| 91 | { | |
| 92 | error: "invalid_mcp_credential", | |
| 93 | message: "A valid T3 Code MCP credential is required.", | |
| 71 | 94 | }, |
| 72 | }, | |
| 73 | ); | |
| 95 | { | |
| 96 | status: 401, | |
| 97 | headers: { | |
| 98 | "cache-control": "no-store", | |
| 99 | "www-authenticate": challenge, | |
| 100 | }, | |
| 101 | }, | |
| 102 | ); | |
| 103 | }; | |
| 104 | ||
| 105 | /** | |
| 106 | * Resolves a bearer token that is not a provider-session token: an OAuth | |
| 107 | * client signed in from outside T3. Undefined when the token is not one. | |
| 108 | */ | |
| 109 | export class McpClientAuthenticator extends Context.Service< | |
| 110 | McpClientAuthenticator, | |
| 111 | { | |
| 112 | readonly authenticate: ( | |
| 113 | request: HttpServerRequest.HttpServerRequest, | |
| 114 | ) => Effect.Effect<McpInvocationContext.McpInvocationScope | undefined>; | |
| 115 | } | |
| 116 | >()("t3/mcp/McpHttpServer/McpClientAuthenticator") {} | |
| 74 | 117 | |
| 75 | 118 | type AuthenticatedHttpEffect = Effect.Effect< |
| 76 | 119 | HttpServerResponse.HttpServerResponse, |
| ... | ... | @@ -98,33 +141,43 @@ export const normalizeMcpHttpResponse = ( |
| 98 | 141 | : response; |
| 99 | 142 | }; |
| 100 | 143 | |
| 101 | const makeMcpAuthMiddleware = McpSessionRegistry.McpSessionRegistry.pipe( | |
| 102 | Effect.map((registry): McpAuthMiddleware => | |
| 103 | Effect.fn("McpHttpServer.authenticateRequest")(function* (httpEffect) { | |
| 104 | const request = yield* HttpServerRequest.HttpServerRequest; | |
| 105 | const authorization = request.headers.authorization; | |
| 106 | const token = | |
| 107 | authorization?.startsWith("Bearer ") === true | |
| 108 | ? authorization.slice("Bearer ".length).trim() | |
| 109 | : ""; | |
| 110 | const invocation = yield* registry.resolve(token); | |
| 111 | if (!invocation) { | |
| 112 | // Without this the only symptom of a dead credential is the agent | |
| 113 | // quietly losing the whole `t3-code` toolkit for the rest of its | |
| 114 | // session, with nothing on the server to explain why. | |
| 115 | yield* Effect.logWarning("rejected MCP request with an unusable credential", { | |
| 116 | reason: token.length === 0 ? "missing_bearer_token" : "unknown_or_expired_token", | |
| 117 | }); | |
| 118 | return unauthorized; | |
| 119 | } | |
| 120 | return yield* httpEffect.pipe( | |
| 121 | Effect.provideService(McpInvocationContext.McpInvocationContext, invocation), | |
| 122 | Effect.map(normalizeMcpHttpResponse), | |
| 123 | ); | |
| 124 | }), | |
| 125 | ), | |
| 126 | Effect.withSpan("McpHttpServer.makeAuthMiddleware"), | |
| 127 | ); | |
| 144 | // Session tokens are `<payload>.<signature>`; registry tokens are a bare base64url secret. | |
| 145 | const looksLikeProviderToken = (token: string) => token.length > 0 && !token.includes("."); | |
| 146 | ||
| 147 | const makeMcpAuthMiddleware = Effect.gen(function* () { | |
| 148 | const registry = yield* McpSessionRegistry.McpSessionRegistry; | |
| 149 | const clients = yield* Effect.serviceOption(McpClientAuthenticator); | |
| 150 | return Effect.fn("McpHttpServer.authenticateRequest")(function* (httpEffect) { | |
| 151 | const request = yield* HttpServerRequest.HttpServerRequest; | |
| 152 | const authorization = request.headers.authorization; | |
| 153 | const token = | |
| 154 | authorization?.startsWith("Bearer ") === true | |
| 155 | ? authorization.slice("Bearer ".length).trim() | |
| 156 | : ""; | |
| 157 | const invocation = | |
| 158 | (yield* registry.resolve(token)) ?? | |
| 159 | (Option.isSome(clients) && token.length > 0 | |
| 160 | ? yield* clients.value.authenticate(request) | |
| 161 | : undefined); | |
| 162 | if (!invocation) { | |
| 163 | // Without this the only symptom of a dead credential is the agent | |
| 164 | // quietly losing the whole `t3-code` toolkit for the rest of its | |
| 165 | // session, with nothing on the server to explain why. | |
| 166 | yield* Effect.logWarning("rejected MCP request with an unusable credential", { | |
| 167 | reason: token.length === 0 ? "missing_bearer_token" : "unknown_or_expired_token", | |
| 168 | }); | |
| 169 | return unauthorized({ | |
| 170 | request, | |
| 171 | presentedToken: token.length > 0, | |
| 172 | offerOAuth: Option.isSome(clients) && !looksLikeProviderToken(token), | |
| 173 | }); | |
| 174 | } | |
| 175 | return yield* httpEffect.pipe( | |
| 176 | Effect.provideService(McpInvocationContext.McpInvocationContext, invocation), | |
| 177 | Effect.map(normalizeMcpHttpResponse), | |
| 178 | ); | |
| 179 | }) satisfies McpAuthMiddleware; | |
| 180 | }).pipe(Effect.withSpan("McpHttpServer.makeAuthMiddleware")); | |
| 128 | 181 | |
| 129 | 182 | const layerMcpAuthMiddleware = HttpRouter.middleware<{ |
| 130 | 183 | provides: McpInvocationContext.McpInvocationContext; |
| ... | ... | @@ -787,7 +840,7 @@ export const layerDeviceToolkit = Layer.mergeAll( |
| 787 | 840 | layerDeviceScreenshotRegistration, |
| 788 | 841 | ); |
| 789 | 842 | |
| 790 | const layerMcpTransport = McpServer.layerHttp({ | |
| 843 | export const layerMcpTransport = McpServer.layerHttp({ | |
| 791 | 844 | name: "T3 Code", |
| 792 | 845 | version: packageJson.version, |
| 793 | 846 | path: "/mcp", |
apps/server/src/mcp/McpInvocationContext.test.ts+1-1| ... | ... | @@ -81,7 +81,7 @@ it.effect("refuses thread-owned capabilities to a caller signed in from outside |
| 81 | 81 | environmentId: EnvironmentId.make("environment-1"), |
| 82 | 82 | requestNamespace: "client:session-1", |
| 83 | 83 | thread: undefined, |
| 84 | client: { sessionId: "session-1", label: "Claude Code", runtimeModeCeiling: "auto" }, | |
| 84 | client: { sessionId: "session-1", label: "Claude Code", access: "auto" }, | |
| 85 | 85 | capabilities: new Set(["preview", "orchestration"]), |
| 86 | 86 | issuedAt: 1, |
| 87 | 87 | }; |
apps/server/src/mcp/McpInvocationContext.ts+11-1| ... | ... | @@ -1,4 +1,5 @@ |
| 1 | 1 | import { |
| 2 | type AuthMcpClientAccess, | |
| 2 | 3 | type EnvironmentId, |
| 3 | 4 | McpCapabilityUnavailableError, |
| 4 | 5 | OrchestratorMcpFailure, |
| ... | ... | @@ -30,9 +31,18 @@ export interface McpThreadCaller { |
| 30 | 31 | export interface McpClientCaller { |
| 31 | 32 | readonly sessionId: string; |
| 32 | 33 | readonly label: string; |
| 33 | readonly runtimeModeCeiling: RuntimeMode; | |
| 34 | /** Read only, or the most the threads it starts or changes may run with. */ | |
| 35 | readonly access: AuthMcpClientAccess; | |
| 34 | 36 | } |
| 35 | 37 | |
| 38 | /** | |
| 39 | * The runtime mode a client caller's writes are capped at. A read-only client | |
| 40 | * never reaches a write (`McpToolAccess` refuses it first), so it maps to the | |
| 41 | * lowest mode rather than to nothing. | |
| 42 | */ | |
| 43 | export const clientRuntimeModeCeiling = (client: McpClientCaller | undefined): RuntimeMode => | |
| 44 | client === undefined || client.access === "read-only" ? "approval-required" : client.access; | |
| 45 | ||
| 36 | 46 | /** |
| 37 | 47 | * Who is calling and what they may do. Tool parameters choose the target |
| 38 | 48 | * (thread, project); the caller sets the limits. A thread caller's omitted |
apps/server/src/mcp/McpToolAccess.race.test.ts+1-1| ... | ... | @@ -66,7 +66,7 @@ const supervisedClient: McpInvocationContext.McpInvocationScope = { |
| 66 | 66 | environmentId: EnvironmentId.make("environment"), |
| 67 | 67 | requestNamespace: "client:race", |
| 68 | 68 | thread: undefined, |
| 69 | client: { sessionId: "race", label: "Claude Code", runtimeModeCeiling: "approval-required" }, | |
| 69 | client: { sessionId: "race", label: "Claude Code", access: "approval-required" }, | |
| 70 | 70 | capabilities: new Set(["orchestration"]), |
| 71 | 71 | issuedAt: 0, |
| 72 | 72 | }; |
apps/server/src/mcp/McpToolAccess.test.ts+10-2| ... | ... | @@ -48,12 +48,12 @@ const threadCaller = (threadId: ThreadId): McpInvocationContext.McpInvocationSco |
| 48 | 48 | }); |
| 49 | 49 | |
| 50 | 50 | const clientCaller = ( |
| 51 | runtimeModeCeiling: RuntimeMode, | |
| 51 | access: McpInvocationContext.McpClientCaller["access"], | |
| 52 | 52 | ): McpInvocationContext.McpInvocationScope => ({ |
| 53 | 53 | environmentId: EnvironmentId.make("environment"), |
| 54 | 54 | requestNamespace: "client:session", |
| 55 | 55 | thread: undefined, |
| 56 | client: { sessionId: "session", label: "Claude Code", runtimeModeCeiling }, | |
| 56 | client: { sessionId: "session", label: "Claude Code", access }, | |
| 57 | 57 | capabilities: new Set(["orchestration"]), |
| 58 | 58 | issuedAt: 0, |
| 59 | 59 | }); |
| ... | ... | @@ -193,6 +193,7 @@ const fullAccess = threadCaller(fullAccessThreadId); |
| 193 | 193 | const ended = threadCaller(endedThreadId); |
| 194 | 194 | const supervisedClient = clientCaller("approval-required"); |
| 195 | 195 | const fullAccessClient = clientCaller("full-access"); |
| 196 | const readOnlyClient = clientCaller("read-only"); | |
| 196 | 197 | // A live full-access thread whose credential may use its browser but not control threads. |
| 197 | 198 | const previewOnly = { ...fullAccess, capabilities: new Set(["preview"] as const) }; |
| 198 | 199 | |
| ... | ... | @@ -200,6 +201,13 @@ it.effect.each([ |
| 200 | 201 | // Reads are open to every caller, even one whose turn ended. |
| 201 | 202 | ["reads", ended, {}, "ran"], |
| 202 | 203 | ["reads", supervisedClient, {}, "ran"], |
| 204 | ["reads", readOnlyClient, {}, "ran"], | |
| 205 | ||
| 206 | // A client approved for read-only access changes nothing. | |
| 207 | ["writes", readOnlyClient, {}, "capability_denied"], | |
| 208 | ["writes_threads", readOnlyClient, { threadId: supervisedThreadId }, "capability_denied"], | |
| 209 | ["starts_threads", readOnlyClient, {}, "capability_denied"], | |
| 210 | ["writes_environment", readOnlyClient, {}, "capability_denied"], | |
| 203 | 211 | |
| 204 | 212 | // What belongs to the calling thread needs one; changing it needs its live turn. |
| 205 | 213 | ["reads_as_caller", ended, {}, "ran"], |
apps/server/src/mcp/McpToolAccess.ts+23-2| ... | ... | @@ -97,15 +97,36 @@ export class Declaration<out Handler> { |
| 97 | 97 | } |
| 98 | 98 | } |
| 99 | 99 | |
| 100 | /** A client approved for read-only access changes nothing. */ | |
| 101 | const refuseReadOnlyClient = McpInvocationContext.McpInvocationContext.pipe( | |
| 102 | Effect.flatMap((scope) => | |
| 103 | scope.client?.access === "read-only" | |
| 104 | ? Effect.fail( | |
| 105 | new OrchestratorMcpFailure({ | |
| 106 | code: "capability_denied", | |
| 107 | message: | |
| 108 | "This tool changes the environment, and this MCP client was approved for read-only access.", | |
| 109 | }), | |
| 110 | ) | |
| 111 | : Effect.void, | |
| 112 | ), | |
| 113 | ); | |
| 114 | ||
| 100 | 115 | /** |
| 101 | 116 | * The caller of a tool that changes something. Tools that act for a |
| 102 | 117 | * capability of their own (preview, device, worktree, pull requests) check it |
| 103 | 118 | * themselves. |
| 104 | 119 | */ |
| 105 | const writingCaller = loadCaller().pipe(Effect.tap(assertLiveCaller)); | |
| 120 | const writingCaller = refuseReadOnlyClient.pipe( | |
| 121 | Effect.andThen(loadCaller()), | |
| 122 | Effect.tap(assertLiveCaller), | |
| 123 | ); | |
| 106 | 124 | |
| 107 | 125 | /** The same, for tools whose only capability is controlling threads. */ |
| 108 | const orchestratingCaller = readCaller().pipe(Effect.tap(assertLiveCaller)); | |
| 126 | const orchestratingCaller = refuseReadOnlyClient.pipe( | |
| 127 | Effect.andThen(readCaller()), | |
| 128 | Effect.tap(assertLiveCaller), | |
| 129 | ); | |
| 109 | 130 | |
| 110 | 131 | const requireThreadCaller = McpInvocationContext.McpInvocationContext.pipe( |
| 111 | 132 | Effect.flatMap((scope) => McpInvocationContext.requireThreadScope(scope, "This tool")), |
apps/server/src/mcp/OrchestratorMcpService.test.ts+20-1| ... | ... | @@ -1603,7 +1603,7 @@ describe("OrchestratorMcpService provider resolution", () => { |
| 1603 | 1603 | client: { |
| 1604 | 1604 | sessionId: "scheduled", |
| 1605 | 1605 | label: "Claude Code", |
| 1606 | runtimeModeCeiling: "approval-required", | |
| 1606 | access: "approval-required", | |
| 1607 | 1607 | }, |
| 1608 | 1608 | capabilities: new Set(["orchestration"]), |
| 1609 | 1609 | issuedAt: 1, |
| ... | ... | @@ -1709,6 +1709,25 @@ describe("OrchestratorMcpService provider resolution", () => { |
| 1709 | 1709 | }), |
| 1710 | 1710 | ); |
| 1711 | 1711 | |
| 1712 | it.effect("never shows a read-only client a webhook URL", () => | |
| 1713 | Effect.gen(function* () { | |
| 1714 | const upserted = yield* Ref.make(0); | |
| 1715 | const listed = yield* OrchestratorMcpService.OrchestratorMcpService.pipe( | |
| 1716 | Effect.flatMap((mcp) => | |
| 1717 | mcp.listScheduledTasks( | |
| 1718 | { | |
| 1719 | ...supervisedClient, | |
| 1720 | client: { sessionId: "scheduled", label: "Claude Code", access: "read-only" }, | |
| 1721 | }, | |
| 1722 | { projectId }, | |
| 1723 | ), | |
| 1724 | ), | |
| 1725 | Effect.provide(service([task({})], null, upserted)), | |
| 1726 | ); | |
| 1727 | assert.equal(listed.tasks[0]?.webhookUrl, undefined); | |
| 1728 | }), | |
| 1729 | ); | |
| 1730 | ||
| 1712 | 1731 | it.effect("checks a bound task against its thread's current modes", () => |
| 1713 | 1732 | Effect.gen(function* () { |
| 1714 | 1733 | const upserted = yield* Ref.make(0); |
apps/server/src/mcp/OrchestratorMcpService.ts+8-5| ... | ... | @@ -84,6 +84,7 @@ import * as ProjectService from "../project/ProjectService.ts"; |
| 84 | 84 | import * as ProviderRegistry from "../provider/ProviderRegistry.ts"; |
| 85 | 85 | import * as ScheduledTaskService from "../scheduledTasks/ScheduledTaskService.ts"; |
| 86 | 86 | import { |
| 87 | clientRuntimeModeCeiling, | |
| 87 | 88 | type McpInvocationScope, |
| 88 | 89 | type McpThreadInvocationScope, |
| 89 | 90 | requireThreadScope, |
| ... | ... | @@ -908,7 +909,7 @@ const make = Effect.gen(function* () { |
| 908 | 909 | return { |
| 909 | 910 | parent: undefined, |
| 910 | 911 | limits: { |
| 911 | runtimeMode: scope.client?.runtimeModeCeiling ?? "approval-required", | |
| 912 | runtimeMode: clientRuntimeModeCeiling(scope.client), | |
| 912 | 913 | interactionMode: "default", |
| 913 | 914 | } satisfies { runtimeMode: RuntimeMode; interactionMode: ProviderInteractionMode }, |
| 914 | 915 | } as const; |
| ... | ... | @@ -1400,8 +1401,9 @@ const make = Effect.gen(function* () { |
| 1400 | 1401 | |
| 1401 | 1402 | /** |
| 1402 | 1403 | * A task as the caller may see it. Its webhook URL starts runs, so only a |
| 1403 | * caller that may start one sees it: a thread caller with a live turn, at | |
| 1404 | * modes covering every mode the task runs at. | |
| 1404 | * caller that may start one sees it: never a client approved for read-only | |
| 1405 | * access, and a thread caller only with a live turn, at modes covering every | |
| 1406 | * mode the task runs at. | |
| 1405 | 1407 | */ |
| 1406 | 1408 | const summarizeScheduledTask = ( |
| 1407 | 1409 | scope: McpInvocationScope, |
| ... | ... | @@ -1416,8 +1418,9 @@ const make = Effect.gen(function* () { |
| 1416 | 1418 | ) => |
| 1417 | 1419 | Effect.gen(function* () { |
| 1418 | 1420 | const live = |
| 1419 | caller.parent === undefined || | |
| 1420 | Exit.isSuccess(yield* Effect.exit(assertLiveCaller(scope, caller.parent))); | |
| 1421 | caller.parent === undefined | |
| 1422 | ? scope.client?.access !== "read-only" | |
| 1423 | : Exit.isSuccess(yield* Effect.exit(assertLiveCaller(scope, caller.parent))); | |
| 1421 | 1424 | // This runs after a save, so a failed lookup of the bound thread hides |
| 1422 | 1425 | // the webhook URL instead of reporting a saved task as an error. |
| 1423 | 1426 | const modes = yield* scheduledTaskRunModes(task).pipe(Effect.option); |
apps/server/src/mcp/threadAccess.ts+1-1| ... | ... | @@ -76,7 +76,7 @@ export const loadCaller = Effect.fn("mcp.loadCaller")(function* () { |
| 76 | 76 | threads, |
| 77 | 77 | caller: undefined, |
| 78 | 78 | limits: { |
| 79 | runtimeMode: scope.client?.runtimeModeCeiling ?? "approval-required", | |
| 79 | runtimeMode: McpInvocationContext.clientRuntimeModeCeiling(scope.client), | |
| 80 | 80 | interactionMode: "default", |
| 81 | 81 | }, |
| 82 | 82 | } satisfies Caller; |
apps/server/src/mcp/toolkits/core.test.ts+72-2| ... | ... | @@ -396,12 +396,12 @@ it.effect("resolves reused attachment references from stored metadata", () => |
| 396 | 396 | ); |
| 397 | 397 | |
| 398 | 398 | const clientScope = ( |
| 399 | runtimeModeCeiling: "approval-required" | "auto-accept-edits" | "auto" | "full-access", | |
| 399 | access: McpInvocationContext.McpClientCaller["access"], | |
| 400 | 400 | ): McpInvocationContext.McpInvocationScope => ({ |
| 401 | 401 | environmentId: EnvironmentId.make("mcp-core-environment"), |
| 402 | 402 | requestNamespace: "client:session-1", |
| 403 | 403 | thread: undefined, |
| 404 | client: { sessionId: "session-1", label: "Claude Code", runtimeModeCeiling }, | |
| 404 | client: { sessionId: "session-1", label: "Claude Code", access }, | |
| 405 | 405 | issuedAt: 0, |
| 406 | 406 | capabilities: new Set(["orchestration", "worktree", "pull-requests"]), |
| 407 | 407 | }); |
| ... | ... | @@ -474,6 +474,76 @@ it.effect("a client caller targets any thread within its ceiling and cannot act |
| 474 | 474 | ), |
| 475 | 475 | ); |
| 476 | 476 | |
| 477 | it.effect("a read-only client reads threads and is refused every write before it runs", () => | |
| 478 | Effect.gen(function* () { | |
| 479 | const server = yield* McpServer.McpServer; | |
| 480 | const call = (name: string, args: Record<string, unknown>) => | |
| 481 | server | |
| 482 | .callTool({ name, arguments: args }) | |
| 483 | .pipe( | |
| 484 | Effect.provideService( | |
| 485 | McpInvocationContext.McpInvocationContext, | |
| 486 | clientScope("read-only"), | |
| 487 | ), | |
| 488 | Effect.provideService(McpSchema.McpServerClient, client), | |
| 489 | ); | |
| 490 | ||
| 491 | const configuration = yield* call("t3_thread_configuration", { | |
| 492 | threadId: "other-project-thread", | |
| 493 | }); | |
| 494 | expect(configuration.isError).toBe(false); | |
| 495 | expect(configuration.structuredContent).toMatchObject({ runtimeMode: "auto" }); | |
| 496 | ||
| 497 | const pinned = yield* call("t3_thread_organize", { | |
| 498 | action: "pin", | |
| 499 | threadId: "other-project-thread", | |
| 500 | }); | |
| 501 | expect(declaredFailure(pinned)).toMatchObject({ code: "capability_denied" }); | |
| 502 | expect(dispatched).toEqual([]); | |
| 503 | ||
| 504 | const configure = yield* call("t3_thread_configure", { | |
| 505 | threadId: "other-project-thread", | |
| 506 | modelSelection: { instanceId: "codex", model: "gpt-5" }, | |
| 507 | }); | |
| 508 | expect(declaredFailure(configure)).toMatchObject({ code: "capability_denied" }); | |
| 509 | expect(dispatched).toEqual([]); | |
| 510 | }).pipe( | |
| 511 | Effect.provide( | |
| 512 | McpHttpServer.layerThreadToolkit.pipe( | |
| 513 | Layer.provideMerge(McpServer.McpServer.layer), | |
| 514 | Layer.provide(NodeCrypto.layer), | |
| 515 | Layer.provide( | |
| 516 | Layer.mock(ThreadManagement.ThreadManagementService)({ | |
| 517 | getThreadShell: () => | |
| 518 | Effect.succeed({ | |
| 519 | id: ThreadId.make("other-project-thread"), | |
| 520 | projectId: "other-project", | |
| 521 | deletedAt: null, | |
| 522 | } as never), | |
| 523 | getProjectThreadRecords: () => | |
| 524 | Effect.succeed({ | |
| 525 | thread: { | |
| 526 | id: ThreadId.make("other-project-thread"), | |
| 527 | projectId: "other-project", | |
| 528 | modelSelection: { instanceId: "codex", model: "gpt-5" }, | |
| 529 | runtimeMode: "auto", | |
| 530 | interactionMode: "default", | |
| 531 | deletedAt: null, | |
| 532 | }, | |
| 533 | } as never), | |
| 534 | dispatch: () => | |
| 535 | Effect.sync(() => { | |
| 536 | dispatched.push("dispatch"); | |
| 537 | return { sequence: 7 } as never; | |
| 538 | }), | |
| 539 | }), | |
| 540 | ), | |
| 541 | ), | |
| 542 | ), | |
| 543 | ), | |
| 544 | ); | |
| 545 | const dispatched: Array<string> = []; | |
| 546 | ||
| 477 | 547 | it.effect("refuses act-as-caller tools to a client caller", () => |
| 478 | 548 | Effect.gen(function* () { |
| 479 | 549 | const server = yield* McpServer.McpServer; |
apps/server/src/mcp/toolkits/project/handlers.test.ts+1-1| ... | ... | @@ -313,7 +313,7 @@ const clientLaunchHarness = (input: { |
| 313 | 313 | client: { |
| 314 | 314 | sessionId: "session-1", |
| 315 | 315 | label: "Claude Code", |
| 316 | runtimeModeCeiling: input.runtimeModeCeiling, | |
| 316 | access: input.runtimeModeCeiling, | |
| 317 | 317 | }, |
| 318 | 318 | issuedAt: 0, |
| 319 | 319 | capabilities: new Set(["orchestration" as const]), |
apps/server/src/server.ts+4| ... | ... | @@ -116,6 +116,8 @@ import * as ServerSecretStore from "./auth/ServerSecretStore.ts"; |
| 116 | 116 | import * as WebhookRoute from "./scheduledTasks/webhookRoute.ts"; |
| 117 | 117 | import * as RelayDeliveryProof from "./scheduledTasks/RelayDeliveryProof.ts"; |
| 118 | 118 | import * as HeldHooksWaker from "./relay/HeldHooksWaker.ts"; |
| 119 | import * as McpOAuth from "./auth/McpOAuth.ts"; | |
| 120 | import * as McpOAuthHttp from "./auth/mcpOAuthHttp.ts"; | |
| 119 | 121 | import { |
| 120 | 122 | relayHookBaseUrl, |
| 121 | 123 | ScheduledTaskWebhookOrigin, |
| ... | ... | @@ -654,6 +656,7 @@ const layerMakeRoutes = Layer.mergeAll( |
| 654 | 656 | Layer.mergeAll( |
| 655 | 657 | HttpApiBuilder.layer(EnvironmentHttpApi).pipe( |
| 656 | 658 | Layer.provide(AuthHttp.layer), |
| 659 | Layer.provide(McpOAuthHttp.layer.pipe(Layer.provide(McpOAuth.layer))), | |
| 657 | 660 | Layer.provide(CloudHttp.layer), |
| 658 | 661 | Layer.provide(OrchestrationHttp.layer), |
| 659 | 662 | Layer.provide(PullRequestHttp.layer), |
| ... | ... | @@ -677,6 +680,7 @@ const layerMakeRoutes = Layer.mergeAll( |
| 677 | 680 | // what dispatch can actually serve. |
| 678 | 681 | McpHttpServer.layer.pipe( |
| 679 | 682 | Layer.provide(ProviderAdapterRegistry.layerFromProviderInstanceRegistry), |
| 683 | Layer.provide(McpOAuth.layerMcpClientAuthenticator), | |
| 680 | 684 | ), |
| 681 | 685 | ).pipe( |
| 682 | 686 | // Both transports consume the same service instance, so caches single-flight across clients |
apps/web/src/components/auth/ConnectAgentSurface.tsx created+317| ... | ... | @@ -0,0 +1,317 @@ |
| 1 | import { | |
| 2 | type AuthMcpApprovalDecision, | |
| 3 | type AuthMcpApprovalDetails, | |
| 4 | AuthMcpApprovalError, | |
| 5 | AuthMcpClientAccess, | |
| 6 | type AuthMcpAuthorizationRequest, | |
| 7 | } from "@t3tools/contracts"; | |
| 8 | import { Radio as RadioPrimitive } from "@base-ui/react/radio"; | |
| 9 | import { EyeIcon, type LucideIcon } from "lucide-react"; | |
| 10 | import type * as Context from "effect/Context"; | |
| 11 | import * as Effect from "effect/Effect"; | |
| 12 | import * as Schema from "effect/Schema"; | |
| 13 | import { type ReactNode, useCallback, useEffect, useState } from "react"; | |
| 14 | ||
| 15 | import { PrimaryEnvironmentHttpClient } from "~/environments/primary/httpClient"; | |
| 16 | import { runPrimaryHttp } from "~/lib/runtime"; | |
| 17 | import { cn } from "~/lib/utils"; | |
| 18 | import { runtimeModeConfig } from "../chat/runtimeModeConfig"; | |
| 19 | import { Alert, AlertDescription } from "../ui/alert"; | |
| 20 | import { Button } from "../ui/button"; | |
| 21 | import { Input } from "../ui/input"; | |
| 22 | import { RadioGroup } from "../ui/radio-group"; | |
| 23 | import { Spinner } from "../ui/spinner"; | |
| 24 | import { AuthSurfaceShell } from "./AuthSurfaceShell"; | |
| 25 | ||
| 26 | const accessConfig: Record< | |
| 27 | AuthMcpClientAccess, | |
| 28 | { readonly label: string; readonly description: string; readonly icon: LucideIcon } | |
| 29 | > = { | |
| 30 | "read-only": { | |
| 31 | label: "Read only", | |
| 32 | description: "Read projects and threads. Cannot start, message or change anything.", | |
| 33 | icon: EyeIcon, | |
| 34 | }, | |
| 35 | ...runtimeModeConfig, | |
| 36 | }; | |
| 37 | ||
| 38 | type Loaded = | |
| 39 | | { readonly status: "loading" } | |
| 40 | | { readonly status: "invalid"; readonly message: string } | |
| 41 | | { readonly status: "ready"; readonly details: AuthMcpApprovalDetails }; | |
| 42 | ||
| 43 | const UNREACHABLE = "Could not reach this environment. Try again."; | |
| 44 | const isApprovalError = Schema.is(AuthMcpApprovalError); | |
| 45 | ||
| 46 | type Answer<A> = | |
| 47 | | { readonly kind: "ok"; readonly value: A } | |
| 48 | | { readonly kind: "redirect"; readonly url: string } | |
| 49 | | { readonly kind: "error"; readonly message: string }; | |
| 50 | ||
| 51 | /** | |
| 52 | * Runs an approval call. The server validates the agent's request again on | |
| 53 | * every call and answers what the page asked for, a message to show, or a URL | |
| 54 | * the browser must follow (an approval, a denial, or a protocol error the | |
| 55 | * agent should receive). | |
| 56 | */ | |
| 57 | function runApproval<A>( | |
| 58 | call: ( | |
| 59 | client: Context.Service.Shape<typeof PrimaryEnvironmentHttpClient>, | |
| 60 | ) => Effect.Effect<A | { readonly redirectTo: string }, unknown>, | |
| 61 | ): Promise<Answer<A>> { | |
| 62 | return runPrimaryHttp( | |
| 63 | PrimaryEnvironmentHttpClient.pipe( | |
| 64 | Effect.flatMap(call), | |
| 65 | Effect.map((value): Answer<A> => | |
| 66 | typeof value === "object" && value !== null && "redirectTo" in value | |
| 67 | ? { kind: "redirect", url: value.redirectTo } | |
| 68 | : { kind: "ok", value: value as A }, | |
| 69 | ), | |
| 70 | Effect.catch((error) => | |
| 71 | Effect.succeed<Answer<A>>({ | |
| 72 | kind: "error", | |
| 73 | message: isApprovalError(error) ? error.message : UNREACHABLE, | |
| 74 | }), | |
| 75 | ), | |
| 76 | ), | |
| 77 | ).catch((): Answer<A> => ({ kind: "error", message: UNREACHABLE })); | |
| 78 | } | |
| 79 | ||
| 80 | function readAuthorizationRequest(): AuthMcpAuthorizationRequest { | |
| 81 | return Object.fromEntries(new URL(window.location.href).searchParams); | |
| 82 | } | |
| 83 | ||
| 84 | /** | |
| 85 | * /connect-agent: where an outside agent's MCP sign-in lands after the server | |
| 86 | * checks the request. The user picks the most the agent may allow, then | |
| 87 | * approves with a pairing code, or in one click when this browser is already | |
| 88 | * signed in to the environment as an administrator. | |
| 89 | */ | |
| 90 | /** | |
| 91 | * Whether this browser's session may approve `access` without a pairing code. | |
| 92 | * A session may only grant access whose scopes it holds itself. | |
| 93 | */ | |
| 94 | function oneClickApproves(details: AuthMcpApprovalDetails, access: AuthMcpClientAccess) { | |
| 95 | return details.csrfToken !== undefined && (details.oneClickAccess ?? []).includes(access); | |
| 96 | } | |
| 97 | ||
| 98 | export function ConnectAgentSurface() { | |
| 99 | const [authorization] = useState(readAuthorizationRequest); | |
| 100 | const [loaded, setLoaded] = useState<Loaded>({ status: "loading" }); | |
| 101 | const [access, setAccess] = useState<AuthMcpClientAccess>("read-only"); | |
| 102 | const [pairingCode, setPairingCode] = useState(""); | |
| 103 | const [errorMessage, setErrorMessage] = useState(""); | |
| 104 | const [pending, setPending] = useState<"approve" | "deny" | null>(null); | |
| 105 | ||
| 106 | useEffect(() => { | |
| 107 | let cancelled = false; | |
| 108 | void runApproval((client) => client.mcpOAuth.approval({ payload: authorization })).then( | |
| 109 | (answer) => { | |
| 110 | if (cancelled) return; | |
| 111 | if (answer.kind === "redirect") { | |
| 112 | window.location.replace(answer.url); | |
| 113 | return; | |
| 114 | } | |
| 115 | setLoaded( | |
| 116 | answer.kind === "error" | |
| 117 | ? { status: "invalid", message: answer.message } | |
| 118 | : { status: "ready", details: answer.value }, | |
| 119 | ); | |
| 120 | }, | |
| 121 | ); | |
| 122 | return () => { | |
| 123 | cancelled = true; | |
| 124 | }; | |
| 125 | }, [authorization]); | |
| 126 | ||
| 127 | const decide = useCallback( | |
| 128 | async (choice: "approve" | "deny") => { | |
| 129 | if (loaded.status !== "ready") return; | |
| 130 | setPending(choice); | |
| 131 | setErrorMessage(""); | |
| 132 | const { csrfToken } = loaded.details; | |
| 133 | const decision: AuthMcpApprovalDecision = | |
| 134 | choice === "deny" | |
| 135 | ? { _tag: "deny" } | |
| 136 | : csrfToken !== undefined && oneClickApproves(loaded.details, access) | |
| 137 | ? { _tag: "browser-session", access, csrfToken } | |
| 138 | : { _tag: "pairing-code", access, code: pairingCode.trim() }; | |
| 139 | const answer = await runApproval((client) => | |
| 140 | client.mcpOAuth.decision({ payload: { authorization, decision } }), | |
| 141 | ); | |
| 142 | if (answer.kind === "redirect") { | |
| 143 | window.location.replace(answer.url); | |
| 144 | return; | |
| 145 | } | |
| 146 | setPending(null); | |
| 147 | setErrorMessage(answer.kind === "error" ? answer.message : "The sign-in could not continue."); | |
| 148 | }, | |
| 149 | [access, authorization, loaded, pairingCode], | |
| 150 | ); | |
| 151 | ||
| 152 | if (loaded.status === "loading") { | |
| 153 | return ( | |
| 154 | <AuthSurfaceShell> | |
| 155 | <ConnectAgentHeading | |
| 156 | title="Checking the sign-in request" | |
| 157 | description="One moment while this environment verifies the agent's request." | |
| 158 | /> | |
| 159 | <Spinner className="mt-6" size="lg" tone="muted" /> | |
| 160 | </AuthSurfaceShell> | |
| 161 | ); | |
| 162 | } | |
| 163 | ||
| 164 | if (loaded.status === "invalid") { | |
| 165 | return ( | |
| 166 | <AuthSurfaceShell> | |
| 167 | <ConnectAgentHeading title="This sign-in cannot continue" description={loaded.message} /> | |
| 168 | <p className="mt-4 text-sm text-muted-foreground"> | |
| 169 | Close this page and start the sign-in again from your agent. | |
| 170 | </p> | |
| 171 | </AuthSurfaceShell> | |
| 172 | ); | |
| 173 | } | |
| 174 | ||
| 175 | const { details } = loaded; | |
| 176 | const oneClick = oneClickApproves(details, access); | |
| 177 | const canApprove = pending === null && (oneClick || pairingCode.trim().length > 0); | |
| 178 | ||
| 179 | return ( | |
| 180 | <AuthSurfaceShell> | |
| 181 | <ConnectAgentHeading | |
| 182 | title={`Connect ${details.clientName}`} | |
| 183 | description={ | |
| 184 | <> | |
| 185 | This agent wants to use the threads in every project on{" "} | |
| 186 | <span className="font-medium text-foreground">{details.environmentHost}</span>. | |
| 187 | </> | |
| 188 | } | |
| 189 | /> | |
| 190 | <p className="mt-2 text-xs text-muted-foreground"> | |
| 191 | The name is chosen by the agent. Approval returns to {details.redirectHost} on the computer | |
| 192 | that opened this page. Only approve a sign-in you just started. | |
| 193 | </p> | |
| 194 | ||
| 195 | <form | |
| 196 | className="mt-6 space-y-5" | |
| 197 | onSubmit={(event) => { | |
| 198 | event.preventDefault(); | |
| 199 | if (canApprove) void decide("approve"); | |
| 200 | }} | |
| 201 | > | |
| 202 | <div className="space-y-2"> | |
| 203 | <span id="connect-agent-access-label" className="text-sm font-medium"> | |
| 204 | What it may do | |
| 205 | </span> | |
| 206 | <RadioGroup | |
| 207 | aria-labelledby="connect-agent-access-label" | |
| 208 | value={access} | |
| 209 | onValueChange={(value) => setAccess(value as AuthMcpClientAccess)} | |
| 210 | > | |
| 211 | {AuthMcpClientAccess.literals.map((option) => ( | |
| 212 | <AccessOption key={option} access={option} selected={option === access} /> | |
| 213 | ))} | |
| 214 | </RadioGroup> | |
| 215 | <p className="text-xs text-muted-foreground"> | |
| 216 | Beyond read only, it can start, message and stop threads, and none of them can run with | |
| 217 | more than the mode you pick. | |
| 218 | </p> | |
| 219 | </div> | |
| 220 | ||
| 221 | {oneClick ? null : ( | |
| 222 | <div className="space-y-2"> | |
| 223 | <label className="text-sm font-medium" htmlFor="connect-agent-pairing-code"> | |
| 224 | Pairing code | |
| 225 | </label> | |
| 226 | <Input | |
| 227 | id="connect-agent-pairing-code" | |
| 228 | autoCapitalize="none" | |
| 229 | autoComplete="one-time-code" | |
| 230 | autoCorrect="off" | |
| 231 | disabled={pending !== null} | |
| 232 | nativeInput | |
| 233 | onChange={(event) => setPairingCode(event.currentTarget.value)} | |
| 234 | placeholder="Paste a one-time pairing code" | |
| 235 | spellCheck={false} | |
| 236 | value={pairingCode} | |
| 237 | /> | |
| 238 | <p className="text-xs text-muted-foreground"> | |
| 239 | Create one in Settings → Connections, or run <code>t3 auth pairing create</code> on | |
| 240 | this machine. | |
| 241 | </p> | |
| 242 | </div> | |
| 243 | )} | |
| 244 | ||
| 245 | {errorMessage ? ( | |
| 246 | <Alert variant="error"> | |
| 247 | <AlertDescription>{errorMessage}</AlertDescription> | |
| 248 | </Alert> | |
| 249 | ) : null} | |
| 250 | ||
| 251 | <div className="flex flex-wrap gap-2"> | |
| 252 | <Button disabled={!canApprove} type="submit"> | |
| 253 | {pending === "approve" ? "Approving…" : "Approve"} | |
| 254 | </Button> | |
| 255 | <Button | |
| 256 | disabled={pending !== null} | |
| 257 | onClick={() => void decide("deny")} | |
| 258 | type="button" | |
| 259 | variant="outline" | |
| 260 | > | |
| 261 | {pending === "deny" ? "Denying…" : "Deny"} | |
| 262 | </Button> | |
| 263 | </div> | |
| 264 | </form> | |
| 265 | </AuthSurfaceShell> | |
| 266 | ); | |
| 267 | } | |
| 268 | ||
| 269 | function ConnectAgentHeading({ | |
| 270 | title, | |
| 271 | description, | |
| 272 | }: { | |
| 273 | readonly title: string; | |
| 274 | readonly description: ReactNode; | |
| 275 | }) { | |
| 276 | return ( | |
| 277 | <> | |
| 278 | <p className="text-3xs font-semibold tracking-widest text-primary uppercase">Agent sign-in</p> | |
| 279 | <h1 className="mt-2 text-2xl font-semibold tracking-tight sm:text-3xl">{title}</h1> | |
| 280 | <p className="mt-2 text-sm leading-relaxed text-muted-foreground">{description}</p> | |
| 281 | </> | |
| 282 | ); | |
| 283 | } | |
| 284 | ||
| 285 | function AccessOption({ | |
| 286 | access, | |
| 287 | selected, | |
| 288 | }: { | |
| 289 | readonly access: AuthMcpClientAccess; | |
| 290 | readonly selected: boolean; | |
| 291 | }) { | |
| 292 | const { label, description, icon: Icon } = accessConfig[access]; | |
| 293 | return ( | |
| 294 | <RadioPrimitive.Root | |
| 295 | value={access} | |
| 296 | className={cn( | |
| 297 | "flex cursor-pointer items-start gap-3 rounded-lg border px-3 py-2.5 text-left outline-none transition-[background-color,border-color,box-shadow]", | |
| 298 | "focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background", | |
| 299 | selected | |
| 300 | ? "border-primary bg-background ring-2 ring-primary/25 dark:border-transparent dark:bg-primary/10 dark:ring-1 dark:ring-primary/30" | |
| 301 | : "border-border bg-background hover:bg-muted/50 dark:border-transparent dark:bg-white/[0.035] dark:hover:bg-accent", | |
| 302 | )} | |
| 303 | > | |
| 304 | <Icon | |
| 305 | aria-hidden | |
| 306 | className={cn( | |
| 307 | "mt-0.5 size-4 shrink-0", | |
| 308 | selected ? "text-primary" : "text-muted-foreground", | |
| 309 | )} | |
| 310 | /> | |
| 311 | <span className="min-w-0"> | |
| 312 | <span className="block text-sm font-medium text-foreground">{label}</span> | |
| 313 | <span className="block text-xs text-muted-foreground">{description}</span> | |
| 314 | </span> | |
| 315 | </RadioPrimitive.Root> | |
| 316 | ); | |
| 317 | } |
apps/web/src/routeTree.gen.ts+21| ... | ... | @@ -13,6 +13,7 @@ import { Route as WelcomeRouteImport } from './routes/welcome' |
| 13 | 13 | import { Route as UsageRouteImport } from './routes/usage' |
| 14 | 14 | import { Route as SettingsRouteImport } from './routes/settings' |
| 15 | 15 | import { Route as PairRouteImport } from './routes/pair' |
| 16 | import { Route as ConnectAgentRouteImport } from './routes/connect-agent' | |
| 16 | 17 | import { Route as ConnectRouteImport } from './routes/connect' |
| 17 | 18 | import { Route as ChatRouteImport } from './routes/_chat' |
| 18 | 19 | import { Route as ChatIndexRouteImport } from './routes/_chat.index' |
| ... | ... | @@ -55,6 +56,11 @@ const PairRoute = PairRouteImport.update({ |
| 55 | 56 | path: '/pair', |
| 56 | 57 | getParentRoute: () => rootRouteImport, |
| 57 | 58 | } as any) |
| 59 | const ConnectAgentRoute = ConnectAgentRouteImport.update({ | |
| 60 | id: '/connect-agent', | |
| 61 | path: '/connect-agent', | |
| 62 | getParentRoute: () => rootRouteImport, | |
| 63 | } as any) | |
| 58 | 64 | const ConnectRoute = ConnectRouteImport.update({ |
| 59 | 65 | id: '/connect', |
| 60 | 66 | path: '/connect', |
| ... | ... | @@ -165,6 +171,7 @@ const ChatEnvironmentIdThreadIdRoute = |
| 165 | 171 | export interface FileRoutesByFullPath { |
| 166 | 172 | '/': typeof ChatIndexRoute |
| 167 | 173 | '/connect': typeof ConnectRoute |
| 174 | '/connect-agent': typeof ConnectAgentRoute | |
| 168 | 175 | '/pair': typeof PairRoute |
| 169 | 176 | '/settings': typeof SettingsRouteWithChildren |
| 170 | 177 | '/usage': typeof UsageRoute |
| ... | ... | @@ -190,6 +197,7 @@ export interface FileRoutesByFullPath { |
| 190 | 197 | } |
| 191 | 198 | export interface FileRoutesByTo { |
| 192 | 199 | '/connect': typeof ConnectRoute |
| 200 | '/connect-agent': typeof ConnectAgentRoute | |
| 193 | 201 | '/pair': typeof PairRoute |
| 194 | 202 | '/settings': typeof SettingsRouteWithChildren |
| 195 | 203 | '/usage': typeof UsageRoute |
| ... | ... | @@ -218,6 +226,7 @@ export interface FileRoutesById { |
| 218 | 226 | __root__: typeof rootRouteImport |
| 219 | 227 | '/_chat': typeof ChatRouteWithChildren |
| 220 | 228 | '/connect': typeof ConnectRoute |
| 229 | '/connect-agent': typeof ConnectAgentRoute | |
| 221 | 230 | '/pair': typeof PairRoute |
| 222 | 231 | '/settings': typeof SettingsRouteWithChildren |
| 223 | 232 | '/usage': typeof UsageRoute |
| ... | ... | @@ -247,6 +256,7 @@ export interface FileRouteTypes { |
| 247 | 256 | fullPaths: |
| 248 | 257 | | '/' |
| 249 | 258 | | '/connect' |
| 259 | | '/connect-agent' | |
| 250 | 260 | | '/pair' |
| 251 | 261 | | '/settings' |
| 252 | 262 | | '/usage' |
| ... | ... | @@ -272,6 +282,7 @@ export interface FileRouteTypes { |
| 272 | 282 | fileRoutesByTo: FileRoutesByTo |
| 273 | 283 | to: |
| 274 | 284 | | '/connect' |
| 285 | | '/connect-agent' | |
| 275 | 286 | | '/pair' |
| 276 | 287 | | '/settings' |
| 277 | 288 | | '/usage' |
| ... | ... | @@ -299,6 +310,7 @@ export interface FileRouteTypes { |
| 299 | 310 | | '__root__' |
| 300 | 311 | | '/_chat' |
| 301 | 312 | | '/connect' |
| 313 | | '/connect-agent' | |
| 302 | 314 | | '/pair' |
| 303 | 315 | | '/settings' |
| 304 | 316 | | '/usage' |
| ... | ... | @@ -327,6 +339,7 @@ export interface FileRouteTypes { |
| 327 | 339 | export interface RootRouteChildren { |
| 328 | 340 | ChatRoute: typeof ChatRouteWithChildren |
| 329 | 341 | ConnectRoute: typeof ConnectRoute |
| 342 | ConnectAgentRoute: typeof ConnectAgentRoute | |
| 330 | 343 | PairRoute: typeof PairRoute |
| 331 | 344 | SettingsRoute: typeof SettingsRouteWithChildren |
| 332 | 345 | UsageRoute: typeof UsageRoute |
| ... | ... | @@ -364,6 +377,13 @@ declare module '@tanstack/react-router' { |
| 364 | 377 | preLoaderRoute: typeof PairRouteImport |
| 365 | 378 | parentRoute: typeof rootRouteImport |
| 366 | 379 | } |
| 380 | '/connect-agent': { | |
| 381 | id: '/connect-agent' | |
| 382 | path: '/connect-agent' | |
| 383 | fullPath: '/connect-agent' | |
| 384 | preLoaderRoute: typeof ConnectAgentRouteImport | |
| 385 | parentRoute: typeof rootRouteImport | |
| 386 | } | |
| 367 | 387 | '/connect': { |
| 368 | 388 | id: '/connect' |
| 369 | 389 | path: '/connect' |
| ... | ... | @@ -571,6 +591,7 @@ const SettingsRouteWithChildren = SettingsRoute._addFileChildren( |
| 571 | 591 | const rootRouteChildren: RootRouteChildren = { |
| 572 | 592 | ChatRoute: ChatRouteWithChildren, |
| 573 | 593 | ConnectRoute: ConnectRoute, |
| 594 | ConnectAgentRoute: ConnectAgentRoute, | |
| 574 | 595 | PairRoute: PairRoute, |
| 575 | 596 | SettingsRoute: SettingsRouteWithChildren, |
| 576 | 597 | UsageRoute: UsageRoute, |
apps/web/src/routes/__root.tsx+1-1| ... | ... | @@ -161,7 +161,7 @@ function RootRouteView() { |
| 161 | 161 | }; |
| 162 | 162 | }, [pathname]); |
| 163 | 163 | |
| 164 | if (pathname === "/pair" || pathname === "/connect") { | |
| 164 | if (pathname === "/pair" || pathname === "/connect" || pathname === "/connect-agent") { | |
| 165 | 165 | return ( |
| 166 | 166 | <> |
| 167 | 167 | <DocumentTitleSync /> |
apps/web/src/routes/connect-agent.tsx created+7| ... | ... | @@ -0,0 +1,7 @@ |
| 1 | import { createFileRoute } from "@tanstack/react-router"; | |
| 2 | ||
| 3 | import { ConnectAgentSurface } from "../components/auth/ConnectAgentSurface"; | |
| 4 | ||
| 5 | export const Route = createFileRoute("/connect-agent")({ | |
| 6 | component: ConnectAgentSurface, | |
| 7 | }); |
apps/web/vite.config.ts+5-2| ... | ... | @@ -9,7 +9,10 @@ import "vite-plus/test/config"; |
| 9 | 9 | import { defineConfig, type Connect, type Plugin } from "vite-plus"; |
| 10 | 10 | import pkg from "./package.json" with { type: "json" }; |
| 11 | 11 | |
| 12 | import { DEV_PROXIED_PATH_PREFIXES } from "@t3tools/shared/devProxy"; | |
| 12 | import { | |
| 13 | DEV_PROXIED_ORIGIN_PRESERVING_PREFIXES, | |
| 14 | DEV_PROXIED_PATH_PREFIXES, | |
| 15 | } from "@t3tools/shared/devProxy"; | |
| 13 | 16 | |
| 14 | 17 | import { loadRepoEnv } from "../../scripts/lib/public-config"; |
| 15 | 18 | import { thirdPartyLicensesPlugin } from "../../scripts/lib/third-party-licenses"; |
| ... | ... | @@ -249,7 +252,7 @@ export default defineConfig(() => { |
| 249 | 252 | prefix, |
| 250 | 253 | { |
| 251 | 254 | target: devProxyTarget, |
| 252 | changeOrigin: true, | |
| 255 | changeOrigin: !DEV_PROXIED_ORIGIN_PRESERVING_PREFIXES.has(prefix), | |
| 253 | 256 | ...(prefix === "/ws" || prefix === "/api" ? { ws: true } : {}), |
| 254 | 257 | }, |
| 255 | 258 | ]), |
docs/internals/environment-auth.md+29-2| ... | ... | @@ -21,8 +21,35 @@ authority. |
| 21 | 21 | Browser cookies, bearer tokens, and DPoP tokens adapt the same scoped session |
| 22 | 22 | model. DPoP binds a token to a client's proof key; an invalid proof must fail |
| 23 | 23 | rather than fall back to bearer authentication. The OAuth token-exchange |
| 24 | vocabulary gives these grants a familiar meaning, but the environment does not | |
| 25 | implement a general-purpose OAuth authorization server. | |
| 24 | vocabulary gives these grants a familiar meaning. | |
| 25 | ||
| 26 | ### MCP clients are a separate audience | |
| 27 | ||
| 28 | Agents T3 Code did not launch sign in to `/mcp` through a narrow OAuth | |
| 29 | authorization-code server ([McpOAuth](../../apps/server/src/auth/McpOAuth.ts)). | |
| 30 | It accepts only loopback redirect URIs: an HTTPS redirect would let anyone send | |
| 31 | the owner an approval link that delivers the code to their own server. Client | |
| 32 | registration is stateless, so an unauthenticated caller cannot grow server | |
| 33 | state. Approval spends a one-time pairing code, or uses a browser session with | |
| 34 | `access:write`; proof-bound T3 Connect codes are refused without being spent. | |
| 35 | ||
| 36 | The user grants either read-only access or a runtime-mode ceiling, not a | |
| 37 | scope list: MCP tools are all orchestration, and `orchestration:operate` | |
| 38 | alone would let an agent start a thread in full access and act through it. | |
| 39 | The result is an ordinary session with subject `mcp-client`. A read-only | |
| 40 | grant holds `orchestration:read` alone. On `/mcp` it passes only tools | |
| 41 | declared as reads in [McpToolAccess](../../apps/server/src/mcp/McpToolAccess.ts), | |
| 42 | where every tool must declare who may call it to compile. Any other grant adds | |
| 43 | `orchestration:operate` and a signed ceiling. Only `/mcp` accepts these sessions. Every other HTTP and WebSocket | |
| 44 | path rejects that subject, because the RPC surface would let the agent act | |
| 45 | above its ceiling. Inside MCP the credential sets the limits and tool | |
| 46 | parameters only pick targets; see | |
| 47 | [threadAccess](../../apps/server/src/mcp/threadAccess.ts). | |
| 48 | ||
| 49 | Issuer and resource URLs come from the request's Host and | |
| 50 | `X-Forwarded-Proto`, so one server answers over loopback, Tailscale Serve and a | |
| 51 | T3 Connect tunnel. A proxy that rewrites Host or drops the protocol header | |
| 52 | breaks sign-in. | |
| 26 | 53 | |
| 27 | 54 | Bearer and DPoP clients obtain short-lived WebSocket tickets through authenticated |
| 28 | 55 | HTTP so long-lived tokens stay out of socket URLs. Browser sessions can |
packages/contracts/src/auth.ts+180| ... | ... | @@ -353,3 +353,183 @@ export const AuthSessionState = Schema.Struct({ |
| 353 | 353 | expiresAt: Schema.optionalKey(Schema.DateTimeUtc), |
| 354 | 354 | }); |
| 355 | 355 | export type AuthSessionState = typeof AuthSessionState.Type; |
| 356 | ||
| 357 | /** | |
| 358 | * What an agent signed in through MCP OAuth may do, least to most: only read, | |
| 359 | * or act on threads that never run above the given runtime mode. | |
| 360 | */ | |
| 361 | export const AuthMcpClientAccess = Schema.Literals([ | |
| 362 | "read-only", | |
| 363 | "approval-required", | |
| 364 | "auto-accept-edits", | |
| 365 | "auto", | |
| 366 | "full-access", | |
| 367 | ]); | |
| 368 | export type AuthMcpClientAccess = typeof AuthMcpClientAccess.Type; | |
| 369 | ||
| 370 | /** RFC 9728 metadata for an environment's `/mcp` resource. */ | |
| 371 | export const AuthMcpProtectedResourceMetadata = Schema.Struct({ | |
| 372 | resource: Schema.String, | |
| 373 | authorization_servers: Schema.Array(Schema.String), | |
| 374 | scopes_supported: Schema.Array(AuthEnvironmentScope), | |
| 375 | bearer_methods_supported: Schema.Array(Schema.Literal("header")), | |
| 376 | resource_name: Schema.String, | |
| 377 | }); | |
| 378 | export type AuthMcpProtectedResourceMetadata = typeof AuthMcpProtectedResourceMetadata.Type; | |
| 379 | ||
| 380 | /** RFC 8414 metadata for the authorization server MCP clients sign in through. */ | |
| 381 | export const AuthMcpAuthorizationServerMetadata = Schema.Struct({ | |
| 382 | issuer: Schema.String, | |
| 383 | authorization_endpoint: Schema.String, | |
| 384 | token_endpoint: Schema.String, | |
| 385 | registration_endpoint: Schema.String, | |
| 386 | response_types_supported: Schema.Array(Schema.Literal("code")), | |
| 387 | grant_types_supported: Schema.Array(Schema.Literal("authorization_code")), | |
| 388 | code_challenge_methods_supported: Schema.Array(Schema.Literal("S256")), | |
| 389 | token_endpoint_auth_methods_supported: Schema.Array(Schema.Literal("none")), | |
| 390 | scopes_supported: Schema.Array(AuthEnvironmentScope), | |
| 391 | authorization_response_iss_parameter_supported: Schema.Boolean, | |
| 392 | }); | |
| 393 | export type AuthMcpAuthorizationServerMetadata = typeof AuthMcpAuthorizationServerMetadata.Type; | |
| 394 | ||
| 395 | /** RFC 7591 client metadata. Fields the server does not use are dropped. */ | |
| 396 | export const AuthMcpClientRegistration = Schema.Struct({ | |
| 397 | client_name: Schema.optionalKey(Schema.String), | |
| 398 | redirect_uris: Schema.optionalKey(Schema.Array(Schema.String)), | |
| 399 | token_endpoint_auth_method: Schema.optionalKey(Schema.String), | |
| 400 | }); | |
| 401 | export type AuthMcpClientRegistration = typeof AuthMcpClientRegistration.Type; | |
| 402 | ||
| 403 | export const AuthMcpRegisteredClient = Schema.Struct({ | |
| 404 | client_id: Schema.String, | |
| 405 | client_name: Schema.String, | |
| 406 | redirect_uris: Schema.Array(Schema.String), | |
| 407 | grant_types: Schema.Array(Schema.Literal("authorization_code")), | |
| 408 | response_types: Schema.Array(Schema.Literal("code")), | |
| 409 | token_endpoint_auth_method: Schema.Literal("none"), | |
| 410 | }).pipe(HttpApiSchema.status(201)); | |
| 411 | export type AuthMcpRegisteredClient = typeof AuthMcpRegisteredClient.Type; | |
| 412 | ||
| 413 | /** RFC 7591 §3.2.2 registration error. */ | |
| 414 | export class AuthMcpRegistrationError extends Schema.Error<AuthMcpRegistrationError>( | |
| 415 | "AuthMcpRegistrationError", | |
| 416 | )( | |
| 417 | { | |
| 418 | error: Schema.Literals(["invalid_client_metadata", "invalid_redirect_uri"]), | |
| 419 | error_description: Schema.String, | |
| 420 | }, | |
| 421 | { httpApiStatus: 400 }, | |
| 422 | ) { | |
| 423 | override get message(): string { | |
| 424 | return this.error_description; | |
| 425 | } | |
| 426 | } | |
| 427 | ||
| 428 | /** | |
| 429 | * An agent's authorization request, as the approval page received it in its | |
| 430 | * URL. Every field is checked by the server, so all are optional here. | |
| 431 | */ | |
| 432 | export const AuthMcpAuthorizationRequest = Schema.Struct({ | |
| 433 | response_type: Schema.optionalKey(Schema.String), | |
| 434 | client_id: Schema.optionalKey(Schema.String), | |
| 435 | redirect_uri: Schema.optionalKey(Schema.String), | |
| 436 | code_challenge: Schema.optionalKey(Schema.String), | |
| 437 | code_challenge_method: Schema.optionalKey(Schema.String), | |
| 438 | state: Schema.optionalKey(Schema.String), | |
| 439 | resource: Schema.optionalKey(Schema.String), | |
| 440 | }); | |
| 441 | export type AuthMcpAuthorizationRequest = typeof AuthMcpAuthorizationRequest.Type; | |
| 442 | ||
| 443 | /** | |
| 444 | * What the approval page needs to show for an MCP OAuth sign-in. The server | |
| 445 | * has already validated the request; nothing here is trusted by the client | |
| 446 | * except for display. | |
| 447 | */ | |
| 448 | export const AuthMcpApprovalDetails = Schema.Struct({ | |
| 449 | /** Self-declared by the client, so shown as such. */ | |
| 450 | clientName: Schema.String, | |
| 451 | /** Where the code goes: always a loopback address on the browser's machine. */ | |
| 452 | redirectHost: Schema.String, | |
| 453 | environmentHost: Schema.String, | |
| 454 | /** Present when this browser's session may approve without a pairing code. */ | |
| 455 | csrfToken: Schema.optionalKey(Schema.String), | |
| 456 | /** | |
| 457 | * What that session may approve in one click: only access whose scopes it | |
| 458 | * holds. Anything else still needs a pairing code. Absent with `csrfToken`. | |
| 459 | */ | |
| 460 | oneClickAccess: Schema.optionalKey(Schema.Array(AuthMcpClientAccess)), | |
| 461 | }); | |
| 462 | export type AuthMcpApprovalDetails = typeof AuthMcpApprovalDetails.Type; | |
| 463 | ||
| 464 | /** | |
| 465 | * Where the approval page sends the browser next: back to the agent with a | |
| 466 | * code, a denial, or a protocol error the agent should receive. | |
| 467 | */ | |
| 468 | export const AuthMcpApprovalRedirect = Schema.Struct({ | |
| 469 | redirectTo: Schema.String, | |
| 470 | }); | |
| 471 | export type AuthMcpApprovalRedirect = typeof AuthMcpApprovalRedirect.Type; | |
| 472 | ||
| 473 | export const AuthMcpApprovalDecision = Schema.Union([ | |
| 474 | Schema.TaggedStruct("deny", {}), | |
| 475 | Schema.TaggedStruct("pairing-code", { | |
| 476 | access: AuthMcpClientAccess, | |
| 477 | code: TrimmedNonEmptyString, | |
| 478 | }), | |
| 479 | /** One click, for a browser session that may approve (see `csrfToken`). */ | |
| 480 | Schema.TaggedStruct("browser-session", { | |
| 481 | access: AuthMcpClientAccess, | |
| 482 | csrfToken: Schema.String, | |
| 483 | }), | |
| 484 | ]); | |
| 485 | export type AuthMcpApprovalDecision = typeof AuthMcpApprovalDecision.Type; | |
| 486 | ||
| 487 | export const AuthMcpApprovalDecisionRequest = Schema.Struct({ | |
| 488 | authorization: AuthMcpAuthorizationRequest, | |
| 489 | decision: AuthMcpApprovalDecision, | |
| 490 | }); | |
| 491 | export type AuthMcpApprovalDecisionRequest = typeof AuthMcpApprovalDecisionRequest.Type; | |
| 492 | ||
| 493 | /** A problem the approval page shows the user without redirecting anywhere. */ | |
| 494 | export class AuthMcpApprovalError extends Schema.TaggedError<AuthMcpApprovalError>()( | |
| 495 | "AuthMcpApprovalError", | |
| 496 | { message: Schema.String }, | |
| 497 | { httpApiStatus: 400 }, | |
| 498 | ) {} | |
| 499 | ||
| 500 | /** RFC 6749 §4.1.3 token request. Every field is checked by the server. */ | |
| 501 | export const AuthMcpTokenRequest = Schema.Struct({ | |
| 502 | grant_type: Schema.optionalKey(Schema.String), | |
| 503 | code: Schema.optionalKey(Schema.String), | |
| 504 | redirect_uri: Schema.optionalKey(Schema.String), | |
| 505 | client_id: Schema.optionalKey(Schema.String), | |
| 506 | code_verifier: Schema.optionalKey(Schema.String), | |
| 507 | resource: Schema.optionalKey(Schema.String), | |
| 508 | }).pipe(HttpApiSchema.asFormUrlEncoded()); | |
| 509 | export type AuthMcpTokenRequest = typeof AuthMcpTokenRequest.Type; | |
| 510 | ||
| 511 | export const AuthMcpTokenResult = Schema.Struct({ | |
| 512 | access_token: Schema.String, | |
| 513 | token_type: Schema.Literal("Bearer"), | |
| 514 | expires_in: Schema.Number, | |
| 515 | scope: Schema.String, | |
| 516 | }); | |
| 517 | export type AuthMcpTokenResult = typeof AuthMcpTokenResult.Type; | |
| 518 | ||
| 519 | /** RFC 6749 §5.2 token error. */ | |
| 520 | export class AuthMcpTokenError extends Schema.Error<AuthMcpTokenError>("AuthMcpTokenError")( | |
| 521 | { | |
| 522 | error: Schema.Literals([ | |
| 523 | "invalid_request", | |
| 524 | "invalid_client", | |
| 525 | "invalid_grant", | |
| 526 | "unsupported_grant_type", | |
| 527 | ]), | |
| 528 | error_description: Schema.String, | |
| 529 | }, | |
| 530 | { httpApiStatus: 400 }, | |
| 531 | ) { | |
| 532 | override get message(): string { | |
| 533 | return this.error_description; | |
| 534 | } | |
| 535 | } |
packages/contracts/src/environmentHttp.ts+66| ... | ... | @@ -15,6 +15,19 @@ import { |
| 15 | 15 | AuthBrowserSessionResult, |
| 16 | 16 | AuthClientSession, |
| 17 | 17 | AuthCreatePairingCredentialInput, |
| 18 | AuthMcpApprovalDecisionRequest, | |
| 19 | AuthMcpApprovalDetails, | |
| 20 | AuthMcpApprovalError, | |
| 21 | AuthMcpApprovalRedirect, | |
| 22 | AuthMcpAuthorizationRequest, | |
| 23 | AuthMcpAuthorizationServerMetadata, | |
| 24 | AuthMcpClientRegistration, | |
| 25 | AuthMcpProtectedResourceMetadata, | |
| 26 | AuthMcpRegisteredClient, | |
| 27 | AuthMcpRegistrationError, | |
| 28 | AuthMcpTokenError, | |
| 29 | AuthMcpTokenRequest, | |
| 30 | AuthMcpTokenResult, | |
| 18 | 31 | AuthPairingCredentialResult, |
| 19 | 32 | AuthPairingLink, |
| 20 | 33 | AuthRevokeClientSessionInput, |
| ... | ... | @@ -514,6 +527,58 @@ class EnvironmentAuthHttpApi extends HttpApiGroup.make("auth") |
| 514 | 527 | }).middleware(EnvironmentAuthenticatedAuth), |
| 515 | 528 | ) {} |
| 516 | 529 | |
| 530 | /** | |
| 531 | * The OAuth authorization server outside agents use to sign in to `/mcp`. | |
| 532 | * `authorize` is where the agent sends the browser: it answers a redirect to | |
| 533 | * the web app's approval page, or a plain error page for a request that names | |
| 534 | * an unverified client or redirect, so the response is not a schema. | |
| 535 | */ | |
| 536 | class EnvironmentMcpOAuthHttpApi extends HttpApiGroup.make("mcpOAuth") | |
| 537 | .add( | |
| 538 | HttpApiEndpoint.get("protectedResource", "/.well-known/oauth-protected-resource", { | |
| 539 | success: AuthMcpProtectedResourceMetadata, | |
| 540 | }), | |
| 541 | ) | |
| 542 | .add( | |
| 543 | HttpApiEndpoint.get("mcpProtectedResource", "/.well-known/oauth-protected-resource/mcp", { | |
| 544 | success: AuthMcpProtectedResourceMetadata, | |
| 545 | }), | |
| 546 | ) | |
| 547 | .add( | |
| 548 | HttpApiEndpoint.get("authorizationServer", "/.well-known/oauth-authorization-server", { | |
| 549 | success: AuthMcpAuthorizationServerMetadata, | |
| 550 | }), | |
| 551 | ) | |
| 552 | .add( | |
| 553 | HttpApiEndpoint.post("register", "/oauth/mcp/register", { | |
| 554 | payload: AuthMcpClientRegistration, | |
| 555 | success: AuthMcpRegisteredClient, | |
| 556 | error: AuthMcpRegistrationError, | |
| 557 | }), | |
| 558 | ) | |
| 559 | .add(HttpApiEndpoint.get("authorize", "/oauth/mcp/authorize")) | |
| 560 | .add( | |
| 561 | HttpApiEndpoint.post("approval", "/oauth/mcp/approval", { | |
| 562 | payload: AuthMcpAuthorizationRequest, | |
| 563 | success: [AuthMcpApprovalDetails, AuthMcpApprovalRedirect], | |
| 564 | error: AuthMcpApprovalError, | |
| 565 | }), | |
| 566 | ) | |
| 567 | .add( | |
| 568 | HttpApiEndpoint.post("decision", "/oauth/mcp/decision", { | |
| 569 | payload: AuthMcpApprovalDecisionRequest, | |
| 570 | success: AuthMcpApprovalRedirect, | |
| 571 | error: AuthMcpApprovalError, | |
| 572 | }), | |
| 573 | ) | |
| 574 | .add( | |
| 575 | HttpApiEndpoint.post("token", "/oauth/mcp/token", { | |
| 576 | payload: AuthMcpTokenRequest, | |
| 577 | success: AuthMcpTokenResult, | |
| 578 | error: AuthMcpTokenError, | |
| 579 | }), | |
| 580 | ) {} | |
| 581 | ||
| 517 | 582 | const EnvironmentOrchestrationThreadSnapshotParams = Schema.Struct({ |
| 518 | 583 | threadId: ThreadId, |
| 519 | 584 | }); |
| ... | ... | @@ -684,6 +749,7 @@ class EnvironmentWebhooksHttpApi extends HttpApiGroup.make("webhooks") |
| 684 | 749 | export class EnvironmentHttpApi extends HttpApi.make("environment") |
| 685 | 750 | .add(EnvironmentMetadataHttpApi) |
| 686 | 751 | .add(EnvironmentAuthHttpApi) |
| 752 | .add(EnvironmentMcpOAuthHttpApi) | |
| 687 | 753 | .add(EnvironmentOrchestrationHttpApi) |
| 688 | 754 | .add(EnvironmentPullRequestsHttpApi) |
| 689 | 755 | .add(EnvironmentProjectsHttpApi) |
packages/shared/src/devProxy.ts+12-1| ... | ... | @@ -8,7 +8,18 @@ |
| 8 | 8 | * prefix only Vite knows gets answered with index.html; a prefix only the |
| 9 | 9 | * server knows redirect-loops through the proxy. |
| 10 | 10 | */ |
| 11 | export const DEV_PROXIED_PATH_PREFIXES = ["/api", "/oauth", "/.well-known", "/ws"] as const; | |
| 11 | export const DEV_PROXIED_PATH_PREFIXES = ["/api", "/oauth", "/.well-known", "/ws", "/mcp"] as const; | |
| 12 | ||
| 13 | /** | |
| 14 | * Prefixes the proxy must forward with the browser's own Host. MCP OAuth | |
| 15 | * derives its issuer and resource URLs from the request, and a client | |
| 16 | * rejects metadata naming a different origin than the one it fetched. | |
| 17 | */ | |
| 18 | export const DEV_PROXIED_ORIGIN_PRESERVING_PREFIXES: ReadonlySet<string> = new Set([ | |
| 19 | "/oauth", | |
| 20 | "/.well-known", | |
| 21 | "/mcp", | |
| 22 | ]); | |
| 12 | 23 | |
| 13 | 24 | export function isDevProxiedPath(pathname: string): boolean { |
| 14 | 25 | return DEV_PROXIED_PATH_PREFIXES.some( |