11import {
22 type EnvironmentId ,
33 McpCapabilityUnavailableError ,
4+ OrchestratorMcpFailure ,
45 PreviewAutomationUnavailableError ,
56 type ProviderInstanceId ,
7+ type RuntimeMode ,
68 type ThreadId ,
79} from "@t3tools/contracts" ;
810import * as Context from "effect/Context" ;
@@ -17,13 +19,35 @@ const ALL_MCP_CAPABILITIES = [
1719] as const ;
1820export type McpCapability = ( typeof ALL_MCP_CAPABILITIES ) [ number ] ;
1921
20- export interface McpInvocationScope {
21- readonly environmentId : EnvironmentId ;
22+ /** A provider session T3 Code launched for one thread. */
23+ export interface McpThreadCaller {
2224 readonly threadId : ThreadId ;
2325 readonly providerSessionId : string ;
2426 readonly providerInstanceId : ProviderInstanceId ;
27+ }
28+
29+ /** An agent T3 Code did not launch, signed in through MCP OAuth. */
30+ export interface McpClientCaller {
31+ readonly sessionId : string ;
32+ readonly label : string ;
33+ readonly runtimeModeCeiling : RuntimeMode ;
34+ }
35+
36+ /**
37+ * Who is calling and what they may do. Tool parameters choose the target
38+ * (thread, project); the caller sets the limits. A thread caller's omitted
39+ * target falls back to its own thread; a client caller has no own thread, so
40+ * tools that act as the caller (delegate_task, preview, worktree handoff)
41+ * need `thread`.
42+ */
43+ export interface McpInvocationScope {
44+ readonly environmentId : EnvironmentId ;
2545 readonly capabilities : ReadonlySet < McpCapability > ;
2646 readonly issuedAt : number ;
47+ /** Namespaces idempotency keys so two callers reusing a clientRequestId cannot collide. */
48+ readonly requestNamespace : string ;
49+ readonly thread : McpThreadCaller | undefined ;
50+ readonly client : McpClientCaller | undefined ;
2751}
2852
2953export class McpInvocationContext extends Context . Service <
@@ -42,9 +66,13 @@ const missingCapability = (
4266) : PreviewAutomationUnavailableError | McpCapabilityUnavailableError => {
4367 const fields = {
4468 environmentId : invocation . environmentId ,
45- threadId : invocation . threadId ,
46- providerSessionId : invocation . providerSessionId ,
47- providerInstanceId : invocation . providerInstanceId ,
69+ ...( invocation . thread === undefined
70+ ? { }
71+ : {
72+ threadId : invocation . thread . threadId ,
73+ providerSessionId : invocation . thread . providerSessionId ,
74+ providerInstanceId : invocation . thread . providerInstanceId ,
75+ } ) ,
4876 } ;
4977 return capability === "preview"
5078 ? new PreviewAutomationUnavailableError ( { capability, ...fields } )
@@ -62,3 +90,40 @@ export const requireMcpCapability = <const C extends McpCapability>(
6290 ) ,
6391 Effect . withSpan ( "mcp.requireCapability" ) ,
6492 ) ;
93+
94+ /**
95+ * Preview tabs and device sessions belong to the calling thread, so their
96+ * capabilities are only ever granted to thread callers. A scope that carries
97+ * one without a thread is refused the same way as a missing capability.
98+ */
99+ export const requireThreadMcpCapability = < const C extends "preview" | "device" > (
100+ capability : C ,
101+ ) : Effect . Effect < McpThreadInvocationScope , McpCapabilityError < C > , McpInvocationContext > =>
102+ McpInvocationContext . pipe (
103+ Effect . filterOrFail (
104+ ( invocation ) : invocation is McpThreadInvocationScope =>
105+ invocation . capabilities . has ( capability ) && invocation . thread !== undefined ,
106+ ( invocation ) => missingCapability ( invocation , capability ) as McpCapabilityError < C > ,
107+ ) ,
108+ Effect . withSpan ( "mcp.requireCapability" ) ,
109+ ) ;
110+
111+ export const threadCallerRequired = ( operation : string ) =>
112+ new OrchestratorMcpFailure ( {
113+ code : "thread_credential_required" ,
114+ 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.` ,
115+ } ) ;
116+
117+ /** The caller's own thread, for tools that act as the caller rather than on a target. */
118+ export const requireThreadCaller = ( scope : McpInvocationScope , operation : string ) =>
119+ scope . thread === undefined
120+ ? Effect . fail ( threadCallerRequired ( operation ) )
121+ : Effect . succeed ( scope . thread ) ;
122+
123+ /** A scope with a thread caller, for tools whose whole surface acts as the caller. */
124+ export type McpThreadInvocationScope = McpInvocationScope & { readonly thread : McpThreadCaller } ;
125+
126+ export const requireThreadScope = ( scope : McpInvocationScope , operation : string ) =>
127+ scope . thread === undefined
128+ ? Effect . fail ( threadCallerRequired ( operation ) )
129+ : Effect . succeed ( scope as McpThreadInvocationScope ) ;
0 commit comments