1/**
2 * The hosted app's side of an agent sign-in on a T3 Connect environment: it reads the request
3 * and sends the decision with the owner's own Connect session there, so no code is needed.
4 */
5import type {
6 AuthMcpApprovalDecisionRequest,
7 AuthMcpApprovalDetails,
8 AuthMcpAuthorizationRequest,
9 EnvironmentId,
10} from "@t3tools/contracts";
11import * as Effect from "effect/Effect";
12import * as Option from "effect/Option";
13import type * as HttpClient from "effect/http/HttpClient";
14import type { Atom } from "effect/reactivity";
15import * as Schema from "effect/Schema";
16
17import * as RemoteEnvironmentAuthorization from "../authorization/service.ts";
18import { RelayConnectionTarget } from "../connection/model.ts";
19import * as ManagedRelay from "../relay/managedRelay.ts";
20import { executeAuthenticatedEnvironmentHttpRequest } from "./environmentHttpAuth.ts";
21import { createRuntimeCommand } from "./runtime.ts";
22
23const REQUEST_TIMEOUT_MS = 10_000;
24
25/** The environment cannot be reached through T3 Connect with this account. */
26export class ConnectMcpApprovalUnavailableError extends Schema.TaggedError<ConnectMcpApprovalUnavailableError>()(
27 "ConnectMcpApprovalUnavailableError",
28 { cause: Schema.Defect() },
29) {
30 override get message(): string {
31 return "Could not reach that environment through T3 Connect with this account.";
32 }
33}
34
35export type ConnectMcpApprovalAnswer =
36 | { readonly kind: "details"; readonly details: AuthMcpApprovalDetails }
37 | { readonly kind: "redirect"; readonly url: string }
38 /** The environment refused the request, with a message for the user. */
39 | { readonly kind: "refused"; readonly message: string };
40
41export type ConnectMcpApprovalCall =
42 | { readonly kind: "approval"; readonly payload: AuthMcpAuthorizationRequest }
43 | { readonly kind: "decision"; readonly payload: AuthMcpApprovalDecisionRequest };
44
45const answerFrom = (
46 value: AuthMcpApprovalDetails | { readonly redirectTo: string },
47): ConnectMcpApprovalAnswer =>
48 "redirectTo" in value
49 ? { kind: "redirect", url: value.redirectTo }
50 : { kind: "details", details: value };
51
52const refused = (error: { readonly message: string }) =>
53 Effect.succeed<ConnectMcpApprovalAnswer>({ kind: "refused", message: error.message });
54
55/**
56 * Sends one approval call to `environmentId` with this account's T3 Connect session. Its
57 * address comes from the relay, which only grants access to the environment's owner.
58 */
59const callConnectMcpApproval = Effect.fn("clientRuntime.state.connectMcpApproval")(
60 function* (input: {
61 readonly environmentId: EnvironmentId;
62 readonly call: ConnectMcpApprovalCall;
63 }) {
64 const remote = yield* RemoteEnvironmentAuthorization.RemoteEnvironmentAuthorization;
65 const authorized = yield* remote.authorizeDpopHttp({
66 expectedEnvironmentId: input.environmentId,
67 });
68 const common = {
69 prepared: {
70 environmentId: input.environmentId,
71 httpBaseUrl: authorized.httpBaseUrl,
72 httpAuthorization: authorized.httpAuthorization,
73 target: new RelayConnectionTarget({
74 environmentId: input.environmentId,
75 label: authorized.label,
76 }),
77 },
78 signer: yield* Effect.serviceOption(ManagedRelay.ManagedRelayDpopSigner),
79 // A token for an unsaved environment is not cached, so reuse this one until it is rejected.
80 remoteAuthorization: Option.some(
81 RemoteEnvironmentAuthorization.RemoteEnvironmentAuthorization.of({
82 ...remote,
83 // Untraced: the relay authorization it may call owns the span.
84 authorizeDpopHttp: Effect.fnUntraced(function* (request) {
85 if (request.rejectedAccessToken === undefined) return authorized;
86 return yield* remote.authorizeDpopHttp(request);
87 }),
88 }),
89 ),
90 group: "mcpOAuth",
91 method: "POST",
92 timeoutMs: REQUEST_TIMEOUT_MS,
93 } as const;
94 const { call } = input;
95 const answer =
96 call.kind === "approval"
97 ? yield* executeAuthenticatedEnvironmentHttpRequest({
98 ...common,
99 url: (urls) => urls.approval(),
100 request: ({ client, headers }) =>
101 client
102 .approval({ headers, payload: call.payload })
103 .pipe(Effect.map(answerFrom), Effect.catchTags({ AuthMcpApprovalError: refused })),
104 // The owner's session can always approve; no CSRF token means a stale credential.
105 isUnauthorizedResponse: (answer) =>
106 answer.kind === "details" && answer.details.csrfToken === undefined,
107 })
108 : yield* executeAuthenticatedEnvironmentHttpRequest({
109 ...common,
110 url: (urls) => urls.decision(),
111 request: ({ client, headers }) =>
112 client
113 .decision({ headers, payload: call.payload })
114 .pipe(Effect.map(answerFrom), Effect.catchTags({ AuthMcpApprovalError: refused })),
115 });
116 return { answer, environmentUrl: authorized.httpBaseUrl, environmentLabel: authorized.label };
117 },
118 Effect.mapError((cause) => new ConnectMcpApprovalUnavailableError({ cause })),
119);
120
121export function createConnectMcpApprovalCommand<R, E>(
122 runtime: Atom.AtomRuntime<
123 RemoteEnvironmentAuthorization.RemoteEnvironmentAuthorization | HttpClient.HttpClient | R,
124 E
125 >,
126) {
127 return createRuntimeCommand(runtime, {
128 label: "environment-data:connect-mcp-approval",
129 execute: callConnectMcpApproval,
130 });
131}