authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-10-04 22:32:55-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-04 22:32:55-07:00
log06e627448bc1ee58487c1a2a9d6bf8d5ef479af3
tree8bc704e49af7ebd5eac4dbe3cc31c82bc4ac1627
parent37de6cbde65c7cf9ba90a2557c232e63b7e16988
signature Signed by PGP key B5690EEEBB952194

feat(server): T3 MCP tools take explicit thread and project targets (#15219)

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

43 files changed, 1657 insertions(+), 524 deletions(-)

apps/server/src/mcp/McpDeviceToolkit.test.ts+7-3
......@@ -19,9 +19,13 @@ const environmentId = EnvironmentId.make("environment-device-test");
1919const threadId = ThreadId.make("thread-device-test");
2020const 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+7-3
......@@ -27,9 +27,13 @@ const alternateTabId = PreviewTabId.make("tab-mcp-alternate");
2727const decodeJsonText = Schema.decodeUnknownSync(Schema.fromJsonString(Schema.Unknown));
2828const 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+42-10
......@@ -13,9 +13,13 @@ import * as McpInvocationContext from "./McpInvocationContext.ts";
1313it.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
4246it.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,7 +64,10 @@ 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),
......@@ -64,3 +75,24 @@ it.effect("reports other missing capabilities with the neutral error", () => {
6475 expect(scope).toBe(invocation);
6576 });
6677});
78
79it.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+64-5
......@@ -1,8 +1,10 @@
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
20export interface McpInvocationScope {
21 readonly environmentId: EnvironmentId;
22/** A provider session T3 Code launched for one thread. */
23export 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. */
30export 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 */
43export 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,34 @@ 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 */
99export 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
111const 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/** A scope with a thread caller, for tools whose whole surface acts as the caller. */
118export type McpThreadInvocationScope = McpInvocationScope & { readonly thread: McpThreadCaller };
119
120export const requireThreadScope = (scope: McpInvocationScope, operation: string) =>
121 scope.thread === undefined
122 ? Effect.fail(threadCallerRequired(operation))
123 : Effect.succeed(scope as McpThreadInvocationScope);
apps/server/src/mcp/McpSessionRegistry.test.ts+2-2
......@@ -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+16-11
......@@ -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. */
5253interface 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 });
apps/server/src/mcp/OrchestratorMcpService.activity.test.ts+112-54
......@@ -2,10 +2,12 @@ import {
22 EnvironmentId,
33 NodeId,
44 type OrchestrationV2ThreadProjection,
5 type OrchestrationV2ThreadShell,
56 ProjectId,
67 ProviderDriverKind,
78 ProviderInstanceId,
89 RunId,
10 ScheduledTaskId,
911 ThreadId,
1012} from "@t3tools/contracts";
1113import * as DateTime from "effect/DateTime";
......@@ -16,6 +18,7 @@ import { expect, it } from "vite-plus/test";
1618
1719import * as ProviderAdapterRegistry from "../orchestration-v2/ProviderAdapterRegistry.ts";
1820import * as ProviderRegistry from "../provider/Services/ProviderRegistry.ts";
21import * as ProjectService from "../project/ProjectService.ts";
1922import * as ScheduledTaskService from "../scheduledTasks/ScheduledTaskService.ts";
2023import * as ThreadManagementService from "../orchestration-v2/ThreadManagementService.ts";
2124import type * as McpInvocationContext from "./McpInvocationContext.ts";
......@@ -37,9 +40,13 @@ const parentInstanceId = ProviderInstanceId.make("codex");
3740
3841const makeScope = (): McpInvocationContext.McpInvocationScope => ({
3942 environmentId,
40 threadId: parentThreadId,
41 providerSessionId: "provider-session-mcp-orchestrator-detail",
42 providerInstanceId: parentInstanceId,
43 requestNamespace: "provider-session-mcp-orchestrator-detail",
44 thread: {
45 threadId: parentThreadId,
46 providerSessionId: "provider-session-mcp-orchestrator-detail",
47 providerInstanceId: parentInstanceId,
48 },
49 client: undefined,
4350 capabilities: new Set(["orchestration"]),
4451 issuedAt: 1,
4552});
......@@ -127,10 +134,21 @@ it("readThread prefers activity-run status over a newer cancelled queued run", a
127134 threadId === parentThreadId
128135 ? Effect.succeed(projection)
129136 : Effect.die(`unexpected thread ${threadId}`),
137 getThreadShell: (threadId) =>
138 Effect.succeed(
139 threadId === parentThreadId
140 ? (projection.thread as unknown as OrchestrationV2ThreadShell)
141 : null,
142 ),
143 getProjectThreadRecords: (input) =>
144 input.threadId === parentThreadId
145 ? Effect.succeed(projection)
146 : Effect.die(`unexpected thread ${input.threadId}`),
130147 } satisfies Partial<ThreadManagementService.ThreadManagementService["Service"]>),
131148 Layer.mock(ProviderRegistry.ProviderRegistry)({
132149 getProviders: Effect.succeed([]),
133150 } satisfies Partial<ProviderRegistry.ProviderRegistry["Service"]>),
151 Layer.mock(ProjectService.ProjectService)({}),
134152 Layer.mock(ScheduledTaskService.ScheduledTaskService)({
135153 list: () => Effect.succeed({ tasks: [] }),
136154 } satisfies Partial<ScheduledTaskService.ScheduledTaskService["Service"]>),
......@@ -180,10 +198,21 @@ it("readThread prefers waiting activity status over a newer cancelled queued run
180198 threadId === parentThreadId
181199 ? Effect.succeed(projection)
182200 : Effect.die(`unexpected thread ${threadId}`),
201 getThreadShell: (threadId) =>
202 Effect.succeed(
203 threadId === parentThreadId
204 ? (projection.thread as unknown as OrchestrationV2ThreadShell)
205 : null,
206 ),
207 getProjectThreadRecords: (input) =>
208 input.threadId === parentThreadId
209 ? Effect.succeed(projection)
210 : Effect.die(`unexpected thread ${input.threadId}`),
183211 } satisfies Partial<ThreadManagementService.ThreadManagementService["Service"]>),
184212 Layer.mock(ProviderRegistry.ProviderRegistry)({
185213 getProviders: Effect.succeed([]),
186214 } satisfies Partial<ProviderRegistry.ProviderRegistry["Service"]>),
215 Layer.mock(ProjectService.ProjectService)({}),
187216 Layer.mock(ScheduledTaskService.ScheduledTaskService)({
188217 list: () => Effect.succeed({ tasks: [] }),
189218 } satisfies Partial<ScheduledTaskService.ScheduledTaskService["Service"]>),
......@@ -295,6 +324,7 @@ it("taskStatus returns task.providerInstanceId rather than the driver kind", asy
295324 Layer.mock(ProviderRegistry.ProviderRegistry)({
296325 getProviders: Effect.succeed([]),
297326 } satisfies Partial<ProviderRegistry.ProviderRegistry["Service"]>),
327 Layer.mock(ProjectService.ProjectService)({}),
298328 Layer.mock(ScheduledTaskService.ScheduledTaskService)({
299329 list: () => Effect.succeed({ tasks: [] }),
300330 } satisfies Partial<ScheduledTaskService.ScheduledTaskService["Service"]>),
......@@ -317,34 +347,12 @@ it("taskStatus returns task.providerInstanceId rather than the driver kind", asy
317347 }).pipe(Effect.provide(layer), Effect.runPromise);
318348});
319349
320it("readThread reaches a thread the user attached as context, but not one an agent attached", async () => {
350it("readThread and sendToThread reach threads in other projects", async () => {
351 let parentRuns: ReadonlyArray<unknown> = [
352 makeRun({ id: RunId.make("run-parent-live"), ordinal: 1, status: "running" }),
353 ];
321354 const foreignProjectId = ProjectId.make("project-mcp-orchestrator-foreign");
322355 const foreignThreadId = ThreadId.make("thread-mcp-orchestrator-foreign");
323 const agentOnlyThreadId = ThreadId.make("thread-mcp-orchestrator-agent-only");
324 const threadRecord = (threadId: ThreadId) => ({
325 version: 1,
326 kind: "thread",
327 contextId: `thread_${threadId}`,
328 label: "Attached",
329 environmentId,
330 threadId,
331 title: "Attached",
332 });
333 const message = (input: { createdBy: "user" | "agent"; threadId: ThreadId }) => ({
334 id: `message-${input.threadId}`,
335 threadId: parentThreadId,
336 runId: null,
337 nodeId: null,
338 role: "user",
339 text: `[Attached](t3-context://v1/thread/thread_${input.threadId})`,
340 context: { version: 1, records: [threadRecord(input.threadId)] },
341 attachments: [],
342 streaming: false,
343 createdBy: input.createdBy,
344 creationSource: input.createdBy === "user" ? "user" : "mcp",
345 createdAt: now,
346 updatedAt: now,
347 });
348356 const parentProjection = {
349357 thread: baseThread({
350358 threadId: parentThreadId,
......@@ -352,13 +360,12 @@ it("readThread reaches a thread the user attached as context, but not one an age
352360 instanceId: parentInstanceId,
353361 model: "gpt-5.4",
354362 }),
355 runs: [],
363 get runs() {
364 return parentRuns;
365 },
356366 visibleTurnItems: [],
357367 runtimeRequests: [],
358 messages: [
359 message({ createdBy: "user", threadId: foreignThreadId }),
360 message({ createdBy: "agent", threadId: agentOnlyThreadId }),
361 ],
368 messages: [],
362369 contextTransfers: [],
363370 subagents: [],
364371 updatedAt: now,
......@@ -406,11 +413,15 @@ it("readThread reaches a thread the user attached as context, but not one an age
406413 Layer.mock(ThreadManagementService.ThreadManagementService)({
407414 getThreadRecords: (threadId) => {
408415 if (threadId === parentThreadId) return Effect.succeed(parentProjection);
409 if (threadId === foreignThreadId || threadId === agentOnlyThreadId) {
410 return Effect.succeed(foreignProjection(threadId));
411 }
416 if (threadId === foreignThreadId) return Effect.succeed(foreignProjection(threadId));
412417 return Effect.die(`unexpected thread ${threadId}`);
413418 },
419 getThreadShell: (threadId) =>
420 Effect.succeed(
421 threadId === foreignThreadId
422 ? (foreignProjection(threadId).thread as unknown as OrchestrationV2ThreadShell)
423 : null,
424 ),
414425 getTimelinePage: (threadId) =>
415426 Effect.succeed({
416427 items: foreignProjection(threadId).visibleTurnItems,
......@@ -418,18 +429,36 @@ it("readThread reaches a thread the user attached as context, but not one an age
418429 hasMore: false,
419430 }),
420431 getProjectThreadRecords: (input) =>
421 Effect.fail(
422 new ThreadManagementService.ThreadManagementThreadNotFoundError({
423 projectId: input.projectId,
424 threadId: input.threadId,
425 }),
426 ),
432 input.threadId === foreignThreadId && input.projectId === foreignProjectId
433 ? Effect.succeed(foreignProjection(input.threadId))
434 : Effect.fail(
435 new ThreadManagementService.ThreadManagementThreadNotFoundError({
436 projectId: input.projectId,
437 threadId: input.threadId,
438 }),
439 ),
440 sendToThread: () =>
441 Effect.succeed({
442 run: { id: "run-foreign", status: "queued" },
443 delivery: "started",
444 } as unknown as ThreadManagementService.ThreadManagementSendResult),
427445 } satisfies Partial<ThreadManagementService.ThreadManagementService["Service"]>),
428446 Layer.mock(ProviderRegistry.ProviderRegistry)({
429447 getProviders: Effect.succeed([]),
430448 } satisfies Partial<ProviderRegistry.ProviderRegistry["Service"]>),
449 Layer.mock(ProjectService.ProjectService)({}),
431450 Layer.mock(ScheduledTaskService.ScheduledTaskService)({
432 list: () => Effect.succeed({ tasks: [] }),
451 list: () =>
452 Effect.succeed({
453 tasks: [
454 {
455 id: "task-foreign",
456 projectId: foreignProjectId,
457 runtimeMode: "approval-required",
458 interactionMode: "default",
459 } as never,
460 ],
461 }),
433462 } satisfies Partial<ScheduledTaskService.ScheduledTaskService["Service"]>),
434463 Layer.mock(ProviderAdapterRegistry.ProviderAdapterRegistryV2)({
435464 list: () => Effect.succeed([]),
......@@ -441,18 +470,47 @@ it("readThread reaches a thread the user attached as context, but not one an age
441470
442471 await Effect.gen(function* () {
443472 const service = yield* OrchestratorMcpService.OrchestratorMcpService;
444 const attached = yield* service.readThread(makeScope(), { threadId: foreignThreadId });
445 expect(attached.thread.threadId).toBe(foreignThreadId);
446 expect(attached.items.map((item) => item.text)).toEqual(["Foreign thread said hello"]);
473 const foreign = yield* service.readThread(makeScope(), { threadId: foreignThreadId });
474 expect(foreign.thread.threadId).toBe(foreignThreadId);
475 expect(foreign.thread.projectId).toBe(foreignProjectId);
476 expect(foreign.items.map((item) => item.text)).toEqual(["Foreign thread said hello"]);
447477
448 const denied = yield* service
449 .readThread(makeScope(), { threadId: agentOnlyThreadId })
450 .pipe(Effect.flip);
451 expect(denied.code).toBe("thread_not_found");
478 const sent = yield* service.sendToThread(makeScope(), {
479 threadId: foreignThreadId,
480 message: "hi",
481 });
482 expect(sent.threadId).toBe(foreignThreadId);
452483
453 const write = yield* service
454 .sendToThread(makeScope(), { threadId: foreignThreadId, message: "hi" })
484 // Once the caller's run ends, it can still read other threads but no longer write to them.
485 parentRuns = [];
486 yield* service.readThread(makeScope(), { threadId: foreignThreadId });
487 const stale = yield* service
488 .sendToThread(makeScope(), { threadId: foreignThreadId, message: "hi again" })
489 .pipe(Effect.flip);
490 expect(stale.code).toBe("parent_not_active");
491 const staleInterrupt = yield* service
492 .interruptThread(makeScope(), { threadId: foreignThreadId })
493 .pipe(Effect.flip);
494 expect(staleInterrupt.code).toBe("parent_not_active");
495 // Nor create, change or remove scheduled work in another project.
496 const staleSchedule = yield* service
497 .scheduleTask(makeScope(), {
498 projectId: foreignProjectId,
499 prompt: "check in later",
500 schedule: { type: "interval", everyMs: 3_600_000 },
501 })
502 .pipe(Effect.flip);
503 expect(staleSchedule.code).toBe("parent_not_active");
504 const staleUpdate = yield* service
505 .updateScheduledTask(makeScope(), {
506 scheduledTaskId: ScheduledTaskId.make("task-foreign"),
507 enabled: false,
508 })
509 .pipe(Effect.flip);
510 expect(staleUpdate.code).toBe("parent_not_active");
511 const staleDelete = yield* service
512 .deleteScheduledTask(makeScope(), { scheduledTaskId: ScheduledTaskId.make("task-foreign") })
455513 .pipe(Effect.flip);
456 expect(write.code).toBe("thread_not_found");
514 expect(staleDelete.code).toBe("parent_not_active");
457515 }).pipe(Effect.provide(layer), Effect.runPromise);
458516});
apps/server/src/mcp/OrchestratorMcpService.test.ts+53-18
......@@ -22,6 +22,7 @@ import * as ProviderAdapterRegistry from "../orchestration-v2/ProviderAdapterReg
2222import * as ThreadManagementService from "../orchestration-v2/ThreadManagementService.ts";
2323import * as ProviderRegistry from "../provider/Services/ProviderRegistry.ts";
2424import { buildUnavailableProviderSnapshot } from "../provider/unavailableProviderSnapshot.ts";
25import * as ProjectService from "../project/ProjectService.ts";
2526import * as ScheduledTaskService from "../scheduledTasks/ScheduledTaskService.ts";
2627import type { McpInvocationScope } from "./McpInvocationContext.ts";
2728import * as OrchestratorMcpService from "./OrchestratorMcpService.ts";
......@@ -118,13 +119,18 @@ describe("OrchestratorMcpService", () => {
118119 Layer.mock(ProviderAdapterRegistry.ProviderAdapterRegistryV2)({
119120 list: () => Effect.succeed([]),
120121 }),
122 Layer.mock(ProjectService.ProjectService)({}),
121123 Layer.mock(ScheduledTaskService.ScheduledTaskService)({}),
122124 );
123125 const scope: McpInvocationScope = {
124126 environmentId: EnvironmentId.make("environment:mcp-ack"),
125 threadId: parentThreadId,
126 providerSessionId: "provider-session:mcp-ack",
127 providerInstanceId: ProviderInstanceId.make("codex"),
127 requestNamespace: "provider-session:mcp-ack",
128 thread: {
129 threadId: parentThreadId,
130 providerSessionId: "provider-session:mcp-ack",
131 providerInstanceId: ProviderInstanceId.make("codex"),
132 },
133 client: undefined,
128134 capabilities: new Set(["orchestration"]),
129135 issuedAt: 1,
130136 };
......@@ -202,13 +208,18 @@ describe("OrchestratorMcpService", () => {
202208 Layer.mock(ProviderAdapterRegistry.ProviderAdapterRegistryV2)({
203209 list: () => Effect.succeed([]),
204210 }),
211 Layer.mock(ProjectService.ProjectService)({}),
205212 Layer.mock(ScheduledTaskService.ScheduledTaskService)({}),
206213 );
207214 const scope: McpInvocationScope = {
208215 environmentId: EnvironmentId.make("environment:mcp-restart"),
209 threadId: parentThreadId,
210 providerSessionId: "provider-session:mcp-restart",
211 providerInstanceId: ProviderInstanceId.make("codex"),
216 requestNamespace: "provider-session:mcp-restart",
217 thread: {
218 threadId: parentThreadId,
219 providerSessionId: "provider-session:mcp-restart",
220 providerInstanceId: ProviderInstanceId.make("codex"),
221 },
222 client: undefined,
212223 capabilities: new Set(["orchestration"]),
213224 issuedAt: 1,
214225 };
......@@ -278,13 +289,18 @@ describe("OrchestratorMcpService", () => {
278289 Layer.mock(ProviderAdapterRegistry.ProviderAdapterRegistryV2)({
279290 list: () => Effect.succeed([]),
280291 }),
292 Layer.mock(ProjectService.ProjectService)({}),
281293 Layer.mock(ScheduledTaskService.ScheduledTaskService)({}),
282294 );
283295 const scope: McpInvocationScope = {
284296 environmentId: EnvironmentId.make("environment:mcp-cancel"),
285 threadId: parentThreadId,
286 providerSessionId: "provider-session:mcp-cancel",
287 providerInstanceId: ProviderInstanceId.make("codex"),
297 requestNamespace: "provider-session:mcp-cancel",
298 thread: {
299 threadId: parentThreadId,
300 providerSessionId: "provider-session:mcp-cancel",
301 providerInstanceId: ProviderInstanceId.make("codex"),
302 },
303 client: undefined,
288304 capabilities: new Set(["orchestration"]),
289305 issuedAt: 1,
290306 };
......@@ -346,13 +362,18 @@ describe("OrchestratorMcpService", () => {
346362 Layer.mock(ProviderAdapterRegistry.ProviderAdapterRegistryV2)({
347363 list: () => Effect.succeed([]),
348364 }),
365 Layer.mock(ProjectService.ProjectService)({}),
349366 Layer.mock(ScheduledTaskService.ScheduledTaskService)({}),
350367 );
351368 const scope: McpInvocationScope = {
352369 environmentId: EnvironmentId.make("environment:mcp-cancel-failed"),
353 threadId: parentThreadId,
354 providerSessionId: "provider-session:mcp-cancel-failed",
355 providerInstanceId: ProviderInstanceId.make("codex"),
370 requestNamespace: "provider-session:mcp-cancel-failed",
371 thread: {
372 threadId: parentThreadId,
373 providerSessionId: "provider-session:mcp-cancel-failed",
374 providerInstanceId: ProviderInstanceId.make("codex"),
375 },
376 client: undefined,
356377 capabilities: new Set(["orchestration"]),
357378 issuedAt: 1,
358379 };
......@@ -421,13 +442,18 @@ describe("OrchestratorMcpService", () => {
421442 Layer.mock(ProviderAdapterRegistry.ProviderAdapterRegistryV2)({
422443 list: () => Effect.succeed([]),
423444 }),
445 Layer.mock(ProjectService.ProjectService)({}),
424446 Layer.mock(ScheduledTaskService.ScheduledTaskService)({}),
425447 );
426448 const scope: McpInvocationScope = {
427449 environmentId: EnvironmentId.make("environment:mcp-cancel-dispose-failed"),
428 threadId: parentThreadId,
429 providerSessionId: "provider-session:mcp-cancel-dispose-failed",
430 providerInstanceId: ProviderInstanceId.make("codex"),
450 requestNamespace: "provider-session:mcp-cancel-dispose-failed",
451 thread: {
452 threadId: parentThreadId,
453 providerSessionId: "provider-session:mcp-cancel-dispose-failed",
454 providerInstanceId: ProviderInstanceId.make("codex"),
455 },
456 client: undefined,
431457 capabilities: new Set(["orchestration"]),
432458 issuedAt: 1,
433459 };
......@@ -460,9 +486,13 @@ describe("OrchestratorMcpService provider resolution", () => {
460486
461487 const scope: McpInvocationScope = {
462488 environmentId: EnvironmentId.make("environment:mcp-providers"),
463 threadId: parentThreadId,
464 providerSessionId: "provider-session:mcp-providers",
465 providerInstanceId: codexInstanceId,
489 requestNamespace: "provider-session:mcp-providers",
490 thread: {
491 threadId: parentThreadId,
492 providerSessionId: "provider-session:mcp-providers",
493 providerInstanceId: codexInstanceId,
494 },
495 client: undefined,
466496 capabilities: new Set(["orchestration"]),
467497 issuedAt: 1,
468498 };
......@@ -613,6 +643,7 @@ describe("OrchestratorMcpService provider resolution", () => {
613643 antigravityInstanceId,
614644 disabledAntigravityInstanceId,
615645 ]),
646 Layer.mock(ProjectService.ProjectService)({}),
616647 Layer.mock(ScheduledTaskService.ScheduledTaskService)({}),
617648 );
618649
......@@ -756,6 +787,7 @@ describe("OrchestratorMcpService provider resolution", () => {
756787 ]),
757788 }),
758789 adapterRegistryLayer([codexInstanceId, antigravityInstanceId]),
790 Layer.mock(ProjectService.ProjectService)({}),
759791 Layer.mock(ScheduledTaskService.ScheduledTaskService)({}),
760792 );
761793
......@@ -849,6 +881,7 @@ describe("OrchestratorMcpService provider resolution", () => {
849881 ]),
850882 }),
851883 adapterRegistryLayer([codexInstanceId, antigravityInstanceId]),
884 Layer.mock(ProjectService.ProjectService)({}),
852885 Layer.mock(ScheduledTaskService.ScheduledTaskService)({}),
853886 );
854887
......@@ -897,6 +930,7 @@ describe("OrchestratorMcpService provider resolution", () => {
897930 ]),
898931 }),
899932 adapterRegistryLayer([codexInstanceId]),
933 Layer.mock(ProjectService.ProjectService)({}),
900934 Layer.mock(ScheduledTaskService.ScheduledTaskService)({}),
901935 );
902936
......@@ -1060,6 +1094,7 @@ describe("OrchestratorMcpService provider resolution", () => {
10601094 ]),
10611095 }),
10621096 adapterRegistryLayer([codexInstanceId, codexAltInstanceId]),
1097 Layer.mock(ProjectService.ProjectService)({}),
10631098 Layer.mock(ScheduledTaskService.ScheduledTaskService)({}),
10641099 );
10651100
apps/server/src/mcp/OrchestratorMcpService.ts+283-128
......@@ -29,6 +29,8 @@ import {
2929 type OrchestratorMcpTaskCancelInput,
3030 type OrchestratorMcpTaskCancelResult,
3131 type OrchestratorMcpUpdateScheduledTaskInput,
32 type OrchestratorMcpListScheduledTasksInput,
33 type ProjectId,
3234 type OrchestratorMcpThreadDetail,
3335 type OrchestratorMcpThreadInterruptInput,
3436 type OrchestratorMcpThreadInterruptResult,
......@@ -68,9 +70,14 @@ import {
6870 delegatedTaskProgress,
6971} from "../orchestration-v2/SubagentProjection.ts";
7072import * as ThreadManagementService from "../orchestration-v2/ThreadManagementService.ts";
73import * as ProjectService from "../project/ProjectService.ts";
7174import * as ProviderRegistry from "../provider/Services/ProviderRegistry.ts";
7275import * as ScheduledTaskService from "../scheduledTasks/ScheduledTaskService.ts";
73import type { McpInvocationScope } from "./McpInvocationContext.ts";
76import {
77 type McpInvocationScope,
78 type McpThreadInvocationScope,
79 requireThreadScope,
80} from "./McpInvocationContext.ts";
7481
7582const DEFAULT_WAIT_TIMEOUT_MS = 10 * 60 * 1_000;
7683const MAX_WAIT_TIMEOUT_MS = 60 * 60 * 1_000;
......@@ -115,6 +122,7 @@ export interface OrchestratorMcpServiceShape {
115122 ) => Effect.Effect<OrchestratorMcpScheduleTaskResult, OrchestratorMcpFailure>;
116123 readonly listScheduledTasks: (
117124 scope: McpInvocationScope,
125 input: OrchestratorMcpListScheduledTasksInput,
118126 ) => Effect.Effect<OrchestratorMcpListScheduledTasksResult, OrchestratorMcpFailure>;
119127 readonly updateScheduledTask: (
120128 scope: McpInvocationScope,
......@@ -486,7 +494,7 @@ function stableCommandId(input: {
486494 [
487495 "command",
488496 "mcp",
489 stablePart(input.scope.providerSessionId),
497 stablePart(input.scope.requestNamespace),
490498 stablePart(input.operation),
491499 stablePart(input.requestKey),
492500 ...(input.index === undefined ? [] : [String(input.index)]),
......@@ -503,7 +511,7 @@ function stableThreadId(input: {
503511 [
504512 "thread",
505513 "mcp",
506 stablePart(input.scope.providerSessionId),
514 stablePart(input.scope.requestNamespace),
507515 stablePart(input.requestKey),
508516 String(input.index),
509517 ].join(":"),
......@@ -519,7 +527,7 @@ function stableMessageId(input: {
519527 [
520528 "message",
521529 "mcp",
522 stablePart(input.scope.providerSessionId),
530 stablePart(input.scope.requestNamespace),
523531 stablePart(input.requestKey),
524532 String(input.index),
525533 ].join(":"),
......@@ -535,7 +543,7 @@ function stableOperationMessageId(input: {
535543 [
536544 "message",
537545 "mcp",
538 stablePart(input.scope.providerSessionId),
546 stablePart(input.scope.requestNamespace),
539547 stablePart(input.operation),
540548 stablePart(input.requestKey),
541549 ].join(":"),
......@@ -760,6 +768,35 @@ const make = Effect.gen(function* () {
760768 const providerRegistry = yield* ProviderRegistry.ProviderRegistry;
761769 const providerAdapters = yield* ProviderAdapterRegistry.ProviderAdapterRegistryV2;
762770 const scheduledTasks = yield* ScheduledTaskService.ScheduledTaskService;
771 const projects = yield* ProjectService.ProjectService;
772
773 /** A caller-named project, which must exist before anything is recorded against it. */
774 const requireProject = (projectId: ProjectId) =>
775 projects.getById(projectId).pipe(
776 Effect.mapError((error) =>
777 failure("orchestration_error", `Unable to read project ${projectId}: ${error.message}`),
778 ),
779 Effect.flatMap(
780 Option.match({
781 onNone: () =>
782 Effect.fail(failure("invalid_request", `Project ${projectId} was not found.`)),
783 onSome: Effect.succeed,
784 }),
785 ),
786 );
787
788 /** A client has no thread to inherit a model from, so it falls back to the project default. */
789 const projectDefaultModelSelection = (
790 project: Effect.Success<ReturnType<typeof requireProject>>,
791 ) =>
792 project.defaultModelSelection === null
793 ? Effect.fail(
794 failure(
795 "invalid_request",
796 `Project ${project.id} has no default model, so this caller cannot pick one for it.`,
797 ),
798 )
799 : Effect.succeed(project.defaultModelSelection);
763800
764801 const requireCapability = (scope: McpInvocationScope) =>
765802 scope.capabilities.has("orchestration")
......@@ -817,61 +854,133 @@ const make = Effect.gen(function* () {
817854 )
818855 .pipe(Effect.mapError(threadManagementFailure));
819856
820 const loadScopedThread = (scope: McpInvocationScope, threadId: ThreadId) =>
857 /**
858 * The caller's own thread and the most it may hand to threads it targets. A
859 * thread caller is capped by its own modes; an OAuth client by the ceiling
860 * chosen when it was approved.
861 */
862 const loadCaller = (scope: McpInvocationScope) =>
821863 Effect.gen(function* () {
822864 yield* requireCapability(scope);
823 const parent = yield* loadProjection(scope.threadId);
865 if (scope.thread === undefined) {
866 return {
867 parent: undefined,
868 limits: {
869 runtimeMode: scope.client?.runtimeModeCeiling ?? "approval-required",
870 interactionMode: "default",
871 } satisfies { runtimeMode: RuntimeMode; interactionMode: ProviderInteractionMode },
872 } as const;
873 }
874 const parent = yield* loadProjection(scope.thread.threadId);
875 return {
876 parent,
877 limits: {
878 runtimeMode: parent.thread.runtimeMode,
879 interactionMode: parent.thread.interactionMode,
880 },
881 } as const;
882 });
883
884 /** The caller's own thread, for operations that act as the caller. */
885 const loadThreadCaller = (scope: McpInvocationScope, operation: string) =>
886 Effect.gen(function* () {
887 yield* requireCapability(scope);
888 const threadScope = yield* requireThreadScope(scope, operation);
889 const parent = yield* loadProjection(threadScope.thread.threadId);
890 return { scope: threadScope, parent } as const;
891 });
892
893 /** A target project: the one passed, else the calling thread's. */
894 const resolveProjectTarget = (
895 parent: Pick<OrchestrationV2ThreadProjection, "thread"> | undefined,
896 projectId: ProjectId | undefined,
897 ) =>
898 projectId !== undefined
899 ? Effect.succeed(projectId)
900 : parent !== undefined
901 ? Effect.succeed(parent.thread.projectId)
902 : Effect.fail(
903 failure(
904 "target_required",
905 "Pass projectId: this MCP client is not running inside a T3 thread.",
906 ),
907 );
908
909 /** Any live thread in the environment. */
910 const loadTargetThread = (threadId: ThreadId) =>
911 Effect.gen(function* () {
912 const shell = yield* threadManagement
913 .getThreadShell(threadId)
914 .pipe(Effect.mapError(threadManagementFailure));
915 if (shell === null || shell.deletedAt !== null) {
916 return yield* failure("thread_not_found", `Thread ${threadId} was not found.`);
917 }
918 return yield* loadProjectThread(shell.projectId, threadId);
919 });
920
921 const loadScopedThread = (scope: McpInvocationScope, threadId: ThreadId) =>
922 Effect.gen(function* () {
923 const caller = yield* loadCaller(scope);
824924 const target =
825 threadId === scope.threadId
826 ? parent
827 : yield* loadProjectThread(parent.thread.projectId, threadId);
828 return { parent, target } as const;
925 caller.parent !== undefined && threadId === caller.parent.thread.id
926 ? caller.parent
927 : yield* loadTargetThread(threadId);
928 return { ...caller, target } as const;
829929 });
830930
831931 /**
832 * A thread the user attached as context (a `thread` record on one of their own messages)
833 * is readable even outside the calling project. Only records the user authored count:
834 * an agent cannot widen its own reach by writing a record.
932 * A thread caller writes to another thread only while its own run is live,
933 * so a credential that outlived its session cannot reach across threads.
835934 */
836 const userAttachedThreadIds = (
837 parent: Pick<OrchestrationV2ThreadProjection, "messages">,
838 ): Set<ThreadId> => {
839 const ids = new Set<ThreadId>();
840 for (const message of parent.messages) {
841 if (message.role !== "user" || message.createdBy !== "user") continue;
842 for (const record of message.context?.records ?? []) {
843 if (record.kind === "thread" && "threadId" in record) ids.add(record.threadId);
844 }
845 }
846 return ids;
935 const assertLiveCallerForOtherThread = (
936 scope: McpInvocationScope,
937 parent: Pick<OrchestrationV2ThreadProjection, "thread" | "runs"> | undefined,
938 target: Pick<OrchestrationV2ThreadProjection, "thread">,
939 ) =>
940 parent === undefined || target.thread.id === parent.thread.id
941 ? Effect.void
942 : assertLiveCaller(scope, parent);
943
944 /** Scheduled work outside the caller's own project needs the same live run. */
945 const assertLiveCallerForOtherProject = (
946 scope: McpInvocationScope,
947 parent: Pick<OrchestrationV2ThreadProjection, "thread" | "runs"> | undefined,
948 projectId: ProjectId,
949 ) =>
950 parent === undefined || projectId === parent.thread.projectId
951 ? Effect.void
952 : assertLiveCaller(scope, parent);
953
954 const assertLiveCaller = (
955 scope: McpInvocationScope,
956 parent: Pick<OrchestrationV2ThreadProjection, "thread" | "runs">,
957 ) => {
958 const activeRun = ThreadManagementService.latestActiveRun(parent);
959 return parent.thread.archivedAt !== null ||
960 activeRun === undefined ||
961 activeRun.providerInstanceId !== scope.thread?.providerInstanceId
962 ? Effect.fail(
963 failure("parent_not_active", "The calling provider no longer owns an active thread run."),
964 )
965 : Effect.void;
847966 };
848967
849968 const loadReadableThread = (scope: McpInvocationScope, threadId: ThreadId) =>
850969 Effect.gen(function* () {
851 yield* requireCapability(scope);
852 const parent = yield* loadProjection(scope.threadId);
853 const loadTarget = () =>
854 threadManagement
855 .getThreadRecords(threadId, ["runs", "runtimeRequests", "contextTransfers"])
856 .pipe(Effect.mapError(threadManagementFailure));
857 if (threadId === scope.threadId) return { parent, target: yield* loadTarget() } as const;
970 const { parent } = yield* loadCaller(scope);
971 const shell = yield* threadManagement
972 .getThreadShell(threadId)
973 .pipe(Effect.mapError(threadManagementFailure));
974 if (shell === null || shell.deletedAt !== null) {
975 return yield* failure("thread_not_found", `Thread ${threadId} is no longer available.`);
976 }
858977 const target = yield* threadManagement
859 .getProjectThreadRecords({ projectId: parent.thread.projectId, threadId }, [
978 .getProjectThreadRecords({ projectId: shell.projectId, threadId }, [
860979 "runs",
861980 "runtimeRequests",
862981 "contextTransfers",
863982 ])
864 .pipe(
865 Effect.mapError(threadManagementFailure),
866 Effect.catchIf(
867 (error) =>
868 error.code === "thread_not_found" && userAttachedThreadIds(parent).has(threadId),
869 loadTarget,
870 ),
871 );
872 if (target.thread.deletedAt !== null) {
873 return yield* failure("thread_not_found", `Thread ${threadId} is no longer available.`);
874 }
983 .pipe(Effect.mapError(threadManagementFailure));
875984 return { parent, target } as const;
876985 });
877986
......@@ -1010,7 +1119,7 @@ const make = Effect.gen(function* () {
10101119 : Effect.succeed(clientRequestId);
10111120
10121121 const readTask = (
1013 scope: McpInvocationScope,
1122 scope: McpThreadInvocationScope,
10141123 taskId: NodeId,
10151124 waitTimedOut = false,
10161125 acknowledgeTerminal = false,
......@@ -1018,17 +1127,17 @@ const make = Effect.gen(function* () {
10181127 ): Effect.Effect<OrchestratorMcpDelegateTaskResult, OrchestratorMcpFailure> =>
10191128 Effect.gen(function* () {
10201129 yield* requireCapability(scope);
1021 const parentProjection = yield* loadProjection(scope.threadId);
1130 const parentProjection = yield* loadProjection(scope.thread.threadId);
10221131 const task = parentProjection.subagents.find(
10231132 (candidate) =>
10241133 candidate.id === taskId &&
10251134 candidate.origin === "app_owned" &&
1026 candidate.threadId === scope.threadId,
1135 candidate.threadId === scope.thread.threadId,
10271136 );
10281137 if (task === undefined || task.childThreadId === null) {
10291138 return yield* failure(
10301139 "task_not_found",
1031 `Delegated task ${taskId} does not belong to thread ${scope.threadId}.`,
1140 `Delegated task ${taskId} does not belong to thread ${scope.thread.threadId}.`,
10321141 );
10331142 }
10341143 const childControls = yield* threadManagement
......@@ -1094,7 +1203,7 @@ const make = Effect.gen(function* () {
10941203 (transfer) =>
10951204 transfer.type === "subagent_result" &&
10961205 transfer.sourceThreadId === task.childThreadId &&
1097 transfer.targetThreadId === scope.threadId,
1206 transfer.targetThreadId === scope.thread.threadId,
10981207 );
10991208 const resultTransferForRun = (run: OrchestrationV2Run | undefined) =>
11001209 !canExposeTaskRunResult(run)
......@@ -1145,10 +1254,10 @@ const make = Effect.gen(function* () {
11451254 requestKey: acknowledgementRequestKey,
11461255 operation: acknowledgementOperation,
11471256 }),
1148 parentThreadId: scope.threadId,
1257 parentThreadId: scope.thread.threadId,
11491258 taskId,
11501259 observedByRunId:
1151 observingRun?.providerInstanceId === scope.providerInstanceId
1260 observingRun?.providerInstanceId === scope.thread.providerInstanceId
11521261 ? observingRun.id
11531262 : null,
11541263 })
......@@ -1164,7 +1273,7 @@ const make = Effect.gen(function* () {
11641273 return response;
11651274 });
11661275
1167 const waitForTask = (scope: McpInvocationScope, taskId: NodeId, timeoutMs: number) =>
1276 const waitForTask = (scope: McpThreadInvocationScope, taskId: NodeId, timeoutMs: number) =>
11681277 Effect.gen(function* () {
11691278 while (true) {
11701279 const result = yield* readTask(scope, taskId, false, true);
......@@ -1173,11 +1282,17 @@ const make = Effect.gen(function* () {
11731282 }
11741283 }).pipe(Effect.timeoutOption(Duration.millis(timeoutMs)));
11751284
1176 // Load a single scheduled task and enforce that it belongs to the calling
1177 // thread's project, so agents can only read/mutate tasks in their own scope.
1178 const loadScopedScheduledTask = (
1179 projectId: ScheduledTask["projectId"],
1285 /**
1286 * A scheduled task the caller may change: one whose modes are no broader
1287 * than the caller's own, so editing its prompt cannot run work above the
1288 * caller's limits.
1289 */
1290 const loadScheduledTask = (
11801291 scheduledTaskId: ScheduledTask["id"],
1292 limits: {
1293 readonly runtimeMode: RuntimeMode;
1294 readonly interactionMode: ProviderInteractionMode;
1295 },
11811296 ): Effect.Effect<ScheduledTask, OrchestratorMcpFailure> =>
11821297 Effect.gen(function* () {
11831298 const { tasks } = yield* scheduledTasks
......@@ -1188,21 +1303,38 @@ const make = Effect.gen(function* () {
11881303 ),
11891304 );
11901305 const task = tasks.find((candidate) => candidate.id === scheduledTaskId);
1191 if (task === undefined || task.projectId !== projectId) {
1192 return yield* failure(
1193 "task_not_found",
1194 `Scheduled task ${scheduledTaskId} was not found in the calling project.`,
1195 );
1306 if (task === undefined) {
1307 return yield* failure("task_not_found", `Scheduled task ${scheduledTaskId} was not found.`);
11961308 }
1309 yield* resolveRuntimeMode(limits.runtimeMode, task.runtimeMode);
1310 yield* resolveInteractionMode(limits.interactionMode, task.interactionMode);
11971311 return task;
11981312 });
11991313
12001314 return OrchestratorMcpService.of({
12011315 scheduleTask: (scope, input) =>
12021316 Effect.gen(function* () {
1203 yield* requireCapability(scope);
1204 const parent = yield* loadProjection(scope.threadId);
1205 const bindToCurrentThread = input.bindToCurrentThread ?? true;
1317 const { parent, limits } = yield* loadCaller(scope);
1318 const projectId = yield* resolveProjectTarget(parent, input.projectId);
1319 yield* assertLiveCallerForOtherProject(scope, parent, projectId);
1320 const project = yield* requireProject(projectId);
1321 // Binding means "wake this thread", which only a thread caller in that project has.
1322 const bindToCurrentThread =
1323 input.bindToCurrentThread ??
1324 (parent !== undefined && parent.thread.projectId === projectId);
1325 if (
1326 bindToCurrentThread &&
1327 (parent === undefined || parent.thread.projectId !== projectId)
1328 ) {
1329 return yield* failure(
1330 "invalid_request",
1331 parent === undefined
1332 ? "bindToCurrentThread needs an agent running inside a T3 thread."
1333 : "bindToCurrentThread binds to this thread, which belongs to a different project.",
1334 );
1335 }
1336 const modelSelection =
1337 parent?.thread.modelSelection ?? (yield* projectDefaultModelSelection(project));
12061338 const derivedTitle = input.prompt.split("\n")[0]?.trim() ?? "";
12071339 const title =
12081340 input.title ?? (derivedTitle.length > 0 ? derivedTitle.slice(0, 80) : "Scheduled task");
......@@ -1211,12 +1343,12 @@ const make = Effect.gen(function* () {
12111343 prompt: input.prompt,
12121344 enabled: input.enabled ?? true,
12131345 schedule: input.schedule,
1214 projectId: parent.thread.projectId,
1215 threadId: bindToCurrentThread ? scope.threadId : null,
1346 projectId,
1347 threadId: bindToCurrentThread && parent !== undefined ? parent.thread.id : null,
12161348 workspaceStrategy: scheduledTaskWorkspaceStrategy(bindToCurrentThread),
1217 modelSelection: parent.thread.modelSelection,
1218 runtimeMode: parent.thread.runtimeMode,
1219 interactionMode: parent.thread.interactionMode,
1349 modelSelection,
1350 runtimeMode: limits.runtimeMode,
1351 interactionMode: limits.interactionMode,
12201352 createdBy: "agent",
12211353 creationSource: "mcp",
12221354 // Scope the idempotency key by provider session so two callers
......@@ -1240,10 +1372,10 @@ const make = Effect.gen(function* () {
12401372 );
12411373 return scheduledTaskSummary(task);
12421374 }),
1243 listScheduledTasks: (scope) =>
1375 listScheduledTasks: (scope, input) =>
12441376 Effect.gen(function* () {
1245 yield* requireCapability(scope);
1246 const parent = yield* loadProjection(scope.threadId);
1377 const { parent } = yield* loadCaller(scope);
1378 const projectId = input.projectId ?? parent?.thread.projectId;
12471379 const { tasks } = yield* scheduledTasks
12481380 .list()
12491381 .pipe(
......@@ -1251,26 +1383,33 @@ const make = Effect.gen(function* () {
12511383 failure("orchestration_error", `Could not list scheduled tasks: ${error.message}`),
12521384 ),
12531385 );
1254 // Only expose tasks belonging to the calling thread's project.
12551386 return {
12561387 tasks: tasks
1257 .filter((task) => task.projectId === parent.thread.projectId)
1388 .filter((task) => projectId === undefined || task.projectId === projectId)
12581389 .map(scheduledTaskSummary),
12591390 };
12601391 }),
12611392 updateScheduledTask: (scope, input) =>
12621393 Effect.gen(function* () {
1263 yield* requireCapability(scope);
1264 const parent = yield* loadProjection(scope.threadId);
1265 const existing = yield* loadScopedScheduledTask(
1266 parent.thread.projectId,
1267 input.scheduledTaskId,
1268 );
1394 const { parent, limits } = yield* loadCaller(scope);
1395 const existing = yield* loadScheduledTask(input.scheduledTaskId, limits);
1396 yield* assertLiveCallerForOtherProject(scope, parent, existing.projectId);
1397 if (
1398 input.bindToCurrentThread === true &&
1399 (parent === undefined || parent.thread.projectId !== existing.projectId)
1400 ) {
1401 return yield* failure(
1402 "invalid_request",
1403 parent === undefined
1404 ? "bindToCurrentThread needs an agent running inside a T3 thread."
1405 : "bindToCurrentThread binds to this thread, which belongs to a different project.",
1406 );
1407 }
12691408 const threadId =
12701409 input.bindToCurrentThread === undefined
12711410 ? existing.threadId
1272 : input.bindToCurrentThread
1273 ? scope.threadId
1411 : input.bindToCurrentThread && parent !== undefined
1412 ? parent.thread.id
12741413 : null;
12751414 // Rebinding changes where runs execute, so the workspace strategy must
12761415 // follow: unbinding a root-strategy task would otherwise run loose
......@@ -1305,12 +1444,9 @@ const make = Effect.gen(function* () {
13051444 }),
13061445 deleteScheduledTask: (scope, input) =>
13071446 Effect.gen(function* () {
1308 yield* requireCapability(scope);
1309 const parent = yield* loadProjection(scope.threadId);
1310 const existing = yield* loadScopedScheduledTask(
1311 parent.thread.projectId,
1312 input.scheduledTaskId,
1313 );
1447 const { parent, limits } = yield* loadCaller(scope);
1448 const existing = yield* loadScheduledTask(input.scheduledTaskId, limits);
1449 yield* assertLiveCallerForOtherProject(scope, parent, existing.projectId);
13141450 yield* scheduledTasks
13151451 .delete({ id: existing.id })
13161452 .pipe(
......@@ -1322,16 +1458,15 @@ const make = Effect.gen(function* () {
13221458 }),
13231459 capabilities: (scope) =>
13241460 Effect.gen(function* () {
1325 yield* requireCapability(scope);
1326 const parent = yield* loadProjection(scope.threadId);
1461 const { parent, limits } = yield* loadCaller(scope);
13271462 const providers = yield* loadProviders;
13281463 const orchestrationCapableInstanceIds = yield* loadOrchestrationCapableInstanceIds();
13291464 return {
1330 parentThreadId: scope.threadId,
1331 inheritedProviderInstanceId: parent.thread.modelSelection.instanceId,
1332 inheritedModel: parent.thread.modelSelection.model,
1333 runtimeMode: parent.thread.runtimeMode,
1334 interactionMode: parent.thread.interactionMode,
1465 parentThreadId: parent?.thread.id ?? null,
1466 inheritedProviderInstanceId: parent?.thread.modelSelection.instanceId ?? null,
1467 inheritedModel: parent?.thread.modelSelection.model ?? null,
1468 runtimeMode: limits.runtimeMode,
1469 interactionMode: limits.interactionMode,
13351470 providers: providers.map((provider) => {
13361471 const constraints = providerConstraints(
13371472 provider,
......@@ -1366,17 +1501,16 @@ const make = Effect.gen(function* () {
13661501 },
13671502 };
13681503 }),
1369 delegateTask: (scope, input) =>
1504 delegateTask: (callerScope, input) =>
13701505 Effect.gen(function* () {
1371 yield* requireCapability(scope);
1372 const parent = yield* loadProjection(scope.threadId);
1506 const { scope, parent } = yield* loadThreadCaller(callerScope, "delegate_task");
13731507 const parentRun = parent.runs
13741508 .filter(ThreadManagementService.isActiveRun)
13751509 .toSorted((left, right) => right.ordinal - left.ordinal)[0];
13761510 if (
13771511 parentRun === undefined ||
13781512 parentRun.rootNodeId === null ||
1379 parentRun.providerInstanceId !== scope.providerInstanceId
1513 parentRun.providerInstanceId !== scope.thread.providerInstanceId
13801514 ) {
13811515 return yield* failure(
13821516 "parent_not_active",
......@@ -1406,7 +1540,7 @@ const make = Effect.gen(function* () {
14061540 createdBy: "agent",
14071541 creationSource: "mcp",
14081542 commandId,
1409 parentThreadId: scope.threadId,
1543 parentThreadId: scope.thread.threadId,
14101544 parentRunId: parentRun.id,
14111545 parentNodeId: parentRun.rootNodeId,
14121546 task: taskPrompt(input),
......@@ -1462,7 +1596,7 @@ const make = Effect.gen(function* () {
14621596 requestKey: key,
14631597 operation: "delegate-task-wake-policy",
14641598 }),
1465 parentThreadId: scope.threadId,
1599 parentThreadId: scope.thread.threadId,
14661600 taskId,
14671601 completionWake: "always",
14681602 })
......@@ -1491,12 +1625,17 @@ const make = Effect.gen(function* () {
14911625 );
14921626 return yield* readTask(scope, taskId, true, true);
14931627 }),
1494 taskStatus: (scope, taskId) => readTask(scope, taskId, false, true),
1495 cancelTask: (scope, input) =>
1628 taskStatus: (callerScope, taskId) =>
14961629 Effect.gen(function* () {
1630 const scope = yield* requireThreadScope(callerScope, "task_status");
1631 return yield* readTask(scope, taskId, false, true);
1632 }),
1633 cancelTask: (callerScope, input) =>
1634 Effect.gen(function* () {
1635 const scope = yield* requireThreadScope(callerScope, "task_cancel");
14971636 const current = yield* readTask(scope, input.taskId);
14981637 const key = yield* requestKey(input.clientRequestId);
1499 const parentProjection = yield* loadProjection(scope.threadId);
1638 const parentProjection = yield* loadProjection(scope.thread.threadId);
15001639 const parentTask = parentProjection.subagents.find(
15011640 (task) => task.id === input.taskId && task.origin === "app_owned",
15021641 );
......@@ -1511,7 +1650,7 @@ const make = Effect.gen(function* () {
15111650 requestKey: key,
15121651 operation: "cancel-task-completion-delivery",
15131652 }),
1514 parentThreadId: scope.threadId,
1653 parentThreadId: scope.thread.threadId,
15151654 taskId: input.taskId,
15161655 })
15171656 .pipe(
......@@ -1573,15 +1712,14 @@ const make = Effect.gen(function* () {
15731712 status: "cancel_requested",
15741713 };
15751714 }),
1576 createThreads: (scope, input) =>
1715 createThreads: (callerScope, input) =>
15771716 Effect.gen(function* () {
1578 yield* requireCapability(scope);
1579 const parent = yield* loadProjection(scope.threadId);
1717 const { scope, parent } = yield* loadThreadCaller(callerScope, "create_threads");
15801718 const parentRun = ThreadManagementService.latestActiveRun(parent);
15811719 if (
15821720 parentRun === undefined ||
15831721 parentRun.rootNodeId === null ||
1584 parentRun.providerInstanceId !== scope.providerInstanceId
1722 parentRun.providerInstanceId !== scope.thread.providerInstanceId
15851723 ) {
15861724 return yield* failure(
15871725 "parent_not_active",
......@@ -1660,7 +1798,7 @@ const make = Effect.gen(function* () {
16601798 index,
16611799 }),
16621800 threadId,
1663 senderThreadId: scope.threadId,
1801 senderThreadId: scope.thread.threadId,
16641802 messageId: stableMessageId({
16651803 scope,
16661804 requestKey: key,
......@@ -1691,7 +1829,7 @@ const make = Effect.gen(function* () {
16911829 operation: "record-created-thread",
16921830 index,
16931831 }),
1694 parentThreadId: scope.threadId,
1832 parentThreadId: scope.thread.threadId,
16951833 parentRunId: parentRun.id,
16961834 parentNodeId,
16971835 targetThreadId: threadId,
......@@ -1722,11 +1860,11 @@ const make = Effect.gen(function* () {
17221860 }),
17231861 listThreads: (scope, input) =>
17241862 Effect.gen(function* () {
1725 yield* requireCapability(scope);
1726 const parent = yield* loadProjection(scope.threadId);
1863 const { parent } = yield* loadCaller(scope);
1864 const projectId = yield* resolveProjectTarget(parent, input.projectId);
17271865 const projectThreads = yield* threadManagement
17281866 .listProjectThreads({
1729 projectId: parent.thread.projectId,
1867 projectId,
17301868 includeSubagents: input.includeSubagents !== false,
17311869 })
17321870 .pipe(
......@@ -1755,8 +1893,8 @@ const make = Effect.gen(function* () {
17551893 const page = filtered.slice(cursor, cursor + limit);
17561894 const nextCursor = cursor + page.length < filtered.length ? cursor + page.length : null;
17571895 return {
1758 projectId: parent.thread.projectId,
1759 currentThreadId: scope.threadId,
1896 projectId,
1897 currentThreadId: parent?.thread.id ?? null,
17601898 threads: page.map(listItemFromShell),
17611899 nextCursor,
17621900 total: filtered.length,
......@@ -1795,8 +1933,13 @@ const make = Effect.gen(function* () {
17951933 { concurrency: 1 },
17961934 );
17971935 const messagesByThreadId = new Map(sourceMessages);
1798 const task = directAppOwnedChildTask(parent, target);
1799 if (task !== undefined && (input.textOffset ?? 0) === 0) {
1936 const task = parent === undefined ? undefined : directAppOwnedChildTask(parent, target);
1937 if (
1938 parent !== undefined &&
1939 scope.thread !== undefined &&
1940 task !== undefined &&
1941 (input.textOffset ?? 0) === 0
1942 ) {
18001943 const transfer = parent.contextTransfers.find(
18011944 (transfer) =>
18021945 transfer.type === "subagent_result" &&
......@@ -1822,7 +1965,13 @@ const make = Effect.gen(function* () {
18221965 maxChars,
18231966 })
18241967 ) {
1825 yield* readTask(scope, task.id, false, true, "thread-read-acknowledge");
1968 yield* readTask(
1969 scope as McpThreadInvocationScope,
1970 task.id,
1971 false,
1972 true,
1973 "thread-read-acknowledge",
1974 );
18261975 }
18271976 }
18281977 return {
......@@ -1845,9 +1994,10 @@ const make = Effect.gen(function* () {
18451994 }),
18461995 sendToThread: (scope, input) =>
18471996 Effect.gen(function* () {
1848 const { parent, target } = yield* loadScopedThread(scope, input.threadId);
1849 yield* resolveRuntimeMode(parent.thread.runtimeMode, target.thread.runtimeMode);
1850 yield* resolveInteractionMode(parent.thread.interactionMode, target.thread.interactionMode);
1997 const { parent, limits, target } = yield* loadScopedThread(scope, input.threadId);
1998 yield* assertLiveCallerForOtherThread(scope, parent, target);
1999 yield* resolveRuntimeMode(limits.runtimeMode, target.thread.runtimeMode);
2000 yield* resolveInteractionMode(limits.interactionMode, target.thread.interactionMode);
18512001
18522002 const mode = input.mode ?? "auto";
18532003 const key = yield* requestKey(input.clientRequestId);
......@@ -1858,14 +2008,14 @@ const make = Effect.gen(function* () {
18582008 });
18592009 const result = yield* threadManagement
18602010 .sendToThread({
1861 projectId: parent.thread.projectId,
2011 projectId: target.thread.projectId,
18622012 commandId: stableCommandId({
18632013 scope,
18642014 requestKey: key,
18652015 operation: "thread-send",
18662016 }),
18672017 threadId: input.threadId,
1868 senderThreadId: scope.threadId,
2018 ...(parent === undefined ? {} : { senderThreadId: parent.thread.id }),
18692019 messageId,
18702020 text: input.message,
18712021 attachments: [],
......@@ -1893,10 +2043,10 @@ const make = Effect.gen(function* () {
18932043 }),
18942044 waitForThread: (scope, input) =>
18952045 Effect.gen(function* () {
1896 const { parent } = yield* loadScopedThread(scope, input.threadId);
2046 const { target } = yield* loadScopedThread(scope, input.threadId);
18972047 const result = yield* threadManagement
18982048 .waitForThread({
1899 projectId: parent.thread.projectId,
2049 projectId: target.thread.projectId,
19002050 threadId: input.threadId,
19012051 ...(input.runId === undefined ? {} : { runId: input.runId }),
19022052 timeoutMs: Math.min(
......@@ -1914,11 +2064,15 @@ const make = Effect.gen(function* () {
19142064 }),
19152065 interruptThread: (scope, input) =>
19162066 Effect.gen(function* () {
1917 const { parent } = yield* loadScopedThread(scope, input.threadId);
2067 const { parent, limits, target } = yield* loadScopedThread(scope, input.threadId);
2068 yield* assertLiveCallerForOtherThread(scope, parent, target);
2069 // Stopping another thread's work is a write: it must run within the caller's modes.
2070 yield* resolveRuntimeMode(limits.runtimeMode, target.thread.runtimeMode);
2071 yield* resolveInteractionMode(limits.interactionMode, target.thread.interactionMode);
19182072 const key = yield* requestKey(input.clientRequestId);
19192073 const result = yield* threadManagement
19202074 .interruptThread({
1921 projectId: parent.thread.projectId,
2075 projectId: target.thread.projectId,
19222076 commandId: stableCommandId({
19232077 scope,
19242078 requestKey: key,
......@@ -1962,4 +2116,5 @@ export const layer: Layer.Layer<
19622116 | ProviderRegistry.ProviderRegistry
19632117 | ProviderAdapterRegistry.ProviderAdapterRegistryV2
19642118 | ScheduledTaskService.ScheduledTaskService
2119 | ProjectService.ProjectService
19652120> = Layer.effect(OrchestratorMcpService, make);
apps/server/src/mcp/OrchestratorMcpToolkit.integration.test.ts+42-15
......@@ -71,6 +71,7 @@ import {
7171 materializeReplayTranscriptWorkspace,
7272} from "../orchestration-v2/testkit/ReplayTranscriptNdjson.ts";
7373import { makeProviderRegistryLayer } from "../provider/testUtils/providerRegistryMock.ts";
74import * as ProjectService from "../project/ProjectService.ts";
7475import * as ScheduledTaskService from "../scheduledTasks/ScheduledTaskService.ts";
7576import * as McpHttpServer from "./McpHttpServer.ts";
7677import * as McpInvocationContext from "./McpInvocationContext.ts";
......@@ -631,6 +632,16 @@ describe("orchestrator MCP toolkit", () => {
631632 Layer.provide(registryLayer),
632633 Layer.provide(providerRegistryLayer),
633634 Layer.provide(scheduledTaskStubLayer),
635 Layer.provide(
636 Layer.mock(ProjectService.ProjectService)({
637 getById: (id) =>
638 Effect.succeed(
639 id === projectId
640 ? Option.some({ id, defaultModelSelection: null } as never)
641 : Option.none(),
642 ),
643 }),
644 ),
634645 Layer.provide(NodeServices.layer),
635646 );
636647
......@@ -676,9 +687,13 @@ describe("orchestrator MCP toolkit", () => {
676687
677688 const invocation: McpInvocationContext.McpInvocationScope = {
678689 environmentId: EnvironmentId.make("environment:mcp-orchestrator"),
679 threadId: parentThreadId,
680 providerSessionId: "mcp-provider-session-parent",
681 providerInstanceId: codexInstanceId,
690 requestNamespace: "mcp-provider-session-parent",
691 thread: {
692 threadId: parentThreadId,
693 providerSessionId: "mcp-provider-session-parent",
694 providerInstanceId: codexInstanceId,
695 },
696 client: undefined,
682697 capabilities: new Set(["orchestration"]),
683698 issuedAt: 1,
684699 };
......@@ -2326,31 +2341,38 @@ describe("orchestrator MCP toolkit", () => {
23262341 branch: null,
23272342 worktreePath: cwd,
23282343 });
2344 // Targets reach the whole environment; the caller's modes still cap writes.
23292345 const foreignOrganizeCall = yield* invoke("t3_thread_organize", {
23302346 threadId: foreignThreadId,
23312347 action: "pin",
23322348 });
2333 expect(foreignOrganizeCall.structuredContent).toMatchObject({
2334 code: "thread_not_found",
2335 });
2336 expect((yield* orchestrator.getThreadShell(foreignThreadId))?.pinnedAt).toBeNull();
2349 expect(foreignOrganizeCall.isError).toBe(false);
2350 expect((yield* orchestrator.getThreadShell(foreignThreadId))?.pinnedAt).not.toBeNull();
23372351
23382352 const foreignReadCall = yield* invoke("t3_thread_read", {
23392353 threadId: foreignThreadId,
23402354 });
23412355 expect(foreignReadCall.structuredContent).toMatchObject({
2342 _tag: "OrchestratorMcpFailure",
2343 code: "thread_not_found",
2356 thread: { threadId: foreignThreadId, projectId: "project:mcp-foreign" },
23442357 });
23452358 const foreignUpdateCall = yield* invoke("t3_thread_update", {
23462359 threadId: foreignThreadId,
23472360 action: "rename",
2348 title: "Should stay foreign",
2361 title: "Renamed from another project",
23492362 });
23502363 expect(foreignUpdateCall.structuredContent).toMatchObject({
2351 _tag: "OrchestratorMcpFailure",
2352 code: "thread_not_found",
2364 threadId: foreignThreadId,
2365 title: "Renamed from another project",
2366 });
2367 const foreignListCall = yield* invoke("t3_thread_list", {
2368 projectId: "project:mcp-foreign",
23532369 });
2370 const foreignListed = yield* decodeThreadListResult(
2371 foreignListCall.structuredContent,
2372 ).pipe(Effect.orDie);
2373 expect(foreignListed.threads.map((thread) => thread.threadId)).toEqual([
2374 foreignThreadId,
2375 ]);
23542376 const listCall = yield* invoke("t3_thread_list", {
23552377 includeSubagents: false,
23562378 limit: 100,
......@@ -3548,6 +3570,7 @@ describe("orchestrator MCP toolkit", () => {
35483570 ),
35493571 Layer.provide(providerRegistryLayer),
35503572 Layer.provide(unusedScheduledTaskStubLayer),
3573 Layer.provide(Layer.mock(ProjectService.ProjectService)({})),
35513574 Layer.provide(NodeServices.layer),
35523575 );
35533576
......@@ -3602,9 +3625,13 @@ describe("orchestrator MCP toolkit", () => {
36023625
36033626 const invocation: McpInvocationContext.McpInvocationScope = {
36043627 environmentId: EnvironmentId.make("environment:mcp-replay"),
3605 threadId: parentThreadId,
3606 providerSessionId: "mcp-provider-session-replay-parent",
3607 providerInstanceId: codexInstanceId,
3628 requestNamespace: "mcp-provider-session-replay-parent",
3629 thread: {
3630 threadId: parentThreadId,
3631 providerSessionId: "mcp-provider-session-replay-parent",
3632 providerInstanceId: codexInstanceId,
3633 },
3634 client: undefined,
36083635 capabilities: new Set(["orchestration"]),
36093636 issuedAt: 1,
36103637 };
apps/server/src/mcp/PreviewAutomationBroker.test.ts+41-27
......@@ -36,9 +36,13 @@ const makeBroker = PreviewAutomationBroker.make.pipe(Effect.provide(NodeServices
3636
3737const scope = {
3838 environmentId: EnvironmentId.make("environment-1"),
39 threadId: ThreadId.make("thread-1"),
40 providerSessionId: "provider-session-1",
41 providerInstanceId: ProviderInstanceId.make("codex"),
39 requestNamespace: "provider-session-1",
40 thread: {
41 threadId: ThreadId.make("thread-1"),
42 providerSessionId: "provider-session-1",
43 providerInstanceId: ProviderInstanceId.make("codex"),
44 },
45 client: undefined,
4246 capabilities: new Set(["preview"] as const),
4347 issuedAt: 1,
4448};
......@@ -366,9 +370,9 @@ it.effect("preserves bounded request and remote selector diagnostics", () => {
366370 expect(error).toMatchObject({
367371 operation: "click",
368372 environmentId: scope.environmentId,
369 threadId: scope.threadId,
370 providerSessionId: scope.providerSessionId,
371 providerInstanceId: scope.providerInstanceId,
373 threadId: scope.thread.threadId,
374 providerSessionId: scope.thread.providerSessionId,
375 providerInstanceId: scope.thread.providerInstanceId,
372376 clientId: "client-1",
373377 requestId: "preview-0",
374378 tabId: "tab-1",
......@@ -468,7 +472,7 @@ it.effect.each([
468472 .pipe(Effect.flip);
469473 expect(error).toMatchObject({
470474 _tag: tag,
471 threadId: scope.threadId,
475 threadId: scope.thread.threadId,
472476 });
473477 expect(error.cause).toBe(remoteError);
474478 expect(error.message).toContain("remains on the desktop");
......@@ -500,9 +504,9 @@ it.effect("distinguishes malformed remote failures", () =>
500504 expect(error).toMatchObject({
501505 operation: "status",
502506 environmentId: scope.environmentId,
503 threadId: scope.threadId,
504 providerSessionId: scope.providerSessionId,
505 providerInstanceId: scope.providerInstanceId,
507 threadId: scope.thread.threadId,
508 providerSessionId: scope.thread.providerSessionId,
509 providerInstanceId: scope.thread.providerInstanceId,
506510 clientId: "client-1",
507511 requestId: "preview-0",
508512 timeoutMs: 2_000,
......@@ -522,9 +526,9 @@ it.effect("rejects calls when no connected host exists", () =>
522526 expect(error).toMatchObject({
523527 operation: "status",
524528 environmentId: scope.environmentId,
525 threadId: scope.threadId,
526 providerSessionId: scope.providerSessionId,
527 providerInstanceId: scope.providerInstanceId,
529 threadId: scope.thread.threadId,
530 providerSessionId: scope.thread.providerSessionId,
531 providerInstanceId: scope.thread.providerInstanceId,
528532 });
529533 }),
530534);
......@@ -590,8 +594,11 @@ it.effect("routes requests for background threads through an environment-level h
590594 const result = yield* broker.invoke<string>({
591595 scope: {
592596 ...scope,
593 threadId: backgroundThreadId,
594 providerSessionId: "provider-session-background",
597 thread: {
598 ...scope.thread,
599 threadId: backgroundThreadId,
600 providerSessionId: "provider-session-background",
601 },
595602 },
596603 operation: "status",
597604 input: {},
......@@ -688,7 +695,7 @@ it.effect("pins a provider session to its initial host despite later focus chang
688695 environmentId: scope.environmentId,
689696 connectionId: "connection-stale",
690697 focused: true,
691 liveTabs: [{ threadId: scope.threadId, tabId: PreviewTabId.make("stale-tab") }],
698 liveTabs: [{ threadId: scope.thread.threadId, tabId: PreviewTabId.make("stale-tab") }],
692699 });
693700 expect(yield* broker.invoke<string>({ scope, operation: "status", input: {} })).toBe(
694701 "second",
......@@ -702,7 +709,7 @@ it.effect("pins a provider session to its initial host despite later focus chang
702709
703710 const firstPinnedScope = {
704711 ...scope,
705 providerSessionId: "provider-session-first-pinned",
712 thread: { ...scope.thread, providerSessionId: "provider-session-first-pinned" },
706713 };
707714 expect(
708715 yield* broker.invoke<string>({ scope: firstPinnedScope, operation: "status", input: {} }),
......@@ -720,7 +727,10 @@ it.effect("pins a provider session to its initial host despite later focus chang
720727 ).toBe("first");
721728 expect(
722729 yield* broker.invoke<string>({
723 scope: { ...scope, providerSessionId: "provider-session-second-pinned" },
730 scope: {
731 ...scope,
732 thread: { ...scope.thread, providerSessionId: "provider-session-second-pinned" },
733 },
724734 operation: "status",
725735 input: {},
726736 }),
......@@ -756,7 +766,7 @@ it.effect("prefers the live tab owner for new sessions without moving existing l
756766 connectionId: connections.get("owner")!,
757767 focused: false,
758768 liveTabs: [
759 { threadId: scope.threadId, tabId: PreviewTabId.make("signed-in"), visible: true },
769 { threadId: scope.thread.threadId, tabId: PreviewTabId.make("signed-in"), visible: true },
760770 ],
761771 });
762772 yield* broker.focusHost({
......@@ -765,7 +775,11 @@ it.effect("prefers the live tab owner for new sessions without moving existing l
765775 connectionId: connections.get("other")!,
766776 focused: true,
767777 liveTabs: [
768 { threadId: scope.threadId, tabId: PreviewTabId.make("signed-in"), visible: false },
778 {
779 threadId: scope.thread.threadId,
780 tabId: PreviewTabId.make("signed-in"),
781 visible: false,
782 },
769783 {
770784 threadId: ThreadId.make("another-thread"),
771785 tabId: PreviewTabId.make("different-tab"),
......@@ -778,7 +792,7 @@ it.effect("prefers the live tab owner for new sessions without moving existing l
778792 );
779793 expect(
780794 yield* broker.invoke<string>({
781 scope: { ...scope, providerSessionId: "explicit-owner" },
795 scope: { ...scope, thread: { ...scope.thread, providerSessionId: "explicit-owner" } },
782796 tabId: PreviewTabId.make("signed-in"),
783797 operation: "snapshot",
784798 input: {},
......@@ -786,7 +800,7 @@ it.effect("prefers the live tab owner for new sessions without moving existing l
786800 ).toBe("owner");
787801 expect(
788802 yield* broker.invoke<string>({
789 scope: { ...scope, providerSessionId: "other-tab" },
803 scope: { ...scope, thread: { ...scope.thread, providerSessionId: "other-tab" } },
790804 tabId: PreviewTabId.make("different-tab"),
791805 operation: "evaluate",
792806 input: {},
......@@ -805,7 +819,7 @@ it.effect("prefers the live tab owner for new sessions without moving existing l
805819 );
806820 expect(
807821 yield* broker.invoke<string>({
808 scope: { ...scope, providerSessionId: "after-tab-closed" },
822 scope: { ...scope, thread: { ...scope.thread, providerSessionId: "after-tab-closed" } },
809823 operation: "evaluate",
810824 input: {},
811825 }),
......@@ -1049,7 +1063,7 @@ it.effect("fails over a pinned provider session only after its host disconnects"
10491063 environmentId: scope.environmentId,
10501064 connectionId: firstConnectionId,
10511065 focused: true,
1052 liveTabs: [{ threadId: scope.threadId, tabId: firstTabId }],
1066 liveTabs: [{ threadId: scope.thread.threadId, tabId: firstTabId }],
10531067 });
10541068 expect(yield* broker.invoke({ scope, operation: "open", input: {} })).toEqual({
10551069 host: "first",
......@@ -1193,9 +1207,9 @@ it.effect("fails requests assigned to the stream that is replaced", () =>
11931207 expect(error).toMatchObject({
11941208 operation: "status",
11951209 environmentId: scope.environmentId,
1196 threadId: scope.threadId,
1197 providerSessionId: scope.providerSessionId,
1198 providerInstanceId: scope.providerInstanceId,
1210 threadId: scope.thread.threadId,
1211 providerSessionId: scope.thread.providerSessionId,
1212 providerInstanceId: scope.thread.providerInstanceId,
11991213 clientId: "client-1",
12001214 requestId: "preview-0",
12011215 timeoutMs: 15_000,
apps/server/src/mcp/PreviewAutomationBroker.ts+14-13
......@@ -40,7 +40,8 @@ import * as SynchronizedRef from "effect/SynchronizedRef";
4040import * as McpInvocationContext from "./McpInvocationContext.ts";
4141
4242export interface PreviewAutomationInvokeInput {
43 readonly scope: McpInvocationContext.McpInvocationScope;
43 /** Preview tabs belong to a thread, so only thread callers reach the broker. */
44 readonly scope: McpInvocationContext.McpThreadInvocationScope;
4445 readonly operation: PreviewAutomationOperation;
4546 readonly input: unknown;
4647 readonly tabId?: PreviewTabId;
......@@ -103,9 +104,9 @@ interface HostAssignment {
103104interface PreviewAutomationRequestErrorContext {
104105 readonly operation: PreviewAutomationOperation;
105106 readonly environmentId: McpInvocationContext.McpInvocationScope["environmentId"];
106 readonly threadId: McpInvocationContext.McpInvocationScope["threadId"];
107 readonly threadId: McpInvocationContext.McpThreadCaller["threadId"];
107108 readonly providerSessionId: string;
108 readonly providerInstanceId: McpInvocationContext.McpInvocationScope["providerInstanceId"];
109 readonly providerInstanceId: McpInvocationContext.McpThreadCaller["providerInstanceId"];
109110 readonly clientId: string;
110111 readonly connectionId: ClientConnection["connectionId"];
111112 readonly requestId: string;
......@@ -160,8 +161,8 @@ const selectorDiagnosticsFromInput = (
160161 return {};
161162};
162163
163const hostAssignmentKey = (scope: McpInvocationContext.McpInvocationScope): string =>
164 `${scope.environmentId}\u0000${scope.providerSessionId}`;
164const hostAssignmentKey = (scope: McpInvocationContext.McpThreadInvocationScope): string =>
165 `${scope.environmentId}\u0000${scope.thread.providerSessionId}`;
165166
166167const isPreviewTabId = Schema.is(PreviewTabId);
167168
......@@ -498,7 +499,7 @@ export const make = Effect.gen(function* PreviewAutomationBrokerMake() {
498499 const ownsTargetTab = (host: ClientConnection, visibleOnly = false) =>
499500 host.liveTabs.some(
500501 (tab) =>
501 tab.threadId === input.scope.threadId &&
502 tab.threadId === input.scope.thread.threadId &&
502503 (!visibleOnly || tab.visible === true) &&
503504 (input.tabId === undefined || tab.tabId === input.tabId),
504505 );
......@@ -545,9 +546,9 @@ export const make = Effect.gen(function* PreviewAutomationBrokerMake() {
545546 const context: PreviewAutomationRequestErrorContext = {
546547 operation: input.operation,
547548 environmentId: input.scope.environmentId,
548 threadId: input.scope.threadId,
549 providerSessionId: input.scope.providerSessionId,
550 providerInstanceId: input.scope.providerInstanceId,
549 threadId: input.scope.thread.threadId,
550 providerSessionId: input.scope.thread.providerSessionId,
551 providerInstanceId: input.scope.thread.providerInstanceId,
551552 clientId: connection.clientId,
552553 connectionId: connection.connectionId,
553554 requestId,
......@@ -566,9 +567,9 @@ export const make = Effect.gen(function* PreviewAutomationBrokerMake() {
566567 return yield* new PreviewAutomationNoAvailableHostError({
567568 operation: input.operation,
568569 environmentId: input.scope.environmentId,
569 threadId: input.scope.threadId,
570 providerSessionId: input.scope.providerSessionId,
571 providerInstanceId: input.scope.providerInstanceId,
570 threadId: input.scope.thread.threadId,
571 providerSessionId: input.scope.thread.providerSessionId,
572 providerInstanceId: input.scope.thread.providerInstanceId,
572573 });
573574 }
574575 const { connection, requestId, requestContext, requestSequence } = route;
......@@ -594,7 +595,7 @@ export const make = Effect.gen(function* PreviewAutomationBrokerMake() {
594595 connectionId: connection.connectionId,
595596 request: {
596597 requestId,
597 threadId: input.scope.threadId,
598 threadId: input.scope.thread.threadId,
598599 tabId: requestContext.tabId,
599600 tabIdExplicit: input.tabId !== undefined,
600601 operation: input.operation,
apps/server/src/mcp/ThreadMetadataMcpService.test.ts+95-3
......@@ -12,9 +12,13 @@ import * as ThreadMetadataMcp from "./ThreadMetadataMcpService.ts";
1212const threadId = ThreadId.make("thread:metadata-caller");
1313const scope: McpInvocationContext.McpInvocationScope = {
1414 environmentId: EnvironmentId.make("environment:metadata-test"),
15 threadId,
16 providerSessionId: "provider-session:metadata-test",
17 providerInstanceId: ProviderInstanceId.make("codex"),
15 requestNamespace: "provider-session:metadata-test",
16 thread: {
17 threadId,
18 providerSessionId: "provider-session:metadata-test",
19 providerInstanceId: ProviderInstanceId.make("codex"),
20 },
21 client: undefined,
1822 capabilities: new Set(["orchestration"]),
1923 issuedAt: 1,
2024};
......@@ -74,3 +78,91 @@ it.effect("keeps calling-thread storage failures as orchestration errors", () =>
7478 expect(error.code).toBe("orchestration_error");
7579 }),
7680);
81
82it.effect("refuses to change a thread that runs above the caller's modes", () =>
83 Effect.gen(function* () {
84 const fullAccessThread = ThreadId.make("thread:metadata-full-access");
85 const shells = new Map([
86 [
87 threadId,
88 {
89 id: threadId,
90 projectId: "project",
91 runtimeMode: "auto",
92 interactionMode: "default",
93 activeRunId: "run-live",
94 archivedAt: null,
95 providerInstanceId: "codex",
96 deletedAt: null,
97 },
98 ],
99 [
100 fullAccessThread,
101 {
102 id: fullAccessThread,
103 projectId: "project",
104 runtimeMode: "full-access",
105 interactionMode: "default",
106 deletedAt: null,
107 },
108 ],
109 ]);
110 const error = yield* Effect.gen(function* () {
111 const service = yield* ThreadMetadataMcp.ThreadMetadataMcpService;
112 return yield* service.update(scope, {
113 threadId: fullAccessThread,
114 action: "rename",
115 title: "Renamed from a narrower thread",
116 });
117 }).pipe(
118 Effect.provide(serviceLayer((id) => Effect.succeed((shells.get(id) ?? null) as never))),
119 Effect.flip,
120 );
121
122 expect(error.code).toBe("runtime_mode_escalation_denied");
123 }),
124);
125
126it.effect("refuses another thread's metadata to a thread caller whose run has ended", () =>
127 Effect.gen(function* () {
128 const otherThread = ThreadId.make("thread:metadata-other");
129 const shells = new Map([
130 [
131 threadId,
132 {
133 id: threadId,
134 projectId: "project",
135 runtimeMode: "full-access",
136 interactionMode: "default",
137 activeRunId: null,
138 archivedAt: null,
139 providerInstanceId: "codex",
140 deletedAt: null,
141 },
142 ],
143 [
144 otherThread,
145 {
146 id: otherThread,
147 projectId: "project",
148 runtimeMode: "approval-required",
149 interactionMode: "default",
150 deletedAt: null,
151 },
152 ],
153 ]);
154 const error = yield* Effect.gen(function* () {
155 const service = yield* ThreadMetadataMcp.ThreadMetadataMcpService;
156 return yield* service.update(scope, {
157 threadId: otherThread,
158 action: "rename",
159 title: "Renamed after the run ended",
160 });
161 }).pipe(
162 Effect.provide(serviceLayer((id) => Effect.succeed((shells.get(id) ?? null) as never))),
163 Effect.flip,
164 );
165
166 expect(error.code).toBe("parent_not_active");
167 }),
168);
apps/server/src/mcp/ThreadMetadataMcpService.ts+52-22
......@@ -16,6 +16,7 @@ import * as Layer from "effect/Layer";
1616
1717import * as ThreadManagementService from "../orchestration-v2/ThreadManagementService.ts";
1818import type { McpInvocationScope } from "./McpInvocationContext.ts";
19import { assertTargetWithinLimits } from "./threadAccess.ts";
1920
2021export class ThreadMetadataMcpService extends Context.Service<
2122 ThreadMetadataMcpService,
......@@ -57,7 +58,7 @@ function commandId(input: {
5758 [
5859 "command",
5960 "mcp",
60 stablePart(input.scope.providerSessionId),
61 stablePart(input.scope.requestNamespace),
6162 "thread-update",
6263 stablePart(input.threadId),
6364 stablePart(input.action),
......@@ -146,36 +147,65 @@ const make = Effect.gen(function* () {
146147 );
147148 }
148149
149 const parentShell = yield* threadManagement
150 .getThreadShell(scope.threadId)
151 .pipe(
152 Effect.mapError((error) =>
153 failure(
154 "orchestration_error",
155 `Unable to locate calling thread ${scope.threadId}: ${errorMessage(error)}`,
156 ),
157 ),
150 const threadId = input.threadId ?? scope.thread?.threadId;
151 if (threadId === undefined) {
152 return yield* failure(
153 "target_required",
154 "Pass threadId: this MCP client is not running inside a T3 thread.",
158155 );
159 if (parentShell === null) {
160 return yield* failure("thread_not_found", `Calling thread ${scope.threadId} was not found.`);
161156 }
162 const parent = yield* threadManagement
163 .getThreadRecords(scope.threadId, [])
157 const shell = yield* threadManagement
158 .getThreadShell(threadId)
164159 .pipe(
165160 Effect.mapError((error) =>
166161 failure(
167162 "orchestration_error",
168 `Unable to read calling thread ${scope.threadId}: ${errorMessage(error)}`,
163 `Unable to locate thread ${threadId}: ${errorMessage(error)}`,
169164 ),
170165 ),
171166 );
172 const threadId = input.threadId ?? scope.threadId;
173 const target =
174 threadId === scope.threadId
175 ? parent
176 : yield* threadManagement
177 .getProjectThreadRecords({ projectId: parent.thread.projectId, threadId }, [])
178 .pipe(Effect.mapError(threadLookupFailure));
167 if (shell === null || shell.deletedAt !== null) {
168 return yield* failure("thread_not_found", `Thread ${threadId} was not found.`);
169 }
170 // Another thread may only be changed if it runs within the caller's own modes.
171 if (threadId !== scope.thread?.threadId) {
172 const limits =
173 scope.thread === undefined
174 ? {
175 runtimeMode: scope.client?.runtimeModeCeiling ?? ("approval-required" as const),
176 interactionMode: "default" as const,
177 }
178 : yield* threadManagement.getThreadShell(scope.thread.threadId).pipe(
179 Effect.mapError((error) =>
180 failure(
181 "orchestration_error",
182 `Unable to locate calling thread: ${errorMessage(error)}`,
183 ),
184 ),
185 Effect.flatMap((caller) =>
186 caller === null
187 ? Effect.fail(failure("thread_not_found", "The calling thread was not found."))
188 : // Like every other cross-thread write, a thread caller needs its live run.
189 caller.archivedAt !== null ||
190 caller.activeRunId === null ||
191 caller.providerInstanceId !== scope.thread?.providerInstanceId
192 ? Effect.fail(
193 failure(
194 "parent_not_active",
195 "The calling provider no longer owns an active thread run.",
196 ),
197 )
198 : Effect.succeed({
199 runtimeMode: caller.runtimeMode,
200 interactionMode: caller.interactionMode,
201 }),
202 ),
203 );
204 yield* assertTargetWithinLimits(limits, shell);
205 }
206 const target = yield* threadManagement
207 .getProjectThreadRecords({ projectId: shell.projectId, threadId }, [])
208 .pipe(Effect.mapError(threadLookupFailure));
179209 const requestKey =
180210 input.clientRequestId === undefined
181211 ? yield* crypto.randomUUIDv4.pipe(Effect.orDie)
apps/server/src/mcp/WorktreeMcpService.test.ts+7-3
......@@ -42,9 +42,13 @@ const makeScope = (
4242 capabilities: ReadonlySet<McpInvocationContext.McpCapability>,
4343): McpInvocationContext.McpInvocationScope => ({
4444 environmentId,
45 threadId,
46 providerSessionId: "provider-session-worktree-test",
47 providerInstanceId: ProviderInstanceId.make("claudeAgent"),
45 requestNamespace: "provider-session-worktree-test",
46 thread: {
47 threadId,
48 providerSessionId: "provider-session-worktree-test",
49 providerInstanceId: ProviderInstanceId.make("claudeAgent"),
50 },
51 client: undefined,
4852 capabilities,
4953 issuedAt: 1,
5054});
apps/server/src/mcp/WorktreeMcpService.ts+43-26
......@@ -23,7 +23,7 @@ import * as ProjectService from "../project/ProjectService.ts";
2323import * as ProjectSetupScriptRunner from "../project/ProjectSetupScriptRunner.ts";
2424import * as ServerSettings from "../serverSettings.ts";
2525import * as VcsStatusBroadcaster from "../vcs/VcsStatusBroadcaster.ts";
26import type { McpInvocationScope } from "./McpInvocationContext.ts";
26import type { McpInvocationScope, McpThreadInvocationScope } from "./McpInvocationContext.ts";
2727
2828export class WorktreeMcpService extends Context.Service<
2929 WorktreeMcpService,
......@@ -70,6 +70,17 @@ const make = Effect.gen(function* () {
7070 // one untracked on disk.
7171 const handoffThreadsInFlight = new Set<string>();
7272
73 // Worktree tools act on the calling thread's own checkout binding.
74 const requireThreadScope = (scope: McpInvocationScope) =>
75 scope.thread === undefined
76 ? Effect.fail(
77 failure(
78 "thread_credential_required",
79 "Worktree handoff and status act as the calling T3 thread, so they need an agent running inside T3 Code.",
80 ),
81 )
82 : Effect.succeed(scope as McpThreadInvocationScope);
83
7384 const requireCapability = (scope: McpInvocationScope) =>
7485 scope.capabilities.has("worktree")
7586 ? Effect.void
......@@ -77,23 +88,23 @@ const make = Effect.gen(function* () {
7788 failure("capability_denied", "This MCP credential does not grant worktree capabilities."),
7889 );
7990
80 const loadThread = (scope: McpInvocationScope) =>
81 threadManagement.getThreadRecords(scope.threadId, []).pipe(
91 const loadThread = (scope: McpThreadInvocationScope) =>
92 threadManagement.getThreadRecords(scope.thread.threadId, []).pipe(
8293 Effect.mapError((error) =>
8394 error._tag === "OrchestratorProjectionError"
84 ? failure("thread_not_found", `Thread '${scope.threadId}' was not found.`)
95 ? failure("thread_not_found", `Thread '${scope.thread.threadId}' was not found.`)
8596 : failure(
8697 "operation_failed",
87 `Unable to read thread ${scope.threadId}: ${errorMessage(error)}`,
98 `Unable to read thread ${scope.thread.threadId}: ${errorMessage(error)}`,
8899 ),
89100 ),
90101 Effect.filterOrFail(
91102 (projection) => projection.thread.deletedAt === null,
92 () => failure("thread_not_found", `Thread '${scope.threadId}' was not found.`),
103 () => failure("thread_not_found", `Thread '${scope.thread.threadId}' was not found.`),
93104 ),
94105 );
95106
96 const loadProject = (scope: McpInvocationScope, projectId: ProjectId) =>
107 const loadProject = (scope: McpThreadInvocationScope, projectId: ProjectId) =>
97108 projects.getById(projectId).pipe(
98109 asOperationFailed(`Unable to read project ${projectId}`),
99110 Effect.flatMap(
......@@ -102,7 +113,7 @@ const make = Effect.gen(function* () {
102113 Effect.fail(
103114 failure(
104115 "project_not_found",
105 `Project '${projectId}' was not found for thread '${scope.threadId}'.`,
116 `Project '${projectId}' was not found for thread '${scope.thread.threadId}'.`,
106117 ),
107118 ),
108119 onSome: Effect.succeed,
......@@ -115,11 +126,13 @@ const make = Effect.gen(function* () {
115126 asOperationFailed("Unable to read server settings"),
116127 );
117128
118 const handoffIds = (scope: McpInvocationScope) =>
129 const handoffIds = (scope: McpThreadInvocationScope) =>
119130 crypto.randomUUIDv4.pipe(
120131 Effect.map((uuid) => {
121132 const part = (kind: string, operation: string) =>
122 [kind, "mcp", encodeURIComponent(scope.providerSessionId), operation, uuid].join(":");
133 [kind, "mcp", encodeURIComponent(scope.thread.providerSessionId), operation, uuid].join(
134 ":",
135 );
123136 return {
124137 commandId: CommandId.make(part("command", "worktree-handoff")),
125138 continuationCommandId: CommandId.make(part("command", "worktree-continuation")),
......@@ -130,13 +143,13 @@ const make = Effect.gen(function* () {
130143 );
131144
132145 const performHandoff = Effect.fn("WorktreeMcpService.performHandoff")(function* (
133 scope: McpInvocationScope,
146 scope: McpThreadInvocationScope,
134147 input: WorktreeMcpHandoffInput,
135148 ) {
136149 const alreadyInWorktree = (worktreePath: string) =>
137150 failure(
138151 "already_in_worktree",
139 `Thread '${scope.threadId}' is already attached to worktree '${worktreePath}'.`,
152 `Thread '${scope.thread.threadId}' is already attached to worktree '${worktreePath}'.`,
140153 );
141154
142155 const projection = yield* loadThread(scope);
......@@ -148,7 +161,7 @@ const make = Effect.gen(function* () {
148161 if (projection.thread.archivedAt !== null) {
149162 return yield* failure(
150163 "invalid_request",
151 `Thread '${scope.threadId}' is archived and cannot be handed off to a worktree.`,
164 `Thread '${scope.thread.threadId}' is archived and cannot be handed off to a worktree.`,
152165 );
153166 }
154167
......@@ -263,7 +276,7 @@ const make = Effect.gen(function* () {
263276 Effect.catchCause((cause: Cause.Cause<unknown>) => {
264277 const detail = errorMessage(Cause.squash(cause));
265278 return Effect.logWarning(logMessage, {
266 threadId: scope.threadId,
279 threadId: scope.thread.threadId,
267280 worktreePath,
268281 detail,
269282 }).pipe(Effect.as({ status: "failed", detail } as const));
......@@ -301,14 +314,14 @@ const make = Effect.gen(function* () {
301314 if (recheck.thread.archivedAt !== null) {
302315 return yield* failure(
303316 "invalid_request",
304 `Thread '${scope.threadId}' was archived while the worktree was being created; the handoff was rolled back.`,
317 `Thread '${scope.thread.threadId}' was archived while the worktree was being created; the handoff was rolled back.`,
305318 );
306319 }
307320 yield* threadManagement
308321 .dispatch({
309322 type: "thread.metadata.update",
310323 commandId: ids.commandId,
311 threadId: scope.threadId,
324 threadId: scope.thread.threadId,
312325 branch: worktree.worktree.refName,
313326 worktreePath,
314327 expectedWorktreePath: null,
......@@ -357,7 +370,7 @@ const make = Effect.gen(function* () {
357370 .sendToThread({
358371 projectId: projection.thread.projectId,
359372 commandId: ids.continuationCommandId,
360 threadId: scope.threadId,
373 threadId: scope.thread.threadId,
361374 messageId: ids.continuationMessageId,
362375 text: input.continuationPrompt,
363376 attachments: [],
......@@ -386,7 +399,7 @@ const make = Effect.gen(function* () {
386399 if (input.runSetupScript ?? true) {
387400 setupScript = yield* setupScriptRunner
388401 .runForThread({
389 threadId: scope.threadId,
402 threadId: scope.thread.threadId,
390403 projectId: projection.thread.projectId,
391404 projectCwd,
392405 worktreePath,
......@@ -430,8 +443,9 @@ const make = Effect.gen(function* () {
430443 });
431444
432445 const handoff: WorktreeMcpService["Service"]["handoff"] = Effect.fn("WorktreeMcpService.handoff")(
433 function* (scope, input) {
434 yield* requireCapability(scope);
446 function* (callerScope, input) {
447 yield* requireCapability(callerScope);
448 const scope = yield* requireThreadScope(callerScope);
435449 // uninterruptibleMask: the guard acquisition and the registration of the
436450 // releasing finalizer happen with no interruptible gap in between. An
437451 // interrupt landing between a bare add() and the start of an ensured
......@@ -439,17 +453,19 @@ const make = Effect.gen(function* () {
439453 // handoff for this thread until restart.
440454 return yield* Effect.uninterruptibleMask((restore) =>
441455 Effect.suspend(() => {
442 if (handoffThreadsInFlight.has(scope.threadId)) {
456 if (handoffThreadsInFlight.has(scope.thread.threadId)) {
443457 return Effect.fail(
444458 failure(
445459 "handoff_in_progress",
446 `A worktree handoff is already in progress for thread '${scope.threadId}'.`,
460 `A worktree handoff is already in progress for thread '${scope.thread.threadId}'.`,
447461 ),
448462 );
449463 }
450 handoffThreadsInFlight.add(scope.threadId);
464 handoffThreadsInFlight.add(scope.thread.threadId);
451465 return restore(performHandoff(scope, input)).pipe(
452 Effect.ensuring(Effect.sync(() => handoffThreadsInFlight.delete(scope.threadId))),
466 Effect.ensuring(
467 Effect.sync(() => handoffThreadsInFlight.delete(scope.thread.threadId)),
468 ),
453469 );
454470 }),
455471 );
......@@ -457,8 +473,9 @@ const make = Effect.gen(function* () {
457473 );
458474
459475 const status: WorktreeMcpService["Service"]["status"] = Effect.fn("WorktreeMcpService.status")(
460 function* (scope) {
461 yield* requireCapability(scope);
476 function* (callerScope) {
477 yield* requireCapability(callerScope);
478 const scope = yield* requireThreadScope(callerScope);
462479 const projection = yield* loadThread(scope);
463480 const project = yield* loadProject(scope, projection.thread.projectId);
464481
apps/server/src/mcp/threadAccess.ts+127-28
......@@ -2,6 +2,9 @@ import type { ProjectionRecordField } from "../orchestration-v2/ProjectionStore.
22import {
33 CommandId,
44 OrchestratorMcpFailure,
5 type ProjectId,
6 type ProviderInteractionMode,
7 type RuntimeMode,
58 type ThreadId,
69 type OrchestrationV2ThreadShell,
710} from "@t3tools/contracts";
......@@ -18,6 +21,24 @@ export const unavailable = () =>
1821 message: "The operation could not be completed.",
1922 });
2023
24/**
25 * The most a caller may hand to the threads it targets. A thread caller is
26 * capped by its own thread's modes; an OAuth client by the ceiling chosen
27 * when it was approved.
28 */
29export interface CallerLimits {
30 readonly runtimeMode: RuntimeMode;
31 readonly interactionMode: ProviderInteractionMode;
32}
33
34export interface Caller {
35 readonly scope: McpInvocationContext.McpInvocationScope;
36 readonly threads: ThreadManagement.ThreadManagementService["Service"];
37 /** The calling thread, absent for a client signed in from outside T3. */
38 readonly caller: OrchestrationV2ThreadShell | undefined;
39 readonly limits: CallerLimits;
40}
41
2142export const readCaller = Effect.fn("mcp.readCaller")(function* () {
2243 const scope = yield* McpInvocationContext.McpInvocationContext;
2344 if (!scope.capabilities.has("orchestration")) {
......@@ -27,26 +48,54 @@ export const readCaller = Effect.fn("mcp.readCaller")(function* () {
2748 });
2849 }
2950 const threads = yield* ThreadManagement.ThreadManagementService;
30 const caller = yield* threads.getThreadShell(scope.threadId).pipe(Effect.mapError(unavailable));
51 if (scope.thread === undefined) {
52 return {
53 scope,
54 threads,
55 caller: undefined,
56 limits: {
57 runtimeMode: scope.client?.runtimeModeCeiling ?? "approval-required",
58 interactionMode: "default",
59 },
60 } satisfies Caller;
61 }
62 const caller = yield* threads
63 .getThreadShell(scope.thread.threadId)
64 .pipe(Effect.mapError(unavailable));
3165 if (caller === null || caller.deletedAt !== null) {
3266 return yield* new OrchestratorMcpFailure({
3367 code: "thread_not_found",
3468 message: "The calling thread was not found.",
3569 });
3670 }
37 return { scope, threads, caller };
71 return {
72 scope,
73 threads,
74 caller,
75 limits: { runtimeMode: caller.runtimeMode, interactionMode: caller.interactionMode },
76 } satisfies Caller;
3877});
3978
40function assertLiveCaller({
41 caller,
42 scope,
43}: {
44 caller: OrchestrationV2ThreadShell;
45 scope: McpInvocationContext.McpInvocationScope;
46}) {
79/**
80 * A caller may change another thread only if that thread runs within the
81 * caller's own modes. Its own thread is always within them.
82 */
83export const assertTargetWithinLimits = (
84 limits: CallerLimits,
85 target: { readonly runtimeMode: RuntimeMode; readonly interactionMode: ProviderInteractionMode },
86) =>
87 OrchestrationMcp.resolveRuntimeMode(limits.runtimeMode, target.runtimeMode).pipe(
88 Effect.andThen(
89 OrchestrationMcp.resolveInteractionMode(limits.interactionMode, target.interactionMode),
90 ),
91 Effect.asVoid,
92 );
93
94function assertLiveCaller({ caller, scope }: Caller) {
95 if (caller === undefined) return Effect.void;
4796 return caller.archivedAt !== null ||
4897 caller.activeRunId === null ||
49 caller.providerInstanceId !== scope.providerInstanceId
98 caller.providerInstanceId !== scope.thread?.providerInstanceId
5099 ? Effect.fail(
51100 new OrchestratorMcpFailure({
52101 code: "parent_not_active",
......@@ -55,34 +104,91 @@ function assertLiveCaller({
55104 )
56105 : Effect.void;
57106}
107
108/**
109 * Mutations from a thread need that thread's live run, so an agent whose turn
110 * ended cannot keep acting. An OAuth client has no run; its session and
111 * ceiling are its authority.
112 */
58113export const readMutationCaller = Effect.fn("mcp.readMutationCaller")(function* () {
59114 const context = yield* readCaller();
60115 yield* assertLiveCaller(context);
61116 return context;
62117});
63118
64/** Resolve the credential's project before looking up a caller-supplied thread. */
119/**
120 * Actions that change the environment itself (projects, preferences, launching
121 * outside a project) need full access: a thread caller in full-access/default
122 * mode, or a client approved with a full-access ceiling.
123 */
124export const readFullAccessCaller = Effect.fn("mcp.readFullAccessCaller")(function* (
125 message: string,
126) {
127 const context = yield* readMutationCaller();
128 if (
129 (context.caller !== undefined && context.caller.archivedAt !== null) ||
130 context.limits.runtimeMode !== "full-access" ||
131 context.limits.interactionMode !== "default"
132 ) {
133 return yield* new OrchestratorMcpFailure({ code: "capability_denied", message });
134 }
135 return context;
136});
137
138/** A target project: the one passed, else the calling thread's. */
139export const resolveProjectId = (context: Caller, projectId: ProjectId | undefined) =>
140 projectId !== undefined
141 ? Effect.succeed(projectId)
142 : context.caller !== undefined
143 ? Effect.succeed(context.caller.projectId)
144 : Effect.fail(
145 new OrchestratorMcpFailure({
146 code: "target_required",
147 message: "Pass projectId: this MCP client is not running inside a T3 thread.",
148 }),
149 );
150
151/** A target thread: the one passed, else the calling thread. */
152const resolveThreadId = (context: Caller, threadId: ThreadId | undefined) =>
153 threadId !== undefined
154 ? Effect.succeed(threadId)
155 : context.caller !== undefined
156 ? Effect.succeed(context.caller.id)
157 : Effect.fail(
158 new OrchestratorMcpFailure({
159 code: "target_required",
160 message: "Pass threadId: this MCP client is not running inside a T3 thread.",
161 }),
162 );
163
164/** Load a target thread anywhere in the environment; an omitted id means the calling thread. */
65165export const readThread = Effect.fn("mcp.readThread")(function* <
66166 K extends ProjectionRecordField = never,
67167>(threadId?: ThreadId, fields: ReadonlyArray<K> = []) {
68 const { scope, threads, caller } = yield* readCaller();
69 const projection = yield* threads
70 .getProjectThreadRecords(
71 { projectId: caller.projectId, threadId: threadId ?? caller.id },
72 fields,
73 { turnItemTypes: ["user_input_request"] },
74 )
168 const context = yield* readCaller();
169 const targetId = yield* resolveThreadId(context, threadId);
170 const shell = yield* context.threads.getThreadShell(targetId).pipe(Effect.mapError(unavailable));
171 if (shell === null || shell.deletedAt !== null) {
172 return yield* new OrchestratorMcpFailure({
173 code: "thread_not_found",
174 message: "The thread was not found.",
175 });
176 }
177 const projection = yield* context.threads
178 .getProjectThreadRecords({ projectId: shell.projectId, threadId: targetId }, fields, {
179 turnItemTypes: ["user_input_request"],
180 })
75181 .pipe(
76182 Effect.mapError((error) =>
77183 error._tag === "ThreadManagementThreadNotFoundError"
78184 ? new OrchestratorMcpFailure({
79185 code: "thread_not_found",
80 message: "The thread was not found in the calling project.",
186 message: "The thread was not found.",
81187 })
82188 : unavailable(),
83189 ),
84190 );
85 return { scope, threads, caller, projection };
191 return { ...context, projection };
86192});
87193
88194export const readWritableThread = Effect.fn("mcp.readWritableThread")(function* <
......@@ -90,14 +196,7 @@ export const readWritableThread = Effect.fn("mcp.readWritableThread")(function*
90196>(threadId?: ThreadId, fields: ReadonlyArray<K> = []) {
91197 const context = yield* readThread(threadId, fields);
92198 yield* assertLiveCaller(context);
93 yield* OrchestrationMcp.resolveRuntimeMode(
94 context.caller.runtimeMode,
95 context.projection.thread.runtimeMode,
96 );
97 yield* OrchestrationMcp.resolveInteractionMode(
98 context.caller.interactionMode,
99 context.projection.thread.interactionMode,
100 );
199 yield* assertTargetWithinLimits(context.limits, context.projection.thread);
101200 return context;
102201});
103202
apps/server/src/mcp/toolkits/attachment/handlers.ts+3-3
......@@ -47,7 +47,7 @@ export const AttachmentHandlersLive = AttachmentToolkit.toLayer({
4747 }),
4848 t3_thread_send_attachments: (input) =>
4949 Effect.gen(function* () {
50 const { caller, projection, scope } = yield* readWritableThread(input.threadId, ["messages"]);
50 const { caller, projection } = yield* readWritableThread(input.threadId, ["messages"]);
5151 if (projection.thread.archivedAt !== null)
5252 return yield* new OrchestratorMcpFailure({
5353 code: "invalid_request",
......@@ -60,11 +60,11 @@ export const AttachmentHandlersLive = AttachmentToolkit.toLayer({
6060 const commandId = yield* newCommandId();
6161 const messageId = MessageId.make(commandId);
6262 const result = yield* ThreadMessageIntake.sendToThread({
63 projectId: caller.projectId,
63 projectId: projection.thread.projectId,
6464 threadId: projection.thread.id,
6565 commandId,
6666 messageId,
67 senderThreadId: scope.threadId,
67 ...(caller === undefined ? {} : { senderThreadId: caller.id }),
6868 text: input.message ?? "",
6969 attachments,
7070 mode: "auto",
apps/server/src/mcp/toolkits/attachment/tools.ts+1-1
......@@ -47,7 +47,7 @@ const AttachmentDiscardTool = Tool.make("t3_attachment_discard", {
4747const AttachmentSendTool = Tool.make("t3_thread_send_attachments", {
4848 ...shared,
4949 description:
50 "Send uploaded attachments to this thread or another thread in the calling project. Each call is a new message, without a retry key. Acceptance does not mean the provider can consume the attachment or has finished the turn. The target cannot have broader permission modes than the caller; failures retain claimed files when dispatch outcome is uncertain.",
50 "Send uploaded attachments to this thread or any other thread in this environment. Each call is a new message, without a retry key. Acceptance does not mean the provider can consume the attachment or has finished the turn. The target cannot have broader permission modes than the caller; failures retain claimed files when dispatch outcome is uncertain.",
5151 parameters: Schema.Struct({
5252 threadId: Schema.optional(ThreadId),
5353 message: Schema.optional(Schema.String.check(Schema.isMaxLength(120000))),
apps/server/src/mcp/toolkits/core.test.ts+244-3
......@@ -14,7 +14,11 @@ import { McpAttachmentInput } from "./attachment/input.ts";
1414import { McpSchema, McpServer, Tool } from "effect/unstable/ai";
1515
1616import { OrchestratorProjectionError } from "../../orchestration-v2/Orchestrator.ts";
17import * as ProviderAdapterRegistry from "../../orchestration-v2/ProviderAdapterRegistry.ts";
1718import * as ThreadManagement from "../../orchestration-v2/ThreadManagementService.ts";
19import * as ProjectService from "../../project/ProjectService.ts";
20import * as ProviderRegistry from "../../provider/Services/ProviderRegistry.ts";
21import * as ScheduledTaskService from "../../scheduledTasks/ScheduledTaskService.ts";
1822import * as McpHttpServer from "../McpHttpServer.ts";
1923import * as McpInvocationContext from "../McpInvocationContext.ts";
2024import { OrchestratorToolkit } from "./orchestrator/tools.ts";
......@@ -78,9 +82,13 @@ it("publishes unique tool names with reference-free object-root inputs", () => {
7882const threadId = ThreadId.make("mcp-core-thread");
7983const scope: McpInvocationContext.McpInvocationScope = {
8084 environmentId: EnvironmentId.make("mcp-core-environment"),
81 threadId,
82 providerSessionId: "mcp-core-session",
83 providerInstanceId: ProviderInstanceId.make("codex"),
85 requestNamespace: "mcp-core-session",
86 thread: {
87 threadId,
88 providerSessionId: "mcp-core-session",
89 providerInstanceId: ProviderInstanceId.make("codex"),
90 },
91 client: undefined,
8492 issuedAt: 0,
8593 capabilities: new Set(["orchestration"]),
8694};
......@@ -207,3 +215,236 @@ it.effect("resolves reused attachment references from stored metadata", () =>
207215 expect(failure.code).toBe("invalid_request");
208216 }),
209217);
218
219const clientScope = (
220 runtimeModeCeiling: "approval-required" | "auto-accept-edits" | "auto" | "full-access",
221): McpInvocationContext.McpInvocationScope => ({
222 environmentId: EnvironmentId.make("mcp-core-environment"),
223 requestNamespace: "client:session-1",
224 thread: undefined,
225 client: { sessionId: "session-1", label: "Claude Code", runtimeModeCeiling },
226 issuedAt: 0,
227 capabilities: new Set(["orchestration", "worktree", "pull-requests"]),
228});
229
230it.effect("a client caller targets any thread within its ceiling and cannot act as a thread", () =>
231 Effect.gen(function* () {
232 const server = yield* McpServer.McpServer;
233 const call = (
234 name: string,
235 args: Record<string, unknown>,
236 invocation: McpInvocationContext.McpInvocationScope,
237 ) =>
238 server
239 .callTool({ name, arguments: args })
240 .pipe(
241 Effect.provideService(McpInvocationContext.McpInvocationContext, invocation),
242 Effect.provideService(McpSchema.McpServerClient, client),
243 );
244
245 const untargeted = yield* call("t3_thread_organize", { action: "pin" }, clientScope("auto"));
246 expect(untargeted.structuredContent).toMatchObject({ code: "target_required" });
247
248 const pinned = yield* call(
249 "t3_thread_organize",
250 { action: "pin", threadId: "other-project-thread" },
251 clientScope("auto"),
252 );
253 expect(pinned.isError).toBe(false);
254 expect(pinned.structuredContent).toMatchObject({ sequence: 7 });
255
256 const aboveCeiling = yield* call(
257 "t3_thread_organize",
258 { action: "pin", threadId: "other-project-thread" },
259 clientScope("approval-required"),
260 );
261 expect(aboveCeiling.structuredContent).toMatchObject({
262 code: "runtime_mode_escalation_denied",
263 });
264
265 const forked = yield* call(
266 "t3_thread_fork",
267 { sourcePoint: { type: "latest_stable" } },
268 clientScope("auto"),
269 );
270 expect(forked.structuredContent).toMatchObject({ code: "target_required" });
271 }).pipe(
272 Effect.provide(
273 McpHttpServer.ThreadToolkitRegistrationLive.pipe(
274 Layer.provideMerge(McpServer.McpServer.layer),
275 Layer.provide(NodeCrypto.layer),
276 Layer.provide(
277 Layer.mock(ThreadManagement.ThreadManagementService)({
278 getThreadShell: () =>
279 Effect.succeed({
280 id: ThreadId.make("other-project-thread"),
281 projectId: "other-project",
282 deletedAt: null,
283 } as never),
284 getProjectThreadRecords: () =>
285 Effect.succeed({
286 thread: {
287 id: ThreadId.make("other-project-thread"),
288 projectId: "other-project",
289 runtimeMode: "auto",
290 interactionMode: "default",
291 deletedAt: null,
292 },
293 } as never),
294 dispatch: () => Effect.succeed({ sequence: 7 } as never),
295 }),
296 ),
297 ),
298 ),
299 ),
300);
301
302it.effect("refuses act-as-caller tools to a client caller", () =>
303 Effect.gen(function* () {
304 const server = yield* McpServer.McpServer;
305 const result = yield* server
306 .callTool({
307 name: "delegate_task",
308 arguments: { task: "Review", mode: "async" },
309 })
310 .pipe(
311 Effect.provideService(
312 McpInvocationContext.McpInvocationContext,
313 clientScope("full-access"),
314 ),
315 Effect.provideService(McpSchema.McpServerClient, client),
316 );
317 expect(result.structuredContent).toMatchObject({ code: "thread_credential_required" });
318 }).pipe(
319 Effect.provide(
320 McpHttpServer.OrchestratorToolkitRegistrationLive.pipe(
321 Layer.provideMerge(McpServer.McpServer.layer),
322 Layer.provide(NodeCrypto.layer),
323 Layer.provide(Layer.mock(ThreadManagement.ThreadManagementService)({})),
324 Layer.provide(Layer.mock(ProviderRegistry.ProviderRegistry)({})),
325 Layer.provide(Layer.mock(ProviderAdapterRegistry.ProviderAdapterRegistryV2)({})),
326 Layer.provide(Layer.mock(ScheduledTaskService.ScheduledTaskService)({})),
327 Layer.provide(Layer.mock(ProjectService.ProjectService)({})),
328 ),
329 ),
330 ),
331);
332
333it.effect("a caller cannot rewrite a scheduled task that runs above its own modes", () =>
334 Effect.gen(function* () {
335 const server = yield* McpServer.McpServer;
336 const call = (name: string, args: Record<string, unknown>) =>
337 server
338 .callTool({ name, arguments: args })
339 .pipe(
340 Effect.provideService(McpInvocationContext.McpInvocationContext, clientScope("auto")),
341 Effect.provideService(McpSchema.McpServerClient, client),
342 );
343 const update = yield* call("update_scheduled_task", {
344 scheduledTaskId: "task-full-access",
345 prompt: "Run something else",
346 });
347 expect(update.structuredContent).toMatchObject({ code: "runtime_mode_escalation_denied" });
348 const remove = yield* call("delete_scheduled_task", { scheduledTaskId: "task-full-access" });
349 expect(remove.structuredContent).toMatchObject({ code: "runtime_mode_escalation_denied" });
350 const allowed = yield* call("update_scheduled_task", {
351 scheduledTaskId: "task-auto",
352 enabled: false,
353 });
354 expect(allowed.isError).toBe(false);
355 }).pipe(
356 Effect.provide(
357 McpHttpServer.OrchestratorToolkitRegistrationLive.pipe(
358 Layer.provideMerge(McpServer.McpServer.layer),
359 Layer.provide(NodeCrypto.layer),
360 Layer.provide(Layer.mock(ThreadManagement.ThreadManagementService)({})),
361 Layer.provide(Layer.mock(ProviderRegistry.ProviderRegistry)({})),
362 Layer.provide(Layer.mock(ProviderAdapterRegistry.ProviderAdapterRegistryV2)({})),
363 Layer.provide(
364 Layer.mock(ScheduledTaskService.ScheduledTaskService)({
365 list: () =>
366 Effect.succeed({
367 tasks: [
368 scheduledTask("task-full-access", "full-access"),
369 scheduledTask("task-auto", "auto"),
370 ],
371 }),
372 upsert: (input) =>
373 Effect.succeed({
374 task: { ...(scheduledTask(input.id ?? "task-auto", "auto") as object), ...input },
375 } as never),
376 }),
377 ),
378 Layer.provide(Layer.mock(ProjectService.ProjectService)({})),
379 ),
380 ),
381 ),
382);
383
384function scheduledTask(id: string, runtimeMode: "auto" | "full-access"): never {
385 return {
386 id,
387 title: id,
388 prompt: "Check the build",
389 enabled: true,
390 projectId: "project-a",
391 threadId: null,
392 schedule: { type: "interval", everyMs: 3_600_000 },
393 workspaceStrategy: { type: "worktree", baseRef: "main", startFromOrigin: true },
394 modelSelection: { instanceId: "codex", model: "gpt-5" },
395 runtimeMode,
396 interactionMode: "default",
397 createdBy: "user",
398 creationSource: "web",
399 nextRunAt: null,
400 lastRunStatus: "never",
401 lastRunAt: null,
402 lastRunThreadId: null,
403 lastRunError: null,
404 runCount: 0,
405 createdAt: "2026-10-01T00:00:00.000Z",
406 updatedAt: "2026-10-01T00:00:00.000Z",
407 } as never;
408}
409
410it.effect("a caller cannot interrupt a thread that runs above its own modes", () =>
411 Effect.gen(function* () {
412 const server = yield* McpServer.McpServer;
413 const result = yield* server
414 .callTool({ name: "t3_thread_interrupt", arguments: { threadId: "full-access-thread" } })
415 .pipe(
416 Effect.provideService(McpInvocationContext.McpInvocationContext, clientScope("auto")),
417 Effect.provideService(McpSchema.McpServerClient, client),
418 );
419 expect(result.structuredContent).toMatchObject({ code: "runtime_mode_escalation_denied" });
420 }).pipe(
421 Effect.provide(
422 McpHttpServer.OrchestratorToolkitRegistrationLive.pipe(
423 Layer.provideMerge(McpServer.McpServer.layer),
424 Layer.provide(NodeCrypto.layer),
425 Layer.provide(
426 Layer.mock(ThreadManagement.ThreadManagementService)({
427 getThreadShell: () =>
428 Effect.succeed({ projectId: "project-a", deletedAt: null } as never),
429 getProjectThreadRecords: () =>
430 Effect.succeed({
431 thread: {
432 id: ThreadId.make("full-access-thread"),
433 projectId: "project-a",
434 runtimeMode: "full-access",
435 interactionMode: "default",
436 deletedAt: null,
437 },
438 runs: [],
439 } as never),
440 interruptThread: () => Effect.die("interrupt must not dispatch above the ceiling"),
441 }),
442 ),
443 Layer.provide(Layer.mock(ProviderRegistry.ProviderRegistry)({})),
444 Layer.provide(Layer.mock(ProviderAdapterRegistry.ProviderAdapterRegistryV2)({})),
445 Layer.provide(Layer.mock(ScheduledTaskService.ScheduledTaskService)({})),
446 Layer.provide(Layer.mock(ProjectService.ProjectService)({})),
447 ),
448 ),
449 ),
450);
apps/server/src/mcp/toolkits/device/handlers.ts+6-6
......@@ -66,7 +66,7 @@ export function agentDeviceQuickStart(
6666 ].join("\n");
6767}
6868
69const requireDeviceAccess = McpInvocationContext.requireMcpCapability("device").pipe(
69const requireDeviceAccess = McpInvocationContext.requireThreadMcpCapability("device").pipe(
7070 Effect.mapError(
7171 () =>
7272 new DeviceToolUnavailableError({
......@@ -132,7 +132,7 @@ const handlers = {
132132 }
133133 const hostId = input?.hostId;
134134 const open = state.sessions
135 .filter((session) => session.threadId === scope.threadId)
135 .filter((session) => session.threadId === scope.thread.threadId)
136136 .map((session) => ({ hostId: session.hostId, deviceId: session.deviceId }));
137137 return {
138138 hostStatuses: Object.fromEntries(
......@@ -159,12 +159,12 @@ const handlers = {
159159 const target = yield* pickDevice(state.devices, input);
160160 // Resolve consent and agent connectivity before booting or registering a session.
161161 const agentArgs = yield* devices.agentTarget({
162 threadId: scope.threadId,
162 threadId: scope.thread.threadId,
163163 hostId: target.hostId,
164164 deviceId: target.id,
165165 });
166166 const session = yield* devices.open({
167 threadId: scope.threadId,
167 threadId: scope.thread.threadId,
168168 hostId: target.hostId,
169169 deviceId: target.id,
170170 platform: target.platform,
......@@ -207,7 +207,7 @@ const handlers = {
207207 Effect.gen(function* () {
208208 const scope = yield* requireDeviceAccess;
209209 const devices = yield* DeviceService.DeviceService;
210 const sessions = yield* devices.sessionsForThread(scope.threadId);
210 const sessions = yield* devices.sessionsForThread(scope.thread.threadId);
211211 const target =
212212 input.deviceId !== undefined
213213 ? { hostId: input.hostId ?? LOCAL_DEVICE_HOST_ID, deviceId: input.deviceId }
......@@ -234,7 +234,7 @@ const handlers = {
234234 const scope = yield* requireDeviceAccess;
235235 const devices = yield* DeviceService.DeviceService;
236236 yield* devices.close({
237 threadId: scope.threadId,
237 threadId: scope.thread.threadId,
238238 ...(input.hostId === undefined ? {} : { hostId: input.hostId }),
239239 ...(input.deviceId === undefined ? {} : { deviceId: input.deviceId }),
240240 ...(input.shutdown === undefined ? {} : { shutdown: input.shutdown }),
apps/server/src/mcp/toolkits/environment/handlers.ts+16-20
......@@ -4,7 +4,7 @@ import * as Environment from "../../../environment/ServerEnvironment.ts";
44import * as ThreadCommandExecutor from "../../../orchestration-v2/ThreadCommandExecutor.ts";
55import * as Settings from "../../../serverSettings.ts";
66import * as McpInvocationContext from "../../McpInvocationContext.ts";
7import { readCaller, readMutationCaller, unavailable } from "../../threadAccess.ts";
7import { readCaller, readFullAccessCaller, unavailable } from "../../threadAccess.ts";
88import { EnvironmentToolkit } from "./tools.ts";
99
1010export function preferences(settings: ServerSettings) {
......@@ -30,7 +30,11 @@ export function preferences(settings: ServerSettings) {
3030}
3131const access = (writable = false) =>
3232 Effect.gen(function* () {
33 const context = yield* writable ? readMutationCaller() : readCaller();
33 const context = yield* writable
34 ? readFullAccessCaller(
35 "Preference updates require a live full-access/default thread or a full-access client.",
36 )
37 : readCaller();
3438 const environment = yield* Environment.ServerEnvironment;
3539 const descriptor = yield* environment.getDescriptor;
3640 if (descriptor.environmentId !== context.scope.environmentId)
......@@ -57,23 +61,15 @@ export const EnvironmentHandlersLive = EnvironmentToolkit.toLayer({
5761 Effect.gen(function* () {
5862 const scope = yield* McpInvocationContext.McpInvocationContext;
5963 const executor = yield* ThreadCommandExecutor.ThreadCommandExecutor;
60 return yield* executor.withLock(
61 scope.threadId,
62 Effect.gen(function* () {
63 const { caller, settings } = yield* access(true);
64 if (
65 caller.archivedAt !== null ||
66 caller.runtimeMode !== "full-access" ||
67 caller.interactionMode !== "default"
68 )
69 return yield* new OrchestratorMcpFailure({
70 code: "capability_denied",
71 message: "Preference updates require a live full-access/default thread.",
72 });
73 return preferences(
74 yield* settings.updateSettings(patch).pipe(Effect.mapError(unavailable)),
75 );
76 }),
77 );
64 const update = Effect.gen(function* () {
65 const { settings } = yield* access(true);
66 return preferences(
67 yield* settings.updateSettings(patch).pipe(Effect.mapError(unavailable)),
68 );
69 });
70 // A thread caller serializes with its own turn; a client has no thread to lock.
71 return yield* scope.thread === undefined
72 ? update
73 : executor.withLock(scope.thread.threadId, update);
7874 }),
7975});
apps/server/src/mcp/toolkits/orchestrator/handlers.ts+2-2
......@@ -36,11 +36,11 @@ const handlers = {
3636 const service = yield* OrchestratorMcpService.OrchestratorMcpService;
3737 return yield* service.scheduleTask(scope, input);
3838 }),
39 list_scheduled_tasks: () =>
39 list_scheduled_tasks: (input) =>
4040 Effect.gen(function* () {
4141 const scope = yield* McpInvocationContext.McpInvocationContext;
4242 const service = yield* OrchestratorMcpService.OrchestratorMcpService;
43 return yield* service.listScheduledTasks(scope);
43 return yield* service.listScheduledTasks(scope, input);
4444 }),
4545 update_scheduled_task: (input) =>
4646 Effect.gen(function* () {
apps/server/src/mcp/toolkits/orchestrator/tools.ts+14-12
......@@ -7,6 +7,7 @@ import {
77 OrchestratorMcpDeleteScheduledTaskInput,
88 OrchestratorMcpDeleteScheduledTaskResult,
99 OrchestratorMcpFailure,
10 OrchestratorMcpListScheduledTasksInput,
1011 OrchestratorMcpListScheduledTasksResult,
1112 OrchestratorMcpScheduleTaskInput,
1213 OrchestratorMcpScheduleTaskResult,
......@@ -44,7 +45,7 @@ const threadMetadataDependencies = [
4445
4546const OrchestratorCapabilitiesTool = Tool.make("orchestrator_capabilities", {
4647 description:
47 "List the V2 provider instances and their current models from the same live catalog as the composer, including configured custom models, inherited runtime settings, and app-owned orchestration features available to this T3 thread. For a separate top-level thread in a new or existing worktree, use t3_thread_launch with workspaceStrategy.",
48 "List the V2 provider instances and their current models from the same live catalog as the composer, including configured custom models, inherited runtime settings, and app-owned orchestration features available to this caller. For a separate top-level thread in a new or existing worktree, use t3_thread_launch with workspaceStrategy.",
4849 success: OrchestratorMcpCapabilitiesResult,
4950 failure: OrchestratorMcpFailure,
5051 failureMode: "return",
......@@ -57,7 +58,7 @@ const OrchestratorCapabilitiesTool = Tool.make("orchestrator_capabilities", {
5758
5859export const DelegateTaskTool = Tool.make("delegate_task", {
5960 description:
60 "Delegate one task to a T3-owned child agent/subagent of THIS thread and run it with only the supplied task prompt, without copying parent conversation history. Choose providers and models from orchestrator_capabilities, which uses the same live catalog as the composer. Prefer native subagent tools for same-provider work only when they support the chosen model. Use this for any model missing from the native tool, including same-provider work, for cross-provider work, or for explicitly T3-owned child tasks. For every T3 delegated review round, call delegate_task again with the original brief, prior findings, responses, and unresolved objections in the task prompt. Track each round by its own taskId and use a distinct clientRequestId per round, stable across retries of that round. The childThreadId is backing storage, not the target for starting another delegated review round through t3_thread_send. Provider, model, model options (see orchestrator_capabilities), runtime mode, and interaction mode inherit unless target overrides them. Prefer mode='async' for long work; mode='wait' blocks until completion or timeout. timeoutMs on mode=wait is only the parent's wait budget and does not cancel the child. waitTimedOut on that wait call means the timeout fired; keep that taskId and read status on later task_status. An async child's completion wakes this thread through a notification, steered into active turns where supported or queued otherwise, so end the turn instead of polling or spawning watchers; use task_status only when the result is needed mid-turn.",
61 "Needs an agent running inside a T3 thread. Delegate one task to a T3-owned child agent/subagent of THIS thread and run it with only the supplied task prompt, without copying parent conversation history. Choose providers and models from orchestrator_capabilities, which uses the same live catalog as the composer. Prefer native subagent tools for same-provider work only when they support the chosen model. Use this for any model missing from the native tool, including same-provider work, for cross-provider work, or for explicitly T3-owned child tasks. For every T3 delegated review round, call delegate_task again with the original brief, prior findings, responses, and unresolved objections in the task prompt. Track each round by its own taskId and use a distinct clientRequestId per round, stable across retries of that round. The childThreadId is backing storage, not the target for starting another delegated review round through t3_thread_send. Provider, model, model options (see orchestrator_capabilities), runtime mode, and interaction mode inherit unless target overrides them. Prefer mode='async' for long work; mode='wait' blocks until completion or timeout. timeoutMs on mode=wait is only the parent's wait budget and does not cancel the child. waitTimedOut on that wait call means the timeout fired; keep that taskId and read status on later task_status. An async child's completion wakes this thread through a notification, steered into active turns where supported or queued otherwise, so end the turn instead of polling or spawning watchers; use task_status only when the result is needed mid-turn.",
6162 parameters: OrchestratorMcpDelegateTaskInput,
6263 success: OrchestratorMcpDelegateTaskResult,
6364 failure: OrchestratorMcpFailure,
......@@ -70,7 +71,7 @@ export const DelegateTaskTool = Tool.make("delegate_task", {
7071
7172const TaskStatusTool = Tool.make("task_status", {
7273 description:
73 "Read a T3-owned delegated task created by this parent thread. childRunId identifies the original delegated run. workState distinguishes working, waiting_for_children, and result_available; a completed turn with live nested work is not a completed task. summary is the final task result, including provider errors on failure, and remains stable after publication. hasPendingChildRuns reports later queued or executing turns in the backing child thread, even after the task is terminal; it does not reopen the task or extend task_cancel to those turns. latestTerminal* provides later non-monitor turn results. Reading a terminal result acknowledges its automatic parent delivery.",
74 "Needs an agent running inside a T3 thread. Read a T3-owned delegated task created by this parent thread. childRunId identifies the original delegated run. workState distinguishes working, waiting_for_children, and result_available; a completed turn with live nested work is not a completed task. summary is the final task result, including provider errors on failure, and remains stable after publication. hasPendingChildRuns reports later queued or executing turns in the backing child thread, even after the task is terminal; it does not reopen the task or extend task_cancel to those turns. latestTerminal* provides later non-monitor turn results. Reading a terminal result acknowledges its automatic parent delivery.",
7475 parameters: OrchestratorMcpTaskStatusInput,
7576 success: OrchestratorMcpDelegateTaskResult,
7677 failure: OrchestratorMcpFailure,
......@@ -84,7 +85,7 @@ const TaskStatusTool = Tool.make("task_status", {
8485
8586const TaskCancelTool = Tool.make("task_cancel", {
8687 description:
87 "Request interruption of an active T3-owned delegated task and dispose its automatic parent delivery. For a terminal task, return its existing status and dispose delivery without interrupting later child-thread runs, even when task_status reports hasPendingChildRuns=true. Published task results remain available. Use t3_thread_interrupt for a later active run.",
88 "Needs an agent running inside a T3 thread. Request interruption of an active T3-owned delegated task and dispose its automatic parent delivery. For a terminal task, return its existing status and dispose delivery without interrupting later child-thread runs, even when task_status reports hasPendingChildRuns=true. Published task results remain available. Use t3_thread_interrupt for a later active run.",
8889 parameters: OrchestratorMcpTaskCancelInput,
8990 success: OrchestratorMcpTaskCancelResult,
9091 failure: OrchestratorMcpFailure,
......@@ -96,7 +97,7 @@ const TaskCancelTool = Tool.make("task_cancel", {
9697
9798export const ScheduleTaskTool = Tool.make("schedule_task", {
9899 description:
99 "Create persistent recurring work in the app scheduler, which runs even when no turn is active. Pass schedule as a STRUCTURED OBJECT, never JSON text: {type:'interval', everyMs:3600000} means hourly; {type:'fixed_time', timeOfDay:'09:00', weekdays:[1,2,3,4,5]} means weekday mornings. By default (bindToCurrentThread=true) each run posts into THIS thread; use false only when the user wants a fresh top-level thread per run. Provider, model, and runtime settings inherit from this thread. Report the returned schedule and nextRunAt after success.",
100 "Create persistent recurring work in the app scheduler, which runs even when no turn is active. Pass schedule as a STRUCTURED OBJECT, never JSON text: {type:'interval', everyMs:3600000} means hourly; {type:'fixed_time', timeOfDay:'09:00', weekdays:[1,2,3,4,5]} means weekday mornings. Omit projectId for this thread's project. In this thread's project, runs post into THIS thread by default (bindToCurrentThread=true); use false only when the user wants a fresh top-level thread per run. Elsewhere each run launches a fresh thread. Provider, model, and runtime settings inherit from this thread, or from the project default when there is no calling thread. Report the returned schedule and nextRunAt after success.",
100101 parameters: OrchestratorMcpScheduleTaskInput,
101102 success: OrchestratorMcpScheduleTaskResult,
102103 failure: OrchestratorMcpFailure,
......@@ -108,8 +109,9 @@ export const ScheduleTaskTool = Tool.make("schedule_task", {
108109 .annotate(Tool.OpenWorld, true);
109110
110111const ListScheduledTasksTool = Tool.make("list_scheduled_tasks", {
112 parameters: OrchestratorMcpListScheduledTasksInput,
111113 description:
112 "List the recurring scheduled tasks in the calling thread's project, including their id, schedule, prompt, enabled state, bound thread, next run time, and last run status. Use the returned scheduledTaskId with update_scheduled_task or delete_scheduled_task.",
114 "List recurring scheduled tasks in a project (omit projectId for the calling thread's project, or every project when there is no calling thread), including their id, schedule, prompt, enabled state, bound thread, next run time, and last run status. Use the returned scheduledTaskId with update_scheduled_task or delete_scheduled_task.",
113115 success: OrchestratorMcpListScheduledTasksResult,
114116 failure: OrchestratorMcpFailure,
115117 failureMode: "return",
......@@ -146,7 +148,7 @@ const DeleteScheduledTaskTool = Tool.make("delete_scheduled_task", {
146148
147149export const CreateThreadsTool = Tool.make("create_threads", {
148150 description:
149 "Create one or more ORDINARY TOP-LEVEL T3 conversations. This is not delegation and does not create child agents/subagents. For delegated work, choose models from orchestrator_capabilities. Prefer native subagents only when they support the chosen model; otherwise call delegate_task, including for same-provider work. Use create_threads for a batch of separate top-level threads sharing this checkout. Prefer t3_thread_launch for a single thread. Both require the user to request separate/new/top-level threads or conversations. Each entry may override provider, model, options, runtime mode, and interaction mode; omitted settings inherit. Project, branch, and worktree always inherit and cannot be overridden here. For independent implementation or a PR stack in its own worktree, use t3_thread_launch with workspaceStrategy instead of asking the agent to create a worktree in its prompt.",
151 "Needs an agent running inside a T3 thread. Create one or more ORDINARY TOP-LEVEL T3 conversations. This is not delegation and does not create child agents/subagents. For delegated work, choose models from orchestrator_capabilities. Prefer native subagents only when they support the chosen model; otherwise call delegate_task, including for same-provider work. Use create_threads for a batch of separate top-level threads sharing this checkout. Prefer t3_thread_launch for a single thread. Both require the user to request separate/new/top-level threads or conversations. Each entry may override provider, model, options, runtime mode, and interaction mode; omitted settings inherit. Project, branch, and worktree always inherit and cannot be overridden here. For independent implementation or a PR stack in its own worktree, use t3_thread_launch with workspaceStrategy instead of asking the agent to create a worktree in its prompt.",
150152 parameters: OrchestratorMcpCreateThreadsInput,
151153 success: OrchestratorMcpCreateThreadsResult,
152154 failure: OrchestratorMcpFailure,
......@@ -159,7 +161,7 @@ export const CreateThreadsTool = Tool.make("create_threads", {
159161
160162const ThreadListTool = Tool.make("t3_thread_list", {
161163 description:
162 "List T3 threads in the calling thread's project, newest first. Filter by durable run status, title, or settled state (settled=true lists threads the user or auto-settlement moved out of the active list) and paginate with the returned cursor. Threads from other projects are never exposed.",
164 "List T3 threads in a project, newest first. Omit projectId for the calling thread's project. Filter by durable run status, title, or settled state (settled=true lists threads the user or auto-settlement moved out of the active list) and paginate with the returned cursor.",
163165 parameters: OrchestratorMcpThreadListInput,
164166 success: OrchestratorMcpThreadListResult,
165167 failure: OrchestratorMcpFailure,
......@@ -173,7 +175,7 @@ const ThreadListTool = Tool.make("t3_thread_list", {
173175
174176const ThreadReadTool = Tool.make("t3_thread_read", {
175177 description:
176 "Read durable state and a paginated timeline from a T3 thread in the calling project, or from a thread the user attached to this conversation as context. The default messages view returns user messages, assistant messages, and proposed plans; activity returns all summarized timeline items. Reading an untruncated terminal assistant result from this parent thread's direct app-owned child acknowledges that child's automatic completion delivery. Continue with afterPosition=nextPosition. Recover long item text with itemId and textOffset=nextTextOffset until nextTextOffset is null; offsets count UTF-16 code units.",
178 "Read durable state and a paginated timeline from any T3 thread in this environment. The default messages view returns user messages, assistant messages, and proposed plans; activity returns all summarized timeline items. Reading an untruncated terminal assistant result from this parent thread's direct app-owned child acknowledges that child's automatic completion delivery. Continue with afterPosition=nextPosition. Recover long item text with itemId and textOffset=nextTextOffset until nextTextOffset is null; offsets count UTF-16 code units.",
177179 parameters: OrchestratorMcpThreadReadInput,
178180 success: OrchestratorMcpThreadReadResult,
179181 failure: OrchestratorMcpFailure,
......@@ -187,7 +189,7 @@ const ThreadReadTool = Tool.make("t3_thread_read", {
187189
188190export const ThreadUpdateTool = Tool.make("t3_thread_update", {
189191 description:
190 "Update metadata for a thread in the calling project. Omit threadId to update this thread. Use action='rename' with title, action='regenerate_title' with no extra field, action='link_pull_request' with pullRequest, or action='unlink_pull_request'. Workspace and branch changes are intentionally not supported. clientRequestId makes retries idempotent.",
192 "Update metadata for a thread. Omit threadId to update this thread. Use action='rename' with title, action='regenerate_title' with no extra field, action='link_pull_request' with pullRequest, or action='unlink_pull_request'. Workspace and branch changes are intentionally not supported. clientRequestId makes retries idempotent.",
191193 parameters: ThreadMetadataMcpUpdateInput,
192194 success: ThreadMetadataMcpUpdateResult,
193195 failure: OrchestratorMcpFailure,
......@@ -200,7 +202,7 @@ export const ThreadUpdateTool = Tool.make("t3_thread_update", {
200202
201203const ThreadSendTool = Tool.make("t3_thread_send", {
202204 description:
203 "Send a message to a T3 thread in the calling project. Do not use a delegated task's childThreadId to start another review round here; use delegate_task with the full review context and a new clientRequestId for that round. Thread messages do not create a new delegated task or reopen a completed task. mode='auto' starts an idle thread, steers a fully active turn, or queues behind a turn that is not yet steerable. Use queue for a separate follow-up turn, steer for an in-flight update, or restart to interrupt-and-restart the active turn. clientRequestId makes retries idempotent.",
205 "Send a message to any T3 thread in this environment. The target cannot have broader permission modes than the caller. Do not use a delegated task's childThreadId to start another review round here; use delegate_task with the full review context and a new clientRequestId for that round. Thread messages do not create a new delegated task or reopen a completed task. mode='auto' starts an idle thread, steers a fully active turn, or queues behind a turn that is not yet steerable. Use queue for a separate follow-up turn, steer for an in-flight update, or restart to interrupt-and-restart the active turn. clientRequestId makes retries idempotent.",
204206 parameters: OrchestratorMcpThreadSendInput,
205207 success: OrchestratorMcpThreadSendResult,
206208 failure: OrchestratorMcpFailure,
......@@ -227,7 +229,7 @@ const ThreadWaitTool = Tool.make("t3_thread_wait", {
227229
228230const ThreadInterruptTool = Tool.make("t3_thread_interrupt", {
229231 description:
230 "Request interruption of a running turn in a T3 thread in the calling project. Without runId, the newest interruptible run is selected. Terminal runs and threads without an active turn return without another side effect. clientRequestId makes retries idempotent.",
232 "Request interruption of a running turn in any T3 thread in this environment. Without runId, the newest interruptible run is selected. Terminal runs and threads without an active turn return without another side effect. clientRequestId makes retries idempotent.",
231233 parameters: OrchestratorMcpThreadInterruptInput,
232234 success: OrchestratorMcpThreadInterruptResult,
233235 failure: OrchestratorMcpFailure,
apps/server/src/mcp/toolkits/preview/handlers.ts+3-3
......@@ -60,7 +60,7 @@ const invoke = Effect.fn("PreviewToolkit.invoke")(function* <A>(
6060 import("@t3tools/contracts").PreviewAutomationError,
6161 McpInvocationContext.McpInvocationContext | PreviewAutomationBroker.PreviewAutomationBroker
6262> {
63 const scope = yield* McpInvocationContext.requireMcpCapability("preview");
63 const scope = yield* McpInvocationContext.requireThreadMcpCapability("preview");
6464 const broker = yield* PreviewAutomationBroker.PreviewAutomationBroker;
6565 let targetTabId = tabId;
6666 const result = yield* broker.invoke<A>({
......@@ -218,7 +218,7 @@ const handlers = {
218218 invokeTargeted<PreviewAutomationRecordingStatus>("recordingStart", input ?? {}),
219219 preview_recording_stop: (input) =>
220220 Effect.gen(function* () {
221 const scope = yield* McpInvocationContext.requireMcpCapability("preview");
221 const scope = yield* McpInvocationContext.requireThreadMcpCapability("preview");
222222 const { tabId, ...operationInput } = input;
223223 const response = yield* invoke<unknown>(
224224 "recordingStop",
......@@ -226,7 +226,7 @@ const handlers = {
226226 PREVIEW_RECORDING_STOP_TIMEOUT_MS,
227227 tabId,
228228 );
229 const artifact = yield* claimPreviewRecording(scope.threadId, response.result);
229 const artifact = yield* claimPreviewRecording(scope.thread.threadId, response.result);
230230 return { ...artifact, ...(response.toolIcon ? { toolIcon: response.toolIcon } : {}) };
231231 }),
232232} satisfies Parameters<typeof PreviewToolkit.toLayer>[0];
apps/server/src/mcp/toolkits/previewControls/handlers.test.ts+7-3
......@@ -35,9 +35,13 @@ it.effect.each([
3535 const effective = resolveProjectSettings(settings, projectId).settings;
3636 const scope: McpInvocationContext.McpInvocationScope = {
3737 environmentId: EnvironmentId.make("preview-controls-environment"),
38 threadId,
39 providerSessionId: "preview-controls-provider-session",
40 providerInstanceId: ProviderInstanceId.make("codex"),
38 requestNamespace: "preview-controls-provider-session",
39 thread: {
40 threadId,
41 providerSessionId: "preview-controls-provider-session",
42 providerInstanceId: ProviderInstanceId.make("codex"),
43 },
44 client: undefined,
4145 capabilities: new Set(effective.enableAgentBrowserAccess ? ["preview"] : []),
4246 issuedAt: 0,
4347 };
apps/server/src/mcp/toolkits/previewControls/handlers.ts+4-4
......@@ -1,19 +1,19 @@
11import * as Effect from "effect/Effect";
22import * as Preview from "../../../preview/Manager.ts";
3import { requireMcpCapability } from "../../McpInvocationContext.ts";
3import { requireThreadMcpCapability } from "../../McpInvocationContext.ts";
44import { unavailable } from "../../threadAccess.ts";
55import { PreviewControlsToolkit } from "./tools.ts";
66
77const access = Effect.gen(function* () {
88 // The preview capability already reflects the calling project's access setting.
9 const scope = yield* requireMcpCapability("preview");
9 const scope = yield* requireThreadMcpCapability("preview");
1010 return { scope, manager: yield* Preview.PreviewManager };
1111});
1212export const PreviewControlsHandlersLive = PreviewControlsToolkit.toLayer({
1313 t3_preview_list: (input) =>
1414 Effect.gen(function* () {
1515 const { scope, manager } = yield* access;
16 const result = yield* manager.list({ threadId: scope.threadId });
16 const result = yield* manager.list({ threadId: scope.thread.threadId });
1717 const start = input.cursor ?? 0;
1818 const end = start + (input.limit ?? 20);
1919 return {
......@@ -26,7 +26,7 @@ export const PreviewControlsHandlersLive = PreviewControlsToolkit.toLayer({
2626 Effect.gen(function* () {
2727 const { scope, manager } = yield* access;
2828 yield* manager
29 .close({ threadId: scope.threadId, tabId: input.tabId })
29 .close({ threadId: scope.thread.threadId, tabId: input.tabId })
3030 .pipe(Effect.mapError(unavailable));
3131 return {};
3232 }),
apps/server/src/mcp/toolkits/project/handlers.test.ts+106-9
......@@ -5,6 +5,7 @@ import {
55 ProviderInstanceId,
66 ThreadId,
77 type OrchestrationV2ThreadShell,
8 type Project as ProjectRecord,
89} from "@t3tools/contracts";
910import * as NodeCrypto from "@effect/platform-node/NodeCrypto";
1011import * as NodeServices from "@effect/platform-node/NodeServices";
......@@ -44,9 +45,13 @@ it.effect("attributes a launched thread's first message to the calling thread",
4445 NodeCrypto.layer,
4546 Layer.succeed(McpInvocationContext.McpInvocationContext, {
4647 environmentId: EnvironmentId.make("environment"),
47 threadId: sourceThreadId,
48 providerSessionId: "session",
49 providerInstanceId,
48 requestNamespace: "session",
49 thread: {
50 threadId: sourceThreadId,
51 providerSessionId: "session",
52 providerInstanceId,
53 },
54 client: undefined,
5055 issuedAt: 0,
5156 capabilities: new Set(["orchestration" as const]),
5257 }),
......@@ -106,9 +111,13 @@ it.effect("launches a scratch thread into the Scratch project", () =>
106111 NodeCrypto.layer,
107112 Layer.succeed(McpInvocationContext.McpInvocationContext, {
108113 environmentId: EnvironmentId.make("environment"),
109 threadId: sourceThreadId,
110 providerSessionId: "session",
111 providerInstanceId,
114 requestNamespace: "session",
115 thread: {
116 threadId: sourceThreadId,
117 providerSessionId: "session",
118 providerInstanceId,
119 },
120 client: undefined,
112121 issuedAt: 0,
113122 capabilities: new Set(["orchestration" as const]),
114123 }),
......@@ -194,9 +203,13 @@ it.effect("starts a project from just a title when workspaceRoot is omitted", ()
194203 NodeCrypto.layer,
195204 Layer.succeed(McpInvocationContext.McpInvocationContext, {
196205 environmentId: EnvironmentId.make("environment"),
197 threadId: sourceThreadId,
198 providerSessionId: "session",
199 providerInstanceId,
206 requestNamespace: "session",
207 thread: {
208 threadId: sourceThreadId,
209 providerSessionId: "session",
210 providerInstanceId,
211 },
212 client: undefined,
200213 issuedAt: 0,
201214 capabilities: new Set(["orchestration" as const]),
202215 }),
......@@ -263,3 +276,87 @@ it.effect("starts a project from just a title when workspaceRoot is omitted", ()
263276 expect(named).toEqual(["Pinball Stats"]);
264277 }),
265278);
279
280const clientLaunchHarness = (input: {
281 readonly runtimeModeCeiling: "approval-required" | "auto-accept-edits" | "auto" | "full-access";
282 readonly launched: Array<ThreadLaunch.ThreadLaunchInput>;
283}) => {
284 const projectId = ProjectId.make("project:client-target");
285 const modelSelection = { instanceId: ProviderInstanceId.make("claude"), model: "claude-opus" };
286 const dependencies = Layer.mergeAll(
287 NodeCrypto.layer,
288 Layer.succeed(McpInvocationContext.McpInvocationContext, {
289 environmentId: EnvironmentId.make("environment"),
290 requestNamespace: "client:session-1",
291 thread: undefined,
292 client: {
293 sessionId: "session-1",
294 label: "Claude Code",
295 runtimeModeCeiling: input.runtimeModeCeiling,
296 },
297 issuedAt: 0,
298 capabilities: new Set(["orchestration" as const]),
299 }),
300 Layer.mock(ThreadManagement.ThreadManagementService)({}),
301 Layer.mock(ThreadLaunch.ThreadLaunchService)({
302 launch: (launch) => {
303 input.launched.push(launch);
304 return Effect.succeed({
305 threadId: launch.threadId,
306 projection: {
307 thread: {
308 id: launch.threadId,
309 projectId: launch.projectId,
310 modelSelection: launch.modelSelection,
311 },
312 runs: [],
313 },
314 resumed: false,
315 } as unknown as ThreadLaunch.ThreadLaunchResult);
316 },
317 }),
318 Layer.mock(Project.ProjectService)({
319 getById: (id) =>
320 Effect.succeed(
321 id === projectId
322 ? Option.some({ id, defaultModelSelection: modelSelection } as unknown as ProjectRecord)
323 : Option.none(),
324 ),
325 }),
326 Layer.mock(ManagedProjectFolders.ManagedProjectFolders)({ namedProjectsRoot: "/projects" }),
327 NodeServices.layer,
328 ServerConfig.layerTest(process.cwd(), { prefix: "t3-client-launch-" }).pipe(
329 Layer.provide(NodeServices.layer),
330 ),
331 );
332 return { projectId, modelSelection, dependencies };
333};
334
335it.effect("a client launches at its ceiling with the project's default model", () =>
336 Effect.gen(function* () {
337 const launched: Array<ThreadLaunch.ThreadLaunchInput> = [];
338 const { projectId, modelSelection, dependencies } = clientLaunchHarness({
339 runtimeModeCeiling: "auto-accept-edits",
340 launched,
341 });
342 const toolkit = yield* ProjectToolkit.pipe(
343 Effect.provide(ProjectHandlersLive.pipe(Layer.provide(dependencies))),
344 );
345 const handle = (params: Parameters<typeof toolkit.handle<"t3_thread_launch">>[1]) =>
346 toolkit
347 .handle("t3_thread_launch", params)
348 .pipe(Stream.unwrap, Stream.runCollect, Effect.provide(dependencies));
349
350 const result = yield* handle({ title: "Fix", projectId, message: "Fix the bug" });
351 expect(result.at(-1)?.result).toMatchObject({ projectId, modelSelection });
352 expect(launched[0]?.runtimeMode).toBe("auto-accept-edits");
353 expect(launched[0]?.initialMessage?.senderThreadId).toBeUndefined();
354
355 const escalated = yield* handle({ title: "Fix", projectId, runtimeMode: "full-access" });
356 expect(escalated.at(-1)?.result).toMatchObject({ code: "runtime_mode_escalation_denied" });
357
358 const untargeted = yield* handle({ title: "Fix" });
359 expect(untargeted.at(-1)?.result).toMatchObject({ code: "target_required" });
360 expect(launched).toHaveLength(1);
361 }),
362);
apps/server/src/mcp/toolkits/project/handlers.ts+45-18
......@@ -6,7 +6,15 @@ import * as Claims from "../../../orchestration-v2/AttachmentClaims.ts";
66import * as Project from "../../../project/ProjectService.ts";
77import * as ManagedProjectFolders from "../../../project/ManagedProjectFolders.ts";
88import * as Repositories from "../../../sourceControl/SourceControlRepositoryService.ts";
9import { newCommandId, readCaller, readMutationCaller, unavailable } from "../../threadAccess.ts";
9import { resolveRuntimeMode } from "../../OrchestratorMcpService.ts";
10import {
11 newCommandId,
12 readCaller,
13 readFullAccessCaller,
14 readMutationCaller,
15 resolveProjectId,
16 unavailable,
17} from "../../threadAccess.ts";
1018import { ProjectToolkit } from "./tools.ts";
1119
1220function projectFailure(error: Project.ProjectServiceError) {
......@@ -25,27 +33,30 @@ const access = Effect.gen(function* () {
2533 return yield* Project.ProjectService;
2634});
2735const mutation = Effect.gen(function* () {
28 const { caller } = yield* readMutationCaller();
29 if (
30 caller.archivedAt !== null ||
31 caller.runtimeMode !== "full-access" ||
32 caller.interactionMode !== "default"
33 )
34 return yield* new OrchestratorMcpFailure({
35 code: "capability_denied",
36 message: "Project changes require a live full-access/default calling thread.",
37 });
36 yield* readFullAccessCaller(
37 "Project changes require a live full-access/default calling thread or a full-access client.",
38 );
3839 return yield* Project.ProjectService;
3940});
4041export const ProjectHandlersLive = ProjectToolkit.toLayer({
4142 t3_thread_launch: (input) =>
4243 Effect.gen(function* () {
43 const { caller, scope } = yield* readMutationCaller();
44 if (caller.runtimeMode !== "full-access" || caller.interactionMode !== "default")
44 const context = yield* readMutationCaller();
45 const { caller, limits } = context;
46 // A thread caller launches only as itself (full-access/default), as before. A client
47 // launches anything up to its ceiling.
48 if (
49 caller !== undefined &&
50 (caller.runtimeMode !== "full-access" || caller.interactionMode !== "default")
51 )
4552 return yield* new OrchestratorMcpFailure({
4653 code: "capability_denied",
4754 message: "Project launches require a full-access/default calling thread.",
4855 });
56 const runtimeMode = yield* resolveRuntimeMode(
57 limits.runtimeMode,
58 input.runtimeMode ?? caller?.runtimeMode,
59 );
4960 const commandId = yield* newCommandId();
5061 const threadId = ThreadId.make(commandId);
5162 const messageId = MessageId.make(commandId);
......@@ -76,22 +87,38 @@ export const ProjectHandlersLive = ProjectToolkit.toLayer({
7687 }),
7788 ),
7889 )).projectId
79 : (input.projectId ?? caller.projectId);
90 : yield* resolveProjectId(context, input.projectId);
91 const modelSelection =
92 input.modelSelection ??
93 caller?.modelSelection ??
94 (yield* Project.ProjectService.pipe(
95 Effect.flatMap((projects) => projects.getById(projectId)),
96 Effect.mapError(unavailable),
97 Effect.map((project) =>
98 Option.getOrUndefined(Option.flatMapNullishOr(project, (p) => p.defaultModelSelection)),
99 ),
100 ));
101 if (modelSelection === undefined)
102 return yield* new OrchestratorMcpFailure({
103 code: "invalid_request",
104 message:
105 "Pass modelSelection: the project has no default model. orchestrator_capabilities lists providers and models.",
106 });
80107 const result = yield* ThreadMessageIntake.launchThread({
81108 commandId,
82109 threadId,
83110 projectId,
84111 title: input.title,
85 modelSelection: input.modelSelection ?? caller.modelSelection,
86 runtimeMode: input.runtimeMode ?? caller.runtimeMode,
87 interactionMode: input.interactionMode ?? caller.interactionMode,
112 modelSelection,
113 runtimeMode,
114 interactionMode: input.interactionMode ?? caller?.interactionMode ?? "default",
88115 workspaceStrategy: input.workspaceStrategy ?? { type: "root" },
89116 ...(input.message === undefined && attachments.length === 0
90117 ? {}
91118 : {
92119 initialMessage: {
93120 messageId,
94 senderThreadId: scope.threadId,
121 ...(caller === undefined ? {} : { senderThreadId: caller.id }),
95122 text: input.message ?? "",
96123 attachments,
97124 },
apps/server/src/mcp/toolkits/project/tools.ts+1-1
......@@ -102,7 +102,7 @@ const ProjectCloneTool = Tool.make("t3_project_clone", {
102102const ThreadLaunchTool = Tool.make("t3_thread_launch", {
103103 ...shared,
104104 description:
105 'Create an ordinary TOP-LEVEL thread with an explicit workspace binding before its agent starts. Use this when the user requests independent work, a new thread, or a PR stack in its own worktree; use delegate_task for child subagents. Set workspaceStrategy to {type:"worktree",baseRef:"parent-branch",branch:"new-branch",startFromOrigin:false} for a new worktree based on local commits, or {type:"existing_worktree",worktreePath:"/absolute/path",branch:"existing-branch"} to use an existing checkout. For upstream commits, set startFromOrigin:true. Omitted workspaceStrategy means the project root, NOT the caller\'s worktree. Omit projectId/modelSelection/modes to inherit those settings. Set scratch:true instead of projectId for a thread without a project: it runs in a fresh folder of its own, outside any repository. Put the task in message. Do not ask the agent to create its own worktree via shell: that does not update the thread binding. Each call creates a new launch with no retry key; retain threadId and use t3_thread_read/t3_thread_wait to follow preparation. After errors or lost responses, inspect t3_thread_list before retrying. Attachments must be pending uploads. Requires a full-access/default caller.',
105 'Create an ordinary TOP-LEVEL thread with an explicit workspace binding before its agent starts. Use this when the user requests independent work, a new thread, or a PR stack in its own worktree; use delegate_task for child subagents. Set workspaceStrategy to {type:"worktree",baseRef:"parent-branch",branch:"new-branch",startFromOrigin:false} for a new worktree based on local commits, or {type:"existing_worktree",worktreePath:"/absolute/path",branch:"existing-branch"} to use an existing checkout. For upstream commits, set startFromOrigin:true. Omitted workspaceStrategy means the project root, NOT the caller\'s worktree. Omit projectId/modelSelection/modes to inherit those settings from the calling thread; a caller outside a T3 thread must pass projectId and gets the project\'s default model. Set scratch:true instead of projectId for a thread without a project: it runs in a fresh folder of its own, outside any repository. Put the task in message. Do not ask the agent to create its own worktree via shell: that does not update the thread binding. Each call creates a new launch with no retry key; retain threadId and use t3_thread_read/t3_thread_wait to follow preparation. After errors or lost responses, inspect t3_thread_list before retrying. Attachments must be pending uploads. Requires a full-access/default calling thread; a caller outside a T3 thread launches up to its approved permission mode.',
106106 parameters: Schema.Struct({
107107 projectId: Schema.optional(ProjectId),
108108 scratch: Schema.optional(
apps/server/src/mcp/toolkits/pullRequests/handlers.test.ts+7-3
......@@ -38,9 +38,13 @@ const invocation = (
3838 capabilities: ReadonlyArray<McpInvocationContext.McpCapability>,
3939): McpInvocationContext.McpInvocationScope => ({
4040 environmentId: EnvironmentId.make("environment-1"),
41 threadId: THREAD_ID,
42 providerSessionId: "provider-session-1",
43 providerInstanceId: ProviderInstanceId.make("codex"),
41 requestNamespace: "provider-session-1",
42 thread: {
43 threadId: THREAD_ID,
44 providerSessionId: "provider-session-1",
45 providerInstanceId: ProviderInstanceId.make("codex"),
46 },
47 client: undefined,
4448 capabilities: new Set(capabilities),
4549 issuedAt: 1,
4650});
apps/server/src/mcp/toolkits/pullRequests/handlers.ts+60-9
......@@ -26,6 +26,7 @@ import * as Option from "effect/Option";
2626import * as Orchestrator from "../../../orchestration-v2/Orchestrator.ts";
2727import * as ProjectService from "../../../project/ProjectService.ts";
2828import * as McpInvocationContext from "../../McpInvocationContext.ts";
29import { assertTargetWithinLimits } from "../../threadAccess.ts";
2930import {
3031 type ListThreadPullRequestsResult,
3132 PullRequestLinkFailedError,
......@@ -38,6 +39,8 @@ import {
3839 type PullRequestTargetInput,
3940 PullRequestWatchFailedError,
4041 PullRequestThreadNotFoundError,
42 PullRequestThreadAboveLimitsError,
43 PullRequestThreadRequiredError,
4144 PullRequestsToolkit,
4245 type ThreadPullRequestEntry,
4346} from "./tools.ts";
......@@ -168,18 +171,64 @@ const make = Effect.gen(function* () {
168171 | typeof PullRequestUnlinkFailedError
169172 | typeof PullRequestListFailedError
170173 | typeof PullRequestWatchFailedError,
174 requested: ThreadId | undefined,
171175 ) {
172176 const scope = yield* McpInvocationContext.requireMcpCapability("pull-requests");
177 const threadId = requested ?? scope.thread?.threadId;
178 if (threadId === undefined) {
179 return yield* new PullRequestThreadRequiredError();
180 }
173181 const thread = yield* engine
174 .getThreadShell(scope.threadId)
182 .getThreadShell(threadId)
175183 .pipe(Effect.map(Option.fromNullishOr))
176184 .pipe(Effect.mapError((cause) => new Failure({ cause })));
177 if (Option.isNone(thread)) {
178 return yield* new PullRequestThreadNotFoundError({ threadId: scope.threadId });
185 if (Option.isNone(thread) || thread.value.deletedAt !== null) {
186 return yield* new PullRequestThreadNotFoundError({ threadId });
179187 }
180188 return thread.value;
181189 });
182190
191 /**
192 * A thread whose pull requests the caller may change: its own, or one that
193 * runs within the caller's modes.
194 */
195 const requireWritableThread = Effect.fn("PullRequestsToolkit.requireWritableThread")(function* (
196 Failure:
197 | typeof PullRequestLinkFailedError
198 | typeof PullRequestUnlinkFailedError
199 | typeof PullRequestWatchFailedError,
200 requested: ThreadId | undefined,
201 ) {
202 const thread = yield* requireThread(Failure, requested);
203 const scope = yield* McpInvocationContext.McpInvocationContext;
204 if (thread.id === scope.thread?.threadId) return thread;
205 const limits =
206 scope.thread === undefined
207 ? {
208 runtimeMode: scope.client?.runtimeModeCeiling ?? ("approval-required" as const),
209 interactionMode: "default" as const,
210 }
211 : yield* engine.getThreadShell(scope.thread.threadId).pipe(
212 Effect.mapError((cause) => new Failure({ cause })),
213 Effect.map((caller) =>
214 // A thread caller changes other threads only while its own run is live.
215 caller === null ||
216 caller.archivedAt !== null ||
217 caller.activeRunId === null ||
218 caller.providerInstanceId !== scope.thread?.providerInstanceId
219 ? undefined
220 : { runtimeMode: caller.runtimeMode, interactionMode: caller.interactionMode },
221 ),
222 );
223 if (limits === undefined) {
224 return yield* new PullRequestThreadAboveLimitsError({ threadId: thread.id });
225 }
226 yield* assertTargetWithinLimits(limits, thread).pipe(
227 Effect.mapError(() => new PullRequestThreadAboveLimitsError({ threadId: thread.id })),
228 );
229 return thread;
230 });
231
183232 const projectOf = (
184233 thread: OrchestrationV2ThreadShell,
185234 Failure:
......@@ -217,7 +266,7 @@ const make = Effect.gen(function* () {
217266 input: PullRequestTargetInput,
218267 watching: boolean,
219268 ) {
220 const thread = yield* requireThread(PullRequestWatchFailedError);
269 const thread = yield* requireWritableThread(PullRequestWatchFailedError, input.threadId);
221270 const project = yield* projectOf(thread, PullRequestWatchFailedError);
222271 const target = yield* resolveTarget(input, project);
223272 const watchedLink = (shell: OrchestrationV2ThreadShell) =>
......@@ -241,7 +290,7 @@ const make = Effect.gen(function* () {
241290 ...(watching ? { link: { url: target.url, source: "agent" as const } } : {}),
242291 })
243292 .pipe(Effect.catchCause(dispatchFailure(PullRequestWatchFailedError)));
244 const after = yield* requireThread(PullRequestWatchFailedError);
293 const after = yield* requireThread(PullRequestWatchFailedError, thread.id);
245294 return {
246295 host: target.host,
247296 repository: target.repository,
......@@ -255,7 +304,7 @@ const make = Effect.gen(function* () {
255304 return PullRequestsToolkit.of({
256305 link_pull_request: (input) =>
257306 Effect.gen(function* () {
258 const thread = yield* requireThread(PullRequestLinkFailedError);
307 const thread = yield* requireWritableThread(PullRequestLinkFailedError, input.threadId);
259308 const project = yield* projectOf(thread, PullRequestLinkFailedError);
260309 const target = yield* resolveTarget(input, project);
261310 const existing = threadPullRequestsOf(thread).find((link) =>
......@@ -285,7 +334,7 @@ const make = Effect.gen(function* () {
285334 }),
286335 unlink_pull_request: (input) =>
287336 Effect.gen(function* () {
288 const thread = yield* requireThread(PullRequestUnlinkFailedError);
337 const thread = yield* requireWritableThread(PullRequestUnlinkFailedError, input.threadId);
289338 const project = yield* projectOf(thread, PullRequestUnlinkFailedError);
290339 const target = yield* resolveTarget(input, project);
291340 if (!threadPullRequestsOf(thread).some((link) => threadPullRequestKeysEqual(link, target)))
......@@ -316,8 +365,10 @@ const make = Effect.gen(function* () {
316365 wasLinked,
317366 };
318367 }),
319 list_thread_pull_requests: () =>
320 requireThread(PullRequestListFailedError).pipe(Effect.map(listThreadPullRequests)),
368 list_thread_pull_requests: (input) =>
369 requireThread(PullRequestListFailedError, input.threadId).pipe(
370 Effect.map(listThreadPullRequests),
371 ),
321372 watch_pull_request: (input) => setWatching(input, true),
322373 unwatch_pull_request: (input) => setWatching(input, false),
323374 });
apps/server/src/mcp/toolkits/pullRequests/tools.ts+32-1
......@@ -4,6 +4,7 @@ import {
44 PullRequestState,
55 ThreadPullRequestLinkSource,
66 TrimmedNonEmptyString,
7 ThreadId,
78} from "@t3tools/contracts";
89import * as Schema from "effect/Schema";
910import * as Tool from "effect/unstable/ai/Tool";
......@@ -28,6 +29,11 @@ const REGISTER_EVERY_PR =
2829 * the host CLI handed back.
2930 */
3031export const PullRequestTargetInput = Schema.Struct({
32 threadId: Schema.optional(
33 ThreadId.annotate({
34 description: "Thread to act on. Omit for this thread.",
35 }),
36 ),
3137 url: Schema.optional(
3238 TrimmedNonEmptyString.annotate({
3339 description:
......@@ -81,6 +87,24 @@ export class PullRequestHostRequiredError extends Schema.TaggedError<PullRequest
8187 }
8288}
8389
90export class PullRequestThreadRequiredError extends Schema.TaggedError<PullRequestThreadRequiredError>()(
91 "PullRequestThreadRequiredError",
92 {},
93) {
94 override get message(): string {
95 return "Pass threadId: this MCP client is not running inside a T3 thread.";
96 }
97}
98
99export class PullRequestThreadAboveLimitsError extends Schema.TaggedError<PullRequestThreadAboveLimitsError>()(
100 "PullRequestThreadAboveLimitsError",
101 { threadId: Schema.String },
102) {
103 override get message(): string {
104 return `Thread ${this.threadId} cannot be changed from here: it runs with broader permissions than this caller, or the calling thread has no active run.`;
105 }
106}
107
84108export class PullRequestThreadNotFoundError extends Schema.TaggedError<PullRequestThreadNotFoundError>()(
85109 "PullRequestThreadNotFoundError",
86110 { threadId: Schema.String },
......@@ -140,6 +164,8 @@ export const PullRequestToolError = Schema.Union([
140164 PullRequestUrlInvalidError,
141165 PullRequestTargetIncompleteError,
142166 PullRequestHostRequiredError,
167 PullRequestThreadRequiredError,
168 PullRequestThreadAboveLimitsError,
143169 PullRequestThreadNotFoundError,
144170 PullRequestLinkFailedError,
145171 PullRequestUnlinkFailedError,
......@@ -245,7 +271,12 @@ const UnlinkPullRequestTool = Tool.make("unlink_pull_request", {
245271 .annotate(Tool.OpenWorld, false);
246272
247273const ListThreadPullRequestsTool = Tool.make("list_thread_pull_requests", {
248 description: `List the pull requests linked to this thread with their last known host state, and how they chain into stacks (bottom to top). ${REGISTER_EVERY_PR}`,
274 description: `List the pull requests linked to a thread (omit threadId for this thread) with their last known host state, and how they chain into stacks (bottom to top). ${REGISTER_EVERY_PR}`,
275 parameters: Schema.Struct({
276 threadId: Schema.optional(
277 ThreadId.annotate({ description: "Thread to list. Omit for this thread." }),
278 ),
279 }),
249280 success: ListThreadPullRequestsResult,
250281 failure: PullRequestToolError,
251282 dependencies,
apps/server/src/mcp/toolkits/thread/handlers.ts+23-20
......@@ -13,7 +13,7 @@ import { modelSelectionCommandType } from "@t3tools/shared/model";
1313import {
1414 newCommandId,
1515 readCaller,
16 readMutationCaller,
16 readFullAccessCaller,
1717 readThread,
1818 readWritableThread,
1919 unavailable,
......@@ -78,22 +78,15 @@ const readQuestion = Effect.fn("mcp.readQuestion")(function* (
7878export const ThreadToolkitHandlersLive = ThreadToolkit.toLayer({
7979 run_scheduled_task_now: (input) =>
8080 Effect.gen(function* () {
81 const { caller } = yield* readMutationCaller();
82 if (
83 caller.archivedAt !== null ||
84 caller.runtimeMode !== "full-access" ||
85 caller.interactionMode !== "default"
86 )
87 return yield* new OrchestratorMcpFailure({
88 code: "capability_denied",
89 message: "Running a scheduled task requires a live full-access/default thread.",
90 });
81 yield* readFullAccessCaller(
82 "Running a scheduled task requires a live full-access/default thread or a full-access client.",
83 );
9184 const scheduler = yield* ScheduledTasks.ScheduledTaskService;
9285 const { tasks } = yield* scheduler.list().pipe(Effect.mapError(unavailable));
93 if (!tasks.some((task) => task.id === input.taskId && task.projectId === caller.projectId))
86 if (!tasks.some((task) => task.id === input.taskId))
9487 return yield* new OrchestratorMcpFailure({
9588 code: "invalid_request",
96 message: "The task was not found in the calling project.",
89 message: "The scheduled task was not found.",
9790 });
9891 const { task } = yield* scheduler
9992 .runNow({ id: input.taskId })
......@@ -109,13 +102,22 @@ export const ThreadToolkitHandlersLive = ThreadToolkit.toLayer({
109102 t3_thread_search: (input) =>
110103 Effect.gen(function* () {
111104 const { caller } = yield* readCaller();
105 const { projectId: requested, ...query } = input;
106 // Like the other project tools, an omitted project means the caller's own; a client
107 // outside a thread searches every project.
108 const projectId = requested ?? caller?.projectId;
112109 const threadSearch = yield* ThreadSearch.ThreadSearch;
113 const result = yield* threadSearch.search(input).pipe(Effect.mapError(unavailable));
114 return { matches: result.matches.filter((match) => match.projectId === caller.projectId) };
110 const result = yield* threadSearch.search(query).pipe(Effect.mapError(unavailable));
111 return {
112 matches:
113 projectId === undefined
114 ? result.matches
115 : result.matches.filter((match) => match.projectId === projectId),
116 };
115117 }),
116118 t3_thread_fork: (input) =>
117119 Effect.gen(function* () {
118 const { threads, projection } = yield* readWritableThread();
120 const { threads, projection } = yield* readWritableThread(input.threadId);
119121 const commandId = yield* newCommandId();
120122 const targetThreadId = ThreadId.make(`${commandId}:fork`);
121123 const result = yield* threads
......@@ -134,12 +136,13 @@ export const ThreadToolkitHandlersLive = ThreadToolkit.toLayer({
134136 }),
135137 t3_thread_merge_back: (input) =>
136138 Effect.gen(function* () {
137 const { threads, caller } = yield* readWritableThread(input.targetThreadId);
138 const result = yield* threads
139 const context = yield* readWritableThread(input.targetThreadId);
140 const source = yield* readWritableThread(input.sourceThreadId);
141 const result = yield* context.threads
139142 .dispatch({
140143 type: "thread.merge_back",
141144 commandId: yield* newCommandId(),
142 sourceThreadId: caller.id,
145 sourceThreadId: source.projection.thread.id,
143146 targetThreadId: input.targetThreadId,
144147 sourcePoint: input.sourcePoint,
145148 createdBy: "agent",
......@@ -179,7 +182,7 @@ export const ThreadToolkitHandlersLive = ThreadToolkit.toLayer({
179182 const {
180183 threads,
181184 projection: { thread },
182 } = yield* readWritableThread();
185 } = yield* readWritableThread(input.threadId);
183186 const type = modelSelectionCommandType(thread.providerInstanceId, input.modelSelection);
184187 const result = yield* threads
185188 .dispatch({
apps/server/src/mcp/toolkits/thread/tools.ts+21-12
......@@ -17,6 +17,7 @@ import {
1717 ThreadId,
1818 RunId,
1919 NonNegativeInt,
20 ProjectId,
2021} from "@t3tools/contracts";
2122import * as Crypto from "effect/Crypto";
2223import * as Schema from "effect/Schema";
......@@ -29,7 +30,7 @@ import * as McpInvocationContext from "../../McpInvocationContext.ts";
2930
3031const ThreadOrganizeTool = Tool.make("t3_thread_organize", {
3132 description:
32 "Pin, snooze, settle, archive, or mark a thread unread in the calling project. Omit threadId for this thread. snooze requires snoozedUntil. Existing thread lifecycle rules apply; this does not schedule a future action.",
33 "Pin, snooze, settle, archive, or mark a thread unread. Omit threadId for this thread. snooze requires snoozedUntil. Existing thread lifecycle rules apply; this does not schedule a future action.",
3334 parameters: Schema.Struct({
3435 threadId: Schema.optional(ThreadId),
3536 action: Schema.Literals([
......@@ -91,7 +92,7 @@ const QueueListTool = Tool.make("t3_queue_list", {
9192 .annotate(Tool.Destructive, false);
9293const QueueReadTool = Tool.make("t3_queue_read", {
9394 ...commandTool,
94 description: "Read up to 16,000 characters of a queued message in the calling project.",
95 description: "Read up to 16,000 characters of a queued message. Omit threadId for this thread.",
9596 parameters: Schema.Struct(queueTarget),
9697 success: queueEntry,
9798})
......@@ -146,7 +147,7 @@ const pendingRequest = Schema.Struct({
146147const PendingRequestListTool = Tool.make("t3_pending_request_list", {
147148 ...commandTool,
148149 description:
149 "List pending user questions in a thread in the calling project. Approval requests are not included.",
150 "List pending user questions in a thread. Omit threadId for this thread. Approval requests are not included.",
150151 parameters: Schema.Struct({ threadId: Schema.optional(ThreadId) }),
151152 success: Schema.Struct({ requestIds: Schema.Array(RuntimeRequestId) }),
152153})
......@@ -173,7 +174,7 @@ const PendingRequestRespondTool = Tool.make("t3_pending_request_respond", {
173174const ThreadConfigurationTool = Tool.make("t3_thread_configuration", {
174175 ...commandTool,
175176 description:
176 "Read a thread's provider/model selection and modes in the calling project. orchestrator_capabilities lists available providers and models.",
177 "Read a thread's provider/model selection and modes. Omit threadId for this thread. orchestrator_capabilities lists available providers and models.",
177178 parameters: Schema.Struct({ threadId: Schema.optional(ThreadId) }),
178179 success: Schema.Struct({
179180 threadId: ThreadId,
......@@ -187,16 +188,20 @@ const ThreadConfigurationTool = Tool.make("t3_thread_configuration", {
187188const ThreadConfigureTool = Tool.make("t3_thread_configure", {
188189 ...commandTool,
189190 description:
190 "Set this calling thread's provider, model and options with the existing selection command. This does not change permission modes or other threads. Use orchestrator_capabilities to choose a selection.",
191 parameters: Schema.Struct({ modelSelection: ModelSelection }),
191 "Set a thread's provider, model and options with the existing selection command. Omit threadId for this thread. This does not change permission modes. Use orchestrator_capabilities to choose a selection.",
192 parameters: Schema.Struct({
193 threadId: Schema.optional(ThreadId),
194 modelSelection: ModelSelection,
195 }),
192196}).annotate(Tool.Destructive, true);
193197
194198const transferResult = Schema.Struct({ sequence: NonNegativeInt, targetThreadId: ThreadId });
195199const ThreadForkTool = Tool.make("t3_thread_fork", {
196200 ...commandTool,
197201 description:
198 "Fork this thread from a stable run or checkpoint using the existing fork command. The fork inherits the source configuration. Acceptance does not mean a provider turn has completed.",
202 "Fork a thread from a stable run or checkpoint using the existing fork command. Omit threadId to fork this thread. The fork inherits the source configuration. Acceptance does not mean a provider turn has completed.",
199203 parameters: Schema.Struct({
204 threadId: Schema.optional(ThreadId),
200205 sourcePoint: OrchestrationV2ThreadForkSourcePoint,
201206 title: Schema.optional(TrimmedNonEmptyString),
202207 }),
......@@ -205,8 +210,9 @@ const ThreadForkTool = Tool.make("t3_thread_fork", {
205210const ThreadMergeBackTool = Tool.make("t3_thread_merge_back", {
206211 ...commandTool,
207212 description:
208 "Merge context from this thread back to a related thread in the same project. Existing lineage and transfer rules apply.",
213 "Merge context from a thread back to a related thread in the same project. Omit sourceThreadId to merge from this thread. Existing lineage and transfer rules apply.",
209214 parameters: Schema.Struct({
215 sourceThreadId: Schema.optional(ThreadId),
210216 targetThreadId: ThreadId,
211217 sourcePoint: OrchestrationV2ThreadForkSourcePoint,
212218 }),
......@@ -214,7 +220,7 @@ const ThreadMergeBackTool = Tool.make("t3_thread_merge_back", {
214220}).annotate(Tool.Destructive, true);
215221const ThreadTransfersTool = Tool.make("t3_thread_transfers", {
216222 ...commandTool,
217 description: "Read context transfer status for a thread in the calling project.",
223 description: "Read context transfer status for a thread. Omit threadId for this thread.",
218224 parameters: Schema.Struct({ threadId: Schema.optional(ThreadId) }),
219225 success: Schema.Struct({
220226 transfers: Schema.Array(
......@@ -233,8 +239,11 @@ const ThreadTransfersTool = Tool.make("t3_thread_transfers", {
233239const ThreadSearchTool = Tool.make("t3_thread_search", {
234240 ...commandTool,
235241 description:
236 "Search active thread titles and content with the app's existing bounded search. Returns matches in the calling project from the global top matches; other-project matches are omitted, so this may return fewer than limit. No pagination or exhaustive-result guarantee.",
237 parameters: OrchestrationSearchThreadsInput,
242 "Search active thread titles and content with the app's existing bounded search. Matches are limited to one project (projectId, else the calling thread's project) out of the global top matches, so this may return fewer than limit. A caller outside a T3 thread that omits projectId searches every project. No pagination or exhaustive-result guarantee.",
243 parameters: Schema.Struct({
244 ...OrchestrationSearchThreadsInput.fields,
245 projectId: Schema.optional(ProjectId),
246 }),
238247 success: OrchestrationSearchThreadsResult,
239248 dependencies: [...commandTool.dependencies, ThreadSearch.ThreadSearch],
240249})
......@@ -244,7 +253,7 @@ const ThreadSearchTool = Tool.make("t3_thread_search", {
244253const ScheduledTaskRunTool = Tool.make("run_scheduled_task_now", {
245254 ...commandTool,
246255 description:
247 "Run a scheduled task in the calling project now through the existing scheduler. Requires a full-access/default caller. Each call is a new manual run; completion means dispatch/bookkeeping completed, not that the provider turn finished.",
256 "Run a scheduled task now through the existing scheduler. Requires a full-access/default caller. Each call is a new manual run; completion means dispatch/bookkeeping completed, not that the provider turn finished.",
248257 parameters: Schema.Struct({ taskId: ScheduledTaskId }),
249258 success: Schema.Struct({
250259 taskId: ScheduledTaskId,
apps/server/src/mcp/toolkits/worktree/handlers.ts+7-4
......@@ -2,7 +2,7 @@ import { OrchestratorMcpFailure } from "@t3tools/contracts";
22import * as Option from "effect/Option";
33import * as GitWorkflow from "../../../git/GitWorkflowService.ts";
44import * as Project from "../../../project/ProjectService.ts";
5import { readCaller, unavailable } from "../../threadAccess.ts";
5import { readThread, unavailable } from "../../threadAccess.ts";
66import * as Effect from "effect/Effect";
77
88import * as McpInvocationContext from "../../McpInvocationContext.ts";
......@@ -18,9 +18,12 @@ const handlers = {
1818 code: "capability_denied",
1919 message: "This credential cannot inspect worktrees.",
2020 });
21 const { caller } = yield* readCaller();
21 const { threadId, ...refs } = input;
22 const {
23 projection: { thread },
24 } = yield* readThread(threadId);
2225 const projects = yield* Project.ProjectService;
23 const project = yield* projects.getById(caller.projectId).pipe(Effect.mapError(unavailable));
26 const project = yield* projects.getById(thread.projectId).pipe(Effect.mapError(unavailable));
2427 if (Option.isNone(project))
2528 return yield* new OrchestratorMcpFailure({
2629 code: "invalid_request",
......@@ -28,7 +31,7 @@ const handlers = {
2831 });
2932 const git = yield* GitWorkflow.GitWorkflowService;
3033 return yield* git
31 .listRefs({ ...input, cwd: caller.worktreePath ?? project.value.workspaceRoot })
34 .listRefs({ ...refs, cwd: thread.worktreePath ?? project.value.workspaceRoot })
3235 .pipe(Effect.mapError(unavailable));
3336 }),
3437 t3_worktree_handoff: (input) =>
apps/server/src/mcp/toolkits/worktree/tools.ts+5-3
......@@ -6,6 +6,7 @@ import {
66 WorktreeMcpHandoffInput,
77 WorktreeMcpHandoffResult,
88 WorktreeMcpStatusResult,
9 ThreadId,
910} from "@t3tools/contracts";
1011import * as Schema from "effect/Schema";
1112import * as GitWorkflowService from "../../../git/GitWorkflowService.ts";
......@@ -23,7 +24,7 @@ const dependencies = [
2324
2425const WorktreeHandoffTool = Tool.make("t3_worktree_handoff", {
2526 description:
26 "Move this agent thread into a new git worktree. To launch a separate agent already bound to a new or existing worktree, use t3_thread_launch with workspaceStrategy instead. Creates the worktree branch (optionally from origin), re-points the thread at the worktree, and by default runs the project's setup script there. Changing the workspace detaches the live provider session, so the current turn ends shortly after the handoff is recorded; call this as the last action of the turn. To keep working after the handoff, pass continuationPrompt with the remaining work: it is queued as the thread's next message and starts a new turn inside the worktree with the conversation preserved. Without it the thread stays idle until the next message. The worktree is not removed automatically when the thread is deleted. Fails if the thread is already attached to a worktree.",
27 "Needs an agent running inside a T3 thread. Move this agent thread into a new git worktree. To launch a separate agent already bound to a new or existing worktree, use t3_thread_launch with workspaceStrategy instead. Creates the worktree branch (optionally from origin), re-points the thread at the worktree, and by default runs the project's setup script there. Changing the workspace detaches the live provider session, so the current turn ends shortly after the handoff is recorded; call this as the last action of the turn. To keep working after the handoff, pass continuationPrompt with the remaining work: it is queued as the thread's next message and starts a new turn inside the worktree with the conversation preserved. Without it the thread stays idle until the next message. The worktree is not removed automatically when the thread is deleted. Fails if the thread is already attached to a worktree.",
2728 parameters: WorktreeMcpHandoffInput,
2829 success: WorktreeMcpHandoffResult,
2930 failure: WorktreeMcpFailure,
......@@ -38,7 +39,7 @@ const WorktreeHandoffTool = Tool.make("t3_worktree_handoff", {
3839
3940const WorktreeStatusTool = Tool.make("t3_worktree_status", {
4041 description:
41 "Report this agent thread's worktree binding: whether it is attached to a git worktree, the worktree path and branch, the project's main workspace root, and the server default for t3_worktree_handoff's startFromOrigin. Call this before t3_worktree_handoff to check whether a handoff is possible or has already happened.",
42 "Needs an agent running inside a T3 thread. Report this agent thread's worktree binding: whether it is attached to a git worktree, the worktree path and branch, the project's main workspace root, and the server default for t3_worktree_handoff's startFromOrigin. Call this before t3_worktree_handoff to check whether a handoff is possible or has already happened.",
4243 // No `parameters`: Tool.make defaults to Tool.EmptyParams, which serializes
4344 // to a top-level `type: "object"` JSON Schema. An explicit empty
4445 // Schema.Struct({}) serializes to `anyOf: [object, array]`, which is not a
......@@ -56,8 +57,9 @@ const WorktreeStatusTool = Tool.make("t3_worktree_status", {
5657
5758const WorktreeListTool = Tool.make("t3_worktree_list", {
5859 description:
59 "List branch refs and their associated checkout paths for this thread's workspace using the app's ref inventory. Detached worktrees without a branch are not included. Use t3_worktree_status for the thread binding and t3_worktree_handoff to create a new worktree.",
60 "List branch refs and their associated checkout paths for a thread's workspace (omit threadId for this thread) using the app's ref inventory. Detached worktrees without a branch are not included. Use t3_worktree_status for the thread binding and t3_worktree_handoff to create a new worktree.",
6061 parameters: Schema.Struct({
62 threadId: Schema.optional(ThreadId),
6163 query: VcsListRefsInput.fields.query,
6264 cursor: VcsListRefsInput.fields.cursor,
6365 limit: VcsListRefsInput.fields.limit,
apps/server/src/orchestration-v2/ProviderSessionManager.test.ts+6-6
......@@ -1082,7 +1082,7 @@ it.effect(
10821082 const token = captured?.authorizationHeader.replace(/^Bearer\s+/, "");
10831083 assert.isDefined(token);
10841084 const resolved = yield* registry.resolve(token!);
1085 assert.equal(resolved?.threadId, threadId);
1085 assert.equal(resolved?.thread.threadId, threadId);
10861086 assert.deepEqual(
10871087 resolved?.capabilities,
10881088 new Set(["preview", "orchestration", "worktree", "pull-requests"]),
......@@ -1317,7 +1317,7 @@ it.effect("ProviderSessionManagerV2 duplicate detach preserves replacement MCP c
13171317 McpProviderSession.readMcpProviderSession(threadId)?.providerSessionId,
13181318 replacement?.providerSessionId,
13191319 );
1320 assert.equal((yield* registry.resolve(replacementToken!))?.threadId, threadId);
1320 assert.equal((yield* registry.resolve(replacementToken!))?.thread.threadId, threadId);
13211321 });
13221322
13231323 yield* effect.pipe(
......@@ -1393,7 +1393,7 @@ it.effect(
13931393 McpProviderSession.readMcpProviderSession(threadId)?.providerSessionId,
13941394 replacement?.providerSessionId,
13951395 );
1396 assert.equal((yield* registry.resolve(replacementToken!))?.threadId, threadId);
1396 assert.equal((yield* registry.resolve(replacementToken!))?.thread.threadId, threadId);
13971397 });
13981398
13991399 yield* effect.pipe(
......@@ -1449,7 +1449,7 @@ it.effect(
14491449 // process's MCP client keeps using the credential it was started with.
14501450 yield* manager.detach({ providerSessionId, threadId, detail: "Workspace changed." });
14511451 assert.equal(
1452 (yield* registry.resolve(originalToken!))?.threadId,
1452 (yield* registry.resolve(originalToken!))?.thread.threadId,
14531453 threadId,
14541454 "detach must not revoke the credential the live provider process still holds",
14551455 );
......@@ -1468,7 +1468,7 @@ it.effect(
14681468 original?.providerSessionId,
14691469 "re-attach must reuse the existing credential, not rotate it",
14701470 );
1471 assert.equal((yield* registry.resolve(originalToken!))?.threadId, threadId);
1471 assert.equal((yield* registry.resolve(originalToken!))?.thread.threadId, threadId);
14721472
14731473 // Releasing the session (provider process gone) still revokes.
14741474 yield* manager.close(providerSessionId);
......@@ -1592,7 +1592,7 @@ it.effect(
15921592 "the credential the adapter was configured with must remain current",
15931593 );
15941594 assert.equal(
1595 (yield* registry.resolve(originalToken!))?.threadId,
1595 (yield* registry.resolve(originalToken!))?.thread.threadId,
15961596 threadId,
15971597 "the predecessor release must not revoke a credential reserved by an in-flight open",
15981598 );
apps/server/src/orchestration-v2/ProviderSessionManager.ts+2-2
......@@ -468,8 +468,8 @@ export const layerWithOptions = (
468468 const resolved = yield* mcpSessionRegistry.resolve(rawToken);
469469 if (
470470 resolved !== undefined &&
471 resolved.threadId === threadId &&
472 resolved.providerInstanceId === providerInstanceId &&
471 resolved.thread.threadId === threadId &&
472 resolved.thread.providerInstanceId === providerInstanceId &&
473473 // A flipped browser-access setting must not survive through
474474 // credential reuse: rotate so the new scope reflects it.
475475 resolved.capabilities.has("preview") === browserToolsAvailable &&
packages/contracts/src/orchestratorMcp.ts+29-4
......@@ -280,7 +280,15 @@ export const OrchestratorMcpThreadStatus = Schema.Union([
280280]);
281281export type OrchestratorMcpThreadStatus = typeof OrchestratorMcpThreadStatus.Type;
282282
283const OrchestratorMcpProjectTarget = Schema.optional(
284 ProjectId.annotate({
285 description:
286 "Project to act on. Omit for the calling thread's project; required when the caller is not a T3 thread.",
287 }),
288);
289
283290export const OrchestratorMcpThreadListInput = Schema.Struct({
291 projectId: OrchestratorMcpProjectTarget,
284292 statuses: Schema.optional(
285293 Schema.Array(OrchestratorMcpThreadStatus).check(Schema.isMaxLength(10)),
286294 ),
......@@ -316,7 +324,8 @@ export type OrchestratorMcpThreadListItem = typeof OrchestratorMcpThreadListItem
316324
317325export const OrchestratorMcpThreadListResult = Schema.Struct({
318326 projectId: ProjectId,
319 currentThreadId: ThreadId,
327 /** The calling thread, or null when the caller is not a T3 thread. */
328 currentThreadId: Schema.NullOr(ThreadId),
320329 threads: Schema.Array(OrchestratorMcpThreadListItem),
321330 nextCursor: Schema.NullOr(NonNegativeInt),
322331 total: NonNegativeInt,
......@@ -475,9 +484,11 @@ export const OrchestratorMcpProviderCapability = Schema.Struct({
475484export type OrchestratorMcpProviderCapability = typeof OrchestratorMcpProviderCapability.Type;
476485
477486export const OrchestratorMcpCapabilitiesResult = Schema.Struct({
478 parentThreadId: ThreadId,
479 inheritedProviderInstanceId: ProviderInstanceId,
480 inheritedModel: Schema.String,
487 /** The calling thread, or null when the caller is not a T3 thread. */
488 parentThreadId: Schema.NullOr(ThreadId),
489 /** The calling thread's selection, or null when the caller is not a T3 thread. */
490 inheritedProviderInstanceId: Schema.NullOr(ProviderInstanceId),
491 inheritedModel: Schema.NullOr(Schema.String),
481492 runtimeMode: RuntimeMode,
482493 interactionMode: ProviderInteractionMode,
483494 providers: Schema.Array(OrchestratorMcpProviderCapability),
......@@ -495,6 +506,7 @@ export const OrchestratorMcpCapabilitiesResult = Schema.Struct({
495506export type OrchestratorMcpCapabilitiesResult = typeof OrchestratorMcpCapabilitiesResult.Type;
496507
497508export const OrchestratorMcpScheduleTaskInput = Schema.Struct({
509 projectId: OrchestratorMcpProjectTarget,
498510 prompt: OrchestratorMcpPrompt.annotate({
499511 description: "Prompt executed on every scheduled run.",
500512 }),
......@@ -535,6 +547,17 @@ export type OrchestratorMcpScheduledTask = typeof OrchestratorMcpScheduledTask.T
535547export const OrchestratorMcpScheduleTaskResult = OrchestratorMcpScheduledTask;
536548export type OrchestratorMcpScheduleTaskResult = typeof OrchestratorMcpScheduleTaskResult.Type;
537549
550export const OrchestratorMcpListScheduledTasksInput = Schema.Struct({
551 projectId: Schema.optional(
552 ProjectId.annotate({
553 description:
554 "Only list this project's tasks. Omit for the calling thread's project, or for every project when the caller is not a T3 thread.",
555 }),
556 ),
557});
558export type OrchestratorMcpListScheduledTasksInput =
559 typeof OrchestratorMcpListScheduledTasksInput.Type;
560
538561export const OrchestratorMcpListScheduledTasksResult = Schema.Struct({
539562 tasks: Schema.Array(OrchestratorMcpScheduledTask),
540563});
......@@ -583,6 +606,8 @@ export class OrchestratorMcpFailure extends Schema.TaggedError<OrchestratorMcpFa
583606 "thread_not_interruptible",
584607 "invalid_request",
585608 "orchestration_error",
609 "thread_credential_required",
610 "target_required",
586611 ]),
587612 message: Schema.String,
588613 },
packages/contracts/src/previewAutomation.ts+4-3
......@@ -642,11 +642,12 @@ export const PreviewAutomationResponse = Schema.Struct({
642642});
643643export type PreviewAutomationResponse = typeof PreviewAutomationResponse.Type;
644644
645// Thread fields are absent when the caller signed in from outside a T3 thread.
645646const McpCapabilityErrorFields = {
646647 environmentId: EnvironmentId,
647 threadId: ThreadId,
648 providerSessionId: TrimmedNonEmptyString,
649 providerInstanceId: ProviderInstanceId,
648 threadId: Schema.optional(ThreadId),
649 providerSessionId: Schema.optional(TrimmedNonEmptyString),
650 providerInstanceId: Schema.optional(ProviderInstanceId),
650651};
651652
652653/** Agents read this message, so it names the next step and not only the failure. */
packages/contracts/src/threadMetadataMcp.ts+1-1
......@@ -80,7 +80,7 @@ export type ThreadMetadataMcpPullRequest = typeof ThreadMetadataMcpPullRequest.T
8080
8181export const ThreadMetadataMcpUpdateInput = Schema.Struct({
8282 threadId: Schema.optional(ThreadId).annotate({
83 description: "Thread in the calling project. Omit to update the calling thread.",
83 description: "Thread to update. Omit to update the calling thread.",
8484 }),
8585 action: ThreadMetadataMcpAction,
8686 title: Schema.optional(ThreadMetadataTitle),
packages/contracts/src/worktreeMcp.ts+1
......@@ -121,6 +121,7 @@ export class WorktreeMcpFailure extends Schema.TaggedError<WorktreeMcpFailure>()
121121 "handoff_in_progress",
122122 "invalid_request",
123123 "operation_failed",
124 "thread_credential_required",
124125 ]),
125126 message: Schema.String,
126127 },