Skip to content

Commit 333d9d7

Browse files
feat(server): T3 MCP tools take explicit thread and project targets
MCP tools now accept optional threadId/projectId targets that fall back to the calling thread, and resolve them anywhere in the environment instead of only the caller's project. The credential still sets the limits: a thread caller is capped by its own runtime and interaction modes and needs a live run to mutate. A caller without a thread (prepared for MCP OAuth clients) must name its targets, is capped by a runtime-mode ceiling, and gets thread_credential_required from tools that act as the calling thread. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
1 parent ce90eec commit 333d9d7

43 files changed

Lines changed: 1235 additions & 519 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎apps/server/src/mcp/McpDeviceToolkit.test.ts‎

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,9 +19,13 @@ const environmentId = EnvironmentId.make("environment-device-test");
1919
const threadId = ThreadId.make("thread-device-test");
2020
const invocation = (capabilities: ReadonlyArray<McpInvocationContext.McpCapability>) => ({
2121
environmentId,
22-
threadId,
23-
providerSessionId: "provider-session-device-test",
24-
providerInstanceId: ProviderInstanceId.make("codex"),
22+
requestNamespace: "provider-session-device-test",
23+
thread: {
24+
threadId,
25+
providerSessionId: "provider-session-device-test",
26+
providerInstanceId: ProviderInstanceId.make("codex"),
27+
},
28+
client: undefined,
2529
capabilities: new Set(capabilities),
2630
issuedAt: 1,
2731
});

‎apps/server/src/mcp/McpHttpServer.test.ts‎

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -27,9 +27,13 @@ const alternateTabId = PreviewTabId.make("tab-mcp-alternate");
2727
const decodeJsonText = Schema.decodeUnknownSync(Schema.fromJsonString(Schema.Unknown));
2828
const invocation = {
2929
environmentId,
30-
threadId,
31-
providerSessionId: "provider-session-mcp-test",
32-
providerInstanceId: ProviderInstanceId.make("codex"),
30+
requestNamespace: "provider-session-mcp-test",
31+
thread: {
32+
threadId,
33+
providerSessionId: "provider-session-mcp-test",
34+
providerInstanceId: ProviderInstanceId.make("codex"),
35+
},
36+
client: undefined,
3337
capabilities: new Set(["preview"] as const),
3438
issuedAt: 1,
3539
};

‎apps/server/src/mcp/McpInvocationContext.test.ts‎

Lines changed: 42 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,13 @@ import * as McpInvocationContext from "./McpInvocationContext.ts";
1313
it.effect("reports the scoped credential context when preview capability is unavailable", () => {
1414
const invocation: McpInvocationContext.McpInvocationScope = {
1515
environmentId: EnvironmentId.make("environment-1"),
16-
threadId: ThreadId.make("thread-1"),
17-
providerSessionId: "provider-session-1",
18-
providerInstanceId: ProviderInstanceId.make("codex"),
16+
requestNamespace: "provider-session-1",
17+
thread: {
18+
threadId: ThreadId.make("thread-1"),
19+
providerSessionId: "provider-session-1",
20+
providerInstanceId: ProviderInstanceId.make("codex"),
21+
},
22+
client: undefined,
1923
capabilities: new Set(),
2024
issuedAt: 1,
2125
};
@@ -30,9 +34,9 @@ it.effect("reports the scoped credential context when preview capability is unav
3034
expect(error).toMatchObject({
3135
capability: "preview",
3236
environmentId: invocation.environmentId,
33-
threadId: invocation.threadId,
34-
providerSessionId: invocation.providerSessionId,
35-
providerInstanceId: invocation.providerInstanceId,
37+
threadId: invocation.thread?.threadId,
38+
providerSessionId: invocation.thread?.providerSessionId,
39+
providerInstanceId: invocation.thread?.providerInstanceId,
3640
});
3741
expect(error.message).toContain("MCP credential does not grant the preview capability");
3842
expect(error.message).toContain("use a headless browser from the shell");
@@ -42,9 +46,13 @@ it.effect("reports the scoped credential context when preview capability is unav
4246
it.effect("reports other missing capabilities with the neutral error", () => {
4347
const invocation: McpInvocationContext.McpInvocationScope = {
4448
environmentId: EnvironmentId.make("environment-1"),
45-
threadId: ThreadId.make("thread-1"),
46-
providerSessionId: "provider-session-1",
47-
providerInstanceId: ProviderInstanceId.make("codex"),
49+
requestNamespace: "provider-session-1",
50+
thread: {
51+
threadId: ThreadId.make("thread-1"),
52+
providerSessionId: "provider-session-1",
53+
providerInstanceId: ProviderInstanceId.make("codex"),
54+
},
55+
client: undefined,
4856
capabilities: new Set(["preview"]),
4957
issuedAt: 1,
5058
};
@@ -56,11 +64,35 @@ it.effect("reports other missing capabilities with the neutral error", () => {
5664
);
5765

5866
expect(error).toBeInstanceOf(McpCapabilityUnavailableError);
59-
expect(error).toMatchObject({ capability: "pull-requests", threadId: invocation.threadId });
67+
expect(error).toMatchObject({
68+
capability: "pull-requests",
69+
threadId: invocation.thread?.threadId,
70+
});
6071

6172
const scope = yield* McpInvocationContext.requireMcpCapability("preview").pipe(
6273
Effect.provideService(McpInvocationContext.McpInvocationContext, invocation),
6374
);
6475
expect(scope).toBe(invocation);
6576
});
6677
});
78+
79+
it.effect("refuses thread-owned capabilities to a caller signed in from outside a thread", () => {
80+
const invocation: McpInvocationContext.McpInvocationScope = {
81+
environmentId: EnvironmentId.make("environment-1"),
82+
requestNamespace: "client:session-1",
83+
thread: undefined,
84+
client: { sessionId: "session-1", label: "Claude Code", runtimeModeCeiling: "auto" },
85+
capabilities: new Set(["preview", "orchestration"]),
86+
issuedAt: 1,
87+
};
88+
89+
return Effect.gen(function* () {
90+
const error = yield* McpInvocationContext.requireThreadMcpCapability("preview").pipe(
91+
Effect.provideService(McpInvocationContext.McpInvocationContext, invocation),
92+
Effect.flip,
93+
);
94+
expect(error).toBeInstanceOf(PreviewAutomationUnavailableError);
95+
expect(error).toMatchObject({ capability: "preview", environmentId: "environment-1" });
96+
expect(error.threadId).toBeUndefined();
97+
});
98+
});

‎apps/server/src/mcp/McpInvocationContext.ts‎

Lines changed: 70 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
import {
22
type EnvironmentId,
33
McpCapabilityUnavailableError,
4+
OrchestratorMcpFailure,
45
PreviewAutomationUnavailableError,
56
type ProviderInstanceId,
7+
type RuntimeMode,
68
type ThreadId,
79
} from "@t3tools/contracts";
810
import * as Context from "effect/Context";
@@ -17,13 +19,35 @@ const ALL_MCP_CAPABILITIES = [
1719
] as const;
1820
export 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

2953
export 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);

‎apps/server/src/mcp/McpSessionRegistry.test.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ it.effect("stores only a token hash, resolves the bearer token, and revokes by t
4747
expect(token.length).toBeGreaterThan(20);
4848

4949
const resolved = yield* registry.resolve(token);
50-
expect(resolved?.threadId).toBe(threadId);
50+
expect(resolved?.thread.threadId).toBe(threadId);
5151
expect(resolved?.capabilities).toEqual(
5252
new Set(["preview", "orchestration", "worktree", "pull-requests"]),
5353
);
@@ -158,7 +158,7 @@ it.effect("keeps a credential alive across turns that never touch an MCP tool",
158158
yield* registry.touch(threadId);
159159
}
160160

161-
expect((yield* registry.resolve(token))?.threadId).toBe(threadId);
161+
expect((yield* registry.resolve(token))?.thread.threadId).toBe(threadId);
162162
}),
163163
);
164164

‎apps/server/src/mcp/McpSessionRegistry.ts‎

Lines changed: 16 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ export interface McpSessionRegistryShape {
3232
readonly issue: (request: McpCredentialRequest) => Effect.Effect<McpIssuedCredential>;
3333
readonly resolve: (
3434
rawToken: string,
35-
) => Effect.Effect<McpInvocationContext.McpInvocationScope | undefined>;
35+
) => Effect.Effect<McpInvocationContext.McpThreadInvocationScope | undefined>;
3636
/**
3737
* Records a sign of life for every credential bound to `threadId`. Provider
3838
* turns call this so that a session which is plainly alive keeps its
@@ -49,9 +49,10 @@ export class McpSessionRegistry extends Context.Service<
4949
McpSessionRegistryShape
5050
>()("t3/mcp/McpSessionRegistry") {}
5151

52+
/** Registry credentials always belong to a provider session, so their scope has a thread. */
5253
interface CredentialRecord {
5354
readonly tokenHash: string;
54-
readonly scope: McpInvocationContext.McpInvocationScope;
55+
readonly scope: McpInvocationContext.McpThreadInvocationScope;
5556
readonly lastAliveAt: number;
5657
}
5758

@@ -127,11 +128,15 @@ const makeWithOptions = Effect.fn("McpSessionRegistry.make")(function* (
127128
const rawToken = yield* crypto.randomBytes(32).pipe(Effect.map(tokenFromBytes), Effect.orDie);
128129
const tokenHash = yield* hashToken(rawToken);
129130
const browserToolsAvailable = request.browserToolsAvailable ?? true;
130-
const scope: McpInvocationContext.McpInvocationScope = {
131+
const scope: McpInvocationContext.McpThreadInvocationScope = {
131132
environmentId,
132-
threadId: ThreadId.make(request.threadId),
133-
providerSessionId,
134-
providerInstanceId: ProviderInstanceId.make(request.providerInstanceId),
133+
requestNamespace: providerSessionId,
134+
thread: {
135+
threadId: ThreadId.make(request.threadId),
136+
providerSessionId,
137+
providerInstanceId: ProviderInstanceId.make(request.providerInstanceId),
138+
},
139+
client: undefined,
135140
capabilities: new Set<McpInvocationContext.McpCapability>([
136141
"orchestration",
137142
"worktree",
@@ -148,9 +153,9 @@ const makeWithOptions = Effect.fn("McpSessionRegistry.make")(function* (
148153
return {
149154
config: {
150155
environmentId,
151-
threadId: scope.threadId,
156+
threadId: scope.thread.threadId,
152157
providerSessionId,
153-
providerInstanceId: scope.providerInstanceId,
158+
providerInstanceId: scope.thread.providerInstanceId,
154159
endpoint,
155160
authorizationHeader: `Bearer ${rawToken}`,
156161
browserToolsAvailable: scope.capabilities.has("preview"),
@@ -183,7 +188,7 @@ const makeWithOptions = Effect.fn("McpSessionRegistry.make")(function* (
183188
const current = pruneDead(records, timestamp);
184189
const next = new Map(current);
185190
for (const [tokenHash, record] of current) {
186-
if (record.scope.threadId === threadId) {
191+
if (record.scope.thread.threadId === threadId) {
187192
next.set(tokenHash, { ...record, lastAliveAt: timestamp });
188193
}
189194
}
@@ -203,11 +208,11 @@ const makeWithOptions = Effect.fn("McpSessionRegistry.make")(function* (
203208
touch,
204209
revokeProviderSession: Effect.fn("McpSessionRegistry.revokeProviderSession")(
205210
function* (providerSessionId) {
206-
yield* revokeWhere((record) => record.scope.providerSessionId === providerSessionId);
211+
yield* revokeWhere((record) => record.scope.thread.providerSessionId === providerSessionId);
207212
},
208213
),
209214
revokeThread: Effect.fn("McpSessionRegistry.revokeThread")(function* (threadId) {
210-
yield* revokeWhere((record) => record.scope.threadId === threadId);
215+
yield* revokeWhere((record) => record.scope.thread.threadId === threadId);
211216
}),
212217
revokeAll: SynchronizedRef.set(state, { records: new Map() }),
213218
});

0 commit comments

Comments
 (0)