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 MCP_PEER_LINK_SOFTWARE_ID,
17 mcpClientScopes,
18 parseMcpApprovalCode,
19 type RuntimeMode,
20} from "@t3tools/contracts";
21import { DEFAULT_HOSTED_APP_URL } from "@t3tools/shared/connectAuth";
22import { encodeOAuthScope } from "@t3tools/shared/oauthScope";
23import * as Clock from "effect/Clock";
24import * as Context from "effect/Context";
25import * as Digest from "effect/crypto/Digest";
26import * as SecureRandom from "effect/crypto/SecureRandom";
27import * as Effect from "effect/Effect";
28import * as Layer from "effect/Layer";
29import * as Option from "effect/Option";
30import * as Redacted from "effect/Redacted";
31import * as Ref from "effect/Ref";
32import * as Schema from "effect/Schema";
33import { HttpServerRequest } from "effect/http";
34
35import { hostedAppUrlConfig } from "../cloud/publicConfig.ts";
36import * as ServerEnvironment from "../environment/ServerEnvironment.ts";
37import * as ServerSettings from "../serverSettings.ts";
38import * as McpHttpServer from "../mcp/McpHttpServer.ts";
39import type * as McpInvocationContext from "../mcp/McpInvocationContext.ts";
40import * as PayloadSigner from "./PayloadSigner.ts";
41import * as EnvironmentAuth from "./EnvironmentAuth.ts";
42import * as ServerSecretStore from "./ServerSecretStore.ts";
43import {
44 base64UrlDecodeUtf8,
45 base64UrlEncode,
46 deriveAuthClientMetadata,
47 timingSafeEqualBase64Url,
48} from "./utils.ts";
49import { requestUrl } from "../requestUrl.ts";
50
51// Request-derived origins support loopback, Tailscale and tunnels.
52// Signed client metadata keeps unauthenticated registration stateless.
53
54const SIGNING_SECRET_NAME = "mcp-oauth-signing-key";
55const AUTHORIZATION_CODE_TTL_MS = 60_000;
56const MAX_CLIENT_NAME_LENGTH = 100;
57const MAX_REDIRECT_URIS = 5;
58const MAX_REDIRECT_URI_LENGTH = 512;
59const DEFAULT_CLIENT_NAME = "MCP client";
60const LOOPBACK_HOSTNAMES = new Set(["localhost", "127.0.0.1", "[::1]"]);
61const CODE_CHALLENGE_PATTERN = /^[A-Za-z0-9_-]{43}$/;
62const CODE_VERIFIER_PATTERN = /^[A-Za-z0-9\-._~]{43,128}$/;
63
64const MCP_OAUTH_SCOPES = [AuthOrchestrationReadScope, AuthOrchestrationOperateScope];
65
66export interface McpOAuthUrls {
67 readonly issuer: string;
68 readonly resource: string;
69}
70
71export const requestUrls = (request: HttpServerRequest.HttpServerRequest): McpOAuthUrls => {
72 const origin = Option.match(requestUrl(request), {
73 onNone: () => "http://localhost",
74 onSome: (url) => url.origin,
75 });
76 return { issuer: origin, resource: `${origin}/mcp` };
77};
78
79export const protectedResourceMetadata = (
80 urls: McpOAuthUrls,
81): AuthMcpProtectedResourceMetadata => ({
82 resource: urls.resource,
83 authorization_servers: [urls.issuer],
84 scopes_supported: MCP_OAUTH_SCOPES,
85 bearer_methods_supported: ["header"],
86 resource_name: "T3 Code",
87});
88
89export const authorizationServerMetadata = (
90 urls: McpOAuthUrls,
91): AuthMcpAuthorizationServerMetadata => ({
92 issuer: urls.issuer,
93 authorization_endpoint: `${urls.issuer}/oauth/mcp/authorize`,
94 token_endpoint: `${urls.issuer}/oauth/mcp/token`,
95 registration_endpoint: `${urls.issuer}/oauth/mcp/register`,
96 response_types_supported: ["code"],
97 grant_types_supported: ["authorization_code"],
98 code_challenge_methods_supported: ["S256"],
99 token_endpoint_auth_methods_supported: ["none"],
100 scopes_supported: MCP_OAUTH_SCOPES,
101 authorization_response_iss_parameter_supported: true,
102});
103
104const parseRedirect = (value: string): URL | undefined => {
105 // URL.hash misses empty fragments, which RFC 6749 §3.1.2 also forbids.
106 if (value.length > MAX_REDIRECT_URI_LENGTH || value.includes("#")) return undefined;
107 try {
108 const url = new URL(value);
109 const allowed =
110 url.protocol === "https:" ||
111 (url.protocol === "http:" && LOOPBACK_HOSTNAMES.has(url.hostname));
112 return allowed && url.username === "" && url.password === "" ? url : undefined;
113 } catch {
114 return undefined;
115 }
116};
117
118/** Loopback redirects ignore port (RFC 8252 §7.3); HTTPS redirects match exactly. */
119export const redirectMatches = (registered: string, presented: string): boolean => {
120 const left = parseRedirect(registered);
121 const right = parseRedirect(presented);
122 if (left === undefined || right === undefined) return false;
123 if (left.protocol === "https:" || right.protocol === "https:") return left.href === right.href;
124 return (
125 left.hostname === right.hostname &&
126 left.pathname === right.pathname &&
127 left.search === right.search
128 );
129};
130
131const sameResource = (urls: McpOAuthUrls, presented: string | undefined): boolean => {
132 if (presented === undefined) return true;
133 try {
134 const url = new URL(presented);
135 return url.hash === "" && `${url.origin}${url.pathname.replace(/\/+$/u, "")}` === urls.resource;
136 } catch {
137 return false;
138 }
139};
140
141const ClientIdPayload = Schema.Struct({
142 v: Schema.Literal(1),
143 n: Schema.String,
144 r: Schema.Array(Schema.String),
145 /** Registered as another T3 Code environment's peer link. */
146 p: Schema.optionalKey(Schema.Literal(1)),
147});
148const decodeClientIdPayload = Schema.decodeUnknownOption(Schema.fromJsonString(ClientIdPayload));
149
150export interface McpOAuthClient {
151 readonly clientId: string;
152 readonly name: string;
153 readonly redirectUris: ReadonlyArray<string>;
154 readonly peerLink: boolean;
155}
156
157export class McpOAuthPageError extends Schema.TaggedError<McpOAuthPageError>()(
158 "McpOAuthPageError",
159 { description: Schema.String },
160) {
161 override get message(): string {
162 return this.description;
163 }
164}
165
166export class McpOAuthRedirectError extends Schema.TaggedError<McpOAuthRedirectError>()(
167 "McpOAuthRedirectError",
168 {
169 error: Schema.Literals(["invalid_request", "unsupported_response_type", "invalid_target"]),
170 description: Schema.String,
171 redirectUri: Schema.String,
172 state: Schema.optional(Schema.RedactedFromValue(Schema.String)),
173 },
174) {}
175
176export interface AuthorizationRequest {
177 readonly client: McpOAuthClient;
178 readonly redirectUri: string;
179 readonly codeChallenge: string;
180 readonly state: Redacted.Redacted<string> | undefined;
181 readonly resource: string;
182 readonly issuer: string;
183}
184
185interface PendingCode {
186 readonly clientId: string;
187 readonly clientName: string;
188 readonly peerLink: boolean;
189 readonly redirectUri: string;
190 readonly codeChallenge: string;
191 readonly resource: string;
192 readonly access: AuthMcpClientAccess;
193 readonly expiresAtMs: number;
194}
195
196export type ApprovalDecision = Exclude<AuthMcpApprovalDecision, { readonly _tag: "deny" }>;
197
198export class McpOAuth extends Context.Service<
199 McpOAuth,
200 {
201 readonly register: (
202 input: AuthMcpClientRegistration,
203 ) => Effect.Effect<McpOAuthClient, AuthMcpRegistrationError>;
204 /** Page errors must never redirect. */
205 readonly validateAuthorization: (input: {
206 readonly urls: McpOAuthUrls;
207 readonly request: AuthMcpAuthorizationRequest;
208 }) => Effect.Effect<AuthorizationRequest, McpOAuthPageError | McpOAuthRedirectError>;
209 /** A browser cookie session, or a T3 Connect session from the hosted app, that may approve. */
210 readonly approvingSession: (
211 request: HttpServerRequest.HttpServerRequest,
212 authorization: AuthorizationRequest,
213 ) => Effect.Effect<
214 | { readonly csrfToken: string; readonly scopes: ReadonlyArray<AuthEnvironmentScope> }
215 | undefined
216 >;
217 readonly approve: (input: {
218 readonly request: HttpServerRequest.HttpServerRequest;
219 readonly authorization: AuthorizationRequest;
220 readonly decision: ApprovalDecision;
221 }) => Effect.Effect<string, EnvironmentAuth.ServerAuthMcpApprovalCodeError | McpOAuthPageError>;
222 readonly deny: (authorization: AuthorizationRequest) => string;
223 /** A managed policy's runtime-mode ceiling, shown on the approval page. */
224 readonly runtimeModeCeiling:
225 | { readonly organization: string; readonly maxRuntimeMode: RuntimeMode }
226 | undefined;
227 readonly exchangeCode: (input: {
228 readonly request: HttpServerRequest.HttpServerRequest;
229 readonly urls: McpOAuthUrls;
230 readonly token: AuthMcpTokenRequest;
231 }) => Effect.Effect<AuthMcpTokenResult, AuthMcpTokenError>;
232 }
233>()("t3/auth/McpOAuth") {}
234
235const redirectWith = (
236 redirectUri: string,
237 params: Record<string, string | Redacted.Redacted<string> | undefined>,
238) => {
239 const url = new URL(redirectUri);
240 for (const [name, value] of Object.entries(params)) {
241 if (value !== undefined) {
242 url.searchParams.set(name, Redacted.isRedacted(value) ? Redacted.value(value) : value);
243 }
244 }
245 return url.toString();
246};
247
248export const redirectForError = (error: McpOAuthRedirectError, issuer: string) =>
249 redirectWith(error.redirectUri, {
250 error: error.error,
251 error_description: error.description,
252 state: error.state,
253 iss: issuer,
254 });
255
256const make = Effect.gen(function* () {
257 const environmentAuth = yield* EnvironmentAuth.EnvironmentAuth;
258 const secretStore = yield* ServerSecretStore.ServerSecretStore;
259 const random = yield* SecureRandom.SecureRandom;
260 const digester = yield* Digest.Digest;
261 const serverSettings = yield* ServerSettings.ServerSettingsService;
262 const linksEnabled = serverSettings.getSettings.pipe(
263 Effect.map((settings) => settings.linkedEnvironmentsEnabled),
264 Effect.orElseSucceed(() => false),
265 );
266 const signer = yield* PayloadSigner.fromSecret(
267 yield* secretStore.getOrCreateRandom(SIGNING_SECRET_NAME, 32).pipe(Effect.orDie),
268 );
269 const codes = yield* Ref.make<ReadonlyMap<string, PendingCode>>(new Map());
270
271 const sign = (domain: string, payload: string) => signer.sign(`${domain}.${payload}`);
272
273 const signClientId = (name: string, redirectUris: ReadonlyArray<string>, peerLink: boolean) => {
274 const body = base64UrlEncode(
275 JSON.stringify({ v: 1, n: name, r: redirectUris, ...(peerLink ? { p: 1 } : {}) }),
276 );
277 return sign("mcp-client-id", body).pipe(Effect.map((signature) => `${body}.${signature}`));
278 };
279
280 // Untraced: only called inside the traced McpOAuth methods below.
281 const parseClientId = Effect.fnUntraced(function* (
282 clientId: string,
283 ): Effect.fn.Return<McpOAuthClient | undefined> {
284 const [body, signature, extra] = clientId.split(".");
285 if (!body || !signature || extra !== undefined) return undefined;
286 const verified = yield* signer.verify(`mcp-client-id.${body}`, signature);
287 if (!verified) return undefined;
288 const json = Option.liftThrowable(base64UrlDecodeUtf8)(body);
289 if (Option.isNone(json)) return undefined;
290 return Option.getOrUndefined(
291 decodeClientIdPayload(json.value).pipe(
292 Option.map((payload) => ({
293 clientId,
294 name: payload.n,
295 redirectUris: payload.r,
296 peerLink: payload.p === 1,
297 })),
298 ),
299 );
300 });
301
302 const pkceVerifies = (verifier: string, challenge: string) =>
303 CODE_VERIFIER_PATTERN.test(verifier)
304 ? digester.digest(Digest.sha256, new TextEncoder().encode(verifier)).pipe(
305 Effect.orDie,
306 Effect.map((digest) => timingSafeEqualBase64Url(base64UrlEncode(digest), challenge)),
307 )
308 : Effect.succeed(false);
309
310 const csrfToken = (holder: string, authorization: AuthorizationRequest) =>
311 sign(
312 "mcp-csrf",
313 JSON.stringify([
314 holder,
315 authorization.client.clientId,
316 authorization.redirectUri,
317 authorization.codeChallenge,
318 ]),
319 );
320
321 const register: McpOAuth["Service"]["register"] = Effect.fn("McpOAuth.register")(
322 function* (metadata) {
323 const rawName = metadata.client_name?.trim() ?? "";
324 const name = (rawName.length > 0 ? rawName : DEFAULT_CLIENT_NAME).slice(
325 0,
326 MAX_CLIENT_NAME_LENGTH,
327 );
328 const redirectUris = metadata.redirect_uris ?? [];
329 if (redirectUris.length === 0 || redirectUris.length > MAX_REDIRECT_URIS) {
330 return yield* new AuthMcpRegistrationError({
331 error: "invalid_redirect_uri",
332 error_description: `Register between 1 and ${MAX_REDIRECT_URIS} redirect URIs.`,
333 });
334 }
335 if (!redirectUris.every((uri) => parseRedirect(uri) !== undefined)) {
336 return yield* new AuthMcpRegistrationError({
337 error: "invalid_redirect_uri",
338 error_description:
339 "Redirect URIs must be https, or http on localhost, 127.0.0.1 or [::1].",
340 });
341 }
342 // RFC 7591 allows ignoring unsupported registration options; all clients use public PKCE.
343 const peerLink = metadata.software_id === MCP_PEER_LINK_SOFTWARE_ID;
344 if (peerLink && !(yield* linksEnabled)) {
345 return yield* new AuthMcpRegistrationError({
346 error: "invalid_client_metadata",
347 error_description:
348 "Linked environments is off on that machine. Turn it on in its Settings → Labs.",
349 });
350 }
351 return {
352 clientId: yield* signClientId(name, redirectUris, peerLink),
353 name,
354 redirectUris,
355 peerLink,
356 };
357 },
358 );
359
360 const validateAuthorization: McpOAuth["Service"]["validateAuthorization"] = Effect.fn(
361 "McpOAuth.validateAuthorization",
362 )(function* ({ urls, request }) {
363 const client = yield* parseClientId(request.client_id ?? "");
364 if (client === undefined) {
365 return yield* new McpOAuthPageError({
366 description: "This sign-in link names an unknown app. Start the sign-in again from it.",
367 });
368 }
369 const redirectUri = request.redirect_uri;
370 if (
371 redirectUri === undefined ||
372 !client.redirectUris.some((registered) => redirectMatches(registered, redirectUri))
373 ) {
374 return yield* new McpOAuthPageError({
375 description: "This sign-in link sends you to an address the app did not register.",
376 });
377 }
378 const state = request.state;
379 const fail = (error: McpOAuthRedirectError["error"], description: string) =>
380 new McpOAuthRedirectError({
381 error,
382 description,
383 redirectUri,
384 ...(state === undefined ? {} : { state }),
385 });
386 if (request.response_type !== "code") {
387 return yield* fail("unsupported_response_type", "Only response_type=code is supported.");
388 }
389 const codeChallenge = request.code_challenge;
390 if (
391 request.code_challenge_method !== "S256" ||
392 codeChallenge === undefined ||
393 !CODE_CHALLENGE_PATTERN.test(codeChallenge)
394 ) {
395 return yield* fail("invalid_request", "PKCE with code_challenge_method=S256 is required.");
396 }
397 if (!sameResource(urls, request.resource)) {
398 return yield* fail("invalid_target", `This server only issues tokens for ${urls.resource}.`);
399 }
400 return {
401 client,
402 redirectUri,
403 codeChallenge,
404 state,
405 resource: urls.resource,
406 issuer: urls.issuer,
407 } satisfies AuthorizationRequest;
408 });
409
410 const approvingSession: McpOAuth["Service"]["approvingSession"] = (request, authorization) =>
411 environmentAuth.authenticateHttpRequest(request).pipe(
412 Effect.flatMap((session) =>
413 // Approving manages access, and a session may only hand out scopes it holds.
414 session.scopes.includes(AuthAccessWriteScope) &&
415 session.scopes.includes(AuthOrchestrationReadScope)
416 ? // The hosted app may get a fresh credential per call, all bound to one browser key.
417 csrfToken(
418 session.proofKeyThumbprint === undefined
419 ? session.sessionId
420 : `dpop:${session.proofKeyThumbprint}`,
421 authorization,
422 ).pipe(Effect.map((token) => ({ csrfToken: token, scopes: session.scopes })))
423 : Effect.undefined,
424 ),
425 Effect.orElseSucceed(() => undefined),
426 );
427
428 const mintCode = Effect.fn("McpOAuth.mintCode")(function* (
429 authorization: AuthorizationRequest,
430 access: AuthMcpClientAccess,
431 ) {
432 const now = yield* Clock.currentTimeMillis;
433 const code = Buffer.from(yield* random.randomBytes(32)).toString("base64url");
434 yield* Ref.update(codes, (current) => {
435 const next = new Map(Array.from(current).filter(([, pending]) => pending.expiresAtMs > now));
436 next.set(code, {
437 clientId: authorization.client.clientId,
438 clientName: authorization.client.name,
439 peerLink: authorization.client.peerLink,
440 redirectUri: authorization.redirectUri,
441 codeChallenge: authorization.codeChallenge,
442 resource: authorization.resource,
443 access,
444 expiresAtMs: now + AUTHORIZATION_CODE_TTL_MS,
445 });
446 return next;
447 });
448 return code;
449 });
450
451 const approve: McpOAuth["Service"]["approve"] = Effect.fn("McpOAuth.approve")(function* (input) {
452 const { decision } = input;
453 let access = decision.access;
454 if (decision._tag === "pairing-code") {
455 // An approval code is for one app's sign-in, never for linking another environment.
456 if (input.authorization.client.peerLink && parseMcpApprovalCode(decision.code)) {
457 return yield* new McpOAuthPageError({
458 description: "Linking needs a plain pairing code, not an app approval code.",
459 });
460 }
461 access = yield* environmentAuth.consumeMcpApprovalCode(decision.code, decision.access).pipe(
462 Effect.catchIf(EnvironmentAuth.isServerAuthInternalError, (cause) =>
463 Effect.logError("MCP approval code check failed.", { cause }).pipe(
464 Effect.andThen(
465 Effect.fail(
466 new McpOAuthPageError({
467 description: "The code could not be checked. Try again.",
468 }),
469 ),
470 ),
471 ),
472 ),
473 );
474 } else {
475 const session = yield* approvingSession(input.request, input.authorization);
476 if (
477 session === undefined ||
478 !timingSafeEqualBase64Url(decision.csrfToken, session.csrfToken) ||
479 !mcpClientScopes(decision.access).every((scope) => session.scopes.includes(scope))
480 ) {
481 return yield* new McpOAuthPageError({
482 description: "Your session cannot approve this request. Enter a pairing code instead.",
483 });
484 }
485 }
486 const code = yield* mintCode(input.authorization, access);
487 yield* Effect.logInfo("Approved an MCP client sign-in.", {
488 client: input.authorization.client.name,
489 access,
490 method: decision._tag,
491 });
492 return redirectWith(input.authorization.redirectUri, {
493 code,
494 state: input.authorization.state,
495 iss: input.authorization.issuer,
496 });
497 });
498
499 const deny: McpOAuth["Service"]["deny"] = (authorization) =>
500 redirectWith(authorization.redirectUri, {
501 error: "access_denied",
502 state: authorization.state,
503 iss: authorization.issuer,
504 });
505
506 const exchangeCode: McpOAuth["Service"]["exchangeCode"] = Effect.fn("McpOAuth.exchangeCode")(
507 function* ({ request, urls, token }) {
508 const fail = (error: AuthMcpTokenError["error"], description: string) =>
509 new AuthMcpTokenError({ error, error_description: description });
510 if (token.grant_type !== "authorization_code") {
511 return yield* fail("unsupported_grant_type", "Only authorization_code is supported.");
512 }
513 const { redirect_uri: redirectUri, client_id: clientId } = token;
514 const code = token.code && Redacted.value(token.code);
515 const verifier = token.code_verifier && Redacted.value(token.code_verifier);
516 if (!code || !redirectUri || !clientId || !verifier) {
517 return yield* fail(
518 "invalid_request",
519 "code, redirect_uri, client_id and code_verifier are required.",
520 );
521 }
522 if ((yield* parseClientId(clientId)) === undefined) {
523 return yield* fail("invalid_client", "The client is unknown.");
524 }
525 // A presented code is spent even when a later check fails (RFC 6749 §4.1.2).
526 const pending = yield* Ref.modify(codes, (current) => {
527 const found = current.get(code);
528 if (found === undefined) return [undefined, current] as const;
529 const next = new Map(current);
530 next.delete(code);
531 return [found, next] as const;
532 });
533 const now = yield* Clock.currentTimeMillis;
534 if (
535 pending === undefined ||
536 pending.expiresAtMs <= now ||
537 pending.clientId !== clientId ||
538 pending.redirectUri !== redirectUri ||
539 pending.resource !== urls.resource ||
540 !sameResource(urls, token.resource) ||
541 !(yield* pkceVerifies(verifier, pending.codeChallenge))
542 ) {
543 return yield* fail("invalid_grant", "The authorization code is invalid or expired.");
544 }
545 const issued = yield* environmentAuth
546 .issueMcpClientSession({
547 label: pending.clientName,
548 access: pending.access,
549 peerLink: pending.peerLink,
550 client: deriveAuthClientMetadata({ request }),
551 })
552 .pipe(
553 Effect.catch((cause) =>
554 Effect.logError("Could not issue an MCP client session.", { cause }).pipe(
555 Effect.andThen(
556 Effect.fail(fail("invalid_grant", "The session could not be issued.")),
557 ),
558 ),
559 ),
560 );
561 return {
562 access_token: issued.token,
563 token_type: "Bearer",
564 expires_in: Math.max(0, Math.floor((issued.expiresAt.epochMilliseconds - now) / 1000)),
565 scope: encodeOAuthScope(mcpClientScopes(pending.access)),
566 };
567 },
568 );
569
570 const managedPolicy = serverSettings.managedPolicy;
571 const maxRuntimeMode = managedPolicy?.limits?.maxRuntimeMode;
572 return McpOAuth.of({
573 runtimeModeCeiling:
574 managedPolicy === undefined || maxRuntimeMode === undefined
575 ? undefined
576 : { organization: managedPolicy.organization, maxRuntimeMode },
577 register,
578 validateAuthorization,
579 approvingSession,
580 approve,
581 deny,
582 exchangeCode,
583 });
584});
585
586export const layer = Layer.effect(McpOAuth, make);
587
588export const layerMcpClientAuthenticator = Layer.effect(
589 McpHttpServer.McpClientAuthenticator,
590 Effect.gen(function* () {
591 const environmentAuth = yield* EnvironmentAuth.EnvironmentAuth;
592 const environment = yield* ServerEnvironment.ServerEnvironment;
593 const environmentId = yield* environment.getEnvironmentId;
594 const hostedAppUrl = yield* hostedAppUrlConfig.pipe(
595 Effect.catch((cause) =>
596 Effect.logWarning("Outside MCP clients get app.t3.codes thread links", { cause }).pipe(
597 Effect.as(DEFAULT_HOSTED_APP_URL),
598 ),
599 ),
600 );
601 return McpHttpServer.McpClientAuthenticator.of({
602 authenticate: Effect.fn("McpHttpServer.McpClientAuthenticator.authenticate")(
603 function (request) {
604 return environmentAuth.authenticateMcpClient(request).pipe(
605 Effect.flatMap((client) =>
606 Clock.currentTimeMillis.pipe(
607 Effect.map((issuedAt): McpInvocationContext.McpInvocationScope => ({
608 environmentId,
609 requestNamespace: `client:${client.sessionId}`,
610 thread: undefined,
611 client: {
612 sessionId: client.sessionId,
613 label: client.label,
614 access: client.access,
615 linked: client.peerLink,
616 ...(client.peerLink ? {} : { hostedAppUrl }),
617 },
618 capabilities: new Set<McpInvocationContext.McpCapability>([
619 "orchestration",
620 "worktree",
621 "pull-requests",
622 ]),
623 issuedAt,
624 })),
625 ),
626 ),
627 Effect.catch((error) =>
628 (EnvironmentAuth.isServerAuthCredentialError(error)
629 ? Effect.void
630 : Effect.logWarning("MCP client authentication failed.", { cause: error })
631 ).pipe(Effect.as(undefined)),
632 ),
633 );
634 },
635 ),
636 });
637 }),
638);