1import {
2 type AuthMcpClientAccess,
3 type EnvironmentId,
4 McpCapabilityUnavailableError,
5 OrchestratorMcpFailure,
6 PreviewAutomationUnavailableError,
7 type ProviderInstanceId,
8 type RuntimeMode,
9 type ThreadId,
10} from "@t3tools/contracts";
11import * as Context from "effect/Context";
12import * as Effect from "effect/Effect";
13
14const ALL_MCP_CAPABILITIES = [
15 "preview",
16 "orchestration",
17 "worktree",
18 "device",
19 "pull-requests",
20] as const;
21export type McpCapability = (typeof ALL_MCP_CAPABILITIES)[number];
22
23/** A provider session T3 Code launched for one thread. */
24export interface McpThreadCaller {
25 readonly threadId: ThreadId;
26 readonly providerSessionId: string;
27 readonly providerInstanceId: ProviderInstanceId;
28}
29
30/** An agent T3 Code did not launch, signed in through MCP OAuth. */
31export interface McpClientCaller {
32 readonly sessionId: string;
33 readonly label: string;
34 /** Read only, or the most the threads it starts or changes may run with. */
35 readonly access: AuthMcpClientAccess;
36}
37
38/**
39 * The runtime mode a client caller's writes are capped at. A read-only client
40 * never reaches a write (`McpToolAccess` refuses it first), so it maps to the
41 * lowest mode rather than to nothing.
42 */
43export const clientRuntimeModeCeiling = (client: McpClientCaller | undefined): RuntimeMode =>
44 client === undefined || client.access === "read-only" ? "approval-required" : client.access;
45
46/**
47 * Who is calling and what they may do. Tool parameters choose the target
48 * (thread, project); the caller sets the limits. A thread caller's omitted
49 * target falls back to its own thread; a client caller has no own thread, so
50 * tools that act as the caller (delegate_task, preview, worktree handoff)
51 * need `thread`.
52 */
53export interface McpInvocationScope {
54 readonly environmentId: EnvironmentId;
55 readonly capabilities: ReadonlySet<McpCapability>;
56 readonly issuedAt: number;
57 /** Namespaces idempotency keys so two callers reusing a clientRequestId cannot collide. */
58 readonly requestNamespace: string;
59 readonly thread: McpThreadCaller | undefined;
60 readonly client: McpClientCaller | undefined;
61}
62
63export class McpInvocationContext extends Context.Service<
64 McpInvocationContext,
65 McpInvocationScope
66>()("t3/mcp/McpInvocationContext") {}
67
68/** The error a missing capability surfaces as; preview keeps its own so the broker can route it. */
69export type McpCapabilityError<C extends McpCapability> = C extends "preview"
70 ? PreviewAutomationUnavailableError
71 : McpCapabilityUnavailableError;
72
73const missingCapability = (
74 invocation: McpInvocationScope,
75 capability: McpCapability,
76): PreviewAutomationUnavailableError | McpCapabilityUnavailableError => {
77 const fields = {
78 environmentId: invocation.environmentId,
79 ...(invocation.thread === undefined
80 ? {}
81 : {
82 threadId: invocation.thread.threadId,
83 providerSessionId: invocation.thread.providerSessionId,
84 providerInstanceId: invocation.thread.providerInstanceId,
85 }),
86 };
87 return capability === "preview"
88 ? new PreviewAutomationUnavailableError({ capability, ...fields })
89 : new McpCapabilityUnavailableError({ capability, ...fields });
90};
91
92export const requireMcpCapability = <const C extends McpCapability>(
93 capability: C,
94): Effect.Effect<McpInvocationScope, McpCapabilityError<C>, McpInvocationContext> =>
95 McpInvocationContext.pipe(
96 Effect.filterOrFail(
97 (invocation) => invocation.capabilities.has(capability),
98 // The conditional type narrows what the literal argument decided at runtime.
99 (invocation) => missingCapability(invocation, capability) as McpCapabilityError<C>,
100 ),
101 Effect.withSpan("mcp.requireCapability"),
102 );
103
104/**
105 * Preview tabs and device sessions belong to the calling thread, so their
106 * capabilities are only ever granted to thread callers. A scope that carries
107 * one without a thread is refused the same way as a missing capability.
108 */
109export const requireThreadMcpCapability = <const C extends "preview" | "device">(
110 capability: C,
111): Effect.Effect<McpThreadInvocationScope, McpCapabilityError<C>, McpInvocationContext> =>
112 McpInvocationContext.pipe(
113 Effect.filterOrFail(
114 (invocation): invocation is McpThreadInvocationScope =>
115 invocation.capabilities.has(capability) && invocation.thread !== undefined,
116 (invocation) => missingCapability(invocation, capability) as McpCapabilityError<C>,
117 ),
118 Effect.withSpan("mcp.requireCapability"),
119 );
120
121const threadCallerRequired = (operation: string) =>
122 new OrchestratorMcpFailure({
123 code: "thread_credential_required",
124 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.`,
125 });
126
127/** A scope with a thread caller, for tools whose whole surface acts as the caller. */
128export type McpThreadInvocationScope = McpInvocationScope & { readonly thread: McpThreadCaller };
129
130export const requireThreadScope = (scope: McpInvocationScope, operation: string) =>
131 scope.thread === undefined
132 ? Effect.fail(threadCallerRequired(operation))
133 : Effect.succeed(scope as McpThreadInvocationScope);