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");...@@ -19,9 +19,13 @@ const environmentId = EnvironmentId.make("environment-device-test");
19const threadId = ThreadId.make("thread-device-test");19const threadId = ThreadId.make("thread-device-test");
20const invocation = (capabilities: ReadonlyArray<McpInvocationContext.McpCapability>) => ({20const invocation = (capabilities: ReadonlyArray<McpInvocationContext.McpCapability>) => ({
21 environmentId,21 environmentId,
22 threadId,22 requestNamespace: "provider-session-device-test",
23 providerSessionId: "provider-session-device-test",23 thread: {
24 providerInstanceId: ProviderInstanceId.make("codex"),24 threadId,
25 providerSessionId: "provider-session-device-test",
26 providerInstanceId: ProviderInstanceId.make("codex"),
27 },
28 client: undefined,
25 capabilities: new Set(capabilities),29 capabilities: new Set(capabilities),
26 issuedAt: 1,30 issuedAt: 1,
27});31});
apps/server/src/mcp/McpHttpServer.test.ts+7-3
...@@ -27,9 +27,13 @@ const alternateTabId = PreviewTabId.make("tab-mcp-alternate");...@@ -27,9 +27,13 @@ const alternateTabId = PreviewTabId.make("tab-mcp-alternate");
27const decodeJsonText = Schema.decodeUnknownSync(Schema.fromJsonString(Schema.Unknown));27const decodeJsonText = Schema.decodeUnknownSync(Schema.fromJsonString(Schema.Unknown));
28const invocation = {28const invocation = {
29 environmentId,29 environmentId,
30 threadId,30 requestNamespace: "provider-session-mcp-test",
31 providerSessionId: "provider-session-mcp-test",31 thread: {
32 providerInstanceId: ProviderInstanceId.make("codex"),32 threadId,
33 providerSessionId: "provider-session-mcp-test",
34 providerInstanceId: ProviderInstanceId.make("codex"),
35 },
36 client: undefined,
33 capabilities: new Set(["preview"] as const),37 capabilities: new Set(["preview"] as const),
34 issuedAt: 1,38 issuedAt: 1,
35};39};
apps/server/src/mcp/McpInvocationContext.test.ts+42-10
...@@ -13,9 +13,13 @@ import * as McpInvocationContext from "./McpInvocationContext.ts";...@@ -13,9 +13,13 @@ import * as McpInvocationContext from "./McpInvocationContext.ts";
13it.effect("reports the scoped credential context when preview capability is unavailable", () => {13it.effect("reports the scoped credential context when preview capability is unavailable", () => {
14 const invocation: McpInvocationContext.McpInvocationScope = {14 const invocation: McpInvocationContext.McpInvocationScope = {
15 environmentId: EnvironmentId.make("environment-1"),15 environmentId: EnvironmentId.make("environment-1"),
16 threadId: ThreadId.make("thread-1"),16 requestNamespace: "provider-session-1",
17 providerSessionId: "provider-session-1",17 thread: {
18 providerInstanceId: ProviderInstanceId.make("codex"),18 threadId: ThreadId.make("thread-1"),
19 providerSessionId: "provider-session-1",
20 providerInstanceId: ProviderInstanceId.make("codex"),
21 },
22 client: undefined,
19 capabilities: new Set(),23 capabilities: new Set(),
20 issuedAt: 1,24 issuedAt: 1,
21 };25 };
...@@ -30,9 +34,9 @@ it.effect("reports the scoped credential context when preview capability is unav...@@ -30,9 +34,9 @@ it.effect("reports the scoped credential context when preview capability is unav
30 expect(error).toMatchObject({34 expect(error).toMatchObject({
31 capability: "preview",35 capability: "preview",
32 environmentId: invocation.environmentId,36 environmentId: invocation.environmentId,
33 threadId: invocation.threadId,37 threadId: invocation.thread?.threadId,
34 providerSessionId: invocation.providerSessionId,38 providerSessionId: invocation.thread?.providerSessionId,
35 providerInstanceId: invocation.providerInstanceId,39 providerInstanceId: invocation.thread?.providerInstanceId,
36 });40 });
37 expect(error.message).toContain("MCP credential does not grant the preview capability");41 expect(error.message).toContain("MCP credential does not grant the preview capability");
38 expect(error.message).toContain("use a headless browser from the shell");42 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...@@ -42,9 +46,13 @@ it.effect("reports the scoped credential context when preview capability is unav
42it.effect("reports other missing capabilities with the neutral error", () => {46it.effect("reports other missing capabilities with the neutral error", () => {
43 const invocation: McpInvocationContext.McpInvocationScope = {47 const invocation: McpInvocationContext.McpInvocationScope = {
44 environmentId: EnvironmentId.make("environment-1"),48 environmentId: EnvironmentId.make("environment-1"),
45 threadId: ThreadId.make("thread-1"),49 requestNamespace: "provider-session-1",
46 providerSessionId: "provider-session-1",50 thread: {
47 providerInstanceId: ProviderInstanceId.make("codex"),51 threadId: ThreadId.make("thread-1"),
52 providerSessionId: "provider-session-1",
53 providerInstanceId: ProviderInstanceId.make("codex"),
54 },
55 client: undefined,
48 capabilities: new Set(["preview"]),56 capabilities: new Set(["preview"]),
49 issuedAt: 1,57 issuedAt: 1,
50 };58 };
...@@ -56,7 +64,10 @@ it.effect("reports other missing capabilities with the neutral error", () => {...@@ -56,7 +64,10 @@ it.effect("reports other missing capabilities with the neutral error", () => {
56 );64 );
5765
58 expect(error).toBeInstanceOf(McpCapabilityUnavailableError);66 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
61 const scope = yield* McpInvocationContext.requireMcpCapability("preview").pipe(72 const scope = yield* McpInvocationContext.requireMcpCapability("preview").pipe(
62 Effect.provideService(McpInvocationContext.McpInvocationContext, invocation),73 Effect.provideService(McpInvocationContext.McpInvocationContext, invocation),
...@@ -64,3 +75,24 @@ it.effect("reports other missing capabilities with the neutral error", () => {...@@ -64,3 +75,24 @@ it.effect("reports other missing capabilities with the neutral error", () => {
64 expect(scope).toBe(invocation);75 expect(scope).toBe(invocation);
65 });76 });
66});77});
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 @@...@@ -1,8 +1,10 @@
1import {1import {
2 type EnvironmentId,2 type EnvironmentId,
3 McpCapabilityUnavailableError,3 McpCapabilityUnavailableError,
4 OrchestratorMcpFailure,
4 PreviewAutomationUnavailableError,5 PreviewAutomationUnavailableError,
5 type ProviderInstanceId,6 type ProviderInstanceId,
7 type RuntimeMode,
6 type ThreadId,8 type ThreadId,
7} from "@t3tools/contracts";9} from "@t3tools/contracts";
8import * as Context from "effect/Context";10import * as Context from "effect/Context";
...@@ -17,13 +19,35 @@ const ALL_MCP_CAPABILITIES = [...@@ -17,13 +19,35 @@ const ALL_MCP_CAPABILITIES = [
17] as const;19] as const;
18export type McpCapability = (typeof ALL_MCP_CAPABILITIES)[number];20export type McpCapability = (typeof ALL_MCP_CAPABILITIES)[number];
1921
20export interface McpInvocationScope {22/** A provider session T3 Code launched for one thread. */
21 readonly environmentId: EnvironmentId;23export interface McpThreadCaller {
22 readonly threadId: ThreadId;24 readonly threadId: ThreadId;
23 readonly providerSessionId: string;25 readonly providerSessionId: string;
24 readonly providerInstanceId: ProviderInstanceId;26 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;
25 readonly capabilities: ReadonlySet<McpCapability>;45 readonly capabilities: ReadonlySet<McpCapability>;
26 readonly issuedAt: number;46 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;
27}51}
2852
29export class McpInvocationContext extends Context.Service<53export class McpInvocationContext extends Context.Service<
...@@ -42,9 +66,13 @@ const missingCapability = (...@@ -42,9 +66,13 @@ const missingCapability = (
42): PreviewAutomationUnavailableError | McpCapabilityUnavailableError => {66): PreviewAutomationUnavailableError | McpCapabilityUnavailableError => {
43 const fields = {67 const fields = {
44 environmentId: invocation.environmentId,68 environmentId: invocation.environmentId,
45 threadId: invocation.threadId,69 ...(invocation.thread === undefined
46 providerSessionId: invocation.providerSessionId,70 ? {}
47 providerInstanceId: invocation.providerInstanceId,71 : {
72 threadId: invocation.thread.threadId,
73 providerSessionId: invocation.thread.providerSessionId,
74 providerInstanceId: invocation.thread.providerInstanceId,
75 }),
48 };76 };
49 return capability === "preview"77 return capability === "preview"
50 ? new PreviewAutomationUnavailableError({ capability, ...fields })78 ? new PreviewAutomationUnavailableError({ capability, ...fields })
...@@ -62,3 +90,34 @@ export const requireMcpCapability = <const C extends McpCapability>(...@@ -62,3 +90,34 @@ export const requireMcpCapability = <const C extends McpCapability>(
62 ),90 ),
63 Effect.withSpan("mcp.requireCapability"),91 Effect.withSpan("mcp.requireCapability"),
64 );92 );
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...@@ -47,7 +47,7 @@ it.effect("stores only a token hash, resolves the bearer token, and revokes by t
47 expect(token.length).toBeGreaterThan(20);47 expect(token.length).toBeGreaterThan(20);
4848
49 const resolved = yield* registry.resolve(token);49 const resolved = yield* registry.resolve(token);
50 expect(resolved?.threadId).toBe(threadId);50 expect(resolved?.thread.threadId).toBe(threadId);
51 expect(resolved?.capabilities).toEqual(51 expect(resolved?.capabilities).toEqual(
52 new Set(["preview", "orchestration", "worktree", "pull-requests"]),52 new Set(["preview", "orchestration", "worktree", "pull-requests"]),
53 );53 );
...@@ -158,7 +158,7 @@ it.effect("keeps a credential alive across turns that never touch an MCP tool",...@@ -158,7 +158,7 @@ it.effect("keeps a credential alive across turns that never touch an MCP tool",
158 yield* registry.touch(threadId);158 yield* registry.touch(threadId);
159 }159 }
160160
161 expect((yield* registry.resolve(token))?.threadId).toBe(threadId);161 expect((yield* registry.resolve(token))?.thread.threadId).toBe(threadId);
162 }),162 }),
163);163);
164164
apps/server/src/mcp/McpSessionRegistry.ts+16-11
...@@ -32,7 +32,7 @@ export interface McpSessionRegistryShape {...@@ -32,7 +32,7 @@ export interface McpSessionRegistryShape {
32 readonly issue: (request: McpCredentialRequest) => Effect.Effect<McpIssuedCredential>;32 readonly issue: (request: McpCredentialRequest) => Effect.Effect<McpIssuedCredential>;
33 readonly resolve: (33 readonly resolve: (
34 rawToken: string,34 rawToken: string,
35 ) => Effect.Effect<McpInvocationContext.McpInvocationScope | undefined>;35 ) => Effect.Effect<McpInvocationContext.McpThreadInvocationScope | undefined>;
36 /**36 /**
37 * Records a sign of life for every credential bound to `threadId`. Provider37 * Records a sign of life for every credential bound to `threadId`. Provider
38 * turns call this so that a session which is plainly alive keeps its38 * turns call this so that a session which is plainly alive keeps its
...@@ -49,9 +49,10 @@ export class McpSessionRegistry extends Context.Service<...@@ -49,9 +49,10 @@ export class McpSessionRegistry extends Context.Service<
49 McpSessionRegistryShape49 McpSessionRegistryShape
50>()("t3/mcp/McpSessionRegistry") {}50>()("t3/mcp/McpSessionRegistry") {}
5151
52/** Registry credentials always belong to a provider session, so their scope has a thread. */
52interface CredentialRecord {53interface CredentialRecord {
53 readonly tokenHash: string;54 readonly tokenHash: string;
54 readonly scope: McpInvocationContext.McpInvocationScope;55 readonly scope: McpInvocationContext.McpThreadInvocationScope;
55 readonly lastAliveAt: number;56 readonly lastAliveAt: number;
56}57}
5758
...@@ -127,11 +128,15 @@ const makeWithOptions = Effect.fn("McpSessionRegistry.make")(function* (...@@ -127,11 +128,15 @@ const makeWithOptions = Effect.fn("McpSessionRegistry.make")(function* (
127 const rawToken = yield* crypto.randomBytes(32).pipe(Effect.map(tokenFromBytes), Effect.orDie);128 const rawToken = yield* crypto.randomBytes(32).pipe(Effect.map(tokenFromBytes), Effect.orDie);
128 const tokenHash = yield* hashToken(rawToken);129 const tokenHash = yield* hashToken(rawToken);
129 const browserToolsAvailable = request.browserToolsAvailable ?? true;130 const browserToolsAvailable = request.browserToolsAvailable ?? true;
130 const scope: McpInvocationContext.McpInvocationScope = {131 const scope: McpInvocationContext.McpThreadInvocationScope = {
131 environmentId,132 environmentId,
132 threadId: ThreadId.make(request.threadId),133 requestNamespace: providerSessionId,
133 providerSessionId,134 thread: {
134 providerInstanceId: ProviderInstanceId.make(request.providerInstanceId),135 threadId: ThreadId.make(request.threadId),
136 providerSessionId,
137 providerInstanceId: ProviderInstanceId.make(request.providerInstanceId),
138 },
139 client: undefined,
135 capabilities: new Set<McpInvocationContext.McpCapability>([140 capabilities: new Set<McpInvocationContext.McpCapability>([
136 "orchestration",141 "orchestration",
137 "worktree",142 "worktree",
...@@ -148,9 +153,9 @@ const makeWithOptions = Effect.fn("McpSessionRegistry.make")(function* (...@@ -148,9 +153,9 @@ const makeWithOptions = Effect.fn("McpSessionRegistry.make")(function* (
148 return {153 return {
149 config: {154 config: {
150 environmentId,155 environmentId,
151 threadId: scope.threadId,156 threadId: scope.thread.threadId,
152 providerSessionId,157 providerSessionId,
153 providerInstanceId: scope.providerInstanceId,158 providerInstanceId: scope.thread.providerInstanceId,
154 endpoint,159 endpoint,
155 authorizationHeader: `Bearer ${rawToken}`,160 authorizationHeader: `Bearer ${rawToken}`,
156 browserToolsAvailable: scope.capabilities.has("preview"),161 browserToolsAvailable: scope.capabilities.has("preview"),
...@@ -183,7 +188,7 @@ const makeWithOptions = Effect.fn("McpSessionRegistry.make")(function* (...@@ -183,7 +188,7 @@ const makeWithOptions = Effect.fn("McpSessionRegistry.make")(function* (
183 const current = pruneDead(records, timestamp);188 const current = pruneDead(records, timestamp);
184 const next = new Map(current);189 const next = new Map(current);
185 for (const [tokenHash, record] of current) {190 for (const [tokenHash, record] of current) {
186 if (record.scope.threadId === threadId) {191 if (record.scope.thread.threadId === threadId) {
187 next.set(tokenHash, { ...record, lastAliveAt: timestamp });192 next.set(tokenHash, { ...record, lastAliveAt: timestamp });
188 }193 }
189 }194 }
...@@ -203,11 +208,11 @@ const makeWithOptions = Effect.fn("McpSessionRegistry.make")(function* (...@@ -203,11 +208,11 @@ const makeWithOptions = Effect.fn("McpSessionRegistry.make")(function* (
203 touch,208 touch,
204 revokeProviderSession: Effect.fn("McpSessionRegistry.revokeProviderSession")(209 revokeProviderSession: Effect.fn("McpSessionRegistry.revokeProviderSession")(
205 function* (providerSessionId) {210 function* (providerSessionId) {
206 yield* revokeWhere((record) => record.scope.providerSessionId === providerSessionId);211 yield* revokeWhere((record) => record.scope.thread.providerSessionId === providerSessionId);
207 },212 },
208 ),213 ),
209 revokeThread: Effect.fn("McpSessionRegistry.revokeThread")(function* (threadId) {214 revokeThread: Effect.fn("McpSessionRegistry.revokeThread")(function* (threadId) {
210 yield* revokeWhere((record) => record.scope.threadId === threadId);215 yield* revokeWhere((record) => record.scope.thread.threadId === threadId);
211 }),216 }),
212 revokeAll: SynchronizedRef.set(state, { records: new Map() }),217 revokeAll: SynchronizedRef.set(state, { records: new Map() }),
213 });218 });
apps/server/src/mcp/OrchestratorMcpService.activity.test.ts+112-54
...@@ -2,10 +2,12 @@ import {...@@ -2,10 +2,12 @@ import {
2 EnvironmentId,2 EnvironmentId,
3 NodeId,3 NodeId,
4 type OrchestrationV2ThreadProjection,4 type OrchestrationV2ThreadProjection,
5 type OrchestrationV2ThreadShell,
5 ProjectId,6 ProjectId,
6 ProviderDriverKind,7 ProviderDriverKind,
7 ProviderInstanceId,8 ProviderInstanceId,
8 RunId,9 RunId,
10 ScheduledTaskId,
9 ThreadId,11 ThreadId,
10} from "@t3tools/contracts";12} from "@t3tools/contracts";
11import * as DateTime from "effect/DateTime";13import * as DateTime from "effect/DateTime";
...@@ -16,6 +18,7 @@ import { expect, it } from "vite-plus/test";...@@ -16,6 +18,7 @@ import { expect, it } from "vite-plus/test";
1618
17import * as ProviderAdapterRegistry from "../orchestration-v2/ProviderAdapterRegistry.ts";19import * as ProviderAdapterRegistry from "../orchestration-v2/ProviderAdapterRegistry.ts";
18import * as ProviderRegistry from "../provider/Services/ProviderRegistry.ts";20import * as ProviderRegistry from "../provider/Services/ProviderRegistry.ts";
21import * as ProjectService from "../project/ProjectService.ts";
19import * as ScheduledTaskService from "../scheduledTasks/ScheduledTaskService.ts";22import * as ScheduledTaskService from "../scheduledTasks/ScheduledTaskService.ts";
20import * as ThreadManagementService from "../orchestration-v2/ThreadManagementService.ts";23import * as ThreadManagementService from "../orchestration-v2/ThreadManagementService.ts";
21import type * as McpInvocationContext from "./McpInvocationContext.ts";24import type * as McpInvocationContext from "./McpInvocationContext.ts";
...@@ -37,9 +40,13 @@ const parentInstanceId = ProviderInstanceId.make("codex");...@@ -37,9 +40,13 @@ const parentInstanceId = ProviderInstanceId.make("codex");
3740
38const makeScope = (): McpInvocationContext.McpInvocationScope => ({41const makeScope = (): McpInvocationContext.McpInvocationScope => ({
39 environmentId,42 environmentId,
40 threadId: parentThreadId,43 requestNamespace: "provider-session-mcp-orchestrator-detail",
41 providerSessionId: "provider-session-mcp-orchestrator-detail",44 thread: {
42 providerInstanceId: parentInstanceId,45 threadId: parentThreadId,
46 providerSessionId: "provider-session-mcp-orchestrator-detail",
47 providerInstanceId: parentInstanceId,
48 },
49 client: undefined,
43 capabilities: new Set(["orchestration"]),50 capabilities: new Set(["orchestration"]),
44 issuedAt: 1,51 issuedAt: 1,
45});52});
...@@ -127,10 +134,21 @@ it("readThread prefers activity-run status over a newer cancelled queued run", a...@@ -127,10 +134,21 @@ it("readThread prefers activity-run status over a newer cancelled queued run", a
127 threadId === parentThreadId134 threadId === parentThreadId
128 ? Effect.succeed(projection)135 ? Effect.succeed(projection)
129 : Effect.die(`unexpected thread ${threadId}`),136 : 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}`),
130 } satisfies Partial<ThreadManagementService.ThreadManagementService["Service"]>),147 } satisfies Partial<ThreadManagementService.ThreadManagementService["Service"]>),
131 Layer.mock(ProviderRegistry.ProviderRegistry)({148 Layer.mock(ProviderRegistry.ProviderRegistry)({
132 getProviders: Effect.succeed([]),149 getProviders: Effect.succeed([]),
133 } satisfies Partial<ProviderRegistry.ProviderRegistry["Service"]>),150 } satisfies Partial<ProviderRegistry.ProviderRegistry["Service"]>),
151 Layer.mock(ProjectService.ProjectService)({}),
134 Layer.mock(ScheduledTaskService.ScheduledTaskService)({152 Layer.mock(ScheduledTaskService.ScheduledTaskService)({
135 list: () => Effect.succeed({ tasks: [] }),153 list: () => Effect.succeed({ tasks: [] }),
136 } satisfies Partial<ScheduledTaskService.ScheduledTaskService["Service"]>),154 } satisfies Partial<ScheduledTaskService.ScheduledTaskService["Service"]>),
...@@ -180,10 +198,21 @@ it("readThread prefers waiting activity status over a newer cancelled queued run...@@ -180,10 +198,21 @@ it("readThread prefers waiting activity status over a newer cancelled queued run
180 threadId === parentThreadId198 threadId === parentThreadId
181 ? Effect.succeed(projection)199 ? Effect.succeed(projection)
182 : Effect.die(`unexpected thread ${threadId}`),200 : 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}`),
183 } satisfies Partial<ThreadManagementService.ThreadManagementService["Service"]>),211 } satisfies Partial<ThreadManagementService.ThreadManagementService["Service"]>),
184 Layer.mock(ProviderRegistry.ProviderRegistry)({212 Layer.mock(ProviderRegistry.ProviderRegistry)({
185 getProviders: Effect.succeed([]),213 getProviders: Effect.succeed([]),
186 } satisfies Partial<ProviderRegistry.ProviderRegistry["Service"]>),214 } satisfies Partial<ProviderRegistry.ProviderRegistry["Service"]>),
215 Layer.mock(ProjectService.ProjectService)({}),
187 Layer.mock(ScheduledTaskService.ScheduledTaskService)({216 Layer.mock(ScheduledTaskService.ScheduledTaskService)({
188 list: () => Effect.succeed({ tasks: [] }),217 list: () => Effect.succeed({ tasks: [] }),
189 } satisfies Partial<ScheduledTaskService.ScheduledTaskService["Service"]>),218 } satisfies Partial<ScheduledTaskService.ScheduledTaskService["Service"]>),
...@@ -295,6 +324,7 @@ it("taskStatus returns task.providerInstanceId rather than the driver kind", asy...@@ -295,6 +324,7 @@ it("taskStatus returns task.providerInstanceId rather than the driver kind", asy
295 Layer.mock(ProviderRegistry.ProviderRegistry)({324 Layer.mock(ProviderRegistry.ProviderRegistry)({
296 getProviders: Effect.succeed([]),325 getProviders: Effect.succeed([]),
297 } satisfies Partial<ProviderRegistry.ProviderRegistry["Service"]>),326 } satisfies Partial<ProviderRegistry.ProviderRegistry["Service"]>),
327 Layer.mock(ProjectService.ProjectService)({}),
298 Layer.mock(ScheduledTaskService.ScheduledTaskService)({328 Layer.mock(ScheduledTaskService.ScheduledTaskService)({
299 list: () => Effect.succeed({ tasks: [] }),329 list: () => Effect.succeed({ tasks: [] }),
300 } satisfies Partial<ScheduledTaskService.ScheduledTaskService["Service"]>),330 } satisfies Partial<ScheduledTaskService.ScheduledTaskService["Service"]>),
...@@ -317,34 +347,12 @@ it("taskStatus returns task.providerInstanceId rather than the driver kind", asy...@@ -317,34 +347,12 @@ it("taskStatus returns task.providerInstanceId rather than the driver kind", asy
317 }).pipe(Effect.provide(layer), Effect.runPromise);347 }).pipe(Effect.provide(layer), Effect.runPromise);
318});348});
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 ];
321 const foreignProjectId = ProjectId.make("project-mcp-orchestrator-foreign");354 const foreignProjectId = ProjectId.make("project-mcp-orchestrator-foreign");
322 const foreignThreadId = ThreadId.make("thread-mcp-orchestrator-foreign");355 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 });
348 const parentProjection = {356 const parentProjection = {
349 thread: baseThread({357 thread: baseThread({
350 threadId: parentThreadId,358 threadId: parentThreadId,
...@@ -352,13 +360,12 @@ it("readThread reaches a thread the user attached as context, but not one an age...@@ -352,13 +360,12 @@ it("readThread reaches a thread the user attached as context, but not one an age
352 instanceId: parentInstanceId,360 instanceId: parentInstanceId,
353 model: "gpt-5.4",361 model: "gpt-5.4",
354 }),362 }),
355 runs: [],363 get runs() {
364 return parentRuns;
365 },
356 visibleTurnItems: [],366 visibleTurnItems: [],
357 runtimeRequests: [],367 runtimeRequests: [],
358 messages: [368 messages: [],
359 message({ createdBy: "user", threadId: foreignThreadId }),
360 message({ createdBy: "agent", threadId: agentOnlyThreadId }),
361 ],
362 contextTransfers: [],369 contextTransfers: [],
363 subagents: [],370 subagents: [],
364 updatedAt: now,371 updatedAt: now,
...@@ -406,11 +413,15 @@ it("readThread reaches a thread the user attached as context, but not one an age...@@ -406,11 +413,15 @@ it("readThread reaches a thread the user attached as context, but not one an age
406 Layer.mock(ThreadManagementService.ThreadManagementService)({413 Layer.mock(ThreadManagementService.ThreadManagementService)({
407 getThreadRecords: (threadId) => {414 getThreadRecords: (threadId) => {
408 if (threadId === parentThreadId) return Effect.succeed(parentProjection);415 if (threadId === parentThreadId) return Effect.succeed(parentProjection);
409 if (threadId === foreignThreadId || threadId === agentOnlyThreadId) {416 if (threadId === foreignThreadId) return Effect.succeed(foreignProjection(threadId));
410 return Effect.succeed(foreignProjection(threadId));
411 }
412 return Effect.die(`unexpected thread ${threadId}`);417 return Effect.die(`unexpected thread ${threadId}`);
413 },418 },
419 getThreadShell: (threadId) =>
420 Effect.succeed(
421 threadId === foreignThreadId
422 ? (foreignProjection(threadId).thread as unknown as OrchestrationV2ThreadShell)
423 : null,
424 ),
414 getTimelinePage: (threadId) =>425 getTimelinePage: (threadId) =>
415 Effect.succeed({426 Effect.succeed({
416 items: foreignProjection(threadId).visibleTurnItems,427 items: foreignProjection(threadId).visibleTurnItems,
...@@ -418,18 +429,36 @@ it("readThread reaches a thread the user attached as context, but not one an age...@@ -418,18 +429,36 @@ it("readThread reaches a thread the user attached as context, but not one an age
418 hasMore: false,429 hasMore: false,
419 }),430 }),
420 getProjectThreadRecords: (input) =>431 getProjectThreadRecords: (input) =>
421 Effect.fail(432 input.threadId === foreignThreadId && input.projectId === foreignProjectId
422 new ThreadManagementService.ThreadManagementThreadNotFoundError({433 ? Effect.succeed(foreignProjection(input.threadId))
423 projectId: input.projectId,434 : Effect.fail(
424 threadId: input.threadId,435 new ThreadManagementService.ThreadManagementThreadNotFoundError({
425 }),436 projectId: input.projectId,
426 ),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),
427 } satisfies Partial<ThreadManagementService.ThreadManagementService["Service"]>),445 } satisfies Partial<ThreadManagementService.ThreadManagementService["Service"]>),
428 Layer.mock(ProviderRegistry.ProviderRegistry)({446 Layer.mock(ProviderRegistry.ProviderRegistry)({
429 getProviders: Effect.succeed([]),447 getProviders: Effect.succeed([]),
430 } satisfies Partial<ProviderRegistry.ProviderRegistry["Service"]>),448 } satisfies Partial<ProviderRegistry.ProviderRegistry["Service"]>),
449 Layer.mock(ProjectService.ProjectService)({}),
431 Layer.mock(ScheduledTaskService.ScheduledTaskService)({450 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 }),
433 } satisfies Partial<ScheduledTaskService.ScheduledTaskService["Service"]>),462 } satisfies Partial<ScheduledTaskService.ScheduledTaskService["Service"]>),
434 Layer.mock(ProviderAdapterRegistry.ProviderAdapterRegistryV2)({463 Layer.mock(ProviderAdapterRegistry.ProviderAdapterRegistryV2)({
435 list: () => Effect.succeed([]),464 list: () => Effect.succeed([]),
...@@ -441,18 +470,47 @@ it("readThread reaches a thread the user attached as context, but not one an age...@@ -441,18 +470,47 @@ it("readThread reaches a thread the user attached as context, but not one an age
441470
442 await Effect.gen(function* () {471 await Effect.gen(function* () {
443 const service = yield* OrchestratorMcpService.OrchestratorMcpService;472 const service = yield* OrchestratorMcpService.OrchestratorMcpService;
444 const attached = yield* service.readThread(makeScope(), { threadId: foreignThreadId });473 const foreign = yield* service.readThread(makeScope(), { threadId: foreignThreadId });
445 expect(attached.thread.threadId).toBe(foreignThreadId);474 expect(foreign.thread.threadId).toBe(foreignThreadId);
446 expect(attached.items.map((item) => item.text)).toEqual(["Foreign thread said hello"]);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* service478 const sent = yield* service.sendToThread(makeScope(), {
449 .readThread(makeScope(), { threadId: agentOnlyThreadId })479 threadId: foreignThreadId,
450 .pipe(Effect.flip);480 message: "hi",
451 expect(denied.code).toBe("thread_not_found");481 });
482 expect(sent.threadId).toBe(foreignThreadId);
452483
453 const write = yield* service484 // Once the caller's run ends, it can still read other threads but no longer write to them.
454 .sendToThread(makeScope(), { threadId: foreignThreadId, message: "hi" })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") })
455 .pipe(Effect.flip);513 .pipe(Effect.flip);
456 expect(write.code).toBe("thread_not_found");514 expect(staleDelete.code).toBe("parent_not_active");
457 }).pipe(Effect.provide(layer), Effect.runPromise);515 }).pipe(Effect.provide(layer), Effect.runPromise);
458});516});
apps/server/src/mcp/OrchestratorMcpService.test.ts+53-18
...@@ -22,6 +22,7 @@ import * as ProviderAdapterRegistry from "../orchestration-v2/ProviderAdapterReg...@@ -22,6 +22,7 @@ import * as ProviderAdapterRegistry from "../orchestration-v2/ProviderAdapterReg
22import * as ThreadManagementService from "../orchestration-v2/ThreadManagementService.ts";22import * as ThreadManagementService from "../orchestration-v2/ThreadManagementService.ts";
23import * as ProviderRegistry from "../provider/Services/ProviderRegistry.ts";23import * as ProviderRegistry from "../provider/Services/ProviderRegistry.ts";
24import { buildUnavailableProviderSnapshot } from "../provider/unavailableProviderSnapshot.ts";24import { buildUnavailableProviderSnapshot } from "../provider/unavailableProviderSnapshot.ts";
25import * as ProjectService from "../project/ProjectService.ts";
25import * as ScheduledTaskService from "../scheduledTasks/ScheduledTaskService.ts";26import * as ScheduledTaskService from "../scheduledTasks/ScheduledTaskService.ts";
26import type { McpInvocationScope } from "./McpInvocationContext.ts";27import type { McpInvocationScope } from "./McpInvocationContext.ts";
27import * as OrchestratorMcpService from "./OrchestratorMcpService.ts";28import * as OrchestratorMcpService from "./OrchestratorMcpService.ts";
...@@ -118,13 +119,18 @@ describe("OrchestratorMcpService", () => {...@@ -118,13 +119,18 @@ describe("OrchestratorMcpService", () => {
118 Layer.mock(ProviderAdapterRegistry.ProviderAdapterRegistryV2)({119 Layer.mock(ProviderAdapterRegistry.ProviderAdapterRegistryV2)({
119 list: () => Effect.succeed([]),120 list: () => Effect.succeed([]),
120 }),121 }),
122 Layer.mock(ProjectService.ProjectService)({}),
121 Layer.mock(ScheduledTaskService.ScheduledTaskService)({}),123 Layer.mock(ScheduledTaskService.ScheduledTaskService)({}),
122 );124 );
123 const scope: McpInvocationScope = {125 const scope: McpInvocationScope = {
124 environmentId: EnvironmentId.make("environment:mcp-ack"),126 environmentId: EnvironmentId.make("environment:mcp-ack"),
125 threadId: parentThreadId,127 requestNamespace: "provider-session:mcp-ack",
126 providerSessionId: "provider-session:mcp-ack",128 thread: {
127 providerInstanceId: ProviderInstanceId.make("codex"),129 threadId: parentThreadId,
130 providerSessionId: "provider-session:mcp-ack",
131 providerInstanceId: ProviderInstanceId.make("codex"),
132 },
133 client: undefined,
128 capabilities: new Set(["orchestration"]),134 capabilities: new Set(["orchestration"]),
129 issuedAt: 1,135 issuedAt: 1,
130 };136 };
...@@ -202,13 +208,18 @@ describe("OrchestratorMcpService", () => {...@@ -202,13 +208,18 @@ describe("OrchestratorMcpService", () => {
202 Layer.mock(ProviderAdapterRegistry.ProviderAdapterRegistryV2)({208 Layer.mock(ProviderAdapterRegistry.ProviderAdapterRegistryV2)({
203 list: () => Effect.succeed([]),209 list: () => Effect.succeed([]),
204 }),210 }),
211 Layer.mock(ProjectService.ProjectService)({}),
205 Layer.mock(ScheduledTaskService.ScheduledTaskService)({}),212 Layer.mock(ScheduledTaskService.ScheduledTaskService)({}),
206 );213 );
207 const scope: McpInvocationScope = {214 const scope: McpInvocationScope = {
208 environmentId: EnvironmentId.make("environment:mcp-restart"),215 environmentId: EnvironmentId.make("environment:mcp-restart"),
209 threadId: parentThreadId,216 requestNamespace: "provider-session:mcp-restart",
210 providerSessionId: "provider-session:mcp-restart",217 thread: {
211 providerInstanceId: ProviderInstanceId.make("codex"),218 threadId: parentThreadId,
219 providerSessionId: "provider-session:mcp-restart",
220 providerInstanceId: ProviderInstanceId.make("codex"),
221 },
222 client: undefined,
212 capabilities: new Set(["orchestration"]),223 capabilities: new Set(["orchestration"]),
213 issuedAt: 1,224 issuedAt: 1,
214 };225 };
...@@ -278,13 +289,18 @@ describe("OrchestratorMcpService", () => {...@@ -278,13 +289,18 @@ describe("OrchestratorMcpService", () => {
278 Layer.mock(ProviderAdapterRegistry.ProviderAdapterRegistryV2)({289 Layer.mock(ProviderAdapterRegistry.ProviderAdapterRegistryV2)({
279 list: () => Effect.succeed([]),290 list: () => Effect.succeed([]),
280 }),291 }),
292 Layer.mock(ProjectService.ProjectService)({}),
281 Layer.mock(ScheduledTaskService.ScheduledTaskService)({}),293 Layer.mock(ScheduledTaskService.ScheduledTaskService)({}),
282 );294 );
283 const scope: McpInvocationScope = {295 const scope: McpInvocationScope = {
284 environmentId: EnvironmentId.make("environment:mcp-cancel"),296 environmentId: EnvironmentId.make("environment:mcp-cancel"),
285 threadId: parentThreadId,297 requestNamespace: "provider-session:mcp-cancel",
286 providerSessionId: "provider-session:mcp-cancel",298 thread: {
287 providerInstanceId: ProviderInstanceId.make("codex"),299 threadId: parentThreadId,
300 providerSessionId: "provider-session:mcp-cancel",
301 providerInstanceId: ProviderInstanceId.make("codex"),
302 },
303 client: undefined,
288 capabilities: new Set(["orchestration"]),304 capabilities: new Set(["orchestration"]),
289 issuedAt: 1,305 issuedAt: 1,
290 };306 };
...@@ -346,13 +362,18 @@ describe("OrchestratorMcpService", () => {...@@ -346,13 +362,18 @@ describe("OrchestratorMcpService", () => {
346 Layer.mock(ProviderAdapterRegistry.ProviderAdapterRegistryV2)({362 Layer.mock(ProviderAdapterRegistry.ProviderAdapterRegistryV2)({
347 list: () => Effect.succeed([]),363 list: () => Effect.succeed([]),
348 }),364 }),
365 Layer.mock(ProjectService.ProjectService)({}),
349 Layer.mock(ScheduledTaskService.ScheduledTaskService)({}),366 Layer.mock(ScheduledTaskService.ScheduledTaskService)({}),
350 );367 );
351 const scope: McpInvocationScope = {368 const scope: McpInvocationScope = {
352 environmentId: EnvironmentId.make("environment:mcp-cancel-failed"),369 environmentId: EnvironmentId.make("environment:mcp-cancel-failed"),
353 threadId: parentThreadId,370 requestNamespace: "provider-session:mcp-cancel-failed",
354 providerSessionId: "provider-session:mcp-cancel-failed",371 thread: {
355 providerInstanceId: ProviderInstanceId.make("codex"),372 threadId: parentThreadId,
373 providerSessionId: "provider-session:mcp-cancel-failed",
374 providerInstanceId: ProviderInstanceId.make("codex"),
375 },
376 client: undefined,
356 capabilities: new Set(["orchestration"]),377 capabilities: new Set(["orchestration"]),
357 issuedAt: 1,378 issuedAt: 1,
358 };379 };
...@@ -421,13 +442,18 @@ describe("OrchestratorMcpService", () => {...@@ -421,13 +442,18 @@ describe("OrchestratorMcpService", () => {
421 Layer.mock(ProviderAdapterRegistry.ProviderAdapterRegistryV2)({442 Layer.mock(ProviderAdapterRegistry.ProviderAdapterRegistryV2)({
422 list: () => Effect.succeed([]),443 list: () => Effect.succeed([]),
423 }),444 }),
445 Layer.mock(ProjectService.ProjectService)({}),
424 Layer.mock(ScheduledTaskService.ScheduledTaskService)({}),446 Layer.mock(ScheduledTaskService.ScheduledTaskService)({}),
425 );447 );
426 const scope: McpInvocationScope = {448 const scope: McpInvocationScope = {
427 environmentId: EnvironmentId.make("environment:mcp-cancel-dispose-failed"),449 environmentId: EnvironmentId.make("environment:mcp-cancel-dispose-failed"),
428 threadId: parentThreadId,450 requestNamespace: "provider-session:mcp-cancel-dispose-failed",
429 providerSessionId: "provider-session:mcp-cancel-dispose-failed",451 thread: {
430 providerInstanceId: ProviderInstanceId.make("codex"),452 threadId: parentThreadId,
453 providerSessionId: "provider-session:mcp-cancel-dispose-failed",
454 providerInstanceId: ProviderInstanceId.make("codex"),
455 },
456 client: undefined,
431 capabilities: new Set(["orchestration"]),457 capabilities: new Set(["orchestration"]),
432 issuedAt: 1,458 issuedAt: 1,
433 };459 };
...@@ -460,9 +486,13 @@ describe("OrchestratorMcpService provider resolution", () => {...@@ -460,9 +486,13 @@ describe("OrchestratorMcpService provider resolution", () => {
460486
461 const scope: McpInvocationScope = {487 const scope: McpInvocationScope = {
462 environmentId: EnvironmentId.make("environment:mcp-providers"),488 environmentId: EnvironmentId.make("environment:mcp-providers"),
463 threadId: parentThreadId,489 requestNamespace: "provider-session:mcp-providers",
464 providerSessionId: "provider-session:mcp-providers",490 thread: {
465 providerInstanceId: codexInstanceId,491 threadId: parentThreadId,
492 providerSessionId: "provider-session:mcp-providers",
493 providerInstanceId: codexInstanceId,
494 },
495 client: undefined,
466 capabilities: new Set(["orchestration"]),496 capabilities: new Set(["orchestration"]),
467 issuedAt: 1,497 issuedAt: 1,
468 };498 };
...@@ -613,6 +643,7 @@ describe("OrchestratorMcpService provider resolution", () => {...@@ -613,6 +643,7 @@ describe("OrchestratorMcpService provider resolution", () => {
613 antigravityInstanceId,643 antigravityInstanceId,
614 disabledAntigravityInstanceId,644 disabledAntigravityInstanceId,
615 ]),645 ]),
646 Layer.mock(ProjectService.ProjectService)({}),
616 Layer.mock(ScheduledTaskService.ScheduledTaskService)({}),647 Layer.mock(ScheduledTaskService.ScheduledTaskService)({}),
617 );648 );
618649
...@@ -756,6 +787,7 @@ describe("OrchestratorMcpService provider resolution", () => {...@@ -756,6 +787,7 @@ describe("OrchestratorMcpService provider resolution", () => {
756 ]),787 ]),
757 }),788 }),
758 adapterRegistryLayer([codexInstanceId, antigravityInstanceId]),789 adapterRegistryLayer([codexInstanceId, antigravityInstanceId]),
790 Layer.mock(ProjectService.ProjectService)({}),
759 Layer.mock(ScheduledTaskService.ScheduledTaskService)({}),791 Layer.mock(ScheduledTaskService.ScheduledTaskService)({}),
760 );792 );
761793
...@@ -849,6 +881,7 @@ describe("OrchestratorMcpService provider resolution", () => {...@@ -849,6 +881,7 @@ describe("OrchestratorMcpService provider resolution", () => {
849 ]),881 ]),
850 }),882 }),
851 adapterRegistryLayer([codexInstanceId, antigravityInstanceId]),883 adapterRegistryLayer([codexInstanceId, antigravityInstanceId]),
884 Layer.mock(ProjectService.ProjectService)({}),
852 Layer.mock(ScheduledTaskService.ScheduledTaskService)({}),885 Layer.mock(ScheduledTaskService.ScheduledTaskService)({}),
853 );886 );
854887
...@@ -897,6 +930,7 @@ describe("OrchestratorMcpService provider resolution", () => {...@@ -897,6 +930,7 @@ describe("OrchestratorMcpService provider resolution", () => {
897 ]),930 ]),
898 }),931 }),
899 adapterRegistryLayer([codexInstanceId]),932 adapterRegistryLayer([codexInstanceId]),
933 Layer.mock(ProjectService.ProjectService)({}),
900 Layer.mock(ScheduledTaskService.ScheduledTaskService)({}),934 Layer.mock(ScheduledTaskService.ScheduledTaskService)({}),
901 );935 );
902936
...@@ -1060,6 +1094,7 @@ describe("OrchestratorMcpService provider resolution", () => {...@@ -1060,6 +1094,7 @@ describe("OrchestratorMcpService provider resolution", () => {
1060 ]),1094 ]),
1061 }),1095 }),
1062 adapterRegistryLayer([codexInstanceId, codexAltInstanceId]),1096 adapterRegistryLayer([codexInstanceId, codexAltInstanceId]),
1097 Layer.mock(ProjectService.ProjectService)({}),
1063 Layer.mock(ScheduledTaskService.ScheduledTaskService)({}),1098 Layer.mock(ScheduledTaskService.ScheduledTaskService)({}),
1064 );1099 );
10651100
apps/server/src/mcp/OrchestratorMcpService.ts+283-128
...@@ -29,6 +29,8 @@ import {...@@ -29,6 +29,8 @@ import {
29 type OrchestratorMcpTaskCancelInput,29 type OrchestratorMcpTaskCancelInput,
30 type OrchestratorMcpTaskCancelResult,30 type OrchestratorMcpTaskCancelResult,
31 type OrchestratorMcpUpdateScheduledTaskInput,31 type OrchestratorMcpUpdateScheduledTaskInput,
32 type OrchestratorMcpListScheduledTasksInput,
33 type ProjectId,
32 type OrchestratorMcpThreadDetail,34 type OrchestratorMcpThreadDetail,
33 type OrchestratorMcpThreadInterruptInput,35 type OrchestratorMcpThreadInterruptInput,
34 type OrchestratorMcpThreadInterruptResult,36 type OrchestratorMcpThreadInterruptResult,
...@@ -68,9 +70,14 @@ import {...@@ -68,9 +70,14 @@ import {
68 delegatedTaskProgress,70 delegatedTaskProgress,
69} from "../orchestration-v2/SubagentProjection.ts";71} from "../orchestration-v2/SubagentProjection.ts";
70import * as ThreadManagementService from "../orchestration-v2/ThreadManagementService.ts";72import * as ThreadManagementService from "../orchestration-v2/ThreadManagementService.ts";
73import * as ProjectService from "../project/ProjectService.ts";
71import * as ProviderRegistry from "../provider/Services/ProviderRegistry.ts";74import * as ProviderRegistry from "../provider/Services/ProviderRegistry.ts";
72import * as ScheduledTaskService from "../scheduledTasks/ScheduledTaskService.ts";75import * 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
75const DEFAULT_WAIT_TIMEOUT_MS = 10 * 60 * 1_000;82const DEFAULT_WAIT_TIMEOUT_MS = 10 * 60 * 1_000;
76const MAX_WAIT_TIMEOUT_MS = 60 * 60 * 1_000;83const MAX_WAIT_TIMEOUT_MS = 60 * 60 * 1_000;
...@@ -115,6 +122,7 @@ export interface OrchestratorMcpServiceShape {...@@ -115,6 +122,7 @@ export interface OrchestratorMcpServiceShape {
115 ) => Effect.Effect<OrchestratorMcpScheduleTaskResult, OrchestratorMcpFailure>;122 ) => Effect.Effect<OrchestratorMcpScheduleTaskResult, OrchestratorMcpFailure>;
116 readonly listScheduledTasks: (123 readonly listScheduledTasks: (
117 scope: McpInvocationScope,124 scope: McpInvocationScope,
125 input: OrchestratorMcpListScheduledTasksInput,
118 ) => Effect.Effect<OrchestratorMcpListScheduledTasksResult, OrchestratorMcpFailure>;126 ) => Effect.Effect<OrchestratorMcpListScheduledTasksResult, OrchestratorMcpFailure>;
119 readonly updateScheduledTask: (127 readonly updateScheduledTask: (
120 scope: McpInvocationScope,128 scope: McpInvocationScope,
...@@ -486,7 +494,7 @@ function stableCommandId(input: {...@@ -486,7 +494,7 @@ function stableCommandId(input: {
486 [494 [
487 "command",495 "command",
488 "mcp",496 "mcp",
489 stablePart(input.scope.providerSessionId),497 stablePart(input.scope.requestNamespace),
490 stablePart(input.operation),498 stablePart(input.operation),
491 stablePart(input.requestKey),499 stablePart(input.requestKey),
492 ...(input.index === undefined ? [] : [String(input.index)]),500 ...(input.index === undefined ? [] : [String(input.index)]),
...@@ -503,7 +511,7 @@ function stableThreadId(input: {...@@ -503,7 +511,7 @@ function stableThreadId(input: {
503 [511 [
504 "thread",512 "thread",
505 "mcp",513 "mcp",
506 stablePart(input.scope.providerSessionId),514 stablePart(input.scope.requestNamespace),
507 stablePart(input.requestKey),515 stablePart(input.requestKey),
508 String(input.index),516 String(input.index),
509 ].join(":"),517 ].join(":"),
...@@ -519,7 +527,7 @@ function stableMessageId(input: {...@@ -519,7 +527,7 @@ function stableMessageId(input: {
519 [527 [
520 "message",528 "message",
521 "mcp",529 "mcp",
522 stablePart(input.scope.providerSessionId),530 stablePart(input.scope.requestNamespace),
523 stablePart(input.requestKey),531 stablePart(input.requestKey),
524 String(input.index),532 String(input.index),
525 ].join(":"),533 ].join(":"),
...@@ -535,7 +543,7 @@ function stableOperationMessageId(input: {...@@ -535,7 +543,7 @@ function stableOperationMessageId(input: {
535 [543 [
536 "message",544 "message",
537 "mcp",545 "mcp",
538 stablePart(input.scope.providerSessionId),546 stablePart(input.scope.requestNamespace),
539 stablePart(input.operation),547 stablePart(input.operation),
540 stablePart(input.requestKey),548 stablePart(input.requestKey),
541 ].join(":"),549 ].join(":"),
...@@ -760,6 +768,35 @@ const make = Effect.gen(function* () {...@@ -760,6 +768,35 @@ const make = Effect.gen(function* () {
760 const providerRegistry = yield* ProviderRegistry.ProviderRegistry;768 const providerRegistry = yield* ProviderRegistry.ProviderRegistry;
761 const providerAdapters = yield* ProviderAdapterRegistry.ProviderAdapterRegistryV2;769 const providerAdapters = yield* ProviderAdapterRegistry.ProviderAdapterRegistryV2;
762 const scheduledTasks = yield* ScheduledTaskService.ScheduledTaskService;770 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
764 const requireCapability = (scope: McpInvocationScope) =>801 const requireCapability = (scope: McpInvocationScope) =>
765 scope.capabilities.has("orchestration")802 scope.capabilities.has("orchestration")
...@@ -817,61 +854,133 @@ const make = Effect.gen(function* () {...@@ -817,61 +854,133 @@ const make = Effect.gen(function* () {
817 )854 )
818 .pipe(Effect.mapError(threadManagementFailure));855 .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) =>
821 Effect.gen(function* () {863 Effect.gen(function* () {
822 yield* requireCapability(scope);864 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);
824 const target =924 const target =
825 threadId === scope.threadId925 caller.parent !== undefined && threadId === caller.parent.thread.id
826 ? parent926 ? caller.parent
827 : yield* loadProjectThread(parent.thread.projectId, threadId);927 : yield* loadTargetThread(threadId);
828 return { parent, target } as const;928 return { ...caller, target } as const;
829 });929 });
830930
831 /**931 /**
832 * A thread the user attached as context (a `thread` record on one of their own messages)932 * A thread caller writes to another thread only while its own run is live,
833 * is readable even outside the calling project. Only records the user authored count:933 * so a credential that outlived its session cannot reach across threads.
834 * an agent cannot widen its own reach by writing a record.
835 */934 */
836 const userAttachedThreadIds = (935 const assertLiveCallerForOtherThread = (
837 parent: Pick<OrchestrationV2ThreadProjection, "messages">,936 scope: McpInvocationScope,
838 ): Set<ThreadId> => {937 parent: Pick<OrchestrationV2ThreadProjection, "thread" | "runs"> | undefined,
839 const ids = new Set<ThreadId>();938 target: Pick<OrchestrationV2ThreadProjection, "thread">,
840 for (const message of parent.messages) {939 ) =>
841 if (message.role !== "user" || message.createdBy !== "user") continue;940 parent === undefined || target.thread.id === parent.thread.id
842 for (const record of message.context?.records ?? []) {941 ? Effect.void
843 if (record.kind === "thread" && "threadId" in record) ids.add(record.threadId);942 : assertLiveCaller(scope, parent);
844 }943
845 }944 /** Scheduled work outside the caller's own project needs the same live run. */
846 return ids;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;
847 };966 };
848967
849 const loadReadableThread = (scope: McpInvocationScope, threadId: ThreadId) =>968 const loadReadableThread = (scope: McpInvocationScope, threadId: ThreadId) =>
850 Effect.gen(function* () {969 Effect.gen(function* () {
851 yield* requireCapability(scope);970 const { parent } = yield* loadCaller(scope);
852 const parent = yield* loadProjection(scope.threadId);971 const shell = yield* threadManagement
853 const loadTarget = () =>972 .getThreadShell(threadId)
854 threadManagement973 .pipe(Effect.mapError(threadManagementFailure));
855 .getThreadRecords(threadId, ["runs", "runtimeRequests", "contextTransfers"])974 if (shell === null || shell.deletedAt !== null) {
856 .pipe(Effect.mapError(threadManagementFailure));975 return yield* failure("thread_not_found", `Thread ${threadId} is no longer available.`);
857 if (threadId === scope.threadId) return { parent, target: yield* loadTarget() } as const;976 }
858 const target = yield* threadManagement977 const target = yield* threadManagement
859 .getProjectThreadRecords({ projectId: parent.thread.projectId, threadId }, [978 .getProjectThreadRecords({ projectId: shell.projectId, threadId }, [
860 "runs",979 "runs",
861 "runtimeRequests",980 "runtimeRequests",
862 "contextTransfers",981 "contextTransfers",
863 ])982 ])
864 .pipe(983 .pipe(Effect.mapError(threadManagementFailure));
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 }
875 return { parent, target } as const;984 return { parent, target } as const;
876 });985 });
877986
...@@ -1010,7 +1119,7 @@ const make = Effect.gen(function* () {...@@ -1010,7 +1119,7 @@ const make = Effect.gen(function* () {
1010 : Effect.succeed(clientRequestId);1119 : Effect.succeed(clientRequestId);
10111120
1012 const readTask = (1121 const readTask = (
1013 scope: McpInvocationScope,1122 scope: McpThreadInvocationScope,
1014 taskId: NodeId,1123 taskId: NodeId,
1015 waitTimedOut = false,1124 waitTimedOut = false,
1016 acknowledgeTerminal = false,1125 acknowledgeTerminal = false,
...@@ -1018,17 +1127,17 @@ const make = Effect.gen(function* () {...@@ -1018,17 +1127,17 @@ const make = Effect.gen(function* () {
1018 ): Effect.Effect<OrchestratorMcpDelegateTaskResult, OrchestratorMcpFailure> =>1127 ): Effect.Effect<OrchestratorMcpDelegateTaskResult, OrchestratorMcpFailure> =>
1019 Effect.gen(function* () {1128 Effect.gen(function* () {
1020 yield* requireCapability(scope);1129 yield* requireCapability(scope);
1021 const parentProjection = yield* loadProjection(scope.threadId);1130 const parentProjection = yield* loadProjection(scope.thread.threadId);
1022 const task = parentProjection.subagents.find(1131 const task = parentProjection.subagents.find(
1023 (candidate) =>1132 (candidate) =>
1024 candidate.id === taskId &&1133 candidate.id === taskId &&
1025 candidate.origin === "app_owned" &&1134 candidate.origin === "app_owned" &&
1026 candidate.threadId === scope.threadId,1135 candidate.threadId === scope.thread.threadId,
1027 );1136 );
1028 if (task === undefined || task.childThreadId === null) {1137 if (task === undefined || task.childThreadId === null) {
1029 return yield* failure(1138 return yield* failure(
1030 "task_not_found",1139 "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}.`,
1032 );1141 );
1033 }1142 }
1034 const childControls = yield* threadManagement1143 const childControls = yield* threadManagement
...@@ -1094,7 +1203,7 @@ const make = Effect.gen(function* () {...@@ -1094,7 +1203,7 @@ const make = Effect.gen(function* () {
1094 (transfer) =>1203 (transfer) =>
1095 transfer.type === "subagent_result" &&1204 transfer.type === "subagent_result" &&
1096 transfer.sourceThreadId === task.childThreadId &&1205 transfer.sourceThreadId === task.childThreadId &&
1097 transfer.targetThreadId === scope.threadId,1206 transfer.targetThreadId === scope.thread.threadId,
1098 );1207 );
1099 const resultTransferForRun = (run: OrchestrationV2Run | undefined) =>1208 const resultTransferForRun = (run: OrchestrationV2Run | undefined) =>
1100 !canExposeTaskRunResult(run)1209 !canExposeTaskRunResult(run)
...@@ -1145,10 +1254,10 @@ const make = Effect.gen(function* () {...@@ -1145,10 +1254,10 @@ const make = Effect.gen(function* () {
1145 requestKey: acknowledgementRequestKey,1254 requestKey: acknowledgementRequestKey,
1146 operation: acknowledgementOperation,1255 operation: acknowledgementOperation,
1147 }),1256 }),
1148 parentThreadId: scope.threadId,1257 parentThreadId: scope.thread.threadId,
1149 taskId,1258 taskId,
1150 observedByRunId:1259 observedByRunId:
1151 observingRun?.providerInstanceId === scope.providerInstanceId1260 observingRun?.providerInstanceId === scope.thread.providerInstanceId
1152 ? observingRun.id1261 ? observingRun.id
1153 : null,1262 : null,
1154 })1263 })
...@@ -1164,7 +1273,7 @@ const make = Effect.gen(function* () {...@@ -1164,7 +1273,7 @@ const make = Effect.gen(function* () {
1164 return response;1273 return response;
1165 });1274 });
11661275
1167 const waitForTask = (scope: McpInvocationScope, taskId: NodeId, timeoutMs: number) =>1276 const waitForTask = (scope: McpThreadInvocationScope, taskId: NodeId, timeoutMs: number) =>
1168 Effect.gen(function* () {1277 Effect.gen(function* () {
1169 while (true) {1278 while (true) {
1170 const result = yield* readTask(scope, taskId, false, true);1279 const result = yield* readTask(scope, taskId, false, true);
...@@ -1173,11 +1282,17 @@ const make = Effect.gen(function* () {...@@ -1173,11 +1282,17 @@ const make = Effect.gen(function* () {
1173 }1282 }
1174 }).pipe(Effect.timeoutOption(Duration.millis(timeoutMs)));1283 }).pipe(Effect.timeoutOption(Duration.millis(timeoutMs)));
11751284
1176 // Load a single scheduled task and enforce that it belongs to the calling1285 /**
1177 // thread's project, so agents can only read/mutate tasks in their own scope.1286 * A scheduled task the caller may change: one whose modes are no broader
1178 const loadScopedScheduledTask = (1287 * than the caller's own, so editing its prompt cannot run work above the
1179 projectId: ScheduledTask["projectId"],1288 * caller's limits.
1289 */
1290 const loadScheduledTask = (
1180 scheduledTaskId: ScheduledTask["id"],1291 scheduledTaskId: ScheduledTask["id"],
1292 limits: {
1293 readonly runtimeMode: RuntimeMode;
1294 readonly interactionMode: ProviderInteractionMode;
1295 },
1181 ): Effect.Effect<ScheduledTask, OrchestratorMcpFailure> =>1296 ): Effect.Effect<ScheduledTask, OrchestratorMcpFailure> =>
1182 Effect.gen(function* () {1297 Effect.gen(function* () {
1183 const { tasks } = yield* scheduledTasks1298 const { tasks } = yield* scheduledTasks
...@@ -1188,21 +1303,38 @@ const make = Effect.gen(function* () {...@@ -1188,21 +1303,38 @@ const make = Effect.gen(function* () {
1188 ),1303 ),
1189 );1304 );
1190 const task = tasks.find((candidate) => candidate.id === scheduledTaskId);1305 const task = tasks.find((candidate) => candidate.id === scheduledTaskId);
1191 if (task === undefined || task.projectId !== projectId) {1306 if (task === undefined) {
1192 return yield* failure(1307 return yield* failure("task_not_found", `Scheduled task ${scheduledTaskId} was not found.`);
1193 "task_not_found",
1194 `Scheduled task ${scheduledTaskId} was not found in the calling project.`,
1195 );
1196 }1308 }
1309 yield* resolveRuntimeMode(limits.runtimeMode, task.runtimeMode);
1310 yield* resolveInteractionMode(limits.interactionMode, task.interactionMode);
1197 return task;1311 return task;
1198 });1312 });
11991313
1200 return OrchestratorMcpService.of({1314 return OrchestratorMcpService.of({
1201 scheduleTask: (scope, input) =>1315 scheduleTask: (scope, input) =>
1202 Effect.gen(function* () {1316 Effect.gen(function* () {
1203 yield* requireCapability(scope);1317 const { parent, limits } = yield* loadCaller(scope);
1204 const parent = yield* loadProjection(scope.threadId);1318 const projectId = yield* resolveProjectTarget(parent, input.projectId);
1205 const bindToCurrentThread = input.bindToCurrentThread ?? true;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));
1206 const derivedTitle = input.prompt.split("\n")[0]?.trim() ?? "";1338 const derivedTitle = input.prompt.split("\n")[0]?.trim() ?? "";
1207 const title =1339 const title =
1208 input.title ?? (derivedTitle.length > 0 ? derivedTitle.slice(0, 80) : "Scheduled task");1340 input.title ?? (derivedTitle.length > 0 ? derivedTitle.slice(0, 80) : "Scheduled task");
...@@ -1211,12 +1343,12 @@ const make = Effect.gen(function* () {...@@ -1211,12 +1343,12 @@ const make = Effect.gen(function* () {
1211 prompt: input.prompt,1343 prompt: input.prompt,
1212 enabled: input.enabled ?? true,1344 enabled: input.enabled ?? true,
1213 schedule: input.schedule,1345 schedule: input.schedule,
1214 projectId: parent.thread.projectId,1346 projectId,
1215 threadId: bindToCurrentThread ? scope.threadId : null,1347 threadId: bindToCurrentThread && parent !== undefined ? parent.thread.id : null,
1216 workspaceStrategy: scheduledTaskWorkspaceStrategy(bindToCurrentThread),1348 workspaceStrategy: scheduledTaskWorkspaceStrategy(bindToCurrentThread),
1217 modelSelection: parent.thread.modelSelection,1349 modelSelection,
1218 runtimeMode: parent.thread.runtimeMode,1350 runtimeMode: limits.runtimeMode,
1219 interactionMode: parent.thread.interactionMode,1351 interactionMode: limits.interactionMode,
1220 createdBy: "agent",1352 createdBy: "agent",
1221 creationSource: "mcp",1353 creationSource: "mcp",
1222 // Scope the idempotency key by provider session so two callers1354 // Scope the idempotency key by provider session so two callers
...@@ -1240,10 +1372,10 @@ const make = Effect.gen(function* () {...@@ -1240,10 +1372,10 @@ const make = Effect.gen(function* () {
1240 );1372 );
1241 return scheduledTaskSummary(task);1373 return scheduledTaskSummary(task);
1242 }),1374 }),
1243 listScheduledTasks: (scope) =>1375 listScheduledTasks: (scope, input) =>
1244 Effect.gen(function* () {1376 Effect.gen(function* () {
1245 yield* requireCapability(scope);1377 const { parent } = yield* loadCaller(scope);
1246 const parent = yield* loadProjection(scope.threadId);1378 const projectId = input.projectId ?? parent?.thread.projectId;
1247 const { tasks } = yield* scheduledTasks1379 const { tasks } = yield* scheduledTasks
1248 .list()1380 .list()
1249 .pipe(1381 .pipe(
...@@ -1251,26 +1383,33 @@ const make = Effect.gen(function* () {...@@ -1251,26 +1383,33 @@ const make = Effect.gen(function* () {
1251 failure("orchestration_error", `Could not list scheduled tasks: ${error.message}`),1383 failure("orchestration_error", `Could not list scheduled tasks: ${error.message}`),
1252 ),1384 ),
1253 );1385 );
1254 // Only expose tasks belonging to the calling thread's project.
1255 return {1386 return {
1256 tasks: tasks1387 tasks: tasks
1257 .filter((task) => task.projectId === parent.thread.projectId)1388 .filter((task) => projectId === undefined || task.projectId === projectId)
1258 .map(scheduledTaskSummary),1389 .map(scheduledTaskSummary),
1259 };1390 };
1260 }),1391 }),
1261 updateScheduledTask: (scope, input) =>1392 updateScheduledTask: (scope, input) =>
1262 Effect.gen(function* () {1393 Effect.gen(function* () {
1263 yield* requireCapability(scope);1394 const { parent, limits } = yield* loadCaller(scope);
1264 const parent = yield* loadProjection(scope.threadId);1395 const existing = yield* loadScheduledTask(input.scheduledTaskId, limits);
1265 const existing = yield* loadScopedScheduledTask(1396 yield* assertLiveCallerForOtherProject(scope, parent, existing.projectId);
1266 parent.thread.projectId,1397 if (
1267 input.scheduledTaskId,1398 input.bindToCurrentThread === true &&
1268 );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 }
1269 const threadId =1408 const threadId =
1270 input.bindToCurrentThread === undefined1409 input.bindToCurrentThread === undefined
1271 ? existing.threadId1410 ? existing.threadId
1272 : input.bindToCurrentThread1411 : input.bindToCurrentThread && parent !== undefined
1273 ? scope.threadId1412 ? parent.thread.id
1274 : null;1413 : null;
1275 // Rebinding changes where runs execute, so the workspace strategy must1414 // Rebinding changes where runs execute, so the workspace strategy must
1276 // follow: unbinding a root-strategy task would otherwise run loose1415 // follow: unbinding a root-strategy task would otherwise run loose
...@@ -1305,12 +1444,9 @@ const make = Effect.gen(function* () {...@@ -1305,12 +1444,9 @@ const make = Effect.gen(function* () {
1305 }),1444 }),
1306 deleteScheduledTask: (scope, input) =>1445 deleteScheduledTask: (scope, input) =>
1307 Effect.gen(function* () {1446 Effect.gen(function* () {
1308 yield* requireCapability(scope);1447 const { parent, limits } = yield* loadCaller(scope);
1309 const parent = yield* loadProjection(scope.threadId);1448 const existing = yield* loadScheduledTask(input.scheduledTaskId, limits);
1310 const existing = yield* loadScopedScheduledTask(1449 yield* assertLiveCallerForOtherProject(scope, parent, existing.projectId);
1311 parent.thread.projectId,
1312 input.scheduledTaskId,
1313 );
1314 yield* scheduledTasks1450 yield* scheduledTasks
1315 .delete({ id: existing.id })1451 .delete({ id: existing.id })
1316 .pipe(1452 .pipe(
...@@ -1322,16 +1458,15 @@ const make = Effect.gen(function* () {...@@ -1322,16 +1458,15 @@ const make = Effect.gen(function* () {
1322 }),1458 }),
1323 capabilities: (scope) =>1459 capabilities: (scope) =>
1324 Effect.gen(function* () {1460 Effect.gen(function* () {
1325 yield* requireCapability(scope);1461 const { parent, limits } = yield* loadCaller(scope);
1326 const parent = yield* loadProjection(scope.threadId);
1327 const providers = yield* loadProviders;1462 const providers = yield* loadProviders;
1328 const orchestrationCapableInstanceIds = yield* loadOrchestrationCapableInstanceIds();1463 const orchestrationCapableInstanceIds = yield* loadOrchestrationCapableInstanceIds();
1329 return {1464 return {
1330 parentThreadId: scope.threadId,1465 parentThreadId: parent?.thread.id ?? null,
1331 inheritedProviderInstanceId: parent.thread.modelSelection.instanceId,1466 inheritedProviderInstanceId: parent?.thread.modelSelection.instanceId ?? null,
1332 inheritedModel: parent.thread.modelSelection.model,1467 inheritedModel: parent?.thread.modelSelection.model ?? null,
1333 runtimeMode: parent.thread.runtimeMode,1468 runtimeMode: limits.runtimeMode,
1334 interactionMode: parent.thread.interactionMode,1469 interactionMode: limits.interactionMode,
1335 providers: providers.map((provider) => {1470 providers: providers.map((provider) => {
1336 const constraints = providerConstraints(1471 const constraints = providerConstraints(
1337 provider,1472 provider,
...@@ -1366,17 +1501,16 @@ const make = Effect.gen(function* () {...@@ -1366,17 +1501,16 @@ const make = Effect.gen(function* () {
1366 },1501 },
1367 };1502 };
1368 }),1503 }),
1369 delegateTask: (scope, input) =>1504 delegateTask: (callerScope, input) =>
1370 Effect.gen(function* () {1505 Effect.gen(function* () {
1371 yield* requireCapability(scope);1506 const { scope, parent } = yield* loadThreadCaller(callerScope, "delegate_task");
1372 const parent = yield* loadProjection(scope.threadId);
1373 const parentRun = parent.runs1507 const parentRun = parent.runs
1374 .filter(ThreadManagementService.isActiveRun)1508 .filter(ThreadManagementService.isActiveRun)
1375 .toSorted((left, right) => right.ordinal - left.ordinal)[0];1509 .toSorted((left, right) => right.ordinal - left.ordinal)[0];
1376 if (1510 if (
1377 parentRun === undefined ||1511 parentRun === undefined ||
1378 parentRun.rootNodeId === null ||1512 parentRun.rootNodeId === null ||
1379 parentRun.providerInstanceId !== scope.providerInstanceId1513 parentRun.providerInstanceId !== scope.thread.providerInstanceId
1380 ) {1514 ) {
1381 return yield* failure(1515 return yield* failure(
1382 "parent_not_active",1516 "parent_not_active",
...@@ -1406,7 +1540,7 @@ const make = Effect.gen(function* () {...@@ -1406,7 +1540,7 @@ const make = Effect.gen(function* () {
1406 createdBy: "agent",1540 createdBy: "agent",
1407 creationSource: "mcp",1541 creationSource: "mcp",
1408 commandId,1542 commandId,
1409 parentThreadId: scope.threadId,1543 parentThreadId: scope.thread.threadId,
1410 parentRunId: parentRun.id,1544 parentRunId: parentRun.id,
1411 parentNodeId: parentRun.rootNodeId,1545 parentNodeId: parentRun.rootNodeId,
1412 task: taskPrompt(input),1546 task: taskPrompt(input),
...@@ -1462,7 +1596,7 @@ const make = Effect.gen(function* () {...@@ -1462,7 +1596,7 @@ const make = Effect.gen(function* () {
1462 requestKey: key,1596 requestKey: key,
1463 operation: "delegate-task-wake-policy",1597 operation: "delegate-task-wake-policy",
1464 }),1598 }),
1465 parentThreadId: scope.threadId,1599 parentThreadId: scope.thread.threadId,
1466 taskId,1600 taskId,
1467 completionWake: "always",1601 completionWake: "always",
1468 })1602 })
...@@ -1491,12 +1625,17 @@ const make = Effect.gen(function* () {...@@ -1491,12 +1625,17 @@ const make = Effect.gen(function* () {
1491 );1625 );
1492 return yield* readTask(scope, taskId, true, true);1626 return yield* readTask(scope, taskId, true, true);
1493 }),1627 }),
1494 taskStatus: (scope, taskId) => readTask(scope, taskId, false, true),1628 taskStatus: (callerScope, taskId) =>
1495 cancelTask: (scope, input) =>
1496 Effect.gen(function* () {1629 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");
1497 const current = yield* readTask(scope, input.taskId);1636 const current = yield* readTask(scope, input.taskId);
1498 const key = yield* requestKey(input.clientRequestId);1637 const key = yield* requestKey(input.clientRequestId);
1499 const parentProjection = yield* loadProjection(scope.threadId);1638 const parentProjection = yield* loadProjection(scope.thread.threadId);
1500 const parentTask = parentProjection.subagents.find(1639 const parentTask = parentProjection.subagents.find(
1501 (task) => task.id === input.taskId && task.origin === "app_owned",1640 (task) => task.id === input.taskId && task.origin === "app_owned",
1502 );1641 );
...@@ -1511,7 +1650,7 @@ const make = Effect.gen(function* () {...@@ -1511,7 +1650,7 @@ const make = Effect.gen(function* () {
1511 requestKey: key,1650 requestKey: key,
1512 operation: "cancel-task-completion-delivery",1651 operation: "cancel-task-completion-delivery",
1513 }),1652 }),
1514 parentThreadId: scope.threadId,1653 parentThreadId: scope.thread.threadId,
1515 taskId: input.taskId,1654 taskId: input.taskId,
1516 })1655 })
1517 .pipe(1656 .pipe(
...@@ -1573,15 +1712,14 @@ const make = Effect.gen(function* () {...@@ -1573,15 +1712,14 @@ const make = Effect.gen(function* () {
1573 status: "cancel_requested",1712 status: "cancel_requested",
1574 };1713 };
1575 }),1714 }),
1576 createThreads: (scope, input) =>1715 createThreads: (callerScope, input) =>
1577 Effect.gen(function* () {1716 Effect.gen(function* () {
1578 yield* requireCapability(scope);1717 const { scope, parent } = yield* loadThreadCaller(callerScope, "create_threads");
1579 const parent = yield* loadProjection(scope.threadId);
1580 const parentRun = ThreadManagementService.latestActiveRun(parent);1718 const parentRun = ThreadManagementService.latestActiveRun(parent);
1581 if (1719 if (
1582 parentRun === undefined ||1720 parentRun === undefined ||
1583 parentRun.rootNodeId === null ||1721 parentRun.rootNodeId === null ||
1584 parentRun.providerInstanceId !== scope.providerInstanceId1722 parentRun.providerInstanceId !== scope.thread.providerInstanceId
1585 ) {1723 ) {
1586 return yield* failure(1724 return yield* failure(
1587 "parent_not_active",1725 "parent_not_active",
...@@ -1660,7 +1798,7 @@ const make = Effect.gen(function* () {...@@ -1660,7 +1798,7 @@ const make = Effect.gen(function* () {
1660 index,1798 index,
1661 }),1799 }),
1662 threadId,1800 threadId,
1663 senderThreadId: scope.threadId,1801 senderThreadId: scope.thread.threadId,
1664 messageId: stableMessageId({1802 messageId: stableMessageId({
1665 scope,1803 scope,
1666 requestKey: key,1804 requestKey: key,
...@@ -1691,7 +1829,7 @@ const make = Effect.gen(function* () {...@@ -1691,7 +1829,7 @@ const make = Effect.gen(function* () {
1691 operation: "record-created-thread",1829 operation: "record-created-thread",
1692 index,1830 index,
1693 }),1831 }),
1694 parentThreadId: scope.threadId,1832 parentThreadId: scope.thread.threadId,
1695 parentRunId: parentRun.id,1833 parentRunId: parentRun.id,
1696 parentNodeId,1834 parentNodeId,
1697 targetThreadId: threadId,1835 targetThreadId: threadId,
...@@ -1722,11 +1860,11 @@ const make = Effect.gen(function* () {...@@ -1722,11 +1860,11 @@ const make = Effect.gen(function* () {
1722 }),1860 }),
1723 listThreads: (scope, input) =>1861 listThreads: (scope, input) =>
1724 Effect.gen(function* () {1862 Effect.gen(function* () {
1725 yield* requireCapability(scope);1863 const { parent } = yield* loadCaller(scope);
1726 const parent = yield* loadProjection(scope.threadId);1864 const projectId = yield* resolveProjectTarget(parent, input.projectId);
1727 const projectThreads = yield* threadManagement1865 const projectThreads = yield* threadManagement
1728 .listProjectThreads({1866 .listProjectThreads({
1729 projectId: parent.thread.projectId,1867 projectId,
1730 includeSubagents: input.includeSubagents !== false,1868 includeSubagents: input.includeSubagents !== false,
1731 })1869 })
1732 .pipe(1870 .pipe(
...@@ -1755,8 +1893,8 @@ const make = Effect.gen(function* () {...@@ -1755,8 +1893,8 @@ const make = Effect.gen(function* () {
1755 const page = filtered.slice(cursor, cursor + limit);1893 const page = filtered.slice(cursor, cursor + limit);
1756 const nextCursor = cursor + page.length < filtered.length ? cursor + page.length : null;1894 const nextCursor = cursor + page.length < filtered.length ? cursor + page.length : null;
1757 return {1895 return {
1758 projectId: parent.thread.projectId,1896 projectId,
1759 currentThreadId: scope.threadId,1897 currentThreadId: parent?.thread.id ?? null,
1760 threads: page.map(listItemFromShell),1898 threads: page.map(listItemFromShell),
1761 nextCursor,1899 nextCursor,
1762 total: filtered.length,1900 total: filtered.length,
...@@ -1795,8 +1933,13 @@ const make = Effect.gen(function* () {...@@ -1795,8 +1933,13 @@ const make = Effect.gen(function* () {
1795 { concurrency: 1 },1933 { concurrency: 1 },
1796 );1934 );
1797 const messagesByThreadId = new Map(sourceMessages);1935 const messagesByThreadId = new Map(sourceMessages);
1798 const task = directAppOwnedChildTask(parent, target);1936 const task = parent === undefined ? undefined : directAppOwnedChildTask(parent, target);
1799 if (task !== undefined && (input.textOffset ?? 0) === 0) {1937 if (
1938 parent !== undefined &&
1939 scope.thread !== undefined &&
1940 task !== undefined &&
1941 (input.textOffset ?? 0) === 0
1942 ) {
1800 const transfer = parent.contextTransfers.find(1943 const transfer = parent.contextTransfers.find(
1801 (transfer) =>1944 (transfer) =>
1802 transfer.type === "subagent_result" &&1945 transfer.type === "subagent_result" &&
...@@ -1822,7 +1965,13 @@ const make = Effect.gen(function* () {...@@ -1822,7 +1965,13 @@ const make = Effect.gen(function* () {
1822 maxChars,1965 maxChars,
1823 })1966 })
1824 ) {1967 ) {
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 );
1826 }1975 }
1827 }1976 }
1828 return {1977 return {
...@@ -1845,9 +1994,10 @@ const make = Effect.gen(function* () {...@@ -1845,9 +1994,10 @@ const make = Effect.gen(function* () {
1845 }),1994 }),
1846 sendToThread: (scope, input) =>1995 sendToThread: (scope, input) =>
1847 Effect.gen(function* () {1996 Effect.gen(function* () {
1848 const { parent, target } = yield* loadScopedThread(scope, input.threadId);1997 const { parent, limits, target } = yield* loadScopedThread(scope, input.threadId);
1849 yield* resolveRuntimeMode(parent.thread.runtimeMode, target.thread.runtimeMode);1998 yield* assertLiveCallerForOtherThread(scope, parent, target);
1850 yield* resolveInteractionMode(parent.thread.interactionMode, target.thread.interactionMode);1999 yield* resolveRuntimeMode(limits.runtimeMode, target.thread.runtimeMode);
2000 yield* resolveInteractionMode(limits.interactionMode, target.thread.interactionMode);
18512001
1852 const mode = input.mode ?? "auto";2002 const mode = input.mode ?? "auto";
1853 const key = yield* requestKey(input.clientRequestId);2003 const key = yield* requestKey(input.clientRequestId);
...@@ -1858,14 +2008,14 @@ const make = Effect.gen(function* () {...@@ -1858,14 +2008,14 @@ const make = Effect.gen(function* () {
1858 });2008 });
1859 const result = yield* threadManagement2009 const result = yield* threadManagement
1860 .sendToThread({2010 .sendToThread({
1861 projectId: parent.thread.projectId,2011 projectId: target.thread.projectId,
1862 commandId: stableCommandId({2012 commandId: stableCommandId({
1863 scope,2013 scope,
1864 requestKey: key,2014 requestKey: key,
1865 operation: "thread-send",2015 operation: "thread-send",
1866 }),2016 }),
1867 threadId: input.threadId,2017 threadId: input.threadId,
1868 senderThreadId: scope.threadId,2018 ...(parent === undefined ? {} : { senderThreadId: parent.thread.id }),
1869 messageId,2019 messageId,
1870 text: input.message,2020 text: input.message,
1871 attachments: [],2021 attachments: [],
...@@ -1893,10 +2043,10 @@ const make = Effect.gen(function* () {...@@ -1893,10 +2043,10 @@ const make = Effect.gen(function* () {
1893 }),2043 }),
1894 waitForThread: (scope, input) =>2044 waitForThread: (scope, input) =>
1895 Effect.gen(function* () {2045 Effect.gen(function* () {
1896 const { parent } = yield* loadScopedThread(scope, input.threadId);2046 const { target } = yield* loadScopedThread(scope, input.threadId);
1897 const result = yield* threadManagement2047 const result = yield* threadManagement
1898 .waitForThread({2048 .waitForThread({
1899 projectId: parent.thread.projectId,2049 projectId: target.thread.projectId,
1900 threadId: input.threadId,2050 threadId: input.threadId,
1901 ...(input.runId === undefined ? {} : { runId: input.runId }),2051 ...(input.runId === undefined ? {} : { runId: input.runId }),
1902 timeoutMs: Math.min(2052 timeoutMs: Math.min(
...@@ -1914,11 +2064,15 @@ const make = Effect.gen(function* () {...@@ -1914,11 +2064,15 @@ const make = Effect.gen(function* () {
1914 }),2064 }),
1915 interruptThread: (scope, input) =>2065 interruptThread: (scope, input) =>
1916 Effect.gen(function* () {2066 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);
1918 const key = yield* requestKey(input.clientRequestId);2072 const key = yield* requestKey(input.clientRequestId);
1919 const result = yield* threadManagement2073 const result = yield* threadManagement
1920 .interruptThread({2074 .interruptThread({
1921 projectId: parent.thread.projectId,2075 projectId: target.thread.projectId,
1922 commandId: stableCommandId({2076 commandId: stableCommandId({
1923 scope,2077 scope,
1924 requestKey: key,2078 requestKey: key,
...@@ -1962,4 +2116,5 @@ export const layer: Layer.Layer<...@@ -1962,4 +2116,5 @@ export const layer: Layer.Layer<
1962 | ProviderRegistry.ProviderRegistry2116 | ProviderRegistry.ProviderRegistry
1963 | ProviderAdapterRegistry.ProviderAdapterRegistryV22117 | ProviderAdapterRegistry.ProviderAdapterRegistryV2
1964 | ScheduledTaskService.ScheduledTaskService2118 | ScheduledTaskService.ScheduledTaskService
2119 | ProjectService.ProjectService
1965> = Layer.effect(OrchestratorMcpService, make);2120> = Layer.effect(OrchestratorMcpService, make);
apps/server/src/mcp/OrchestratorMcpToolkit.integration.test.ts+42-15
...@@ -71,6 +71,7 @@ import {...@@ -71,6 +71,7 @@ import {
71 materializeReplayTranscriptWorkspace,71 materializeReplayTranscriptWorkspace,
72} from "../orchestration-v2/testkit/ReplayTranscriptNdjson.ts";72} from "../orchestration-v2/testkit/ReplayTranscriptNdjson.ts";
73import { makeProviderRegistryLayer } from "../provider/testUtils/providerRegistryMock.ts";73import { makeProviderRegistryLayer } from "../provider/testUtils/providerRegistryMock.ts";
74import * as ProjectService from "../project/ProjectService.ts";
74import * as ScheduledTaskService from "../scheduledTasks/ScheduledTaskService.ts";75import * as ScheduledTaskService from "../scheduledTasks/ScheduledTaskService.ts";
75import * as McpHttpServer from "./McpHttpServer.ts";76import * as McpHttpServer from "./McpHttpServer.ts";
76import * as McpInvocationContext from "./McpInvocationContext.ts";77import * as McpInvocationContext from "./McpInvocationContext.ts";
...@@ -631,6 +632,16 @@ describe("orchestrator MCP toolkit", () => {...@@ -631,6 +632,16 @@ describe("orchestrator MCP toolkit", () => {
631 Layer.provide(registryLayer),632 Layer.provide(registryLayer),
632 Layer.provide(providerRegistryLayer),633 Layer.provide(providerRegistryLayer),
633 Layer.provide(scheduledTaskStubLayer),634 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 ),
634 Layer.provide(NodeServices.layer),645 Layer.provide(NodeServices.layer),
635 );646 );
636647
...@@ -676,9 +687,13 @@ describe("orchestrator MCP toolkit", () => {...@@ -676,9 +687,13 @@ describe("orchestrator MCP toolkit", () => {
676687
677 const invocation: McpInvocationContext.McpInvocationScope = {688 const invocation: McpInvocationContext.McpInvocationScope = {
678 environmentId: EnvironmentId.make("environment:mcp-orchestrator"),689 environmentId: EnvironmentId.make("environment:mcp-orchestrator"),
679 threadId: parentThreadId,690 requestNamespace: "mcp-provider-session-parent",
680 providerSessionId: "mcp-provider-session-parent",691 thread: {
681 providerInstanceId: codexInstanceId,692 threadId: parentThreadId,
693 providerSessionId: "mcp-provider-session-parent",
694 providerInstanceId: codexInstanceId,
695 },
696 client: undefined,
682 capabilities: new Set(["orchestration"]),697 capabilities: new Set(["orchestration"]),
683 issuedAt: 1,698 issuedAt: 1,
684 };699 };
...@@ -2326,31 +2341,38 @@ describe("orchestrator MCP toolkit", () => {...@@ -2326,31 +2341,38 @@ describe("orchestrator MCP toolkit", () => {
2326 branch: null,2341 branch: null,
2327 worktreePath: cwd,2342 worktreePath: cwd,
2328 });2343 });
2344 // Targets reach the whole environment; the caller's modes still cap writes.
2329 const foreignOrganizeCall = yield* invoke("t3_thread_organize", {2345 const foreignOrganizeCall = yield* invoke("t3_thread_organize", {
2330 threadId: foreignThreadId,2346 threadId: foreignThreadId,
2331 action: "pin",2347 action: "pin",
2332 });2348 });
2333 expect(foreignOrganizeCall.structuredContent).toMatchObject({2349 expect(foreignOrganizeCall.isError).toBe(false);
2334 code: "thread_not_found",2350 expect((yield* orchestrator.getThreadShell(foreignThreadId))?.pinnedAt).not.toBeNull();
2335 });
2336 expect((yield* orchestrator.getThreadShell(foreignThreadId))?.pinnedAt).toBeNull();
23372351
2338 const foreignReadCall = yield* invoke("t3_thread_read", {2352 const foreignReadCall = yield* invoke("t3_thread_read", {
2339 threadId: foreignThreadId,2353 threadId: foreignThreadId,
2340 });2354 });
2341 expect(foreignReadCall.structuredContent).toMatchObject({2355 expect(foreignReadCall.structuredContent).toMatchObject({
2342 _tag: "OrchestratorMcpFailure",2356 thread: { threadId: foreignThreadId, projectId: "project:mcp-foreign" },
2343 code: "thread_not_found",
2344 });2357 });
2345 const foreignUpdateCall = yield* invoke("t3_thread_update", {2358 const foreignUpdateCall = yield* invoke("t3_thread_update", {
2346 threadId: foreignThreadId,2359 threadId: foreignThreadId,
2347 action: "rename",2360 action: "rename",
2348 title: "Should stay foreign",2361 title: "Renamed from another project",
2349 });2362 });
2350 expect(foreignUpdateCall.structuredContent).toMatchObject({2363 expect(foreignUpdateCall.structuredContent).toMatchObject({
2351 _tag: "OrchestratorMcpFailure",2364 threadId: foreignThreadId,
2352 code: "thread_not_found",2365 title: "Renamed from another project",
2366 });
2367 const foreignListCall = yield* invoke("t3_thread_list", {
2368 projectId: "project:mcp-foreign",
2353 });2369 });
2370 const foreignListed = yield* decodeThreadListResult(
2371 foreignListCall.structuredContent,
2372 ).pipe(Effect.orDie);
2373 expect(foreignListed.threads.map((thread) => thread.threadId)).toEqual([
2374 foreignThreadId,
2375 ]);
2354 const listCall = yield* invoke("t3_thread_list", {2376 const listCall = yield* invoke("t3_thread_list", {
2355 includeSubagents: false,2377 includeSubagents: false,
2356 limit: 100,2378 limit: 100,
...@@ -3548,6 +3570,7 @@ describe("orchestrator MCP toolkit", () => {...@@ -3548,6 +3570,7 @@ describe("orchestrator MCP toolkit", () => {
3548 ),3570 ),
3549 Layer.provide(providerRegistryLayer),3571 Layer.provide(providerRegistryLayer),
3550 Layer.provide(unusedScheduledTaskStubLayer),3572 Layer.provide(unusedScheduledTaskStubLayer),
3573 Layer.provide(Layer.mock(ProjectService.ProjectService)({})),
3551 Layer.provide(NodeServices.layer),3574 Layer.provide(NodeServices.layer),
3552 );3575 );
35533576
...@@ -3602,9 +3625,13 @@ describe("orchestrator MCP toolkit", () => {...@@ -3602,9 +3625,13 @@ describe("orchestrator MCP toolkit", () => {
36023625
3603 const invocation: McpInvocationContext.McpInvocationScope = {3626 const invocation: McpInvocationContext.McpInvocationScope = {
3604 environmentId: EnvironmentId.make("environment:mcp-replay"),3627 environmentId: EnvironmentId.make("environment:mcp-replay"),
3605 threadId: parentThreadId,3628 requestNamespace: "mcp-provider-session-replay-parent",
3606 providerSessionId: "mcp-provider-session-replay-parent",3629 thread: {
3607 providerInstanceId: codexInstanceId,3630 threadId: parentThreadId,
3631 providerSessionId: "mcp-provider-session-replay-parent",
3632 providerInstanceId: codexInstanceId,
3633 },
3634 client: undefined,
3608 capabilities: new Set(["orchestration"]),3635 capabilities: new Set(["orchestration"]),
3609 issuedAt: 1,3636 issuedAt: 1,
3610 };3637 };
apps/server/src/mcp/PreviewAutomationBroker.test.ts+41-27
...@@ -36,9 +36,13 @@ const makeBroker = PreviewAutomationBroker.make.pipe(Effect.provide(NodeServices...@@ -36,9 +36,13 @@ const makeBroker = PreviewAutomationBroker.make.pipe(Effect.provide(NodeServices
3636
37const scope = {37const scope = {
38 environmentId: EnvironmentId.make("environment-1"),38 environmentId: EnvironmentId.make("environment-1"),
39 threadId: ThreadId.make("thread-1"),39 requestNamespace: "provider-session-1",
40 providerSessionId: "provider-session-1",40 thread: {
41 providerInstanceId: ProviderInstanceId.make("codex"),41 threadId: ThreadId.make("thread-1"),
42 providerSessionId: "provider-session-1",
43 providerInstanceId: ProviderInstanceId.make("codex"),
44 },
45 client: undefined,
42 capabilities: new Set(["preview"] as const),46 capabilities: new Set(["preview"] as const),
43 issuedAt: 1,47 issuedAt: 1,
44};48};
...@@ -366,9 +370,9 @@ it.effect("preserves bounded request and remote selector diagnostics", () => {...@@ -366,9 +370,9 @@ it.effect("preserves bounded request and remote selector diagnostics", () => {
366 expect(error).toMatchObject({370 expect(error).toMatchObject({
367 operation: "click",371 operation: "click",
368 environmentId: scope.environmentId,372 environmentId: scope.environmentId,
369 threadId: scope.threadId,373 threadId: scope.thread.threadId,
370 providerSessionId: scope.providerSessionId,374 providerSessionId: scope.thread.providerSessionId,
371 providerInstanceId: scope.providerInstanceId,375 providerInstanceId: scope.thread.providerInstanceId,
372 clientId: "client-1",376 clientId: "client-1",
373 requestId: "preview-0",377 requestId: "preview-0",
374 tabId: "tab-1",378 tabId: "tab-1",
...@@ -468,7 +472,7 @@ it.effect.each([...@@ -468,7 +472,7 @@ it.effect.each([
468 .pipe(Effect.flip);472 .pipe(Effect.flip);
469 expect(error).toMatchObject({473 expect(error).toMatchObject({
470 _tag: tag,474 _tag: tag,
471 threadId: scope.threadId,475 threadId: scope.thread.threadId,
472 });476 });
473 expect(error.cause).toBe(remoteError);477 expect(error.cause).toBe(remoteError);
474 expect(error.message).toContain("remains on the desktop");478 expect(error.message).toContain("remains on the desktop");
...@@ -500,9 +504,9 @@ it.effect("distinguishes malformed remote failures", () =>...@@ -500,9 +504,9 @@ it.effect("distinguishes malformed remote failures", () =>
500 expect(error).toMatchObject({504 expect(error).toMatchObject({
501 operation: "status",505 operation: "status",
502 environmentId: scope.environmentId,506 environmentId: scope.environmentId,
503 threadId: scope.threadId,507 threadId: scope.thread.threadId,
504 providerSessionId: scope.providerSessionId,508 providerSessionId: scope.thread.providerSessionId,
505 providerInstanceId: scope.providerInstanceId,509 providerInstanceId: scope.thread.providerInstanceId,
506 clientId: "client-1",510 clientId: "client-1",
507 requestId: "preview-0",511 requestId: "preview-0",
508 timeoutMs: 2_000,512 timeoutMs: 2_000,
...@@ -522,9 +526,9 @@ it.effect("rejects calls when no connected host exists", () =>...@@ -522,9 +526,9 @@ it.effect("rejects calls when no connected host exists", () =>
522 expect(error).toMatchObject({526 expect(error).toMatchObject({
523 operation: "status",527 operation: "status",
524 environmentId: scope.environmentId,528 environmentId: scope.environmentId,
525 threadId: scope.threadId,529 threadId: scope.thread.threadId,
526 providerSessionId: scope.providerSessionId,530 providerSessionId: scope.thread.providerSessionId,
527 providerInstanceId: scope.providerInstanceId,531 providerInstanceId: scope.thread.providerInstanceId,
528 });532 });
529 }),533 }),
530);534);
...@@ -590,8 +594,11 @@ it.effect("routes requests for background threads through an environment-level h...@@ -590,8 +594,11 @@ it.effect("routes requests for background threads through an environment-level h
590 const result = yield* broker.invoke<string>({594 const result = yield* broker.invoke<string>({
591 scope: {595 scope: {
592 ...scope,596 ...scope,
593 threadId: backgroundThreadId,597 thread: {
594 providerSessionId: "provider-session-background",598 ...scope.thread,
599 threadId: backgroundThreadId,
600 providerSessionId: "provider-session-background",
601 },
595 },602 },
596 operation: "status",603 operation: "status",
597 input: {},604 input: {},
...@@ -688,7 +695,7 @@ it.effect("pins a provider session to its initial host despite later focus chang...@@ -688,7 +695,7 @@ it.effect("pins a provider session to its initial host despite later focus chang
688 environmentId: scope.environmentId,695 environmentId: scope.environmentId,
689 connectionId: "connection-stale",696 connectionId: "connection-stale",
690 focused: true,697 focused: true,
691 liveTabs: [{ threadId: scope.threadId, tabId: PreviewTabId.make("stale-tab") }],698 liveTabs: [{ threadId: scope.thread.threadId, tabId: PreviewTabId.make("stale-tab") }],
692 });699 });
693 expect(yield* broker.invoke<string>({ scope, operation: "status", input: {} })).toBe(700 expect(yield* broker.invoke<string>({ scope, operation: "status", input: {} })).toBe(
694 "second",701 "second",
...@@ -702,7 +709,7 @@ it.effect("pins a provider session to its initial host despite later focus chang...@@ -702,7 +709,7 @@ it.effect("pins a provider session to its initial host despite later focus chang
702709
703 const firstPinnedScope = {710 const firstPinnedScope = {
704 ...scope,711 ...scope,
705 providerSessionId: "provider-session-first-pinned",712 thread: { ...scope.thread, providerSessionId: "provider-session-first-pinned" },
706 };713 };
707 expect(714 expect(
708 yield* broker.invoke<string>({ scope: firstPinnedScope, operation: "status", input: {} }),715 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...@@ -720,7 +727,10 @@ it.effect("pins a provider session to its initial host despite later focus chang
720 ).toBe("first");727 ).toBe("first");
721 expect(728 expect(
722 yield* broker.invoke<string>({729 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 },
724 operation: "status",734 operation: "status",
725 input: {},735 input: {},
726 }),736 }),
...@@ -756,7 +766,7 @@ it.effect("prefers the live tab owner for new sessions without moving existing l...@@ -756,7 +766,7 @@ it.effect("prefers the live tab owner for new sessions without moving existing l
756 connectionId: connections.get("owner")!,766 connectionId: connections.get("owner")!,
757 focused: false,767 focused: false,
758 liveTabs: [768 liveTabs: [
759 { threadId: scope.threadId, tabId: PreviewTabId.make("signed-in"), visible: true },769 { threadId: scope.thread.threadId, tabId: PreviewTabId.make("signed-in"), visible: true },
760 ],770 ],
761 });771 });
762 yield* broker.focusHost({772 yield* broker.focusHost({
...@@ -765,7 +775,11 @@ it.effect("prefers the live tab owner for new sessions without moving existing l...@@ -765,7 +775,11 @@ it.effect("prefers the live tab owner for new sessions without moving existing l
765 connectionId: connections.get("other")!,775 connectionId: connections.get("other")!,
766 focused: true,776 focused: true,
767 liveTabs: [777 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 },
769 {783 {
770 threadId: ThreadId.make("another-thread"),784 threadId: ThreadId.make("another-thread"),
771 tabId: PreviewTabId.make("different-tab"),785 tabId: PreviewTabId.make("different-tab"),
...@@ -778,7 +792,7 @@ it.effect("prefers the live tab owner for new sessions without moving existing l...@@ -778,7 +792,7 @@ it.effect("prefers the live tab owner for new sessions without moving existing l
778 );792 );
779 expect(793 expect(
780 yield* broker.invoke<string>({794 yield* broker.invoke<string>({
781 scope: { ...scope, providerSessionId: "explicit-owner" },795 scope: { ...scope, thread: { ...scope.thread, providerSessionId: "explicit-owner" } },
782 tabId: PreviewTabId.make("signed-in"),796 tabId: PreviewTabId.make("signed-in"),
783 operation: "snapshot",797 operation: "snapshot",
784 input: {},798 input: {},
...@@ -786,7 +800,7 @@ it.effect("prefers the live tab owner for new sessions without moving existing l...@@ -786,7 +800,7 @@ it.effect("prefers the live tab owner for new sessions without moving existing l
786 ).toBe("owner");800 ).toBe("owner");
787 expect(801 expect(
788 yield* broker.invoke<string>({802 yield* broker.invoke<string>({
789 scope: { ...scope, providerSessionId: "other-tab" },803 scope: { ...scope, thread: { ...scope.thread, providerSessionId: "other-tab" } },
790 tabId: PreviewTabId.make("different-tab"),804 tabId: PreviewTabId.make("different-tab"),
791 operation: "evaluate",805 operation: "evaluate",
792 input: {},806 input: {},
...@@ -805,7 +819,7 @@ it.effect("prefers the live tab owner for new sessions without moving existing l...@@ -805,7 +819,7 @@ it.effect("prefers the live tab owner for new sessions without moving existing l
805 );819 );
806 expect(820 expect(
807 yield* broker.invoke<string>({821 yield* broker.invoke<string>({
808 scope: { ...scope, providerSessionId: "after-tab-closed" },822 scope: { ...scope, thread: { ...scope.thread, providerSessionId: "after-tab-closed" } },
809 operation: "evaluate",823 operation: "evaluate",
810 input: {},824 input: {},
811 }),825 }),
...@@ -1049,7 +1063,7 @@ it.effect("fails over a pinned provider session only after its host disconnects"...@@ -1049,7 +1063,7 @@ it.effect("fails over a pinned provider session only after its host disconnects"
1049 environmentId: scope.environmentId,1063 environmentId: scope.environmentId,
1050 connectionId: firstConnectionId,1064 connectionId: firstConnectionId,
1051 focused: true,1065 focused: true,
1052 liveTabs: [{ threadId: scope.threadId, tabId: firstTabId }],1066 liveTabs: [{ threadId: scope.thread.threadId, tabId: firstTabId }],
1053 });1067 });
1054 expect(yield* broker.invoke({ scope, operation: "open", input: {} })).toEqual({1068 expect(yield* broker.invoke({ scope, operation: "open", input: {} })).toEqual({
1055 host: "first",1069 host: "first",
...@@ -1193,9 +1207,9 @@ it.effect("fails requests assigned to the stream that is replaced", () =>...@@ -1193,9 +1207,9 @@ it.effect("fails requests assigned to the stream that is replaced", () =>
1193 expect(error).toMatchObject({1207 expect(error).toMatchObject({
1194 operation: "status",1208 operation: "status",
1195 environmentId: scope.environmentId,1209 environmentId: scope.environmentId,
1196 threadId: scope.threadId,1210 threadId: scope.thread.threadId,
1197 providerSessionId: scope.providerSessionId,1211 providerSessionId: scope.thread.providerSessionId,
1198 providerInstanceId: scope.providerInstanceId,1212 providerInstanceId: scope.thread.providerInstanceId,
1199 clientId: "client-1",1213 clientId: "client-1",
1200 requestId: "preview-0",1214 requestId: "preview-0",
1201 timeoutMs: 15_000,1215 timeoutMs: 15_000,
apps/server/src/mcp/PreviewAutomationBroker.ts+14-13
...@@ -40,7 +40,8 @@ import * as SynchronizedRef from "effect/SynchronizedRef";...@@ -40,7 +40,8 @@ import * as SynchronizedRef from "effect/SynchronizedRef";
40import * as McpInvocationContext from "./McpInvocationContext.ts";40import * as McpInvocationContext from "./McpInvocationContext.ts";
4141
42export interface PreviewAutomationInvokeInput {42export 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;
44 readonly operation: PreviewAutomationOperation;45 readonly operation: PreviewAutomationOperation;
45 readonly input: unknown;46 readonly input: unknown;
46 readonly tabId?: PreviewTabId;47 readonly tabId?: PreviewTabId;
...@@ -103,9 +104,9 @@ interface HostAssignment {...@@ -103,9 +104,9 @@ interface HostAssignment {
103interface PreviewAutomationRequestErrorContext {104interface PreviewAutomationRequestErrorContext {
104 readonly operation: PreviewAutomationOperation;105 readonly operation: PreviewAutomationOperation;
105 readonly environmentId: McpInvocationContext.McpInvocationScope["environmentId"];106 readonly environmentId: McpInvocationContext.McpInvocationScope["environmentId"];
106 readonly threadId: McpInvocationContext.McpInvocationScope["threadId"];107 readonly threadId: McpInvocationContext.McpThreadCaller["threadId"];
107 readonly providerSessionId: string;108 readonly providerSessionId: string;
108 readonly providerInstanceId: McpInvocationContext.McpInvocationScope["providerInstanceId"];109 readonly providerInstanceId: McpInvocationContext.McpThreadCaller["providerInstanceId"];
109 readonly clientId: string;110 readonly clientId: string;
110 readonly connectionId: ClientConnection["connectionId"];111 readonly connectionId: ClientConnection["connectionId"];
111 readonly requestId: string;112 readonly requestId: string;
...@@ -160,8 +161,8 @@ const selectorDiagnosticsFromInput = (...@@ -160,8 +161,8 @@ const selectorDiagnosticsFromInput = (
160 return {};161 return {};
161};162};
162163
163const hostAssignmentKey = (scope: McpInvocationContext.McpInvocationScope): string =>164const hostAssignmentKey = (scope: McpInvocationContext.McpThreadInvocationScope): string =>
164 `${scope.environmentId}\u0000${scope.providerSessionId}`;165 `${scope.environmentId}\u0000${scope.thread.providerSessionId}`;
165166
166const isPreviewTabId = Schema.is(PreviewTabId);167const isPreviewTabId = Schema.is(PreviewTabId);
167168
...@@ -498,7 +499,7 @@ export const make = Effect.gen(function* PreviewAutomationBrokerMake() {...@@ -498,7 +499,7 @@ export const make = Effect.gen(function* PreviewAutomationBrokerMake() {
498 const ownsTargetTab = (host: ClientConnection, visibleOnly = false) =>499 const ownsTargetTab = (host: ClientConnection, visibleOnly = false) =>
499 host.liveTabs.some(500 host.liveTabs.some(
500 (tab) =>501 (tab) =>
501 tab.threadId === input.scope.threadId &&502 tab.threadId === input.scope.thread.threadId &&
502 (!visibleOnly || tab.visible === true) &&503 (!visibleOnly || tab.visible === true) &&
503 (input.tabId === undefined || tab.tabId === input.tabId),504 (input.tabId === undefined || tab.tabId === input.tabId),
504 );505 );
...@@ -545,9 +546,9 @@ export const make = Effect.gen(function* PreviewAutomationBrokerMake() {...@@ -545,9 +546,9 @@ export const make = Effect.gen(function* PreviewAutomationBrokerMake() {
545 const context: PreviewAutomationRequestErrorContext = {546 const context: PreviewAutomationRequestErrorContext = {
546 operation: input.operation,547 operation: input.operation,
547 environmentId: input.scope.environmentId,548 environmentId: input.scope.environmentId,
548 threadId: input.scope.threadId,549 threadId: input.scope.thread.threadId,
549 providerSessionId: input.scope.providerSessionId,550 providerSessionId: input.scope.thread.providerSessionId,
550 providerInstanceId: input.scope.providerInstanceId,551 providerInstanceId: input.scope.thread.providerInstanceId,
551 clientId: connection.clientId,552 clientId: connection.clientId,
552 connectionId: connection.connectionId,553 connectionId: connection.connectionId,
553 requestId,554 requestId,
...@@ -566,9 +567,9 @@ export const make = Effect.gen(function* PreviewAutomationBrokerMake() {...@@ -566,9 +567,9 @@ export const make = Effect.gen(function* PreviewAutomationBrokerMake() {
566 return yield* new PreviewAutomationNoAvailableHostError({567 return yield* new PreviewAutomationNoAvailableHostError({
567 operation: input.operation,568 operation: input.operation,
568 environmentId: input.scope.environmentId,569 environmentId: input.scope.environmentId,
569 threadId: input.scope.threadId,570 threadId: input.scope.thread.threadId,
570 providerSessionId: input.scope.providerSessionId,571 providerSessionId: input.scope.thread.providerSessionId,
571 providerInstanceId: input.scope.providerInstanceId,572 providerInstanceId: input.scope.thread.providerInstanceId,
572 });573 });
573 }574 }
574 const { connection, requestId, requestContext, requestSequence } = route;575 const { connection, requestId, requestContext, requestSequence } = route;
...@@ -594,7 +595,7 @@ export const make = Effect.gen(function* PreviewAutomationBrokerMake() {...@@ -594,7 +595,7 @@ export const make = Effect.gen(function* PreviewAutomationBrokerMake() {
594 connectionId: connection.connectionId,595 connectionId: connection.connectionId,
595 request: {596 request: {
596 requestId,597 requestId,
597 threadId: input.scope.threadId,598 threadId: input.scope.thread.threadId,
598 tabId: requestContext.tabId,599 tabId: requestContext.tabId,
599 tabIdExplicit: input.tabId !== undefined,600 tabIdExplicit: input.tabId !== undefined,
600 operation: input.operation,601 operation: input.operation,
apps/server/src/mcp/ThreadMetadataMcpService.test.ts+95-3
...@@ -12,9 +12,13 @@ import * as ThreadMetadataMcp from "./ThreadMetadataMcpService.ts";...@@ -12,9 +12,13 @@ import * as ThreadMetadataMcp from "./ThreadMetadataMcpService.ts";
12const threadId = ThreadId.make("thread:metadata-caller");12const threadId = ThreadId.make("thread:metadata-caller");
13const scope: McpInvocationContext.McpInvocationScope = {13const scope: McpInvocationContext.McpInvocationScope = {
14 environmentId: EnvironmentId.make("environment:metadata-test"),14 environmentId: EnvironmentId.make("environment:metadata-test"),
15 threadId,15 requestNamespace: "provider-session:metadata-test",
16 providerSessionId: "provider-session:metadata-test",16 thread: {
17 providerInstanceId: ProviderInstanceId.make("codex"),17 threadId,
18 providerSessionId: "provider-session:metadata-test",
19 providerInstanceId: ProviderInstanceId.make("codex"),
20 },
21 client: undefined,
18 capabilities: new Set(["orchestration"]),22 capabilities: new Set(["orchestration"]),
19 issuedAt: 1,23 issuedAt: 1,
20};24};
...@@ -74,3 +78,91 @@ it.effect("keeps calling-thread storage failures as orchestration errors", () =>...@@ -74,3 +78,91 @@ it.effect("keeps calling-thread storage failures as orchestration errors", () =>
74 expect(error.code).toBe("orchestration_error");78 expect(error.code).toBe("orchestration_error");
75 }),79 }),
76);80);
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";...@@ -16,6 +16,7 @@ import * as Layer from "effect/Layer";
1616
17import * as ThreadManagementService from "../orchestration-v2/ThreadManagementService.ts";17import * as ThreadManagementService from "../orchestration-v2/ThreadManagementService.ts";
18import type { McpInvocationScope } from "./McpInvocationContext.ts";18import type { McpInvocationScope } from "./McpInvocationContext.ts";
19import { assertTargetWithinLimits } from "./threadAccess.ts";
1920
20export class ThreadMetadataMcpService extends Context.Service<21export class ThreadMetadataMcpService extends Context.Service<
21 ThreadMetadataMcpService,22 ThreadMetadataMcpService,
...@@ -57,7 +58,7 @@ function commandId(input: {...@@ -57,7 +58,7 @@ function commandId(input: {
57 [58 [
58 "command",59 "command",
59 "mcp",60 "mcp",
60 stablePart(input.scope.providerSessionId),61 stablePart(input.scope.requestNamespace),
61 "thread-update",62 "thread-update",
62 stablePart(input.threadId),63 stablePart(input.threadId),
63 stablePart(input.action),64 stablePart(input.action),
...@@ -146,36 +147,65 @@ const make = Effect.gen(function* () {...@@ -146,36 +147,65 @@ const make = Effect.gen(function* () {
146 );147 );
147 }148 }
148149
149 const parentShell = yield* threadManagement150 const threadId = input.threadId ?? scope.thread?.threadId;
150 .getThreadShell(scope.threadId)151 if (threadId === undefined) {
151 .pipe(152 return yield* failure(
152 Effect.mapError((error) =>153 "target_required",
153 failure(154 "Pass threadId: this MCP client is not running inside a T3 thread.",
154 "orchestration_error",
155 `Unable to locate calling thread ${scope.threadId}: ${errorMessage(error)}`,
156 ),
157 ),
158 );155 );
159 if (parentShell === null) {
160 return yield* failure("thread_not_found", `Calling thread ${scope.threadId} was not found.`);
161 }156 }
162 const parent = yield* threadManagement157 const shell = yield* threadManagement
163 .getThreadRecords(scope.threadId, [])158 .getThreadShell(threadId)
164 .pipe(159 .pipe(
165 Effect.mapError((error) =>160 Effect.mapError((error) =>
166 failure(161 failure(
167 "orchestration_error",162 "orchestration_error",
168 `Unable to read calling thread ${scope.threadId}: ${errorMessage(error)}`,163 `Unable to locate thread ${threadId}: ${errorMessage(error)}`,
169 ),164 ),
170 ),165 ),
171 );166 );
172 const threadId = input.threadId ?? scope.threadId;167 if (shell === null || shell.deletedAt !== null) {
173 const target =168 return yield* failure("thread_not_found", `Thread ${threadId} was not found.`);
174 threadId === scope.threadId169 }
175 ? parent170 // Another thread may only be changed if it runs within the caller's own modes.
176 : yield* threadManagement171 if (threadId !== scope.thread?.threadId) {
177 .getProjectThreadRecords({ projectId: parent.thread.projectId, threadId }, [])172 const limits =
178 .pipe(Effect.mapError(threadLookupFailure));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));
179 const requestKey =209 const requestKey =
180 input.clientRequestId === undefined210 input.clientRequestId === undefined
181 ? yield* crypto.randomUUIDv4.pipe(Effect.orDie)211 ? yield* crypto.randomUUIDv4.pipe(Effect.orDie)
apps/server/src/mcp/WorktreeMcpService.test.ts+7-3
...@@ -42,9 +42,13 @@ const makeScope = (...@@ -42,9 +42,13 @@ const makeScope = (
42 capabilities: ReadonlySet<McpInvocationContext.McpCapability>,42 capabilities: ReadonlySet<McpInvocationContext.McpCapability>,
43): McpInvocationContext.McpInvocationScope => ({43): McpInvocationContext.McpInvocationScope => ({
44 environmentId,44 environmentId,
45 threadId,45 requestNamespace: "provider-session-worktree-test",
46 providerSessionId: "provider-session-worktree-test",46 thread: {
47 providerInstanceId: ProviderInstanceId.make("claudeAgent"),47 threadId,
48 providerSessionId: "provider-session-worktree-test",
49 providerInstanceId: ProviderInstanceId.make("claudeAgent"),
50 },
51 client: undefined,
48 capabilities,52 capabilities,
49 issuedAt: 1,53 issuedAt: 1,
50});54});
apps/server/src/mcp/WorktreeMcpService.ts+43-26
...@@ -23,7 +23,7 @@ import * as ProjectService from "../project/ProjectService.ts";...@@ -23,7 +23,7 @@ import * as ProjectService from "../project/ProjectService.ts";
23import * as ProjectSetupScriptRunner from "../project/ProjectSetupScriptRunner.ts";23import * as ProjectSetupScriptRunner from "../project/ProjectSetupScriptRunner.ts";
24import * as ServerSettings from "../serverSettings.ts";24import * as ServerSettings from "../serverSettings.ts";
25import * as VcsStatusBroadcaster from "../vcs/VcsStatusBroadcaster.ts";25import * as VcsStatusBroadcaster from "../vcs/VcsStatusBroadcaster.ts";
26import type { McpInvocationScope } from "./McpInvocationContext.ts";26import type { McpInvocationScope, McpThreadInvocationScope } from "./McpInvocationContext.ts";
2727
28export class WorktreeMcpService extends Context.Service<28export class WorktreeMcpService extends Context.Service<
29 WorktreeMcpService,29 WorktreeMcpService,
...@@ -70,6 +70,17 @@ const make = Effect.gen(function* () {...@@ -70,6 +70,17 @@ const make = Effect.gen(function* () {
70 // one untracked on disk.70 // one untracked on disk.
71 const handoffThreadsInFlight = new Set<string>();71 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
73 const requireCapability = (scope: McpInvocationScope) =>84 const requireCapability = (scope: McpInvocationScope) =>
74 scope.capabilities.has("worktree")85 scope.capabilities.has("worktree")
75 ? Effect.void86 ? Effect.void
...@@ -77,23 +88,23 @@ const make = Effect.gen(function* () {...@@ -77,23 +88,23 @@ const make = Effect.gen(function* () {
77 failure("capability_denied", "This MCP credential does not grant worktree capabilities."),88 failure("capability_denied", "This MCP credential does not grant worktree capabilities."),
78 );89 );
7990
80 const loadThread = (scope: McpInvocationScope) =>91 const loadThread = (scope: McpThreadInvocationScope) =>
81 threadManagement.getThreadRecords(scope.threadId, []).pipe(92 threadManagement.getThreadRecords(scope.thread.threadId, []).pipe(
82 Effect.mapError((error) =>93 Effect.mapError((error) =>
83 error._tag === "OrchestratorProjectionError"94 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.`)
85 : failure(96 : failure(
86 "operation_failed",97 "operation_failed",
87 `Unable to read thread ${scope.threadId}: ${errorMessage(error)}`,98 `Unable to read thread ${scope.thread.threadId}: ${errorMessage(error)}`,
88 ),99 ),
89 ),100 ),
90 Effect.filterOrFail(101 Effect.filterOrFail(
91 (projection) => projection.thread.deletedAt === null,102 (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.`),
93 ),104 ),
94 );105 );
95106
96 const loadProject = (scope: McpInvocationScope, projectId: ProjectId) =>107 const loadProject = (scope: McpThreadInvocationScope, projectId: ProjectId) =>
97 projects.getById(projectId).pipe(108 projects.getById(projectId).pipe(
98 asOperationFailed(`Unable to read project ${projectId}`),109 asOperationFailed(`Unable to read project ${projectId}`),
99 Effect.flatMap(110 Effect.flatMap(
...@@ -102,7 +113,7 @@ const make = Effect.gen(function* () {...@@ -102,7 +113,7 @@ const make = Effect.gen(function* () {
102 Effect.fail(113 Effect.fail(
103 failure(114 failure(
104 "project_not_found",115 "project_not_found",
105 `Project '${projectId}' was not found for thread '${scope.threadId}'.`,116 `Project '${projectId}' was not found for thread '${scope.thread.threadId}'.`,
106 ),117 ),
107 ),118 ),
108 onSome: Effect.succeed,119 onSome: Effect.succeed,
...@@ -115,11 +126,13 @@ const make = Effect.gen(function* () {...@@ -115,11 +126,13 @@ const make = Effect.gen(function* () {
115 asOperationFailed("Unable to read server settings"),126 asOperationFailed("Unable to read server settings"),
116 );127 );
117128
118 const handoffIds = (scope: McpInvocationScope) =>129 const handoffIds = (scope: McpThreadInvocationScope) =>
119 crypto.randomUUIDv4.pipe(130 crypto.randomUUIDv4.pipe(
120 Effect.map((uuid) => {131 Effect.map((uuid) => {
121 const part = (kind: string, operation: string) =>132 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 );
123 return {136 return {
124 commandId: CommandId.make(part("command", "worktree-handoff")),137 commandId: CommandId.make(part("command", "worktree-handoff")),
125 continuationCommandId: CommandId.make(part("command", "worktree-continuation")),138 continuationCommandId: CommandId.make(part("command", "worktree-continuation")),
...@@ -130,13 +143,13 @@ const make = Effect.gen(function* () {...@@ -130,13 +143,13 @@ const make = Effect.gen(function* () {
130 );143 );
131144
132 const performHandoff = Effect.fn("WorktreeMcpService.performHandoff")(function* (145 const performHandoff = Effect.fn("WorktreeMcpService.performHandoff")(function* (
133 scope: McpInvocationScope,146 scope: McpThreadInvocationScope,
134 input: WorktreeMcpHandoffInput,147 input: WorktreeMcpHandoffInput,
135 ) {148 ) {
136 const alreadyInWorktree = (worktreePath: string) =>149 const alreadyInWorktree = (worktreePath: string) =>
137 failure(150 failure(
138 "already_in_worktree",151 "already_in_worktree",
139 `Thread '${scope.threadId}' is already attached to worktree '${worktreePath}'.`,152 `Thread '${scope.thread.threadId}' is already attached to worktree '${worktreePath}'.`,
140 );153 );
141154
142 const projection = yield* loadThread(scope);155 const projection = yield* loadThread(scope);
...@@ -148,7 +161,7 @@ const make = Effect.gen(function* () {...@@ -148,7 +161,7 @@ const make = Effect.gen(function* () {
148 if (projection.thread.archivedAt !== null) {161 if (projection.thread.archivedAt !== null) {
149 return yield* failure(162 return yield* failure(
150 "invalid_request",163 "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.`,
152 );165 );
153 }166 }
154167
...@@ -263,7 +276,7 @@ const make = Effect.gen(function* () {...@@ -263,7 +276,7 @@ const make = Effect.gen(function* () {
263 Effect.catchCause((cause: Cause.Cause<unknown>) => {276 Effect.catchCause((cause: Cause.Cause<unknown>) => {
264 const detail = errorMessage(Cause.squash(cause));277 const detail = errorMessage(Cause.squash(cause));
265 return Effect.logWarning(logMessage, {278 return Effect.logWarning(logMessage, {
266 threadId: scope.threadId,279 threadId: scope.thread.threadId,
267 worktreePath,280 worktreePath,
268 detail,281 detail,
269 }).pipe(Effect.as({ status: "failed", detail } as const));282 }).pipe(Effect.as({ status: "failed", detail } as const));
...@@ -301,14 +314,14 @@ const make = Effect.gen(function* () {...@@ -301,14 +314,14 @@ const make = Effect.gen(function* () {
301 if (recheck.thread.archivedAt !== null) {314 if (recheck.thread.archivedAt !== null) {
302 return yield* failure(315 return yield* failure(
303 "invalid_request",316 "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.`,
305 );318 );
306 }319 }
307 yield* threadManagement320 yield* threadManagement
308 .dispatch({321 .dispatch({
309 type: "thread.metadata.update",322 type: "thread.metadata.update",
310 commandId: ids.commandId,323 commandId: ids.commandId,
311 threadId: scope.threadId,324 threadId: scope.thread.threadId,
312 branch: worktree.worktree.refName,325 branch: worktree.worktree.refName,
313 worktreePath,326 worktreePath,
314 expectedWorktreePath: null,327 expectedWorktreePath: null,
...@@ -357,7 +370,7 @@ const make = Effect.gen(function* () {...@@ -357,7 +370,7 @@ const make = Effect.gen(function* () {
357 .sendToThread({370 .sendToThread({
358 projectId: projection.thread.projectId,371 projectId: projection.thread.projectId,
359 commandId: ids.continuationCommandId,372 commandId: ids.continuationCommandId,
360 threadId: scope.threadId,373 threadId: scope.thread.threadId,
361 messageId: ids.continuationMessageId,374 messageId: ids.continuationMessageId,
362 text: input.continuationPrompt,375 text: input.continuationPrompt,
363 attachments: [],376 attachments: [],
...@@ -386,7 +399,7 @@ const make = Effect.gen(function* () {...@@ -386,7 +399,7 @@ const make = Effect.gen(function* () {
386 if (input.runSetupScript ?? true) {399 if (input.runSetupScript ?? true) {
387 setupScript = yield* setupScriptRunner400 setupScript = yield* setupScriptRunner
388 .runForThread({401 .runForThread({
389 threadId: scope.threadId,402 threadId: scope.thread.threadId,
390 projectId: projection.thread.projectId,403 projectId: projection.thread.projectId,
391 projectCwd,404 projectCwd,
392 worktreePath,405 worktreePath,
...@@ -430,8 +443,9 @@ const make = Effect.gen(function* () {...@@ -430,8 +443,9 @@ const make = Effect.gen(function* () {
430 });443 });
431444
432 const handoff: WorktreeMcpService["Service"]["handoff"] = Effect.fn("WorktreeMcpService.handoff")(445 const handoff: WorktreeMcpService["Service"]["handoff"] = Effect.fn("WorktreeMcpService.handoff")(
433 function* (scope, input) {446 function* (callerScope, input) {
434 yield* requireCapability(scope);447 yield* requireCapability(callerScope);
448 const scope = yield* requireThreadScope(callerScope);
435 // uninterruptibleMask: the guard acquisition and the registration of the449 // uninterruptibleMask: the guard acquisition and the registration of the
436 // releasing finalizer happen with no interruptible gap in between. An450 // releasing finalizer happen with no interruptible gap in between. An
437 // interrupt landing between a bare add() and the start of an ensured451 // interrupt landing between a bare add() and the start of an ensured
...@@ -439,17 +453,19 @@ const make = Effect.gen(function* () {...@@ -439,17 +453,19 @@ const make = Effect.gen(function* () {
439 // handoff for this thread until restart.453 // handoff for this thread until restart.
440 return yield* Effect.uninterruptibleMask((restore) =>454 return yield* Effect.uninterruptibleMask((restore) =>
441 Effect.suspend(() => {455 Effect.suspend(() => {
442 if (handoffThreadsInFlight.has(scope.threadId)) {456 if (handoffThreadsInFlight.has(scope.thread.threadId)) {
443 return Effect.fail(457 return Effect.fail(
444 failure(458 failure(
445 "handoff_in_progress",459 "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}'.`,
447 ),461 ),
448 );462 );
449 }463 }
450 handoffThreadsInFlight.add(scope.threadId);464 handoffThreadsInFlight.add(scope.thread.threadId);
451 return restore(performHandoff(scope, input)).pipe(465 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 ),
453 );469 );
454 }),470 }),
455 );471 );
...@@ -457,8 +473,9 @@ const make = Effect.gen(function* () {...@@ -457,8 +473,9 @@ const make = Effect.gen(function* () {
457 );473 );
458474
459 const status: WorktreeMcpService["Service"]["status"] = Effect.fn("WorktreeMcpService.status")(475 const status: WorktreeMcpService["Service"]["status"] = Effect.fn("WorktreeMcpService.status")(
460 function* (scope) {476 function* (callerScope) {
461 yield* requireCapability(scope);477 yield* requireCapability(callerScope);
478 const scope = yield* requireThreadScope(callerScope);
462 const projection = yield* loadThread(scope);479 const projection = yield* loadThread(scope);
463 const project = yield* loadProject(scope, projection.thread.projectId);480 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....@@ -2,6 +2,9 @@ import type { ProjectionRecordField } from "../orchestration-v2/ProjectionStore.
2import {2import {
3 CommandId,3 CommandId,
4 OrchestratorMcpFailure,4 OrchestratorMcpFailure,
5 type ProjectId,
6 type ProviderInteractionMode,
7 type RuntimeMode,
5 type ThreadId,8 type ThreadId,
6 type OrchestrationV2ThreadShell,9 type OrchestrationV2ThreadShell,
7} from "@t3tools/contracts";10} from "@t3tools/contracts";
...@@ -18,6 +21,24 @@ export const unavailable = () =>...@@ -18,6 +21,24 @@ export const unavailable = () =>
18 message: "The operation could not be completed.",21 message: "The operation could not be completed.",
19 });22 });
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
21export const readCaller = Effect.fn("mcp.readCaller")(function* () {42export const readCaller = Effect.fn("mcp.readCaller")(function* () {
22 const scope = yield* McpInvocationContext.McpInvocationContext;43 const scope = yield* McpInvocationContext.McpInvocationContext;
23 if (!scope.capabilities.has("orchestration")) {44 if (!scope.capabilities.has("orchestration")) {
...@@ -27,26 +48,54 @@ export const readCaller = Effect.fn("mcp.readCaller")(function* () {...@@ -27,26 +48,54 @@ export const readCaller = Effect.fn("mcp.readCaller")(function* () {
27 });48 });
28 }49 }
29 const threads = yield* ThreadManagement.ThreadManagementService;50 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));
31 if (caller === null || caller.deletedAt !== null) {65 if (caller === null || caller.deletedAt !== null) {
32 return yield* new OrchestratorMcpFailure({66 return yield* new OrchestratorMcpFailure({
33 code: "thread_not_found",67 code: "thread_not_found",
34 message: "The calling thread was not found.",68 message: "The calling thread was not found.",
35 });69 });
36 }70 }
37 return { scope, threads, caller };71 return {
72 scope,
73 threads,
74 caller,
75 limits: { runtimeMode: caller.runtimeMode, interactionMode: caller.interactionMode },
76 } satisfies Caller;
38});77});
3978
40function assertLiveCaller({79/**
41 caller,80 * A caller may change another thread only if that thread runs within the
42 scope,81 * caller's own modes. Its own thread is always within them.
43}: {82 */
44 caller: OrchestrationV2ThreadShell;83export const assertTargetWithinLimits = (
45 scope: McpInvocationContext.McpInvocationScope;84 limits: CallerLimits,
46}) {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;
47 return caller.archivedAt !== null ||96 return caller.archivedAt !== null ||
48 caller.activeRunId === null ||97 caller.activeRunId === null ||
49 caller.providerInstanceId !== scope.providerInstanceId98 caller.providerInstanceId !== scope.thread?.providerInstanceId
50 ? Effect.fail(99 ? Effect.fail(
51 new OrchestratorMcpFailure({100 new OrchestratorMcpFailure({
52 code: "parent_not_active",101 code: "parent_not_active",
...@@ -55,34 +104,91 @@ function assertLiveCaller({...@@ -55,34 +104,91 @@ function assertLiveCaller({
55 )104 )
56 : Effect.void;105 : Effect.void;
57}106}
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 */
58export const readMutationCaller = Effect.fn("mcp.readMutationCaller")(function* () {113export const readMutationCaller = Effect.fn("mcp.readMutationCaller")(function* () {
59 const context = yield* readCaller();114 const context = yield* readCaller();
60 yield* assertLiveCaller(context);115 yield* assertLiveCaller(context);
61 return context;116 return context;
62});117});
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. */
65export const readThread = Effect.fn("mcp.readThread")(function* <165export const readThread = Effect.fn("mcp.readThread")(function* <
66 K extends ProjectionRecordField = never,166 K extends ProjectionRecordField = never,
67>(threadId?: ThreadId, fields: ReadonlyArray<K> = []) {167>(threadId?: ThreadId, fields: ReadonlyArray<K> = []) {
68 const { scope, threads, caller } = yield* readCaller();168 const context = yield* readCaller();
69 const projection = yield* threads169 const targetId = yield* resolveThreadId(context, threadId);
70 .getProjectThreadRecords(170 const shell = yield* context.threads.getThreadShell(targetId).pipe(Effect.mapError(unavailable));
71 { projectId: caller.projectId, threadId: threadId ?? caller.id },171 if (shell === null || shell.deletedAt !== null) {
72 fields,172 return yield* new OrchestratorMcpFailure({
73 { turnItemTypes: ["user_input_request"] },173 code: "thread_not_found",
74 )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 })
75 .pipe(181 .pipe(
76 Effect.mapError((error) =>182 Effect.mapError((error) =>
77 error._tag === "ThreadManagementThreadNotFoundError"183 error._tag === "ThreadManagementThreadNotFoundError"
78 ? new OrchestratorMcpFailure({184 ? new OrchestratorMcpFailure({
79 code: "thread_not_found",185 code: "thread_not_found",
80 message: "The thread was not found in the calling project.",186 message: "The thread was not found.",
81 })187 })
82 : unavailable(),188 : unavailable(),
83 ),189 ),
84 );190 );
85 return { scope, threads, caller, projection };191 return { ...context, projection };
86});192});
87193
88export const readWritableThread = Effect.fn("mcp.readWritableThread")(function* <194export const readWritableThread = Effect.fn("mcp.readWritableThread")(function* <
...@@ -90,14 +196,7 @@ export const readWritableThread = Effect.fn("mcp.readWritableThread")(function*...@@ -90,14 +196,7 @@ export const readWritableThread = Effect.fn("mcp.readWritableThread")(function*
90>(threadId?: ThreadId, fields: ReadonlyArray<K> = []) {196>(threadId?: ThreadId, fields: ReadonlyArray<K> = []) {
91 const context = yield* readThread(threadId, fields);197 const context = yield* readThread(threadId, fields);
92 yield* assertLiveCaller(context);198 yield* assertLiveCaller(context);
93 yield* OrchestrationMcp.resolveRuntimeMode(199 yield* assertTargetWithinLimits(context.limits, context.projection.thread);
94 context.caller.runtimeMode,
95 context.projection.thread.runtimeMode,
96 );
97 yield* OrchestrationMcp.resolveInteractionMode(
98 context.caller.interactionMode,
99 context.projection.thread.interactionMode,
100 );
101 return context;200 return context;
102});201});
103202
apps/server/src/mcp/toolkits/attachment/handlers.ts+3-3
...@@ -47,7 +47,7 @@ export const AttachmentHandlersLive = AttachmentToolkit.toLayer({...@@ -47,7 +47,7 @@ export const AttachmentHandlersLive = AttachmentToolkit.toLayer({
47 }),47 }),
48 t3_thread_send_attachments: (input) =>48 t3_thread_send_attachments: (input) =>
49 Effect.gen(function* () {49 Effect.gen(function* () {
50 const { caller, projection, scope } = yield* readWritableThread(input.threadId, ["messages"]);50 const { caller, projection } = yield* readWritableThread(input.threadId, ["messages"]);
51 if (projection.thread.archivedAt !== null)51 if (projection.thread.archivedAt !== null)
52 return yield* new OrchestratorMcpFailure({52 return yield* new OrchestratorMcpFailure({
53 code: "invalid_request",53 code: "invalid_request",
...@@ -60,11 +60,11 @@ export const AttachmentHandlersLive = AttachmentToolkit.toLayer({...@@ -60,11 +60,11 @@ export const AttachmentHandlersLive = AttachmentToolkit.toLayer({
60 const commandId = yield* newCommandId();60 const commandId = yield* newCommandId();
61 const messageId = MessageId.make(commandId);61 const messageId = MessageId.make(commandId);
62 const result = yield* ThreadMessageIntake.sendToThread({62 const result = yield* ThreadMessageIntake.sendToThread({
63 projectId: caller.projectId,63 projectId: projection.thread.projectId,
64 threadId: projection.thread.id,64 threadId: projection.thread.id,
65 commandId,65 commandId,
66 messageId,66 messageId,
67 senderThreadId: scope.threadId,67 ...(caller === undefined ? {} : { senderThreadId: caller.id }),
68 text: input.message ?? "",68 text: input.message ?? "",
69 attachments,69 attachments,
70 mode: "auto",70 mode: "auto",
apps/server/src/mcp/toolkits/attachment/tools.ts+1-1
...@@ -47,7 +47,7 @@ const AttachmentDiscardTool = Tool.make("t3_attachment_discard", {...@@ -47,7 +47,7 @@ const AttachmentDiscardTool = Tool.make("t3_attachment_discard", {
47const AttachmentSendTool = Tool.make("t3_thread_send_attachments", {47const AttachmentSendTool = Tool.make("t3_thread_send_attachments", {
48 ...shared,48 ...shared,
49 description:49 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.",
51 parameters: Schema.Struct({51 parameters: Schema.Struct({
52 threadId: Schema.optional(ThreadId),52 threadId: Schema.optional(ThreadId),
53 message: Schema.optional(Schema.String.check(Schema.isMaxLength(120000))),53 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";...@@ -14,7 +14,11 @@ import { McpAttachmentInput } from "./attachment/input.ts";
14import { McpSchema, McpServer, Tool } from "effect/unstable/ai";14import { McpSchema, McpServer, Tool } from "effect/unstable/ai";
1515
16import { OrchestratorProjectionError } from "../../orchestration-v2/Orchestrator.ts";16import { OrchestratorProjectionError } from "../../orchestration-v2/Orchestrator.ts";
17import * as ProviderAdapterRegistry from "../../orchestration-v2/ProviderAdapterRegistry.ts";
17import * as ThreadManagement from "../../orchestration-v2/ThreadManagementService.ts";18import * 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";
18import * as McpHttpServer from "../McpHttpServer.ts";22import * as McpHttpServer from "../McpHttpServer.ts";
19import * as McpInvocationContext from "../McpInvocationContext.ts";23import * as McpInvocationContext from "../McpInvocationContext.ts";
20import { OrchestratorToolkit } from "./orchestrator/tools.ts";24import { OrchestratorToolkit } from "./orchestrator/tools.ts";
...@@ -78,9 +82,13 @@ it("publishes unique tool names with reference-free object-root inputs", () => {...@@ -78,9 +82,13 @@ it("publishes unique tool names with reference-free object-root inputs", () => {
78const threadId = ThreadId.make("mcp-core-thread");82const threadId = ThreadId.make("mcp-core-thread");
79const scope: McpInvocationContext.McpInvocationScope = {83const scope: McpInvocationContext.McpInvocationScope = {
80 environmentId: EnvironmentId.make("mcp-core-environment"),84 environmentId: EnvironmentId.make("mcp-core-environment"),
81 threadId,85 requestNamespace: "mcp-core-session",
82 providerSessionId: "mcp-core-session",86 thread: {
83 providerInstanceId: ProviderInstanceId.make("codex"),87 threadId,
88 providerSessionId: "mcp-core-session",
89 providerInstanceId: ProviderInstanceId.make("codex"),
90 },
91 client: undefined,
84 issuedAt: 0,92 issuedAt: 0,
85 capabilities: new Set(["orchestration"]),93 capabilities: new Set(["orchestration"]),
86};94};
...@@ -207,3 +215,236 @@ it.effect("resolves reused attachment references from stored metadata", () =>...@@ -207,3 +215,236 @@ it.effect("resolves reused attachment references from stored metadata", () =>
207 expect(failure.code).toBe("invalid_request");215 expect(failure.code).toBe("invalid_request");
208 }),216 }),
209);217);
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(...@@ -66,7 +66,7 @@ export function agentDeviceQuickStart(
66 ].join("\n");66 ].join("\n");
67}67}
6868
69const requireDeviceAccess = McpInvocationContext.requireMcpCapability("device").pipe(69const requireDeviceAccess = McpInvocationContext.requireThreadMcpCapability("device").pipe(
70 Effect.mapError(70 Effect.mapError(
71 () =>71 () =>
72 new DeviceToolUnavailableError({72 new DeviceToolUnavailableError({
...@@ -132,7 +132,7 @@ const handlers = {...@@ -132,7 +132,7 @@ const handlers = {
132 }132 }
133 const hostId = input?.hostId;133 const hostId = input?.hostId;
134 const open = state.sessions134 const open = state.sessions
135 .filter((session) => session.threadId === scope.threadId)135 .filter((session) => session.threadId === scope.thread.threadId)
136 .map((session) => ({ hostId: session.hostId, deviceId: session.deviceId }));136 .map((session) => ({ hostId: session.hostId, deviceId: session.deviceId }));
137 return {137 return {
138 hostStatuses: Object.fromEntries(138 hostStatuses: Object.fromEntries(
...@@ -159,12 +159,12 @@ const handlers = {...@@ -159,12 +159,12 @@ const handlers = {
159 const target = yield* pickDevice(state.devices, input);159 const target = yield* pickDevice(state.devices, input);
160 // Resolve consent and agent connectivity before booting or registering a session.160 // Resolve consent and agent connectivity before booting or registering a session.
161 const agentArgs = yield* devices.agentTarget({161 const agentArgs = yield* devices.agentTarget({
162 threadId: scope.threadId,162 threadId: scope.thread.threadId,
163 hostId: target.hostId,163 hostId: target.hostId,
164 deviceId: target.id,164 deviceId: target.id,
165 });165 });
166 const session = yield* devices.open({166 const session = yield* devices.open({
167 threadId: scope.threadId,167 threadId: scope.thread.threadId,
168 hostId: target.hostId,168 hostId: target.hostId,
169 deviceId: target.id,169 deviceId: target.id,
170 platform: target.platform,170 platform: target.platform,
...@@ -207,7 +207,7 @@ const handlers = {...@@ -207,7 +207,7 @@ const handlers = {
207 Effect.gen(function* () {207 Effect.gen(function* () {
208 const scope = yield* requireDeviceAccess;208 const scope = yield* requireDeviceAccess;
209 const devices = yield* DeviceService.DeviceService;209 const devices = yield* DeviceService.DeviceService;
210 const sessions = yield* devices.sessionsForThread(scope.threadId);210 const sessions = yield* devices.sessionsForThread(scope.thread.threadId);
211 const target =211 const target =
212 input.deviceId !== undefined212 input.deviceId !== undefined
213 ? { hostId: input.hostId ?? LOCAL_DEVICE_HOST_ID, deviceId: input.deviceId }213 ? { hostId: input.hostId ?? LOCAL_DEVICE_HOST_ID, deviceId: input.deviceId }
...@@ -234,7 +234,7 @@ const handlers = {...@@ -234,7 +234,7 @@ const handlers = {
234 const scope = yield* requireDeviceAccess;234 const scope = yield* requireDeviceAccess;
235 const devices = yield* DeviceService.DeviceService;235 const devices = yield* DeviceService.DeviceService;
236 yield* devices.close({236 yield* devices.close({
237 threadId: scope.threadId,237 threadId: scope.thread.threadId,
238 ...(input.hostId === undefined ? {} : { hostId: input.hostId }),238 ...(input.hostId === undefined ? {} : { hostId: input.hostId }),
239 ...(input.deviceId === undefined ? {} : { deviceId: input.deviceId }),239 ...(input.deviceId === undefined ? {} : { deviceId: input.deviceId }),
240 ...(input.shutdown === undefined ? {} : { shutdown: input.shutdown }),240 ...(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";...@@ -4,7 +4,7 @@ import * as Environment from "../../../environment/ServerEnvironment.ts";
4import * as ThreadCommandExecutor from "../../../orchestration-v2/ThreadCommandExecutor.ts";4import * as ThreadCommandExecutor from "../../../orchestration-v2/ThreadCommandExecutor.ts";
5import * as Settings from "../../../serverSettings.ts";5import * as Settings from "../../../serverSettings.ts";
6import * as McpInvocationContext from "../../McpInvocationContext.ts";6import * as McpInvocationContext from "../../McpInvocationContext.ts";
7import { readCaller, readMutationCaller, unavailable } from "../../threadAccess.ts";7import { readCaller, readFullAccessCaller, unavailable } from "../../threadAccess.ts";
8import { EnvironmentToolkit } from "./tools.ts";8import { EnvironmentToolkit } from "./tools.ts";
99
10export function preferences(settings: ServerSettings) {10export function preferences(settings: ServerSettings) {
...@@ -30,7 +30,11 @@ export function preferences(settings: ServerSettings) {...@@ -30,7 +30,11 @@ export function preferences(settings: ServerSettings) {
30}30}
31const access = (writable = false) =>31const access = (writable = false) =>
32 Effect.gen(function* () {32 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();
34 const environment = yield* Environment.ServerEnvironment;38 const environment = yield* Environment.ServerEnvironment;
35 const descriptor = yield* environment.getDescriptor;39 const descriptor = yield* environment.getDescriptor;
36 if (descriptor.environmentId !== context.scope.environmentId)40 if (descriptor.environmentId !== context.scope.environmentId)
...@@ -57,23 +61,15 @@ export const EnvironmentHandlersLive = EnvironmentToolkit.toLayer({...@@ -57,23 +61,15 @@ export const EnvironmentHandlersLive = EnvironmentToolkit.toLayer({
57 Effect.gen(function* () {61 Effect.gen(function* () {
58 const scope = yield* McpInvocationContext.McpInvocationContext;62 const scope = yield* McpInvocationContext.McpInvocationContext;
59 const executor = yield* ThreadCommandExecutor.ThreadCommandExecutor;63 const executor = yield* ThreadCommandExecutor.ThreadCommandExecutor;
60 return yield* executor.withLock(64 const update = Effect.gen(function* () {
61 scope.threadId,65 const { settings } = yield* access(true);
62 Effect.gen(function* () {66 return preferences(
63 const { caller, settings } = yield* access(true);67 yield* settings.updateSettings(patch).pipe(Effect.mapError(unavailable)),
64 if (68 );
65 caller.archivedAt !== null ||69 });
66 caller.runtimeMode !== "full-access" ||70 // A thread caller serializes with its own turn; a client has no thread to lock.
67 caller.interactionMode !== "default"71 return yield* scope.thread === undefined
68 )72 ? update
69 return yield* new OrchestratorMcpFailure({73 : executor.withLock(scope.thread.threadId, update);
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 );
78 }),74 }),
79});75});
apps/server/src/mcp/toolkits/orchestrator/handlers.ts+2-2
...@@ -36,11 +36,11 @@ const handlers = {...@@ -36,11 +36,11 @@ const handlers = {
36 const service = yield* OrchestratorMcpService.OrchestratorMcpService;36 const service = yield* OrchestratorMcpService.OrchestratorMcpService;
37 return yield* service.scheduleTask(scope, input);37 return yield* service.scheduleTask(scope, input);
38 }),38 }),
39 list_scheduled_tasks: () =>39 list_scheduled_tasks: (input) =>
40 Effect.gen(function* () {40 Effect.gen(function* () {
41 const scope = yield* McpInvocationContext.McpInvocationContext;41 const scope = yield* McpInvocationContext.McpInvocationContext;
42 const service = yield* OrchestratorMcpService.OrchestratorMcpService;42 const service = yield* OrchestratorMcpService.OrchestratorMcpService;
43 return yield* service.listScheduledTasks(scope);43 return yield* service.listScheduledTasks(scope, input);
44 }),44 }),
45 update_scheduled_task: (input) =>45 update_scheduled_task: (input) =>
46 Effect.gen(function* () {46 Effect.gen(function* () {
apps/server/src/mcp/toolkits/orchestrator/tools.ts+14-12
...@@ -7,6 +7,7 @@ import {...@@ -7,6 +7,7 @@ import {
7 OrchestratorMcpDeleteScheduledTaskInput,7 OrchestratorMcpDeleteScheduledTaskInput,
8 OrchestratorMcpDeleteScheduledTaskResult,8 OrchestratorMcpDeleteScheduledTaskResult,
9 OrchestratorMcpFailure,9 OrchestratorMcpFailure,
10 OrchestratorMcpListScheduledTasksInput,
10 OrchestratorMcpListScheduledTasksResult,11 OrchestratorMcpListScheduledTasksResult,
11 OrchestratorMcpScheduleTaskInput,12 OrchestratorMcpScheduleTaskInput,
12 OrchestratorMcpScheduleTaskResult,13 OrchestratorMcpScheduleTaskResult,
...@@ -44,7 +45,7 @@ const threadMetadataDependencies = [...@@ -44,7 +45,7 @@ const threadMetadataDependencies = [
4445
45const OrchestratorCapabilitiesTool = Tool.make("orchestrator_capabilities", {46const OrchestratorCapabilitiesTool = Tool.make("orchestrator_capabilities", {
46 description:47 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.",
48 success: OrchestratorMcpCapabilitiesResult,49 success: OrchestratorMcpCapabilitiesResult,
49 failure: OrchestratorMcpFailure,50 failure: OrchestratorMcpFailure,
50 failureMode: "return",51 failureMode: "return",
...@@ -57,7 +58,7 @@ const OrchestratorCapabilitiesTool = Tool.make("orchestrator_capabilities", {...@@ -57,7 +58,7 @@ const OrchestratorCapabilitiesTool = Tool.make("orchestrator_capabilities", {
5758
58export const DelegateTaskTool = Tool.make("delegate_task", {59export const DelegateTaskTool = Tool.make("delegate_task", {
59 description:60 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.",
61 parameters: OrchestratorMcpDelegateTaskInput,62 parameters: OrchestratorMcpDelegateTaskInput,
62 success: OrchestratorMcpDelegateTaskResult,63 success: OrchestratorMcpDelegateTaskResult,
63 failure: OrchestratorMcpFailure,64 failure: OrchestratorMcpFailure,
...@@ -70,7 +71,7 @@ export const DelegateTaskTool = Tool.make("delegate_task", {...@@ -70,7 +71,7 @@ export const DelegateTaskTool = Tool.make("delegate_task", {
7071
71const TaskStatusTool = Tool.make("task_status", {72const TaskStatusTool = Tool.make("task_status", {
72 description:73 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.",
74 parameters: OrchestratorMcpTaskStatusInput,75 parameters: OrchestratorMcpTaskStatusInput,
75 success: OrchestratorMcpDelegateTaskResult,76 success: OrchestratorMcpDelegateTaskResult,
76 failure: OrchestratorMcpFailure,77 failure: OrchestratorMcpFailure,
...@@ -84,7 +85,7 @@ const TaskStatusTool = Tool.make("task_status", {...@@ -84,7 +85,7 @@ const TaskStatusTool = Tool.make("task_status", {
8485
85const TaskCancelTool = Tool.make("task_cancel", {86const TaskCancelTool = Tool.make("task_cancel", {
86 description:87 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.",
88 parameters: OrchestratorMcpTaskCancelInput,89 parameters: OrchestratorMcpTaskCancelInput,
89 success: OrchestratorMcpTaskCancelResult,90 success: OrchestratorMcpTaskCancelResult,
90 failure: OrchestratorMcpFailure,91 failure: OrchestratorMcpFailure,
...@@ -96,7 +97,7 @@ const TaskCancelTool = Tool.make("task_cancel", {...@@ -96,7 +97,7 @@ const TaskCancelTool = Tool.make("task_cancel", {
9697
97export const ScheduleTaskTool = Tool.make("schedule_task", {98export const ScheduleTaskTool = Tool.make("schedule_task", {
98 description:99 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.",
100 parameters: OrchestratorMcpScheduleTaskInput,101 parameters: OrchestratorMcpScheduleTaskInput,
101 success: OrchestratorMcpScheduleTaskResult,102 success: OrchestratorMcpScheduleTaskResult,
102 failure: OrchestratorMcpFailure,103 failure: OrchestratorMcpFailure,
...@@ -108,8 +109,9 @@ export const ScheduleTaskTool = Tool.make("schedule_task", {...@@ -108,8 +109,9 @@ export const ScheduleTaskTool = Tool.make("schedule_task", {
108 .annotate(Tool.OpenWorld, true);109 .annotate(Tool.OpenWorld, true);
109110
110const ListScheduledTasksTool = Tool.make("list_scheduled_tasks", {111const ListScheduledTasksTool = Tool.make("list_scheduled_tasks", {
112 parameters: OrchestratorMcpListScheduledTasksInput,
111 description:113 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.",
113 success: OrchestratorMcpListScheduledTasksResult,115 success: OrchestratorMcpListScheduledTasksResult,
114 failure: OrchestratorMcpFailure,116 failure: OrchestratorMcpFailure,
115 failureMode: "return",117 failureMode: "return",
...@@ -146,7 +148,7 @@ const DeleteScheduledTaskTool = Tool.make("delete_scheduled_task", {...@@ -146,7 +148,7 @@ const DeleteScheduledTaskTool = Tool.make("delete_scheduled_task", {
146148
147export const CreateThreadsTool = Tool.make("create_threads", {149export const CreateThreadsTool = Tool.make("create_threads", {
148 description:150 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.",
150 parameters: OrchestratorMcpCreateThreadsInput,152 parameters: OrchestratorMcpCreateThreadsInput,
151 success: OrchestratorMcpCreateThreadsResult,153 success: OrchestratorMcpCreateThreadsResult,
152 failure: OrchestratorMcpFailure,154 failure: OrchestratorMcpFailure,
...@@ -159,7 +161,7 @@ export const CreateThreadsTool = Tool.make("create_threads", {...@@ -159,7 +161,7 @@ export const CreateThreadsTool = Tool.make("create_threads", {
159161
160const ThreadListTool = Tool.make("t3_thread_list", {162const ThreadListTool = Tool.make("t3_thread_list", {
161 description:163 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.",
163 parameters: OrchestratorMcpThreadListInput,165 parameters: OrchestratorMcpThreadListInput,
164 success: OrchestratorMcpThreadListResult,166 success: OrchestratorMcpThreadListResult,
165 failure: OrchestratorMcpFailure,167 failure: OrchestratorMcpFailure,
...@@ -173,7 +175,7 @@ const ThreadListTool = Tool.make("t3_thread_list", {...@@ -173,7 +175,7 @@ const ThreadListTool = Tool.make("t3_thread_list", {
173175
174const ThreadReadTool = Tool.make("t3_thread_read", {176const ThreadReadTool = Tool.make("t3_thread_read", {
175 description:177 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.",
177 parameters: OrchestratorMcpThreadReadInput,179 parameters: OrchestratorMcpThreadReadInput,
178 success: OrchestratorMcpThreadReadResult,180 success: OrchestratorMcpThreadReadResult,
179 failure: OrchestratorMcpFailure,181 failure: OrchestratorMcpFailure,
...@@ -187,7 +189,7 @@ const ThreadReadTool = Tool.make("t3_thread_read", {...@@ -187,7 +189,7 @@ const ThreadReadTool = Tool.make("t3_thread_read", {
187189
188export const ThreadUpdateTool = Tool.make("t3_thread_update", {190export const ThreadUpdateTool = Tool.make("t3_thread_update", {
189 description:191 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.",
191 parameters: ThreadMetadataMcpUpdateInput,193 parameters: ThreadMetadataMcpUpdateInput,
192 success: ThreadMetadataMcpUpdateResult,194 success: ThreadMetadataMcpUpdateResult,
193 failure: OrchestratorMcpFailure,195 failure: OrchestratorMcpFailure,
...@@ -200,7 +202,7 @@ export const ThreadUpdateTool = Tool.make("t3_thread_update", {...@@ -200,7 +202,7 @@ export const ThreadUpdateTool = Tool.make("t3_thread_update", {
200202
201const ThreadSendTool = Tool.make("t3_thread_send", {203const ThreadSendTool = Tool.make("t3_thread_send", {
202 description:204 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.",
204 parameters: OrchestratorMcpThreadSendInput,206 parameters: OrchestratorMcpThreadSendInput,
205 success: OrchestratorMcpThreadSendResult,207 success: OrchestratorMcpThreadSendResult,
206 failure: OrchestratorMcpFailure,208 failure: OrchestratorMcpFailure,
...@@ -227,7 +229,7 @@ const ThreadWaitTool = Tool.make("t3_thread_wait", {...@@ -227,7 +229,7 @@ const ThreadWaitTool = Tool.make("t3_thread_wait", {
227229
228const ThreadInterruptTool = Tool.make("t3_thread_interrupt", {230const ThreadInterruptTool = Tool.make("t3_thread_interrupt", {
229 description:231 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.",
231 parameters: OrchestratorMcpThreadInterruptInput,233 parameters: OrchestratorMcpThreadInterruptInput,
232 success: OrchestratorMcpThreadInterruptResult,234 success: OrchestratorMcpThreadInterruptResult,
233 failure: OrchestratorMcpFailure,235 failure: OrchestratorMcpFailure,
apps/server/src/mcp/toolkits/preview/handlers.ts+3-3
...@@ -60,7 +60,7 @@ const invoke = Effect.fn("PreviewToolkit.invoke")(function* <A>(...@@ -60,7 +60,7 @@ const invoke = Effect.fn("PreviewToolkit.invoke")(function* <A>(
60 import("@t3tools/contracts").PreviewAutomationError,60 import("@t3tools/contracts").PreviewAutomationError,
61 McpInvocationContext.McpInvocationContext | PreviewAutomationBroker.PreviewAutomationBroker61 McpInvocationContext.McpInvocationContext | PreviewAutomationBroker.PreviewAutomationBroker
62> {62> {
63 const scope = yield* McpInvocationContext.requireMcpCapability("preview");63 const scope = yield* McpInvocationContext.requireThreadMcpCapability("preview");
64 const broker = yield* PreviewAutomationBroker.PreviewAutomationBroker;64 const broker = yield* PreviewAutomationBroker.PreviewAutomationBroker;
65 let targetTabId = tabId;65 let targetTabId = tabId;
66 const result = yield* broker.invoke<A>({66 const result = yield* broker.invoke<A>({
...@@ -218,7 +218,7 @@ const handlers = {...@@ -218,7 +218,7 @@ const handlers = {
218 invokeTargeted<PreviewAutomationRecordingStatus>("recordingStart", input ?? {}),218 invokeTargeted<PreviewAutomationRecordingStatus>("recordingStart", input ?? {}),
219 preview_recording_stop: (input) =>219 preview_recording_stop: (input) =>
220 Effect.gen(function* () {220 Effect.gen(function* () {
221 const scope = yield* McpInvocationContext.requireMcpCapability("preview");221 const scope = yield* McpInvocationContext.requireThreadMcpCapability("preview");
222 const { tabId, ...operationInput } = input;222 const { tabId, ...operationInput } = input;
223 const response = yield* invoke<unknown>(223 const response = yield* invoke<unknown>(
224 "recordingStop",224 "recordingStop",
...@@ -226,7 +226,7 @@ const handlers = {...@@ -226,7 +226,7 @@ const handlers = {
226 PREVIEW_RECORDING_STOP_TIMEOUT_MS,226 PREVIEW_RECORDING_STOP_TIMEOUT_MS,
227 tabId,227 tabId,
228 );228 );
229 const artifact = yield* claimPreviewRecording(scope.threadId, response.result);229 const artifact = yield* claimPreviewRecording(scope.thread.threadId, response.result);
230 return { ...artifact, ...(response.toolIcon ? { toolIcon: response.toolIcon } : {}) };230 return { ...artifact, ...(response.toolIcon ? { toolIcon: response.toolIcon } : {}) };
231 }),231 }),
232} satisfies Parameters<typeof PreviewToolkit.toLayer>[0];232} satisfies Parameters<typeof PreviewToolkit.toLayer>[0];
apps/server/src/mcp/toolkits/previewControls/handlers.test.ts+7-3
...@@ -35,9 +35,13 @@ it.effect.each([...@@ -35,9 +35,13 @@ it.effect.each([
35 const effective = resolveProjectSettings(settings, projectId).settings;35 const effective = resolveProjectSettings(settings, projectId).settings;
36 const scope: McpInvocationContext.McpInvocationScope = {36 const scope: McpInvocationContext.McpInvocationScope = {
37 environmentId: EnvironmentId.make("preview-controls-environment"),37 environmentId: EnvironmentId.make("preview-controls-environment"),
38 threadId,38 requestNamespace: "preview-controls-provider-session",
39 providerSessionId: "preview-controls-provider-session",39 thread: {
40 providerInstanceId: ProviderInstanceId.make("codex"),40 threadId,
41 providerSessionId: "preview-controls-provider-session",
42 providerInstanceId: ProviderInstanceId.make("codex"),
43 },
44 client: undefined,
41 capabilities: new Set(effective.enableAgentBrowserAccess ? ["preview"] : []),45 capabilities: new Set(effective.enableAgentBrowserAccess ? ["preview"] : []),
42 issuedAt: 0,46 issuedAt: 0,
43 };47 };
apps/server/src/mcp/toolkits/previewControls/handlers.ts+4-4
...@@ -1,19 +1,19 @@...@@ -1,19 +1,19 @@
1import * as Effect from "effect/Effect";1import * as Effect from "effect/Effect";
2import * as Preview from "../../../preview/Manager.ts";2import * as Preview from "../../../preview/Manager.ts";
3import { requireMcpCapability } from "../../McpInvocationContext.ts";3import { requireThreadMcpCapability } from "../../McpInvocationContext.ts";
4import { unavailable } from "../../threadAccess.ts";4import { unavailable } from "../../threadAccess.ts";
5import { PreviewControlsToolkit } from "./tools.ts";5import { PreviewControlsToolkit } from "./tools.ts";
66
7const access = Effect.gen(function* () {7const access = Effect.gen(function* () {
8 // The preview capability already reflects the calling project's access setting.8 // The preview capability already reflects the calling project's access setting.
9 const scope = yield* requireMcpCapability("preview");9 const scope = yield* requireThreadMcpCapability("preview");
10 return { scope, manager: yield* Preview.PreviewManager };10 return { scope, manager: yield* Preview.PreviewManager };
11});11});
12export const PreviewControlsHandlersLive = PreviewControlsToolkit.toLayer({12export const PreviewControlsHandlersLive = PreviewControlsToolkit.toLayer({
13 t3_preview_list: (input) =>13 t3_preview_list: (input) =>
14 Effect.gen(function* () {14 Effect.gen(function* () {
15 const { scope, manager } = yield* access;15 const { scope, manager } = yield* access;
16 const result = yield* manager.list({ threadId: scope.threadId });16 const result = yield* manager.list({ threadId: scope.thread.threadId });
17 const start = input.cursor ?? 0;17 const start = input.cursor ?? 0;
18 const end = start + (input.limit ?? 20);18 const end = start + (input.limit ?? 20);
19 return {19 return {
...@@ -26,7 +26,7 @@ export const PreviewControlsHandlersLive = PreviewControlsToolkit.toLayer({...@@ -26,7 +26,7 @@ export const PreviewControlsHandlersLive = PreviewControlsToolkit.toLayer({
26 Effect.gen(function* () {26 Effect.gen(function* () {
27 const { scope, manager } = yield* access;27 const { scope, manager } = yield* access;
28 yield* manager28 yield* manager
29 .close({ threadId: scope.threadId, tabId: input.tabId })29 .close({ threadId: scope.thread.threadId, tabId: input.tabId })
30 .pipe(Effect.mapError(unavailable));30 .pipe(Effect.mapError(unavailable));
31 return {};31 return {};
32 }),32 }),
apps/server/src/mcp/toolkits/project/handlers.test.ts+106-9
...@@ -5,6 +5,7 @@ import {...@@ -5,6 +5,7 @@ import {
5 ProviderInstanceId,5 ProviderInstanceId,
6 ThreadId,6 ThreadId,
7 type OrchestrationV2ThreadShell,7 type OrchestrationV2ThreadShell,
8 type Project as ProjectRecord,
8} from "@t3tools/contracts";9} from "@t3tools/contracts";
9import * as NodeCrypto from "@effect/platform-node/NodeCrypto";10import * as NodeCrypto from "@effect/platform-node/NodeCrypto";
10import * as NodeServices from "@effect/platform-node/NodeServices";11import * as NodeServices from "@effect/platform-node/NodeServices";
...@@ -44,9 +45,13 @@ it.effect("attributes a launched thread's first message to the calling thread",...@@ -44,9 +45,13 @@ it.effect("attributes a launched thread's first message to the calling thread",
44 NodeCrypto.layer,45 NodeCrypto.layer,
45 Layer.succeed(McpInvocationContext.McpInvocationContext, {46 Layer.succeed(McpInvocationContext.McpInvocationContext, {
46 environmentId: EnvironmentId.make("environment"),47 environmentId: EnvironmentId.make("environment"),
47 threadId: sourceThreadId,48 requestNamespace: "session",
48 providerSessionId: "session",49 thread: {
49 providerInstanceId,50 threadId: sourceThreadId,
51 providerSessionId: "session",
52 providerInstanceId,
53 },
54 client: undefined,
50 issuedAt: 0,55 issuedAt: 0,
51 capabilities: new Set(["orchestration" as const]),56 capabilities: new Set(["orchestration" as const]),
52 }),57 }),
...@@ -106,9 +111,13 @@ it.effect("launches a scratch thread into the Scratch project", () =>...@@ -106,9 +111,13 @@ it.effect("launches a scratch thread into the Scratch project", () =>
106 NodeCrypto.layer,111 NodeCrypto.layer,
107 Layer.succeed(McpInvocationContext.McpInvocationContext, {112 Layer.succeed(McpInvocationContext.McpInvocationContext, {
108 environmentId: EnvironmentId.make("environment"),113 environmentId: EnvironmentId.make("environment"),
109 threadId: sourceThreadId,114 requestNamespace: "session",
110 providerSessionId: "session",115 thread: {
111 providerInstanceId,116 threadId: sourceThreadId,
117 providerSessionId: "session",
118 providerInstanceId,
119 },
120 client: undefined,
112 issuedAt: 0,121 issuedAt: 0,
113 capabilities: new Set(["orchestration" as const]),122 capabilities: new Set(["orchestration" as const]),
114 }),123 }),
...@@ -194,9 +203,13 @@ it.effect("starts a project from just a title when workspaceRoot is omitted", ()...@@ -194,9 +203,13 @@ it.effect("starts a project from just a title when workspaceRoot is omitted", ()
194 NodeCrypto.layer,203 NodeCrypto.layer,
195 Layer.succeed(McpInvocationContext.McpInvocationContext, {204 Layer.succeed(McpInvocationContext.McpInvocationContext, {
196 environmentId: EnvironmentId.make("environment"),205 environmentId: EnvironmentId.make("environment"),
197 threadId: sourceThreadId,206 requestNamespace: "session",
198 providerSessionId: "session",207 thread: {
199 providerInstanceId,208 threadId: sourceThreadId,
209 providerSessionId: "session",
210 providerInstanceId,
211 },
212 client: undefined,
200 issuedAt: 0,213 issuedAt: 0,
201 capabilities: new Set(["orchestration" as const]),214 capabilities: new Set(["orchestration" as const]),
202 }),215 }),
...@@ -263,3 +276,87 @@ it.effect("starts a project from just a title when workspaceRoot is omitted", ()...@@ -263,3 +276,87 @@ it.effect("starts a project from just a title when workspaceRoot is omitted", ()
263 expect(named).toEqual(["Pinball Stats"]);276 expect(named).toEqual(["Pinball Stats"]);
264 }),277 }),
265);278);
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";...@@ -6,7 +6,15 @@ import * as Claims from "../../../orchestration-v2/AttachmentClaims.ts";
6import * as Project from "../../../project/ProjectService.ts";6import * as Project from "../../../project/ProjectService.ts";
7import * as ManagedProjectFolders from "../../../project/ManagedProjectFolders.ts";7import * as ManagedProjectFolders from "../../../project/ManagedProjectFolders.ts";
8import * as Repositories from "../../../sourceControl/SourceControlRepositoryService.ts";8import * 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";
10import { ProjectToolkit } from "./tools.ts";18import { ProjectToolkit } from "./tools.ts";
1119
12function projectFailure(error: Project.ProjectServiceError) {20function projectFailure(error: Project.ProjectServiceError) {
...@@ -25,27 +33,30 @@ const access = Effect.gen(function* () {...@@ -25,27 +33,30 @@ const access = Effect.gen(function* () {
25 return yield* Project.ProjectService;33 return yield* Project.ProjectService;
26});34});
27const mutation = Effect.gen(function* () {35const mutation = Effect.gen(function* () {
28 const { caller } = yield* readMutationCaller();36 yield* readFullAccessCaller(
29 if (37 "Project changes require a live full-access/default calling thread or a full-access client.",
30 caller.archivedAt !== null ||38 );
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 });
38 return yield* Project.ProjectService;39 return yield* Project.ProjectService;
39});40});
40export const ProjectHandlersLive = ProjectToolkit.toLayer({41export const ProjectHandlersLive = ProjectToolkit.toLayer({
41 t3_thread_launch: (input) =>42 t3_thread_launch: (input) =>
42 Effect.gen(function* () {43 Effect.gen(function* () {
43 const { caller, scope } = yield* readMutationCaller();44 const context = yield* readMutationCaller();
44 if (caller.runtimeMode !== "full-access" || caller.interactionMode !== "default")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 )
45 return yield* new OrchestratorMcpFailure({52 return yield* new OrchestratorMcpFailure({
46 code: "capability_denied",53 code: "capability_denied",
47 message: "Project launches require a full-access/default calling thread.",54 message: "Project launches require a full-access/default calling thread.",
48 });55 });
56 const runtimeMode = yield* resolveRuntimeMode(
57 limits.runtimeMode,
58 input.runtimeMode ?? caller?.runtimeMode,
59 );
49 const commandId = yield* newCommandId();60 const commandId = yield* newCommandId();
50 const threadId = ThreadId.make(commandId);61 const threadId = ThreadId.make(commandId);
51 const messageId = MessageId.make(commandId);62 const messageId = MessageId.make(commandId);
...@@ -76,22 +87,38 @@ export const ProjectHandlersLive = ProjectToolkit.toLayer({...@@ -76,22 +87,38 @@ export const ProjectHandlersLive = ProjectToolkit.toLayer({
76 }),87 }),
77 ),88 ),
78 )).projectId89 )).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 });
80 const result = yield* ThreadMessageIntake.launchThread({107 const result = yield* ThreadMessageIntake.launchThread({
81 commandId,108 commandId,
82 threadId,109 threadId,
83 projectId,110 projectId,
84 title: input.title,111 title: input.title,
85 modelSelection: input.modelSelection ?? caller.modelSelection,112 modelSelection,
86 runtimeMode: input.runtimeMode ?? caller.runtimeMode,113 runtimeMode,
87 interactionMode: input.interactionMode ?? caller.interactionMode,114 interactionMode: input.interactionMode ?? caller?.interactionMode ?? "default",
88 workspaceStrategy: input.workspaceStrategy ?? { type: "root" },115 workspaceStrategy: input.workspaceStrategy ?? { type: "root" },
89 ...(input.message === undefined && attachments.length === 0116 ...(input.message === undefined && attachments.length === 0
90 ? {}117 ? {}
91 : {118 : {
92 initialMessage: {119 initialMessage: {
93 messageId,120 messageId,
94 senderThreadId: scope.threadId,121 ...(caller === undefined ? {} : { senderThreadId: caller.id }),
95 text: input.message ?? "",122 text: input.message ?? "",
96 attachments,123 attachments,
97 },124 },
apps/server/src/mcp/toolkits/project/tools.ts+1-1
...@@ -102,7 +102,7 @@ const ProjectCloneTool = Tool.make("t3_project_clone", {...@@ -102,7 +102,7 @@ const ProjectCloneTool = Tool.make("t3_project_clone", {
102const ThreadLaunchTool = Tool.make("t3_thread_launch", {102const ThreadLaunchTool = Tool.make("t3_thread_launch", {
103 ...shared,103 ...shared,
104 description:104 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.',
106 parameters: Schema.Struct({106 parameters: Schema.Struct({
107 projectId: Schema.optional(ProjectId),107 projectId: Schema.optional(ProjectId),
108 scratch: Schema.optional(108 scratch: Schema.optional(
apps/server/src/mcp/toolkits/pullRequests/handlers.test.ts+7-3
...@@ -38,9 +38,13 @@ const invocation = (...@@ -38,9 +38,13 @@ const invocation = (
38 capabilities: ReadonlyArray<McpInvocationContext.McpCapability>,38 capabilities: ReadonlyArray<McpInvocationContext.McpCapability>,
39): McpInvocationContext.McpInvocationScope => ({39): McpInvocationContext.McpInvocationScope => ({
40 environmentId: EnvironmentId.make("environment-1"),40 environmentId: EnvironmentId.make("environment-1"),
41 threadId: THREAD_ID,41 requestNamespace: "provider-session-1",
42 providerSessionId: "provider-session-1",42 thread: {
43 providerInstanceId: ProviderInstanceId.make("codex"),43 threadId: THREAD_ID,
44 providerSessionId: "provider-session-1",
45 providerInstanceId: ProviderInstanceId.make("codex"),
46 },
47 client: undefined,
44 capabilities: new Set(capabilities),48 capabilities: new Set(capabilities),
45 issuedAt: 1,49 issuedAt: 1,
46});50});
apps/server/src/mcp/toolkits/pullRequests/handlers.ts+60-9
...@@ -26,6 +26,7 @@ import * as Option from "effect/Option";...@@ -26,6 +26,7 @@ import * as Option from "effect/Option";
26import * as Orchestrator from "../../../orchestration-v2/Orchestrator.ts";26import * as Orchestrator from "../../../orchestration-v2/Orchestrator.ts";
27import * as ProjectService from "../../../project/ProjectService.ts";27import * as ProjectService from "../../../project/ProjectService.ts";
28import * as McpInvocationContext from "../../McpInvocationContext.ts";28import * as McpInvocationContext from "../../McpInvocationContext.ts";
29import { assertTargetWithinLimits } from "../../threadAccess.ts";
29import {30import {
30 type ListThreadPullRequestsResult,31 type ListThreadPullRequestsResult,
31 PullRequestLinkFailedError,32 PullRequestLinkFailedError,
...@@ -38,6 +39,8 @@ import {...@@ -38,6 +39,8 @@ import {
38 type PullRequestTargetInput,39 type PullRequestTargetInput,
39 PullRequestWatchFailedError,40 PullRequestWatchFailedError,
40 PullRequestThreadNotFoundError,41 PullRequestThreadNotFoundError,
42 PullRequestThreadAboveLimitsError,
43 PullRequestThreadRequiredError,
41 PullRequestsToolkit,44 PullRequestsToolkit,
42 type ThreadPullRequestEntry,45 type ThreadPullRequestEntry,
43} from "./tools.ts";46} from "./tools.ts";
...@@ -168,18 +171,64 @@ const make = Effect.gen(function* () {...@@ -168,18 +171,64 @@ const make = Effect.gen(function* () {
168 | typeof PullRequestUnlinkFailedError171 | typeof PullRequestUnlinkFailedError
169 | typeof PullRequestListFailedError172 | typeof PullRequestListFailedError
170 | typeof PullRequestWatchFailedError,173 | typeof PullRequestWatchFailedError,
174 requested: ThreadId | undefined,
171 ) {175 ) {
172 const scope = yield* McpInvocationContext.requireMcpCapability("pull-requests");176 const scope = yield* McpInvocationContext.requireMcpCapability("pull-requests");
177 const threadId = requested ?? scope.thread?.threadId;
178 if (threadId === undefined) {
179 return yield* new PullRequestThreadRequiredError();
180 }
173 const thread = yield* engine181 const thread = yield* engine
174 .getThreadShell(scope.threadId)182 .getThreadShell(threadId)
175 .pipe(Effect.map(Option.fromNullishOr))183 .pipe(Effect.map(Option.fromNullishOr))
176 .pipe(Effect.mapError((cause) => new Failure({ cause })));184 .pipe(Effect.mapError((cause) => new Failure({ cause })));
177 if (Option.isNone(thread)) {185 if (Option.isNone(thread) || thread.value.deletedAt !== null) {
178 return yield* new PullRequestThreadNotFoundError({ threadId: scope.threadId });186 return yield* new PullRequestThreadNotFoundError({ threadId });
179 }187 }
180 return thread.value;188 return thread.value;
181 });189 });
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
183 const projectOf = (232 const projectOf = (
184 thread: OrchestrationV2ThreadShell,233 thread: OrchestrationV2ThreadShell,
185 Failure:234 Failure:
...@@ -217,7 +266,7 @@ const make = Effect.gen(function* () {...@@ -217,7 +266,7 @@ const make = Effect.gen(function* () {
217 input: PullRequestTargetInput,266 input: PullRequestTargetInput,
218 watching: boolean,267 watching: boolean,
219 ) {268 ) {
220 const thread = yield* requireThread(PullRequestWatchFailedError);269 const thread = yield* requireWritableThread(PullRequestWatchFailedError, input.threadId);
221 const project = yield* projectOf(thread, PullRequestWatchFailedError);270 const project = yield* projectOf(thread, PullRequestWatchFailedError);
222 const target = yield* resolveTarget(input, project);271 const target = yield* resolveTarget(input, project);
223 const watchedLink = (shell: OrchestrationV2ThreadShell) =>272 const watchedLink = (shell: OrchestrationV2ThreadShell) =>
...@@ -241,7 +290,7 @@ const make = Effect.gen(function* () {...@@ -241,7 +290,7 @@ const make = Effect.gen(function* () {
241 ...(watching ? { link: { url: target.url, source: "agent" as const } } : {}),290 ...(watching ? { link: { url: target.url, source: "agent" as const } } : {}),
242 })291 })
243 .pipe(Effect.catchCause(dispatchFailure(PullRequestWatchFailedError)));292 .pipe(Effect.catchCause(dispatchFailure(PullRequestWatchFailedError)));
244 const after = yield* requireThread(PullRequestWatchFailedError);293 const after = yield* requireThread(PullRequestWatchFailedError, thread.id);
245 return {294 return {
246 host: target.host,295 host: target.host,
247 repository: target.repository,296 repository: target.repository,
...@@ -255,7 +304,7 @@ const make = Effect.gen(function* () {...@@ -255,7 +304,7 @@ const make = Effect.gen(function* () {
255 return PullRequestsToolkit.of({304 return PullRequestsToolkit.of({
256 link_pull_request: (input) =>305 link_pull_request: (input) =>
257 Effect.gen(function* () {306 Effect.gen(function* () {
258 const thread = yield* requireThread(PullRequestLinkFailedError);307 const thread = yield* requireWritableThread(PullRequestLinkFailedError, input.threadId);
259 const project = yield* projectOf(thread, PullRequestLinkFailedError);308 const project = yield* projectOf(thread, PullRequestLinkFailedError);
260 const target = yield* resolveTarget(input, project);309 const target = yield* resolveTarget(input, project);
261 const existing = threadPullRequestsOf(thread).find((link) =>310 const existing = threadPullRequestsOf(thread).find((link) =>
...@@ -285,7 +334,7 @@ const make = Effect.gen(function* () {...@@ -285,7 +334,7 @@ const make = Effect.gen(function* () {
285 }),334 }),
286 unlink_pull_request: (input) =>335 unlink_pull_request: (input) =>
287 Effect.gen(function* () {336 Effect.gen(function* () {
288 const thread = yield* requireThread(PullRequestUnlinkFailedError);337 const thread = yield* requireWritableThread(PullRequestUnlinkFailedError, input.threadId);
289 const project = yield* projectOf(thread, PullRequestUnlinkFailedError);338 const project = yield* projectOf(thread, PullRequestUnlinkFailedError);
290 const target = yield* resolveTarget(input, project);339 const target = yield* resolveTarget(input, project);
291 if (!threadPullRequestsOf(thread).some((link) => threadPullRequestKeysEqual(link, target)))340 if (!threadPullRequestsOf(thread).some((link) => threadPullRequestKeysEqual(link, target)))
...@@ -316,8 +365,10 @@ const make = Effect.gen(function* () {...@@ -316,8 +365,10 @@ const make = Effect.gen(function* () {
316 wasLinked,365 wasLinked,
317 };366 };
318 }),367 }),
319 list_thread_pull_requests: () =>368 list_thread_pull_requests: (input) =>
320 requireThread(PullRequestListFailedError).pipe(Effect.map(listThreadPullRequests)),369 requireThread(PullRequestListFailedError, input.threadId).pipe(
370 Effect.map(listThreadPullRequests),
371 ),
321 watch_pull_request: (input) => setWatching(input, true),372 watch_pull_request: (input) => setWatching(input, true),
322 unwatch_pull_request: (input) => setWatching(input, false),373 unwatch_pull_request: (input) => setWatching(input, false),
323 });374 });
apps/server/src/mcp/toolkits/pullRequests/tools.ts+32-1
...@@ -4,6 +4,7 @@ import {...@@ -4,6 +4,7 @@ import {
4 PullRequestState,4 PullRequestState,
5 ThreadPullRequestLinkSource,5 ThreadPullRequestLinkSource,
6 TrimmedNonEmptyString,6 TrimmedNonEmptyString,
7 ThreadId,
7} from "@t3tools/contracts";8} from "@t3tools/contracts";
8import * as Schema from "effect/Schema";9import * as Schema from "effect/Schema";
9import * as Tool from "effect/unstable/ai/Tool";10import * as Tool from "effect/unstable/ai/Tool";
...@@ -28,6 +29,11 @@ const REGISTER_EVERY_PR =...@@ -28,6 +29,11 @@ const REGISTER_EVERY_PR =
28 * the host CLI handed back.29 * the host CLI handed back.
29 */30 */
30export const PullRequestTargetInput = Schema.Struct({31export const PullRequestTargetInput = Schema.Struct({
32 threadId: Schema.optional(
33 ThreadId.annotate({
34 description: "Thread to act on. Omit for this thread.",
35 }),
36 ),
31 url: Schema.optional(37 url: Schema.optional(
32 TrimmedNonEmptyString.annotate({38 TrimmedNonEmptyString.annotate({
33 description:39 description:
...@@ -81,6 +87,24 @@ export class PullRequestHostRequiredError extends Schema.TaggedError<PullRequest...@@ -81,6 +87,24 @@ export class PullRequestHostRequiredError extends Schema.TaggedError<PullRequest
81 }87 }
82}88}
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
84export class PullRequestThreadNotFoundError extends Schema.TaggedError<PullRequestThreadNotFoundError>()(108export class PullRequestThreadNotFoundError extends Schema.TaggedError<PullRequestThreadNotFoundError>()(
85 "PullRequestThreadNotFoundError",109 "PullRequestThreadNotFoundError",
86 { threadId: Schema.String },110 { threadId: Schema.String },
...@@ -140,6 +164,8 @@ export const PullRequestToolError = Schema.Union([...@@ -140,6 +164,8 @@ export const PullRequestToolError = Schema.Union([
140 PullRequestUrlInvalidError,164 PullRequestUrlInvalidError,
141 PullRequestTargetIncompleteError,165 PullRequestTargetIncompleteError,
142 PullRequestHostRequiredError,166 PullRequestHostRequiredError,
167 PullRequestThreadRequiredError,
168 PullRequestThreadAboveLimitsError,
143 PullRequestThreadNotFoundError,169 PullRequestThreadNotFoundError,
144 PullRequestLinkFailedError,170 PullRequestLinkFailedError,
145 PullRequestUnlinkFailedError,171 PullRequestUnlinkFailedError,
...@@ -245,7 +271,12 @@ const UnlinkPullRequestTool = Tool.make("unlink_pull_request", {...@@ -245,7 +271,12 @@ const UnlinkPullRequestTool = Tool.make("unlink_pull_request", {
245 .annotate(Tool.OpenWorld, false);271 .annotate(Tool.OpenWorld, false);
246272
247const ListThreadPullRequestsTool = Tool.make("list_thread_pull_requests", {273const 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 }),
249 success: ListThreadPullRequestsResult,280 success: ListThreadPullRequestsResult,
250 failure: PullRequestToolError,281 failure: PullRequestToolError,
251 dependencies,282 dependencies,
apps/server/src/mcp/toolkits/thread/handlers.ts+23-20
...@@ -13,7 +13,7 @@ import { modelSelectionCommandType } from "@t3tools/shared/model";...@@ -13,7 +13,7 @@ import { modelSelectionCommandType } from "@t3tools/shared/model";
13import {13import {
14 newCommandId,14 newCommandId,
15 readCaller,15 readCaller,
16 readMutationCaller,16 readFullAccessCaller,
17 readThread,17 readThread,
18 readWritableThread,18 readWritableThread,
19 unavailable,19 unavailable,
...@@ -78,22 +78,15 @@ const readQuestion = Effect.fn("mcp.readQuestion")(function* (...@@ -78,22 +78,15 @@ const readQuestion = Effect.fn("mcp.readQuestion")(function* (
78export const ThreadToolkitHandlersLive = ThreadToolkit.toLayer({78export const ThreadToolkitHandlersLive = ThreadToolkit.toLayer({
79 run_scheduled_task_now: (input) =>79 run_scheduled_task_now: (input) =>
80 Effect.gen(function* () {80 Effect.gen(function* () {
81 const { caller } = yield* readMutationCaller();81 yield* readFullAccessCaller(
82 if (82 "Running a scheduled task requires a live full-access/default thread or a full-access client.",
83 caller.archivedAt !== null ||83 );
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 });
91 const scheduler = yield* ScheduledTasks.ScheduledTaskService;84 const scheduler = yield* ScheduledTasks.ScheduledTaskService;
92 const { tasks } = yield* scheduler.list().pipe(Effect.mapError(unavailable));85 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))
94 return yield* new OrchestratorMcpFailure({87 return yield* new OrchestratorMcpFailure({
95 code: "invalid_request",88 code: "invalid_request",
96 message: "The task was not found in the calling project.",89 message: "The scheduled task was not found.",
97 });90 });
98 const { task } = yield* scheduler91 const { task } = yield* scheduler
99 .runNow({ id: input.taskId })92 .runNow({ id: input.taskId })
...@@ -109,13 +102,22 @@ export const ThreadToolkitHandlersLive = ThreadToolkit.toLayer({...@@ -109,13 +102,22 @@ export const ThreadToolkitHandlersLive = ThreadToolkit.toLayer({
109 t3_thread_search: (input) =>102 t3_thread_search: (input) =>
110 Effect.gen(function* () {103 Effect.gen(function* () {
111 const { caller } = yield* readCaller();104 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;
112 const threadSearch = yield* ThreadSearch.ThreadSearch;109 const threadSearch = yield* ThreadSearch.ThreadSearch;
113 const result = yield* threadSearch.search(input).pipe(Effect.mapError(unavailable));110 const result = yield* threadSearch.search(query).pipe(Effect.mapError(unavailable));
114 return { matches: result.matches.filter((match) => match.projectId === caller.projectId) };111 return {
112 matches:
113 projectId === undefined
114 ? result.matches
115 : result.matches.filter((match) => match.projectId === projectId),
116 };
115 }),117 }),
116 t3_thread_fork: (input) =>118 t3_thread_fork: (input) =>
117 Effect.gen(function* () {119 Effect.gen(function* () {
118 const { threads, projection } = yield* readWritableThread();120 const { threads, projection } = yield* readWritableThread(input.threadId);
119 const commandId = yield* newCommandId();121 const commandId = yield* newCommandId();
120 const targetThreadId = ThreadId.make(`${commandId}:fork`);122 const targetThreadId = ThreadId.make(`${commandId}:fork`);
121 const result = yield* threads123 const result = yield* threads
...@@ -134,12 +136,13 @@ export const ThreadToolkitHandlersLive = ThreadToolkit.toLayer({...@@ -134,12 +136,13 @@ export const ThreadToolkitHandlersLive = ThreadToolkit.toLayer({
134 }),136 }),
135 t3_thread_merge_back: (input) =>137 t3_thread_merge_back: (input) =>
136 Effect.gen(function* () {138 Effect.gen(function* () {
137 const { threads, caller } = yield* readWritableThread(input.targetThreadId);139 const context = yield* readWritableThread(input.targetThreadId);
138 const result = yield* threads140 const source = yield* readWritableThread(input.sourceThreadId);
141 const result = yield* context.threads
139 .dispatch({142 .dispatch({
140 type: "thread.merge_back",143 type: "thread.merge_back",
141 commandId: yield* newCommandId(),144 commandId: yield* newCommandId(),
142 sourceThreadId: caller.id,145 sourceThreadId: source.projection.thread.id,
143 targetThreadId: input.targetThreadId,146 targetThreadId: input.targetThreadId,
144 sourcePoint: input.sourcePoint,147 sourcePoint: input.sourcePoint,
145 createdBy: "agent",148 createdBy: "agent",
...@@ -179,7 +182,7 @@ export const ThreadToolkitHandlersLive = ThreadToolkit.toLayer({...@@ -179,7 +182,7 @@ export const ThreadToolkitHandlersLive = ThreadToolkit.toLayer({
179 const {182 const {
180 threads,183 threads,
181 projection: { thread },184 projection: { thread },
182 } = yield* readWritableThread();185 } = yield* readWritableThread(input.threadId);
183 const type = modelSelectionCommandType(thread.providerInstanceId, input.modelSelection);186 const type = modelSelectionCommandType(thread.providerInstanceId, input.modelSelection);
184 const result = yield* threads187 const result = yield* threads
185 .dispatch({188 .dispatch({
apps/server/src/mcp/toolkits/thread/tools.ts+21-12
...@@ -17,6 +17,7 @@ import {...@@ -17,6 +17,7 @@ import {
17 ThreadId,17 ThreadId,
18 RunId,18 RunId,
19 NonNegativeInt,19 NonNegativeInt,
20 ProjectId,
20} from "@t3tools/contracts";21} from "@t3tools/contracts";
21import * as Crypto from "effect/Crypto";22import * as Crypto from "effect/Crypto";
22import * as Schema from "effect/Schema";23import * as Schema from "effect/Schema";
...@@ -29,7 +30,7 @@ import * as McpInvocationContext from "../../McpInvocationContext.ts";...@@ -29,7 +30,7 @@ import * as McpInvocationContext from "../../McpInvocationContext.ts";
2930
30const ThreadOrganizeTool = Tool.make("t3_thread_organize", {31const ThreadOrganizeTool = Tool.make("t3_thread_organize", {
31 description:32 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.",
33 parameters: Schema.Struct({34 parameters: Schema.Struct({
34 threadId: Schema.optional(ThreadId),35 threadId: Schema.optional(ThreadId),
35 action: Schema.Literals([36 action: Schema.Literals([
...@@ -91,7 +92,7 @@ const QueueListTool = Tool.make("t3_queue_list", {...@@ -91,7 +92,7 @@ const QueueListTool = Tool.make("t3_queue_list", {
91 .annotate(Tool.Destructive, false);92 .annotate(Tool.Destructive, false);
92const QueueReadTool = Tool.make("t3_queue_read", {93const QueueReadTool = Tool.make("t3_queue_read", {
93 ...commandTool,94 ...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.",
95 parameters: Schema.Struct(queueTarget),96 parameters: Schema.Struct(queueTarget),
96 success: queueEntry,97 success: queueEntry,
97})98})
...@@ -146,7 +147,7 @@ const pendingRequest = Schema.Struct({...@@ -146,7 +147,7 @@ const pendingRequest = Schema.Struct({
146const PendingRequestListTool = Tool.make("t3_pending_request_list", {147const PendingRequestListTool = Tool.make("t3_pending_request_list", {
147 ...commandTool,148 ...commandTool,
148 description:149 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.",
150 parameters: Schema.Struct({ threadId: Schema.optional(ThreadId) }),151 parameters: Schema.Struct({ threadId: Schema.optional(ThreadId) }),
151 success: Schema.Struct({ requestIds: Schema.Array(RuntimeRequestId) }),152 success: Schema.Struct({ requestIds: Schema.Array(RuntimeRequestId) }),
152})153})
...@@ -173,7 +174,7 @@ const PendingRequestRespondTool = Tool.make("t3_pending_request_respond", {...@@ -173,7 +174,7 @@ const PendingRequestRespondTool = Tool.make("t3_pending_request_respond", {
173const ThreadConfigurationTool = Tool.make("t3_thread_configuration", {174const ThreadConfigurationTool = Tool.make("t3_thread_configuration", {
174 ...commandTool,175 ...commandTool,
175 description:176 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.",
177 parameters: Schema.Struct({ threadId: Schema.optional(ThreadId) }),178 parameters: Schema.Struct({ threadId: Schema.optional(ThreadId) }),
178 success: Schema.Struct({179 success: Schema.Struct({
179 threadId: ThreadId,180 threadId: ThreadId,
...@@ -187,16 +188,20 @@ const ThreadConfigurationTool = Tool.make("t3_thread_configuration", {...@@ -187,16 +188,20 @@ const ThreadConfigurationTool = Tool.make("t3_thread_configuration", {
187const ThreadConfigureTool = Tool.make("t3_thread_configure", {188const ThreadConfigureTool = Tool.make("t3_thread_configure", {
188 ...commandTool,189 ...commandTool,
189 description:190 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 "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.",
191 parameters: Schema.Struct({ modelSelection: ModelSelection }),192 parameters: Schema.Struct({
193 threadId: Schema.optional(ThreadId),
194 modelSelection: ModelSelection,
195 }),
192}).annotate(Tool.Destructive, true);196}).annotate(Tool.Destructive, true);
193197
194const transferResult = Schema.Struct({ sequence: NonNegativeInt, targetThreadId: ThreadId });198const transferResult = Schema.Struct({ sequence: NonNegativeInt, targetThreadId: ThreadId });
195const ThreadForkTool = Tool.make("t3_thread_fork", {199const ThreadForkTool = Tool.make("t3_thread_fork", {
196 ...commandTool,200 ...commandTool,
197 description:201 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.",
199 parameters: Schema.Struct({203 parameters: Schema.Struct({
204 threadId: Schema.optional(ThreadId),
200 sourcePoint: OrchestrationV2ThreadForkSourcePoint,205 sourcePoint: OrchestrationV2ThreadForkSourcePoint,
201 title: Schema.optional(TrimmedNonEmptyString),206 title: Schema.optional(TrimmedNonEmptyString),
202 }),207 }),
...@@ -205,8 +210,9 @@ const ThreadForkTool = Tool.make("t3_thread_fork", {...@@ -205,8 +210,9 @@ const ThreadForkTool = Tool.make("t3_thread_fork", {
205const ThreadMergeBackTool = Tool.make("t3_thread_merge_back", {210const ThreadMergeBackTool = Tool.make("t3_thread_merge_back", {
206 ...commandTool,211 ...commandTool,
207 description:212 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.",
209 parameters: Schema.Struct({214 parameters: Schema.Struct({
215 sourceThreadId: Schema.optional(ThreadId),
210 targetThreadId: ThreadId,216 targetThreadId: ThreadId,
211 sourcePoint: OrchestrationV2ThreadForkSourcePoint,217 sourcePoint: OrchestrationV2ThreadForkSourcePoint,
212 }),218 }),
...@@ -214,7 +220,7 @@ const ThreadMergeBackTool = Tool.make("t3_thread_merge_back", {...@@ -214,7 +220,7 @@ const ThreadMergeBackTool = Tool.make("t3_thread_merge_back", {
214}).annotate(Tool.Destructive, true);220}).annotate(Tool.Destructive, true);
215const ThreadTransfersTool = Tool.make("t3_thread_transfers", {221const ThreadTransfersTool = Tool.make("t3_thread_transfers", {
216 ...commandTool,222 ...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.",
218 parameters: Schema.Struct({ threadId: Schema.optional(ThreadId) }),224 parameters: Schema.Struct({ threadId: Schema.optional(ThreadId) }),
219 success: Schema.Struct({225 success: Schema.Struct({
220 transfers: Schema.Array(226 transfers: Schema.Array(
...@@ -233,8 +239,11 @@ const ThreadTransfersTool = Tool.make("t3_thread_transfers", {...@@ -233,8 +239,11 @@ const ThreadTransfersTool = Tool.make("t3_thread_transfers", {
233const ThreadSearchTool = Tool.make("t3_thread_search", {239const ThreadSearchTool = Tool.make("t3_thread_search", {
234 ...commandTool,240 ...commandTool,
235 description:241 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.",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.",
237 parameters: OrchestrationSearchThreadsInput,243 parameters: Schema.Struct({
244 ...OrchestrationSearchThreadsInput.fields,
245 projectId: Schema.optional(ProjectId),
246 }),
238 success: OrchestrationSearchThreadsResult,247 success: OrchestrationSearchThreadsResult,
239 dependencies: [...commandTool.dependencies, ThreadSearch.ThreadSearch],248 dependencies: [...commandTool.dependencies, ThreadSearch.ThreadSearch],
240})249})
...@@ -244,7 +253,7 @@ const ThreadSearchTool = Tool.make("t3_thread_search", {...@@ -244,7 +253,7 @@ const ThreadSearchTool = Tool.make("t3_thread_search", {
244const ScheduledTaskRunTool = Tool.make("run_scheduled_task_now", {253const ScheduledTaskRunTool = Tool.make("run_scheduled_task_now", {
245 ...commandTool,254 ...commandTool,
246 description:255 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.",
248 parameters: Schema.Struct({ taskId: ScheduledTaskId }),257 parameters: Schema.Struct({ taskId: ScheduledTaskId }),
249 success: Schema.Struct({258 success: Schema.Struct({
250 taskId: ScheduledTaskId,259 taskId: ScheduledTaskId,
apps/server/src/mcp/toolkits/worktree/handlers.ts+7-4
...@@ -2,7 +2,7 @@ import { OrchestratorMcpFailure } from "@t3tools/contracts";...@@ -2,7 +2,7 @@ import { OrchestratorMcpFailure } from "@t3tools/contracts";
2import * as Option from "effect/Option";2import * as Option from "effect/Option";
3import * as GitWorkflow from "../../../git/GitWorkflowService.ts";3import * as GitWorkflow from "../../../git/GitWorkflowService.ts";
4import * as Project from "../../../project/ProjectService.ts";4import * as Project from "../../../project/ProjectService.ts";
5import { readCaller, unavailable } from "../../threadAccess.ts";5import { readThread, unavailable } from "../../threadAccess.ts";
6import * as Effect from "effect/Effect";6import * as Effect from "effect/Effect";
77
8import * as McpInvocationContext from "../../McpInvocationContext.ts";8import * as McpInvocationContext from "../../McpInvocationContext.ts";
...@@ -18,9 +18,12 @@ const handlers = {...@@ -18,9 +18,12 @@ const handlers = {
18 code: "capability_denied",18 code: "capability_denied",
19 message: "This credential cannot inspect worktrees.",19 message: "This credential cannot inspect worktrees.",
20 });20 });
21 const { caller } = yield* readCaller();21 const { threadId, ...refs } = input;
22 const {
23 projection: { thread },
24 } = yield* readThread(threadId);
22 const projects = yield* Project.ProjectService;25 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));
24 if (Option.isNone(project))27 if (Option.isNone(project))
25 return yield* new OrchestratorMcpFailure({28 return yield* new OrchestratorMcpFailure({
26 code: "invalid_request",29 code: "invalid_request",
...@@ -28,7 +31,7 @@ const handlers = {...@@ -28,7 +31,7 @@ const handlers = {
28 });31 });
29 const git = yield* GitWorkflow.GitWorkflowService;32 const git = yield* GitWorkflow.GitWorkflowService;
30 return yield* git33 return yield* git
31 .listRefs({ ...input, cwd: caller.worktreePath ?? project.value.workspaceRoot })34 .listRefs({ ...refs, cwd: thread.worktreePath ?? project.value.workspaceRoot })
32 .pipe(Effect.mapError(unavailable));35 .pipe(Effect.mapError(unavailable));
33 }),36 }),
34 t3_worktree_handoff: (input) =>37 t3_worktree_handoff: (input) =>
apps/server/src/mcp/toolkits/worktree/tools.ts+5-3
...@@ -6,6 +6,7 @@ import {...@@ -6,6 +6,7 @@ import {
6 WorktreeMcpHandoffInput,6 WorktreeMcpHandoffInput,
7 WorktreeMcpHandoffResult,7 WorktreeMcpHandoffResult,
8 WorktreeMcpStatusResult,8 WorktreeMcpStatusResult,
9 ThreadId,
9} from "@t3tools/contracts";10} from "@t3tools/contracts";
10import * as Schema from "effect/Schema";11import * as Schema from "effect/Schema";
11import * as GitWorkflowService from "../../../git/GitWorkflowService.ts";12import * as GitWorkflowService from "../../../git/GitWorkflowService.ts";
...@@ -23,7 +24,7 @@ const dependencies = [...@@ -23,7 +24,7 @@ const dependencies = [
2324
24const WorktreeHandoffTool = Tool.make("t3_worktree_handoff", {25const WorktreeHandoffTool = Tool.make("t3_worktree_handoff", {
25 description:26 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.",
27 parameters: WorktreeMcpHandoffInput,28 parameters: WorktreeMcpHandoffInput,
28 success: WorktreeMcpHandoffResult,29 success: WorktreeMcpHandoffResult,
29 failure: WorktreeMcpFailure,30 failure: WorktreeMcpFailure,
...@@ -38,7 +39,7 @@ const WorktreeHandoffTool = Tool.make("t3_worktree_handoff", {...@@ -38,7 +39,7 @@ const WorktreeHandoffTool = Tool.make("t3_worktree_handoff", {
3839
39const WorktreeStatusTool = Tool.make("t3_worktree_status", {40const WorktreeStatusTool = Tool.make("t3_worktree_status", {
40 description:41 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.",
42 // No `parameters`: Tool.make defaults to Tool.EmptyParams, which serializes43 // No `parameters`: Tool.make defaults to Tool.EmptyParams, which serializes
43 // to a top-level `type: "object"` JSON Schema. An explicit empty44 // to a top-level `type: "object"` JSON Schema. An explicit empty
44 // Schema.Struct({}) serializes to `anyOf: [object, array]`, which is not a45 // Schema.Struct({}) serializes to `anyOf: [object, array]`, which is not a
...@@ -56,8 +57,9 @@ const WorktreeStatusTool = Tool.make("t3_worktree_status", {...@@ -56,8 +57,9 @@ const WorktreeStatusTool = Tool.make("t3_worktree_status", {
5657
57const WorktreeListTool = Tool.make("t3_worktree_list", {58const WorktreeListTool = Tool.make("t3_worktree_list", {
58 description:59 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.",
60 parameters: Schema.Struct({61 parameters: Schema.Struct({
62 threadId: Schema.optional(ThreadId),
61 query: VcsListRefsInput.fields.query,63 query: VcsListRefsInput.fields.query,
62 cursor: VcsListRefsInput.fields.cursor,64 cursor: VcsListRefsInput.fields.cursor,
63 limit: VcsListRefsInput.fields.limit,65 limit: VcsListRefsInput.fields.limit,
apps/server/src/orchestration-v2/ProviderSessionManager.test.ts+6-6
...@@ -1082,7 +1082,7 @@ it.effect(...@@ -1082,7 +1082,7 @@ it.effect(
1082 const token = captured?.authorizationHeader.replace(/^Bearer\s+/, "");1082 const token = captured?.authorizationHeader.replace(/^Bearer\s+/, "");
1083 assert.isDefined(token);1083 assert.isDefined(token);
1084 const resolved = yield* registry.resolve(token!);1084 const resolved = yield* registry.resolve(token!);
1085 assert.equal(resolved?.threadId, threadId);1085 assert.equal(resolved?.thread.threadId, threadId);
1086 assert.deepEqual(1086 assert.deepEqual(
1087 resolved?.capabilities,1087 resolved?.capabilities,
1088 new Set(["preview", "orchestration", "worktree", "pull-requests"]),1088 new Set(["preview", "orchestration", "worktree", "pull-requests"]),
...@@ -1317,7 +1317,7 @@ it.effect("ProviderSessionManagerV2 duplicate detach preserves replacement MCP c...@@ -1317,7 +1317,7 @@ it.effect("ProviderSessionManagerV2 duplicate detach preserves replacement MCP c
1317 McpProviderSession.readMcpProviderSession(threadId)?.providerSessionId,1317 McpProviderSession.readMcpProviderSession(threadId)?.providerSessionId,
1318 replacement?.providerSessionId,1318 replacement?.providerSessionId,
1319 );1319 );
1320 assert.equal((yield* registry.resolve(replacementToken!))?.threadId, threadId);1320 assert.equal((yield* registry.resolve(replacementToken!))?.thread.threadId, threadId);
1321 });1321 });
13221322
1323 yield* effect.pipe(1323 yield* effect.pipe(
...@@ -1393,7 +1393,7 @@ it.effect(...@@ -1393,7 +1393,7 @@ it.effect(
1393 McpProviderSession.readMcpProviderSession(threadId)?.providerSessionId,1393 McpProviderSession.readMcpProviderSession(threadId)?.providerSessionId,
1394 replacement?.providerSessionId,1394 replacement?.providerSessionId,
1395 );1395 );
1396 assert.equal((yield* registry.resolve(replacementToken!))?.threadId, threadId);1396 assert.equal((yield* registry.resolve(replacementToken!))?.thread.threadId, threadId);
1397 });1397 });
13981398
1399 yield* effect.pipe(1399 yield* effect.pipe(
...@@ -1449,7 +1449,7 @@ it.effect(...@@ -1449,7 +1449,7 @@ it.effect(
1449 // process's MCP client keeps using the credential it was started with.1449 // process's MCP client keeps using the credential it was started with.
1450 yield* manager.detach({ providerSessionId, threadId, detail: "Workspace changed." });1450 yield* manager.detach({ providerSessionId, threadId, detail: "Workspace changed." });
1451 assert.equal(1451 assert.equal(
1452 (yield* registry.resolve(originalToken!))?.threadId,1452 (yield* registry.resolve(originalToken!))?.thread.threadId,
1453 threadId,1453 threadId,
1454 "detach must not revoke the credential the live provider process still holds",1454 "detach must not revoke the credential the live provider process still holds",
1455 );1455 );
...@@ -1468,7 +1468,7 @@ it.effect(...@@ -1468,7 +1468,7 @@ it.effect(
1468 original?.providerSessionId,1468 original?.providerSessionId,
1469 "re-attach must reuse the existing credential, not rotate it",1469 "re-attach must reuse the existing credential, not rotate it",
1470 );1470 );
1471 assert.equal((yield* registry.resolve(originalToken!))?.threadId, threadId);1471 assert.equal((yield* registry.resolve(originalToken!))?.thread.threadId, threadId);
14721472
1473 // Releasing the session (provider process gone) still revokes.1473 // Releasing the session (provider process gone) still revokes.
1474 yield* manager.close(providerSessionId);1474 yield* manager.close(providerSessionId);
...@@ -1592,7 +1592,7 @@ it.effect(...@@ -1592,7 +1592,7 @@ it.effect(
1592 "the credential the adapter was configured with must remain current",1592 "the credential the adapter was configured with must remain current",
1593 );1593 );
1594 assert.equal(1594 assert.equal(
1595 (yield* registry.resolve(originalToken!))?.threadId,1595 (yield* registry.resolve(originalToken!))?.thread.threadId,
1596 threadId,1596 threadId,
1597 "the predecessor release must not revoke a credential reserved by an in-flight open",1597 "the predecessor release must not revoke a credential reserved by an in-flight open",
1598 );1598 );
apps/server/src/orchestration-v2/ProviderSessionManager.ts+2-2
...@@ -468,8 +468,8 @@ export const layerWithOptions = (...@@ -468,8 +468,8 @@ export const layerWithOptions = (
468 const resolved = yield* mcpSessionRegistry.resolve(rawToken);468 const resolved = yield* mcpSessionRegistry.resolve(rawToken);
469 if (469 if (
470 resolved !== undefined &&470 resolved !== undefined &&
471 resolved.threadId === threadId &&471 resolved.thread.threadId === threadId &&
472 resolved.providerInstanceId === providerInstanceId &&472 resolved.thread.providerInstanceId === providerInstanceId &&
473 // A flipped browser-access setting must not survive through473 // A flipped browser-access setting must not survive through
474 // credential reuse: rotate so the new scope reflects it.474 // credential reuse: rotate so the new scope reflects it.
475 resolved.capabilities.has("preview") === browserToolsAvailable &&475 resolved.capabilities.has("preview") === browserToolsAvailable &&
packages/contracts/src/orchestratorMcp.ts+29-4
...@@ -280,7 +280,15 @@ export const OrchestratorMcpThreadStatus = Schema.Union([...@@ -280,7 +280,15 @@ export const OrchestratorMcpThreadStatus = Schema.Union([
280]);280]);
281export type OrchestratorMcpThreadStatus = typeof OrchestratorMcpThreadStatus.Type;281export 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
283export const OrchestratorMcpThreadListInput = Schema.Struct({290export const OrchestratorMcpThreadListInput = Schema.Struct({
291 projectId: OrchestratorMcpProjectTarget,
284 statuses: Schema.optional(292 statuses: Schema.optional(
285 Schema.Array(OrchestratorMcpThreadStatus).check(Schema.isMaxLength(10)),293 Schema.Array(OrchestratorMcpThreadStatus).check(Schema.isMaxLength(10)),
286 ),294 ),
...@@ -316,7 +324,8 @@ export type OrchestratorMcpThreadListItem = typeof OrchestratorMcpThreadListItem...@@ -316,7 +324,8 @@ export type OrchestratorMcpThreadListItem = typeof OrchestratorMcpThreadListItem
316324
317export const OrchestratorMcpThreadListResult = Schema.Struct({325export const OrchestratorMcpThreadListResult = Schema.Struct({
318 projectId: ProjectId,326 projectId: ProjectId,
319 currentThreadId: ThreadId,327 /** The calling thread, or null when the caller is not a T3 thread. */
328 currentThreadId: Schema.NullOr(ThreadId),
320 threads: Schema.Array(OrchestratorMcpThreadListItem),329 threads: Schema.Array(OrchestratorMcpThreadListItem),
321 nextCursor: Schema.NullOr(NonNegativeInt),330 nextCursor: Schema.NullOr(NonNegativeInt),
322 total: NonNegativeInt,331 total: NonNegativeInt,
...@@ -475,9 +484,11 @@ export const OrchestratorMcpProviderCapability = Schema.Struct({...@@ -475,9 +484,11 @@ export const OrchestratorMcpProviderCapability = Schema.Struct({
475export type OrchestratorMcpProviderCapability = typeof OrchestratorMcpProviderCapability.Type;484export type OrchestratorMcpProviderCapability = typeof OrchestratorMcpProviderCapability.Type;
476485
477export const OrchestratorMcpCapabilitiesResult = Schema.Struct({486export const OrchestratorMcpCapabilitiesResult = Schema.Struct({
478 parentThreadId: ThreadId,487 /** The calling thread, or null when the caller is not a T3 thread. */
479 inheritedProviderInstanceId: ProviderInstanceId,488 parentThreadId: Schema.NullOr(ThreadId),
480 inheritedModel: Schema.String,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),
481 runtimeMode: RuntimeMode,492 runtimeMode: RuntimeMode,
482 interactionMode: ProviderInteractionMode,493 interactionMode: ProviderInteractionMode,
483 providers: Schema.Array(OrchestratorMcpProviderCapability),494 providers: Schema.Array(OrchestratorMcpProviderCapability),
...@@ -495,6 +506,7 @@ export const OrchestratorMcpCapabilitiesResult = Schema.Struct({...@@ -495,6 +506,7 @@ export const OrchestratorMcpCapabilitiesResult = Schema.Struct({
495export type OrchestratorMcpCapabilitiesResult = typeof OrchestratorMcpCapabilitiesResult.Type;506export type OrchestratorMcpCapabilitiesResult = typeof OrchestratorMcpCapabilitiesResult.Type;
496507
497export const OrchestratorMcpScheduleTaskInput = Schema.Struct({508export const OrchestratorMcpScheduleTaskInput = Schema.Struct({
509 projectId: OrchestratorMcpProjectTarget,
498 prompt: OrchestratorMcpPrompt.annotate({510 prompt: OrchestratorMcpPrompt.annotate({
499 description: "Prompt executed on every scheduled run.",511 description: "Prompt executed on every scheduled run.",
500 }),512 }),
...@@ -535,6 +547,17 @@ export type OrchestratorMcpScheduledTask = typeof OrchestratorMcpScheduledTask.T...@@ -535,6 +547,17 @@ export type OrchestratorMcpScheduledTask = typeof OrchestratorMcpScheduledTask.T
535export const OrchestratorMcpScheduleTaskResult = OrchestratorMcpScheduledTask;547export const OrchestratorMcpScheduleTaskResult = OrchestratorMcpScheduledTask;
536export type OrchestratorMcpScheduleTaskResult = typeof OrchestratorMcpScheduleTaskResult.Type;548export 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
538export const OrchestratorMcpListScheduledTasksResult = Schema.Struct({561export const OrchestratorMcpListScheduledTasksResult = Schema.Struct({
539 tasks: Schema.Array(OrchestratorMcpScheduledTask),562 tasks: Schema.Array(OrchestratorMcpScheduledTask),
540});563});
...@@ -583,6 +606,8 @@ export class OrchestratorMcpFailure extends Schema.TaggedError<OrchestratorMcpFa...@@ -583,6 +606,8 @@ export class OrchestratorMcpFailure extends Schema.TaggedError<OrchestratorMcpFa
583 "thread_not_interruptible",606 "thread_not_interruptible",
584 "invalid_request",607 "invalid_request",
585 "orchestration_error",608 "orchestration_error",
609 "thread_credential_required",
610 "target_required",
586 ]),611 ]),
587 message: Schema.String,612 message: Schema.String,
588 },613 },
packages/contracts/src/previewAutomation.ts+4-3
...@@ -642,11 +642,12 @@ export const PreviewAutomationResponse = Schema.Struct({...@@ -642,11 +642,12 @@ export const PreviewAutomationResponse = Schema.Struct({
642});642});
643export type PreviewAutomationResponse = typeof PreviewAutomationResponse.Type;643export type PreviewAutomationResponse = typeof PreviewAutomationResponse.Type;
644644
645// Thread fields are absent when the caller signed in from outside a T3 thread.
645const McpCapabilityErrorFields = {646const McpCapabilityErrorFields = {
646 environmentId: EnvironmentId,647 environmentId: EnvironmentId,
647 threadId: ThreadId,648 threadId: Schema.optional(ThreadId),
648 providerSessionId: TrimmedNonEmptyString,649 providerSessionId: Schema.optional(TrimmedNonEmptyString),
649 providerInstanceId: ProviderInstanceId,650 providerInstanceId: Schema.optional(ProviderInstanceId),
650};651};
651652
652/** Agents read this message, so it names the next step and not only the failure. */653/** 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...@@ -80,7 +80,7 @@ export type ThreadMetadataMcpPullRequest = typeof ThreadMetadataMcpPullRequest.T
8080
81export const ThreadMetadataMcpUpdateInput = Schema.Struct({81export const ThreadMetadataMcpUpdateInput = Schema.Struct({
82 threadId: Schema.optional(ThreadId).annotate({82 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.",
84 }),84 }),
85 action: ThreadMetadataMcpAction,85 action: ThreadMetadataMcpAction,
86 title: Schema.optional(ThreadMetadataTitle),86 title: Schema.optional(ThreadMetadataTitle),
packages/contracts/src/worktreeMcp.ts+1
...@@ -121,6 +121,7 @@ export class WorktreeMcpFailure extends Schema.TaggedError<WorktreeMcpFailure>()...@@ -121,6 +121,7 @@ export class WorktreeMcpFailure extends Schema.TaggedError<WorktreeMcpFailure>()
121 "handoff_in_progress",121 "handoff_in_progress",
122 "invalid_request",122 "invalid_request",
123 "operation_failed",123 "operation_failed",
124 "thread_credential_required",
124 ]),125 ]),
125 message: Schema.String,126 message: Schema.String,
126 },127 },