authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-10-06 14:09:35-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-06 14:09:35-07:00
log2c8be5893eb3754071162e344a8c8e8a67bef597
tree147bd933ebff3d13738a697c43a0fedac8e4d706
parent2f85686d9dca5ec98c892a2a26c321e09d746d71
signature Signed by PGP key B5690EEEBB952194

feat(server): outside agents sign in to the T3 MCP server with OAuth (#16336)

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 {
22 AuthAccessTokenType,
33 AuthAccessWriteScope,
44 AuthAdministrativeScopes,
5 AuthOrchestrationOperateScope,
6 AuthOrchestrationReadScope,
57 AuthStandardClientScopes,
68 type AuthAccessTokenResult,
79 type AuthBrowserSessionResult,
......@@ -9,6 +11,7 @@ import {
911 type AuthClientSession,
1012 type AuthCreatePairingCredentialInput,
1113 type AuthEnvironmentScope,
14 type AuthMcpClientAccess,
1215 type AuthPairingLink,
1316 type AuthPairingCredentialResult,
1417 type AuthSessionId,
......@@ -63,6 +66,31 @@ export interface IssuedBearerSession {
6366 readonly expiresAt: DateTime.Utc;
6467}
6568
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 */
78const MCP_CLIENT_SUBJECT = "mcp-client";
79const MCP_CLIENT_SESSION_TTL = Duration.days(30);
80
81export const mcpClientScopes = (
82 access: AuthMcpClientAccess,
83): ReadonlyArray<AuthEnvironmentScope> =>
84 access === "read-only"
85 ? [AuthOrchestrationReadScope]
86 : [AuthOrchestrationReadScope, AuthOrchestrationOperateScope];
87
88export interface McpClientSession {
89 readonly sessionId: AuthSessionId;
90 readonly label: string;
91 readonly access: AuthMcpClientAccess;
92}
93
6694export interface AuthenticatedSession {
6795 readonly sessionId: AuthSessionId;
6896 readonly subject: string;
......@@ -406,6 +434,19 @@ export const serverAuthInvalidRequestReason = (
406434): "invalid_scope" | "scope_not_granted" =>
407435 error._tag === "ServerAuthInvalidScopeError" ? "invalid_scope" : "scope_not_granted";
408436
437export 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
409450export class ServerAuthForbiddenOperationError extends Schema.TaggedError<ServerAuthForbiddenOperationError>()(
410451 "ServerAuthForbiddenOperationError",
411452 {},
......@@ -502,6 +543,32 @@ export class EnvironmentAuth extends Context.Service<
502543 readonly issueStartupPairingUrl: (
503544 baseUrl: string,
504545 ) => 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>;
505572 }
506573>()("t3/auth/EnvironmentAuth") {}
507574
......@@ -540,6 +607,15 @@ export function toBootstrapExchangeError(
540607 });
541608}
542609
610const 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
543619const mapSessionVerificationErrors = <A, R>(
544620 effect: Effect.Effect<A, SessionStore.SessionCredentialError, R>,
545621): Effect.Effect<A, ServerAuthInvalidCredentialError | ServerAuthInternalError, R> =>
......@@ -624,6 +700,8 @@ export const make = Effect.gen(function* () {
624700 )
625701 : Effect.void,
626702 ),
703 mapSessionVerificationErrors,
704 Effect.flatMap(rejectMcpClientAudience),
627705 Effect.map((session) => ({
628706 sessionId: session.sessionId,
629707 subject: session.subject,
......@@ -632,7 +710,6 @@ export const make = Effect.gen(function* () {
632710 ...(session.proofKeyThumbprint ? { proofKeyThumbprint: session.proofKeyThumbprint } : {}),
633711 ...(session.expiresAt ? { expiresAt: session.expiresAt } : {}),
634712 })),
635 mapSessionVerificationErrors,
636713 );
637714
638715 const authenticateRequest = (
......@@ -1079,6 +1156,8 @@ export const make = Effect.gen(function* () {
10791156 const websocketTicket = requestUrl.value.searchParams.get(WEBSOCKET_TICKET_QUERY_PARAM);
10801157 if (websocketTicket && websocketTicket.trim().length > 0) {
10811158 return yield* sessions.verifyWebSocketToken(websocketTicket).pipe(
1159 mapSessionVerificationErrors,
1160 Effect.flatMap(rejectMcpClientAudience),
10821161 Effect.map((session) => ({
10831162 sessionId: session.sessionId,
10841163 subject: session.subject,
......@@ -1086,7 +1165,6 @@ export const make = Effect.gen(function* () {
10861165 scopes: session.scopes,
10871166 ...(session.expiresAt ? { expiresAt: session.expiresAt } : {}),
10881167 })),
1089 mapSessionVerificationErrors,
10901168 );
10911169 }
10921170 }
......@@ -1094,6 +1172,83 @@ export const make = Effect.gen(function* () {
10941172 return yield* authenticateRequest(request);
10951173 });
10961174
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
10971252 return EnvironmentAuth.of({
10981253 getDescriptor: () =>
10991254 Effect.succeed(descriptor).pipe(Effect.withSpan("EnvironmentAuth.getDescriptor")),
......@@ -1116,6 +1271,10 @@ export const make = Effect.gen(function* () {
11161271 authenticateWebSocketUpgrade,
11171272 issueWebSocketTicket,
11181273 issueStartupPairingUrl,
1274 authenticateMcpClient,
1275 issueMcpClientSession,
1276 consumeMcpApprovalCode,
1277 authenticateBrowserSession,
11191278 });
11201279});
11211280
apps/server/src/auth/McpOAuth.test.ts created+505
......@@ -0,0 +1,505 @@
1import * as NodeServices from "@effect/platform-node/NodeServices";
2import {
3 AuthAdministrativeScopes,
4 type AuthEnvironmentScope,
5 EnvironmentHttpApi,
6} from "@t3tools/contracts";
7import { expect, it } from "@effect/vitest";
8import * as Context from "effect/Context";
9import * as Crypto from "effect/Crypto";
10import * as Effect from "effect/Effect";
11import * as Layer from "effect/Layer";
12import * as Etag from "effect/http/Etag";
13import * as HttpPlatform from "effect/http/HttpPlatform";
14import * as HttpRouter from "effect/http/HttpRouter";
15import * as HttpServerRequest from "effect/http/HttpServerRequest";
16import * as HttpApi from "effect/http-api/HttpApi";
17import * as HttpApiBuilder from "effect/http-api/HttpApiBuilder";
18import * as Schema from "effect/Schema";
19
20import * as ServerConfig from "../config.ts";
21import * as ServerEnvironment from "../environment/ServerEnvironment.ts";
22import * as Sqlite from "../persistence/Sqlite.ts";
23import * as EnvironmentAuth from "./EnvironmentAuth.ts";
24import * as McpOAuth from "./McpOAuth.ts";
25import * as McpOAuthHttp from "./mcpOAuthHttp.ts";
26import * as ServerSecretStore from "./ServerSecretStore.ts";
27import * as AuthHttp from "./http.ts";
28
29class AuthTestApi extends HttpApi.make("environment")
30 .add(EnvironmentHttpApi.groups.auth)
31 .add(EnvironmentHttpApi.groups.mcpOAuth) {}
32
33const layerConfig = ServerConfig.layerTest(process.cwd(), { prefix: "t3-mcp-oauth-test-" });
34const 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.
41const 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
63const encodeJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown));
64const ORIGIN = "https://box.example.ts.net";
65const REDIRECT = "http://localhost/callback";
66const verifier = "a".repeat(43) + "-verifier-for-tests";
67/** base64url(SHA-256(verifier)), the S256 challenge for `verifier`. */
68const challenge = "DeB41nTVkPwpbbYecrnqtVq7VXLezustdHAK4SWt13c";
69
70/** Requests as they arrive behind an https proxy: plain http with the public Host. */
71const 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 });
76const 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
82type Handler = (request: Request) => Effect.Effect<Response>;
83
84const 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
107const json = <A>(response: Response) => Effect.promise(() => response.json() as Promise<A>);
108
109const 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. */
117const 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
131const 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
145const 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
151const 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
161it.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
183it.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
236it.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
311it.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
344it.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
415it.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
492it("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 @@
1import {
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";
17import { encodeOAuthScope } from "@t3tools/shared/oauthScope";
18import * as Clock from "effect/Clock";
19import * as Context from "effect/Context";
20import * as Crypto from "effect/Crypto";
21import * as Effect from "effect/Effect";
22import * as Layer from "effect/Layer";
23import * as Option from "effect/Option";
24import * as Ref from "effect/Ref";
25import * as Schema from "effect/Schema";
26import { HttpServerRequest } from "effect/http";
27
28import * as ServerEnvironment from "../environment/ServerEnvironment.ts";
29import * as McpHttpServer from "../mcp/McpHttpServer.ts";
30import type * as McpInvocationContext from "../mcp/McpInvocationContext.ts";
31import * as EnvironmentAuth from "./EnvironmentAuth.ts";
32import * as ServerSecretStore from "./ServerSecretStore.ts";
33import {
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
53const SIGNING_SECRET_NAME = "mcp-oauth-signing-key";
54const AUTHORIZATION_CODE_TTL_MS = 60_000;
55const MAX_CLIENT_NAME_LENGTH = 100;
56const MAX_REDIRECT_URIS = 5;
57const MAX_REDIRECT_URI_LENGTH = 512;
58const DEFAULT_CLIENT_NAME = "MCP client";
59const LOOPBACK_HOSTNAMES = new Set(["localhost", "127.0.0.1", "[::1]"]);
60const CODE_CHALLENGE_PATTERN = /^[A-Za-z0-9_-]{43}$/;
61const CODE_VERIFIER_PATTERN = /^[A-Za-z0-9\-._~]{43,128}$/;
62
63const MCP_OAUTH_SCOPES = [AuthOrchestrationReadScope, AuthOrchestrationOperateScope];
64
65export 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 */
75export 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
83export 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
93export 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
108const 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. */
125export 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
137const 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
147const ClientIdPayload = Schema.Struct({
148 v: Schema.Literal(1),
149 n: Schema.String,
150 r: Schema.Array(Schema.String),
151});
152const decodeClientIdPayload = Schema.decodeUnknownOption(Schema.fromJsonString(ClientIdPayload));
153
154export 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. */
161export 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. */
171export 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
181export 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
190interface 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
200export type ApprovalDecision = Exclude<AuthMcpApprovalDecision, { readonly _tag: "deny" }>;
201
202export 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. */
241const 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
249export 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
257const 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
558export 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 */
565export 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) => {
276276 expect(verified.expiresAt?.toString()).toBe(issued.expiresAt.toString());
277277 }).pipe(Effect.provide(layerSessionStore())),
278278 );
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 );
279293 it.effect("rejects malformed session tokens", () =>
280294 Effect.gen(function* () {
281295 const sessions = yield* SessionStore.SessionStore;
apps/server/src/auth/SessionStore.ts+7
......@@ -7,6 +7,7 @@ import {
77 type AuthClientSession,
88 type AuthEnvironmentScope,
99 type ClientSurface,
10 RuntimeMode,
1011 type ServerAuthSessionMethod,
1112} from "@t3tools/contracts";
1213import * as Context from "effect/Context";
......@@ -58,6 +59,8 @@ export interface VerifiedSession {
5859 readonly subject: string;
5960 readonly scopes: ReadonlyArray<AuthEnvironmentScope>;
6061 readonly proofKeyThumbprint?: string;
62 /** The most an MCP client approved through OAuth may hand to the threads it drives. */
63 readonly runtimeModeCeiling?: RuntimeMode;
6164}
6265
6366export type SessionCredentialChange =
......@@ -374,6 +377,7 @@ export class SessionStore extends Context.Service<
374377 readonly scopes?: ReadonlyArray<AuthEnvironmentScope>;
375378 readonly client?: AuthClientMetadata;
376379 readonly proofKeyThumbprint?: string;
380 readonly runtimeModeCeiling?: RuntimeMode;
377381 /**
378382 * Atomically revoke active sessions with the same subject and method
379383 * before storing this session.
......@@ -430,6 +434,7 @@ const SessionClaims = Schema.Struct({
430434 scopes: AuthEnvironmentScopes,
431435 method: Schema.Literals(["browser-session-cookie", "bearer-access-token", "dpop-access-token"]),
432436 jkt: Schema.optionalKey(Schema.String),
437 rtc: Schema.optionalKey(RuntimeMode),
433438 iat: Schema.Number,
434439 exp: Schema.Number,
435440});
......@@ -665,6 +670,7 @@ export const make = Effect.gen(function* () {
665670 scopes: input?.scopes ?? AuthStandardClientScopes,
666671 method: input?.method ?? "browser-session-cookie",
667672 ...(input?.proofKeyThumbprint ? { jkt: input.proofKeyThumbprint } : {}),
673 ...(input?.runtimeModeCeiling ? { rtc: input.runtimeModeCeiling } : {}),
668674 iat: issuedAt.epochMilliseconds,
669675 exp: expiresAt.epochMilliseconds,
670676 };
......@@ -839,6 +845,7 @@ export const make = Effect.gen(function* () {
839845 subject: claims.sub,
840846 scopes: claims.scopes,
841847 ...(claims.jkt ? { proofKeyThumbprint: claims.jkt } : {}),
848 ...(claims.rtc ? { runtimeModeCeiling: claims.rtc } : {}),
842849 } satisfies VerifiedSession;
843850 },
844851 );
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 */
6const escapeHtml = (value: string) =>
7 value.replace(
8 /[&<>"']/gu,
9 (character) =>
10 ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[character]!,
11 );
12
13const 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
25const 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
39export 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 @@
1import {
2 AuthMcpApprovalError,
3 type AuthMcpAuthorizationRequest,
4 AuthMcpClientAccess,
5 EnvironmentHttpApi,
6} from "@t3tools/contracts";
7import * as Effect from "effect/Effect";
8import * as Result from "effect/Result";
9import { HttpServerRequest, HttpServerResponse } from "effect/http";
10import * as HttpEffect from "effect/http/HttpEffect";
11import * as HttpApiBuilder from "effect/http-api/HttpApiBuilder";
12
13import * as EnvironmentAuth from "./EnvironmentAuth.ts";
14import * as McpOAuth from "./McpOAuth.ts";
15import { renderErrorPage } from "./mcpOAuthHtml.ts";
16
17/** Where the approval page lives in the web app. */
18const APPROVAL_PAGE_PATH = "/connect-agent";
19
20const 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. */
30const 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. */
37const 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 */
44const 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
60export 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";
33import { expect, it } from "@effect/vitest";
44import { NodeHttpServer } from "@effect/platform-node";
55import * as NodeServices from "@effect/platform-node/NodeServices";
6import { EnvironmentId, PreviewTabId, ProviderInstanceId, ThreadId } from "@t3tools/contracts";
6import {
7 EnvironmentId,
8 OrchestratorMcpFailure,
9 PreviewTabId,
10 ProviderInstanceId,
11 ThreadId,
12} from "@t3tools/contracts";
713import * as Deferred from "effect/Deferred";
814import * as Effect from "effect/Effect";
915import * as FileSystem from "effect/FileSystem";
......@@ -11,14 +17,16 @@ import * as Layer from "effect/Layer";
1117import * as Path from "effect/Path";
1218import * as Schema from "effect/Schema";
1319import * as Stream from "effect/Stream";
14import { McpProtocol, McpSchema, McpServer } from "effect/ai";
20import { McpProtocol, McpSchema, McpServer, Tool, Toolkit } from "effect/ai";
1521import { HttpBody, HttpClient, HttpRouter, HttpServerResponse } from "effect/http";
1622
1723import * as ProjectService from "../project/ProjectService.ts";
1824import * as ServerConfig from "../config.ts";
1925import * as McpHttpServer from "./McpHttpServer.ts";
26import * as McpToolAccess from "./McpToolAccess.ts";
2027import * as McpToolAccessTestkit from "./McpToolAccess.testkit.ts";
2128import * as McpInvocationContext from "./McpInvocationContext.ts";
29import * as McpSessionRegistry from "./McpSessionRegistry.ts";
2230import * as PreviewAutomationBroker from "./PreviewAutomationBroker.ts";
2331
2432const environmentId = EnvironmentId.make("environment-mcp-test");
......@@ -356,6 +364,28 @@ it.effect.each([
356364 ).pipe(Effect.provide(layerTest)),
357365);
358366
367it.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
359389it.effect("rejects non-boolean snapshot image options before selecting a browser host", () =>
360390 Effect.gen(function* () {
361391 const server = yield* McpServer.McpServer;
......@@ -916,3 +946,124 @@ it.effect("registers annotated tools and preserves authenticated request context
916946 }),
917947 ).pipe(Effect.provide(layerTest)),
918948);
949
950it.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 {
5858import * as HtmlHandlers from "./toolkits/html/handlers.ts";
5959import { HtmlPreviewTool, HtmlPreviewToolkit, HtmlRenderToolkit } from "./toolkits/html/tools.ts";
6060
61const 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. */
62const 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 */
73const 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.",
7194 },
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 */
109export 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") {}
74117
75118type AuthenticatedHttpEffect = Effect.Effect<
76119 HttpServerResponse.HttpServerResponse,
......@@ -98,33 +141,43 @@ export const normalizeMcpHttpResponse = (
98141 : response;
99142};
100143
101const 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.
145const looksLikeProviderToken = (token: string) => token.length > 0 && !token.includes(".");
146
147const 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"));
128181
129182const layerMcpAuthMiddleware = HttpRouter.middleware<{
130183 provides: McpInvocationContext.McpInvocationContext;
......@@ -787,7 +840,7 @@ export const layerDeviceToolkit = Layer.mergeAll(
787840 layerDeviceScreenshotRegistration,
788841);
789842
790const layerMcpTransport = McpServer.layerHttp({
843export const layerMcpTransport = McpServer.layerHttp({
791844 name: "T3 Code",
792845 version: packageJson.version,
793846 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
8181 environmentId: EnvironmentId.make("environment-1"),
8282 requestNamespace: "client:session-1",
8383 thread: undefined,
84 client: { sessionId: "session-1", label: "Claude Code", runtimeModeCeiling: "auto" },
84 client: { sessionId: "session-1", label: "Claude Code", access: "auto" },
8585 capabilities: new Set(["preview", "orchestration"]),
8686 issuedAt: 1,
8787 };
apps/server/src/mcp/McpInvocationContext.ts+11-1
......@@ -1,4 +1,5 @@
11import {
2 type AuthMcpClientAccess,
23 type EnvironmentId,
34 McpCapabilityUnavailableError,
45 OrchestratorMcpFailure,
......@@ -30,9 +31,18 @@ export interface McpThreadCaller {
3031export interface McpClientCaller {
3132 readonly sessionId: string;
3233 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;
3436}
3537
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 */
43export const clientRuntimeModeCeiling = (client: McpClientCaller | undefined): RuntimeMode =>
44 client === undefined || client.access === "read-only" ? "approval-required" : client.access;
45
3646/**
3747 * Who is calling and what they may do. Tool parameters choose the target
3848 * (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 = {
6666 environmentId: EnvironmentId.make("environment"),
6767 requestNamespace: "client:race",
6868 thread: undefined,
69 client: { sessionId: "race", label: "Claude Code", runtimeModeCeiling: "approval-required" },
69 client: { sessionId: "race", label: "Claude Code", access: "approval-required" },
7070 capabilities: new Set(["orchestration"]),
7171 issuedAt: 0,
7272};
apps/server/src/mcp/McpToolAccess.test.ts+10-2
......@@ -48,12 +48,12 @@ const threadCaller = (threadId: ThreadId): McpInvocationContext.McpInvocationSco
4848});
4949
5050const clientCaller = (
51 runtimeModeCeiling: RuntimeMode,
51 access: McpInvocationContext.McpClientCaller["access"],
5252): McpInvocationContext.McpInvocationScope => ({
5353 environmentId: EnvironmentId.make("environment"),
5454 requestNamespace: "client:session",
5555 thread: undefined,
56 client: { sessionId: "session", label: "Claude Code", runtimeModeCeiling },
56 client: { sessionId: "session", label: "Claude Code", access },
5757 capabilities: new Set(["orchestration"]),
5858 issuedAt: 0,
5959});
......@@ -193,6 +193,7 @@ const fullAccess = threadCaller(fullAccessThreadId);
193193const ended = threadCaller(endedThreadId);
194194const supervisedClient = clientCaller("approval-required");
195195const fullAccessClient = clientCaller("full-access");
196const readOnlyClient = clientCaller("read-only");
196197// A live full-access thread whose credential may use its browser but not control threads.
197198const previewOnly = { ...fullAccess, capabilities: new Set(["preview"] as const) };
198199
......@@ -200,6 +201,13 @@ it.effect.each([
200201 // Reads are open to every caller, even one whose turn ended.
201202 ["reads", ended, {}, "ran"],
202203 ["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"],
203211
204212 // What belongs to the calling thread needs one; changing it needs its live turn.
205213 ["reads_as_caller", ended, {}, "ran"],
apps/server/src/mcp/McpToolAccess.ts+23-2
......@@ -97,15 +97,36 @@ export class Declaration<out Handler> {
9797 }
9898}
9999
100/** A client approved for read-only access changes nothing. */
101const 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
100115/**
101116 * The caller of a tool that changes something. Tools that act for a
102117 * capability of their own (preview, device, worktree, pull requests) check it
103118 * themselves.
104119 */
105const writingCaller = loadCaller().pipe(Effect.tap(assertLiveCaller));
120const writingCaller = refuseReadOnlyClient.pipe(
121 Effect.andThen(loadCaller()),
122 Effect.tap(assertLiveCaller),
123);
106124
107125/** The same, for tools whose only capability is controlling threads. */
108const orchestratingCaller = readCaller().pipe(Effect.tap(assertLiveCaller));
126const orchestratingCaller = refuseReadOnlyClient.pipe(
127 Effect.andThen(readCaller()),
128 Effect.tap(assertLiveCaller),
129);
109130
110131const requireThreadCaller = McpInvocationContext.McpInvocationContext.pipe(
111132 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", () => {
16031603 client: {
16041604 sessionId: "scheduled",
16051605 label: "Claude Code",
1606 runtimeModeCeiling: "approval-required",
1606 access: "approval-required",
16071607 },
16081608 capabilities: new Set(["orchestration"]),
16091609 issuedAt: 1,
......@@ -1709,6 +1709,25 @@ describe("OrchestratorMcpService provider resolution", () => {
17091709 }),
17101710 );
17111711
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
17121731 it.effect("checks a bound task against its thread's current modes", () =>
17131732 Effect.gen(function* () {
17141733 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";
8484import * as ProviderRegistry from "../provider/ProviderRegistry.ts";
8585import * as ScheduledTaskService from "../scheduledTasks/ScheduledTaskService.ts";
8686import {
87 clientRuntimeModeCeiling,
8788 type McpInvocationScope,
8889 type McpThreadInvocationScope,
8990 requireThreadScope,
......@@ -908,7 +909,7 @@ const make = Effect.gen(function* () {
908909 return {
909910 parent: undefined,
910911 limits: {
911 runtimeMode: scope.client?.runtimeModeCeiling ?? "approval-required",
912 runtimeMode: clientRuntimeModeCeiling(scope.client),
912913 interactionMode: "default",
913914 } satisfies { runtimeMode: RuntimeMode; interactionMode: ProviderInteractionMode },
914915 } as const;
......@@ -1400,8 +1401,9 @@ const make = Effect.gen(function* () {
14001401
14011402 /**
14021403 * 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.
14051407 */
14061408 const summarizeScheduledTask = (
14071409 scope: McpInvocationScope,
......@@ -1416,8 +1418,9 @@ const make = Effect.gen(function* () {
14161418 ) =>
14171419 Effect.gen(function* () {
14181420 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)));
14211424 // This runs after a save, so a failed lookup of the bound thread hides
14221425 // the webhook URL instead of reporting a saved task as an error.
14231426 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* () {
7676 threads,
7777 caller: undefined,
7878 limits: {
79 runtimeMode: scope.client?.runtimeModeCeiling ?? "approval-required",
79 runtimeMode: McpInvocationContext.clientRuntimeModeCeiling(scope.client),
8080 interactionMode: "default",
8181 },
8282 } 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", () =>
396396);
397397
398398const clientScope = (
399 runtimeModeCeiling: "approval-required" | "auto-accept-edits" | "auto" | "full-access",
399 access: McpInvocationContext.McpClientCaller["access"],
400400): McpInvocationContext.McpInvocationScope => ({
401401 environmentId: EnvironmentId.make("mcp-core-environment"),
402402 requestNamespace: "client:session-1",
403403 thread: undefined,
404 client: { sessionId: "session-1", label: "Claude Code", runtimeModeCeiling },
404 client: { sessionId: "session-1", label: "Claude Code", access },
405405 issuedAt: 0,
406406 capabilities: new Set(["orchestration", "worktree", "pull-requests"]),
407407});
......@@ -474,6 +474,76 @@ it.effect("a client caller targets any thread within its ceiling and cannot act
474474 ),
475475);
476476
477it.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);
545const dispatched: Array<string> = [];
546
477547it.effect("refuses act-as-caller tools to a client caller", () =>
478548 Effect.gen(function* () {
479549 const server = yield* McpServer.McpServer;
apps/server/src/mcp/toolkits/project/handlers.test.ts+1-1
......@@ -313,7 +313,7 @@ const clientLaunchHarness = (input: {
313313 client: {
314314 sessionId: "session-1",
315315 label: "Claude Code",
316 runtimeModeCeiling: input.runtimeModeCeiling,
316 access: input.runtimeModeCeiling,
317317 },
318318 issuedAt: 0,
319319 capabilities: new Set(["orchestration" as const]),
apps/server/src/server.ts+4
......@@ -116,6 +116,8 @@ import * as ServerSecretStore from "./auth/ServerSecretStore.ts";
116116import * as WebhookRoute from "./scheduledTasks/webhookRoute.ts";
117117import * as RelayDeliveryProof from "./scheduledTasks/RelayDeliveryProof.ts";
118118import * as HeldHooksWaker from "./relay/HeldHooksWaker.ts";
119import * as McpOAuth from "./auth/McpOAuth.ts";
120import * as McpOAuthHttp from "./auth/mcpOAuthHttp.ts";
119121import {
120122 relayHookBaseUrl,
121123 ScheduledTaskWebhookOrigin,
......@@ -654,6 +656,7 @@ const layerMakeRoutes = Layer.mergeAll(
654656 Layer.mergeAll(
655657 HttpApiBuilder.layer(EnvironmentHttpApi).pipe(
656658 Layer.provide(AuthHttp.layer),
659 Layer.provide(McpOAuthHttp.layer.pipe(Layer.provide(McpOAuth.layer))),
657660 Layer.provide(CloudHttp.layer),
658661 Layer.provide(OrchestrationHttp.layer),
659662 Layer.provide(PullRequestHttp.layer),
......@@ -677,6 +680,7 @@ const layerMakeRoutes = Layer.mergeAll(
677680 // what dispatch can actually serve.
678681 McpHttpServer.layer.pipe(
679682 Layer.provide(ProviderAdapterRegistry.layerFromProviderInstanceRegistry),
683 Layer.provide(McpOAuth.layerMcpClientAuthenticator),
680684 ),
681685).pipe(
682686 // 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 @@
1import {
2 type AuthMcpApprovalDecision,
3 type AuthMcpApprovalDetails,
4 AuthMcpApprovalError,
5 AuthMcpClientAccess,
6 type AuthMcpAuthorizationRequest,
7} from "@t3tools/contracts";
8import { Radio as RadioPrimitive } from "@base-ui/react/radio";
9import { EyeIcon, type LucideIcon } from "lucide-react";
10import type * as Context from "effect/Context";
11import * as Effect from "effect/Effect";
12import * as Schema from "effect/Schema";
13import { type ReactNode, useCallback, useEffect, useState } from "react";
14
15import { PrimaryEnvironmentHttpClient } from "~/environments/primary/httpClient";
16import { runPrimaryHttp } from "~/lib/runtime";
17import { cn } from "~/lib/utils";
18import { runtimeModeConfig } from "../chat/runtimeModeConfig";
19import { Alert, AlertDescription } from "../ui/alert";
20import { Button } from "../ui/button";
21import { Input } from "../ui/input";
22import { RadioGroup } from "../ui/radio-group";
23import { Spinner } from "../ui/spinner";
24import { AuthSurfaceShell } from "./AuthSurfaceShell";
25
26const 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
38type Loaded =
39 | { readonly status: "loading" }
40 | { readonly status: "invalid"; readonly message: string }
41 | { readonly status: "ready"; readonly details: AuthMcpApprovalDetails };
42
43const UNREACHABLE = "Could not reach this environment. Try again.";
44const isApprovalError = Schema.is(AuthMcpApprovalError);
45
46type 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 */
57function 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
80function 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 */
94function oneClickApproves(details: AuthMcpApprovalDetails, access: AuthMcpClientAccess) {
95 return details.csrfToken !== undefined && (details.oneClickAccess ?? []).includes(access);
96}
97
98export 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
269function 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
285function 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'
1313import { Route as UsageRouteImport } from './routes/usage'
1414import { Route as SettingsRouteImport } from './routes/settings'
1515import { Route as PairRouteImport } from './routes/pair'
16import { Route as ConnectAgentRouteImport } from './routes/connect-agent'
1617import { Route as ConnectRouteImport } from './routes/connect'
1718import { Route as ChatRouteImport } from './routes/_chat'
1819import { Route as ChatIndexRouteImport } from './routes/_chat.index'
......@@ -55,6 +56,11 @@ const PairRoute = PairRouteImport.update({
5556 path: '/pair',
5657 getParentRoute: () => rootRouteImport,
5758} as any)
59const ConnectAgentRoute = ConnectAgentRouteImport.update({
60 id: '/connect-agent',
61 path: '/connect-agent',
62 getParentRoute: () => rootRouteImport,
63} as any)
5864const ConnectRoute = ConnectRouteImport.update({
5965 id: '/connect',
6066 path: '/connect',
......@@ -165,6 +171,7 @@ const ChatEnvironmentIdThreadIdRoute =
165171export interface FileRoutesByFullPath {
166172 '/': typeof ChatIndexRoute
167173 '/connect': typeof ConnectRoute
174 '/connect-agent': typeof ConnectAgentRoute
168175 '/pair': typeof PairRoute
169176 '/settings': typeof SettingsRouteWithChildren
170177 '/usage': typeof UsageRoute
......@@ -190,6 +197,7 @@ export interface FileRoutesByFullPath {
190197}
191198export interface FileRoutesByTo {
192199 '/connect': typeof ConnectRoute
200 '/connect-agent': typeof ConnectAgentRoute
193201 '/pair': typeof PairRoute
194202 '/settings': typeof SettingsRouteWithChildren
195203 '/usage': typeof UsageRoute
......@@ -218,6 +226,7 @@ export interface FileRoutesById {
218226 __root__: typeof rootRouteImport
219227 '/_chat': typeof ChatRouteWithChildren
220228 '/connect': typeof ConnectRoute
229 '/connect-agent': typeof ConnectAgentRoute
221230 '/pair': typeof PairRoute
222231 '/settings': typeof SettingsRouteWithChildren
223232 '/usage': typeof UsageRoute
......@@ -247,6 +256,7 @@ export interface FileRouteTypes {
247256 fullPaths:
248257 | '/'
249258 | '/connect'
259 | '/connect-agent'
250260 | '/pair'
251261 | '/settings'
252262 | '/usage'
......@@ -272,6 +282,7 @@ export interface FileRouteTypes {
272282 fileRoutesByTo: FileRoutesByTo
273283 to:
274284 | '/connect'
285 | '/connect-agent'
275286 | '/pair'
276287 | '/settings'
277288 | '/usage'
......@@ -299,6 +310,7 @@ export interface FileRouteTypes {
299310 | '__root__'
300311 | '/_chat'
301312 | '/connect'
313 | '/connect-agent'
302314 | '/pair'
303315 | '/settings'
304316 | '/usage'
......@@ -327,6 +339,7 @@ export interface FileRouteTypes {
327339export interface RootRouteChildren {
328340 ChatRoute: typeof ChatRouteWithChildren
329341 ConnectRoute: typeof ConnectRoute
342 ConnectAgentRoute: typeof ConnectAgentRoute
330343 PairRoute: typeof PairRoute
331344 SettingsRoute: typeof SettingsRouteWithChildren
332345 UsageRoute: typeof UsageRoute
......@@ -364,6 +377,13 @@ declare module '@tanstack/react-router' {
364377 preLoaderRoute: typeof PairRouteImport
365378 parentRoute: typeof rootRouteImport
366379 }
380 '/connect-agent': {
381 id: '/connect-agent'
382 path: '/connect-agent'
383 fullPath: '/connect-agent'
384 preLoaderRoute: typeof ConnectAgentRouteImport
385 parentRoute: typeof rootRouteImport
386 }
367387 '/connect': {
368388 id: '/connect'
369389 path: '/connect'
......@@ -571,6 +591,7 @@ const SettingsRouteWithChildren = SettingsRoute._addFileChildren(
571591const rootRouteChildren: RootRouteChildren = {
572592 ChatRoute: ChatRouteWithChildren,
573593 ConnectRoute: ConnectRoute,
594 ConnectAgentRoute: ConnectAgentRoute,
574595 PairRoute: PairRoute,
575596 SettingsRoute: SettingsRouteWithChildren,
576597 UsageRoute: UsageRoute,
apps/web/src/routes/__root.tsx+1-1
......@@ -161,7 +161,7 @@ function RootRouteView() {
161161 };
162162 }, [pathname]);
163163
164 if (pathname === "/pair" || pathname === "/connect") {
164 if (pathname === "/pair" || pathname === "/connect" || pathname === "/connect-agent") {
165165 return (
166166 <>
167167 <DocumentTitleSync />
apps/web/src/routes/connect-agent.tsx created+7
......@@ -0,0 +1,7 @@
1import { createFileRoute } from "@tanstack/react-router";
2
3import { ConnectAgentSurface } from "../components/auth/ConnectAgentSurface";
4
5export 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";
99import { defineConfig, type Connect, type Plugin } from "vite-plus";
1010import pkg from "./package.json" with { type: "json" };
1111
12import { DEV_PROXIED_PATH_PREFIXES } from "@t3tools/shared/devProxy";
12import {
13 DEV_PROXIED_ORIGIN_PRESERVING_PREFIXES,
14 DEV_PROXIED_PATH_PREFIXES,
15} from "@t3tools/shared/devProxy";
1316
1417import { loadRepoEnv } from "../../scripts/lib/public-config";
1518import { thirdPartyLicensesPlugin } from "../../scripts/lib/third-party-licenses";
......@@ -249,7 +252,7 @@ export default defineConfig(() => {
249252 prefix,
250253 {
251254 target: devProxyTarget,
252 changeOrigin: true,
255 changeOrigin: !DEV_PROXIED_ORIGIN_PRESERVING_PREFIXES.has(prefix),
253256 ...(prefix === "/ws" || prefix === "/api" ? { ws: true } : {}),
254257 },
255258 ]),
docs/internals/environment-auth.md+29-2
......@@ -21,8 +21,35 @@ authority.
2121Browser cookies, bearer tokens, and DPoP tokens adapt the same scoped session
2222model. DPoP binds a token to a client's proof key; an invalid proof must fail
2323rather than fall back to bearer authentication. The OAuth token-exchange
24vocabulary gives these grants a familiar meaning, but the environment does not
25implement a general-purpose OAuth authorization server.
24vocabulary gives these grants a familiar meaning.
25
26### MCP clients are a separate audience
27
28Agents T3 Code did not launch sign in to `/mcp` through a narrow OAuth
29authorization-code server ([McpOAuth](../../apps/server/src/auth/McpOAuth.ts)).
30It accepts only loopback redirect URIs: an HTTPS redirect would let anyone send
31the owner an approval link that delivers the code to their own server. Client
32registration is stateless, so an unauthenticated caller cannot grow server
33state. 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
36The user grants either read-only access or a runtime-mode ceiling, not a
37scope list: MCP tools are all orchestration, and `orchestration:operate`
38alone would let an agent start a thread in full access and act through it.
39The result is an ordinary session with subject `mcp-client`. A read-only
40grant holds `orchestration:read` alone. On `/mcp` it passes only tools
41declared as reads in [McpToolAccess](../../apps/server/src/mcp/McpToolAccess.ts),
42where 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
44path rejects that subject, because the RPC surface would let the agent act
45above its ceiling. Inside MCP the credential sets the limits and tool
46parameters only pick targets; see
47[threadAccess](../../apps/server/src/mcp/threadAccess.ts).
48
49Issuer and resource URLs come from the request's Host and
50`X-Forwarded-Proto`, so one server answers over loopback, Tailscale Serve and a
51T3 Connect tunnel. A proxy that rewrites Host or drops the protocol header
52breaks sign-in.
2653
2754Bearer and DPoP clients obtain short-lived WebSocket tickets through authenticated
2855HTTP 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({
353353 expiresAt: Schema.optionalKey(Schema.DateTimeUtc),
354354});
355355export 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 */
361export const AuthMcpClientAccess = Schema.Literals([
362 "read-only",
363 "approval-required",
364 "auto-accept-edits",
365 "auto",
366 "full-access",
367]);
368export type AuthMcpClientAccess = typeof AuthMcpClientAccess.Type;
369
370/** RFC 9728 metadata for an environment's `/mcp` resource. */
371export 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});
378export type AuthMcpProtectedResourceMetadata = typeof AuthMcpProtectedResourceMetadata.Type;
379
380/** RFC 8414 metadata for the authorization server MCP clients sign in through. */
381export 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});
393export type AuthMcpAuthorizationServerMetadata = typeof AuthMcpAuthorizationServerMetadata.Type;
394
395/** RFC 7591 client metadata. Fields the server does not use are dropped. */
396export 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});
401export type AuthMcpClientRegistration = typeof AuthMcpClientRegistration.Type;
402
403export 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));
411export type AuthMcpRegisteredClient = typeof AuthMcpRegisteredClient.Type;
412
413/** RFC 7591 §3.2.2 registration error. */
414export 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 */
432export 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});
441export 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 */
448export 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});
462export 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 */
468export const AuthMcpApprovalRedirect = Schema.Struct({
469 redirectTo: Schema.String,
470});
471export type AuthMcpApprovalRedirect = typeof AuthMcpApprovalRedirect.Type;
472
473export 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]);
485export type AuthMcpApprovalDecision = typeof AuthMcpApprovalDecision.Type;
486
487export const AuthMcpApprovalDecisionRequest = Schema.Struct({
488 authorization: AuthMcpAuthorizationRequest,
489 decision: AuthMcpApprovalDecision,
490});
491export type AuthMcpApprovalDecisionRequest = typeof AuthMcpApprovalDecisionRequest.Type;
492
493/** A problem the approval page shows the user without redirecting anywhere. */
494export 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. */
501export 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());
509export type AuthMcpTokenRequest = typeof AuthMcpTokenRequest.Type;
510
511export 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});
517export type AuthMcpTokenResult = typeof AuthMcpTokenResult.Type;
518
519/** RFC 6749 §5.2 token error. */
520export 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 {
1515 AuthBrowserSessionResult,
1616 AuthClientSession,
1717 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,
1831 AuthPairingCredentialResult,
1932 AuthPairingLink,
2033 AuthRevokeClientSessionInput,
......@@ -514,6 +527,58 @@ class EnvironmentAuthHttpApi extends HttpApiGroup.make("auth")
514527 }).middleware(EnvironmentAuthenticatedAuth),
515528 ) {}
516529
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 */
536class 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
517582const EnvironmentOrchestrationThreadSnapshotParams = Schema.Struct({
518583 threadId: ThreadId,
519584});
......@@ -684,6 +749,7 @@ class EnvironmentWebhooksHttpApi extends HttpApiGroup.make("webhooks")
684749export class EnvironmentHttpApi extends HttpApi.make("environment")
685750 .add(EnvironmentMetadataHttpApi)
686751 .add(EnvironmentAuthHttpApi)
752 .add(EnvironmentMcpOAuthHttpApi)
687753 .add(EnvironmentOrchestrationHttpApi)
688754 .add(EnvironmentPullRequestsHttpApi)
689755 .add(EnvironmentProjectsHttpApi)
packages/shared/src/devProxy.ts+12-1
......@@ -8,7 +8,18 @@
88 * prefix only Vite knows gets answered with index.html; a prefix only the
99 * server knows redirect-loops through the proxy.
1010 */
11export const DEV_PROXIED_PATH_PREFIXES = ["/api", "/oauth", "/.well-known", "/ws"] as const;
11export 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 */
18export const DEV_PROXIED_ORIGIN_PRESERVING_PREFIXES: ReadonlySet<string> = new Set([
19 "/oauth",
20 "/.well-known",
21 "/mcp",
22]);
1223
1324export function isDevProxiedPath(pathname: string): boolean {
1425 return DEV_PROXIED_PATH_PREFIXES.some(