1import {
2 type AuthMcpClientAccess,
3 type EnvironmentId,
4 McpCapabilityUnavailableError,
5 OrchestratorMcpFailure,
6 PreviewAutomationUnavailableError,
7 type ProviderInstanceId,
8 type ProviderInteractionMode,
9 type OrchestrationV2LinkedSender,
10 type RuntimeMode,
11 type ThreadId,
12} from "@t3tools/contracts";
13import * as Context from "effect/Context";
14import * as Effect from "effect/Effect";
15import * as Fiber from "effect/Fiber";
16import { formatThreadLink, hostedThreadLink } from "@t3tools/shared/threadLinks";
17
18const ALL_MCP_CAPABILITIES = [
19 "preview",
20 "orchestration",
21 "worktree",
22 "device",
23 "pull-requests",
24] as const;
25export type McpCapability = (typeof ALL_MCP_CAPABILITIES)[number];
26
27export interface McpThreadCaller {
28 readonly threadId: ThreadId;
29 readonly providerSessionId: string;
30 readonly providerInstanceId: ProviderInstanceId;
31 /** A delegated child task; its parent owns the work it hands back. */
32 readonly subagent?: boolean;
33}
34
35export interface McpClientCaller {
36 readonly sessionId: string;
37 readonly label: string;
38 readonly access: AuthMcpClientAccess;
39 /**
40 * Another T3 Code environment's peer link. What it starts here carries its
41 * session as `linkOrigin`, and it changes only that work (`linkOrigin.ts`).
42 */
43 readonly linked?: boolean | undefined;
44 /**
45 * Narrower modes the client asked for on this request (`T3-Mode-Limit`):
46 * another environment passing on the limits of the agent it calls for.
47 */
48 readonly narrowedTo?: ClientModes | undefined;
49 /** A linked environment's thread this request is made for (`T3-Sender-Thread`). */
50 readonly senderThread?: OrchestrationV2LinkedSender | undefined;
51 /** An outside client's thread links go through this hosted app, as Copy thread's do. */
52 readonly hostedAppUrl?: string | undefined;
53}
54
55/**
56 * `T3-Mode-Limit: <runtimeMode>/<interactionMode>` lets an OAuth client cap
57 * a request below what it was approved with: another environment passes on
58 * the limits of the agent it calls for. It can only narrow (see
59 * `clientModeCeiling`), so a client gains nothing by sending a broad one.
60 */
61export const MODE_LIMIT_HEADER = "t3-mode-limit";
62
63/**
64 * `T3-Sender-Thread: <environmentId>/<threadId>/<title>`, each URI-encoded: the
65 * thread another environment makes a request for, so what it sends here can name
66 * it. Read only from a linked client.
67 */
68export const SENDER_THREAD_HEADER = "t3-sender-thread";
69
70export interface ClientModes {
71 readonly runtimeMode: RuntimeMode;
72 readonly interactionMode: ProviderInteractionMode;
73}
74
75const runtimeModeRank: Record<RuntimeMode, number> = {
76 "approval-required": 0,
77 "auto-accept-edits": 1,
78 auto: 2,
79 "full-access": 3,
80};
81const interactionModeRank: Record<ProviderInteractionMode, number> = { plan: 0, default: 1 };
82
83/**
84 * The modes a client caller's writes are capped at: what it was approved
85 * with, narrowed by what it asked for on this request. A read-only client
86 * never reaches a write (`McpToolAccess` refuses it first), so it maps to the
87 * lowest runtime mode rather than to nothing.
88 */
89export const clientModeCeiling = (client: McpClientCaller | undefined): ClientModes => {
90 const approved: ClientModes = {
91 runtimeMode:
92 client === undefined || client.access === "read-only" ? "approval-required" : client.access,
93 interactionMode: "default",
94 };
95 const asked = client?.narrowedTo;
96 if (asked === undefined) return approved;
97 return {
98 runtimeMode:
99 runtimeModeRank[asked.runtimeMode] < runtimeModeRank[approved.runtimeMode]
100 ? asked.runtimeMode
101 : approved.runtimeMode,
102 interactionMode:
103 interactionModeRank[asked.interactionMode] < interactionModeRank[approved.interactionMode]
104 ? asked.interactionMode
105 : approved.interactionMode,
106 };
107};
108
109/**
110 * A client approved above an administrator's runtime-mode ceiling acts as if it had narrowed
111 * itself to the ceiling, so what it starts and schedules is not refused for a mode it never chose.
112 */
113export const withRuntimeModeCeiling = (
114 scope: McpInvocationScope,
115 ceiling: RuntimeMode | undefined,
116): McpInvocationScope => {
117 if (ceiling === undefined || scope.client === undefined) return scope;
118 const asked = scope.client.narrowedTo;
119 return {
120 ...scope,
121 client: {
122 ...scope.client,
123 narrowedTo: {
124 runtimeMode:
125 asked !== undefined && runtimeModeRank[asked.runtimeMode] < runtimeModeRank[ceiling]
126 ? asked.runtimeMode
127 : ceiling,
128 interactionMode: asked?.interactionMode ?? "default",
129 },
130 },
131 };
132};
133
134/** Thread callers default to their own thread; client callers need thread for tools that act as the caller. */
135export interface McpInvocationScope {
136 readonly environmentId: EnvironmentId;
137 readonly capabilities: ReadonlySet<McpCapability>;
138 readonly issuedAt: number;
139 readonly requestNamespace: string;
140 readonly thread: McpThreadCaller | undefined;
141 readonly client: McpClientCaller | undefined;
142 /** False while the Linked environments beta is off here; `/mcp` reads it per request. */
143 readonly linkedEnvironments?: boolean;
144}
145
146/**
147 * T3 Code renders `t3-thread://` links as thread chips. Other MCP clients cannot open them, so
148 * they get the hosted link Copy thread copies, which opens the thread in either app or a browser.
149 * A known `title` labels the chip for clients that cannot load this environment's threads.
150 */
151export const threadLinkFor = (
152 scope: Pick<McpInvocationScope, "environmentId" | "client">,
153 threadId: ThreadId,
154 title?: string,
155): string => {
156 const hostedAppUrl = scope.client?.hostedAppUrl;
157 const ref = { environmentId: scope.environmentId, threadId };
158 return hostedAppUrl === undefined
159 ? formatThreadLink(ref, title)
160 : hostedThreadLink(ref, hostedAppUrl);
161};
162
163export class McpInvocationContext extends Context.Service<
164 McpInvocationContext,
165 McpInvocationScope
166>()("t3/mcp/McpInvocationContext") {}
167
168/**
169 * `McpSchema.EnabledWhen` for tools a delegated child must not see or call.
170 * EnabledWhen only receives the MCP client profile, so read the T3 caller from the request fiber.
171 */
172export const hiddenFromSubagents = (): boolean => {
173 const fiber = Fiber.getCurrent();
174 const scope = fiber && Context.getOrUndefined(fiber.context, McpInvocationContext);
175 return scope?.thread?.subagent !== true;
176};
177
178/** `McpSchema.EnabledWhen` for tools that only serve links, hidden while the beta is off. */
179export const hiddenWhileLinksOff = (): boolean => {
180 const fiber = Fiber.getCurrent();
181 const scope = fiber && Context.getOrUndefined(fiber.context, McpInvocationContext);
182 return scope?.linkedEnvironments !== false;
183};
184
185export type McpCapabilityError<C extends McpCapability> = C extends "preview"
186 ? PreviewAutomationUnavailableError
187 : McpCapabilityUnavailableError;
188
189const missingCapability = (
190 invocation: McpInvocationScope,
191 capability: McpCapability,
192): PreviewAutomationUnavailableError | McpCapabilityUnavailableError => {
193 const fields = {
194 environmentId: invocation.environmentId,
195 ...(invocation.thread === undefined
196 ? {}
197 : {
198 threadId: invocation.thread.threadId,
199 providerSessionId: invocation.thread.providerSessionId,
200 providerInstanceId: invocation.thread.providerInstanceId,
201 }),
202 };
203 return capability === "preview"
204 ? new PreviewAutomationUnavailableError({ capability, ...fields })
205 : new McpCapabilityUnavailableError({ capability, ...fields });
206};
207
208export const requireMcpCapability = <const C extends McpCapability>(
209 capability: C,
210): Effect.Effect<McpInvocationScope, McpCapabilityError<C>, McpInvocationContext> =>
211 McpInvocationContext.pipe(
212 Effect.filterOrFail(
213 (invocation) => invocation.capabilities.has(capability),
214 (invocation) => missingCapability(invocation, capability) as McpCapabilityError<C>,
215 ),
216 Effect.withSpan("mcp.requireCapability"),
217 );
218
219export const requireThreadMcpCapability = <const C extends "preview" | "device">(
220 capability: C,
221): Effect.Effect<McpThreadInvocationScope, McpCapabilityError<C>, McpInvocationContext> =>
222 McpInvocationContext.pipe(
223 Effect.filterOrFail(
224 (invocation): invocation is McpThreadInvocationScope =>
225 invocation.capabilities.has(capability) && invocation.thread !== undefined,
226 (invocation) => missingCapability(invocation, capability) as McpCapabilityError<C>,
227 ),
228 Effect.withSpan("mcp.requireCapability"),
229 );
230
231const threadCallerRequired = (operation: string) =>
232 new OrchestratorMcpFailure({
233 code: "thread_credential_required",
234 message: `${operation} acts as the calling T3 thread, so it needs an agent running inside T3 Code. This MCP client signed in from outside a thread.`,
235 });
236
237export type McpThreadInvocationScope = McpInvocationScope & { readonly thread: McpThreadCaller };
238
239export const requireThreadScope = (scope: McpInvocationScope, operation: string) =>
240 scope.thread === undefined
241 ? Effect.fail(threadCallerRequired(operation))
242 : Effect.succeed(scope as McpThreadInvocationScope);