| author | |
| committer | |
| log | 06e627448bc1ee58487c1a2a9d6bf8d5ef479af3 |
| tree | 8bc704e49af7ebd5eac4dbe3cc31c82bc4ac1627 |
| parent | 37de6cbde65c7cf9ba90a2557c232e63b7e16988 |
| signature | Signed by PGP key B5690EEEBB952194 |
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"); |
| 19 | const threadId = ThreadId.make("thread-device-test"); | 19 | const threadId = ThreadId.make("thread-device-test"); |
| 20 | const invocation = (capabilities: ReadonlyArray<McpInvocationContext.McpCapability>) => ({ | 20 | const 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"); |
| 27 | const decodeJsonText = Schema.decodeUnknownSync(Schema.fromJsonString(Schema.Unknown)); | 27 | const decodeJsonText = Schema.decodeUnknownSync(Schema.fromJsonString(Schema.Unknown)); |
| 28 | const invocation = { | 28 | const 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"; |
| 13 | it.effect("reports the scoped credential context when preview capability is unavailable", () => { | 13 | it.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 |
| 42 | it.effect("reports other missing capabilities with the neutral error", () => { | 46 | it.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 | ); |
| 57 | 65 | ||
| 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 | }); | ||
| 60 | 71 | ||
| 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 | |||
| 79 | it.effect("refuses thread-owned capabilities to a caller signed in from outside a thread", () => { | ||
| 80 | const invocation: McpInvocationContext.McpInvocationScope = { | ||
| 81 | environmentId: EnvironmentId.make("environment-1"), | ||
| 82 | requestNamespace: "client:session-1", | ||
| 83 | thread: undefined, | ||
| 84 | client: { sessionId: "session-1", label: "Claude Code", runtimeModeCeiling: "auto" }, | ||
| 85 | capabilities: new Set(["preview", "orchestration"]), | ||
| 86 | issuedAt: 1, | ||
| 87 | }; | ||
| 88 | |||
| 89 | return Effect.gen(function* () { | ||
| 90 | const error = yield* McpInvocationContext.requireThreadMcpCapability("preview").pipe( | ||
| 91 | Effect.provideService(McpInvocationContext.McpInvocationContext, invocation), | ||
| 92 | Effect.flip, | ||
| 93 | ); | ||
| 94 | expect(error).toBeInstanceOf(PreviewAutomationUnavailableError); | ||
| 95 | expect(error).toMatchObject({ capability: "preview", environmentId: "environment-1" }); | ||
| 96 | expect(error.threadId).toBeUndefined(); | ||
| 97 | }); | ||
| 98 | }); |
apps/server/src/mcp/McpInvocationContext.ts+64-5| ... | @@ -1,8 +1,10 @@ | ... | @@ -1,8 +1,10 @@ |
| 1 | import { | 1 | import { |
| 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"; |
| 8 | import * as Context from "effect/Context"; | 10 | import * 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; |
| 18 | export type McpCapability = (typeof ALL_MCP_CAPABILITIES)[number]; | 20 | export type McpCapability = (typeof ALL_MCP_CAPABILITIES)[number]; |
| 19 | 21 | ||
| 20 | export interface McpInvocationScope { | 22 | /** A provider session T3 Code launched for one thread. */ |
| 21 | readonly environmentId: EnvironmentId; | 23 | export 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. */ | ||
| 30 | export interface McpClientCaller { | ||
| 31 | readonly sessionId: string; | ||
| 32 | readonly label: string; | ||
| 33 | readonly runtimeModeCeiling: RuntimeMode; | ||
| 34 | } | ||
| 35 | |||
| 36 | /** | ||
| 37 | * Who is calling and what they may do. Tool parameters choose the target | ||
| 38 | * (thread, project); the caller sets the limits. A thread caller's omitted | ||
| 39 | * target falls back to its own thread; a client caller has no own thread, so | ||
| 40 | * tools that act as the caller (delegate_task, preview, worktree handoff) | ||
| 41 | * need `thread`. | ||
| 42 | */ | ||
| 43 | export interface McpInvocationScope { | ||
| 44 | readonly environmentId: EnvironmentId; | ||
| 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 | } |
| 28 | 52 | ||
| 29 | export class McpInvocationContext extends Context.Service< | 53 | export 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 | */ | ||
| 99 | export const requireThreadMcpCapability = <const C extends "preview" | "device">( | ||
| 100 | capability: C, | ||
| 101 | ): Effect.Effect<McpThreadInvocationScope, McpCapabilityError<C>, McpInvocationContext> => | ||
| 102 | McpInvocationContext.pipe( | ||
| 103 | Effect.filterOrFail( | ||
| 104 | (invocation): invocation is McpThreadInvocationScope => | ||
| 105 | invocation.capabilities.has(capability) && invocation.thread !== undefined, | ||
| 106 | (invocation) => missingCapability(invocation, capability) as McpCapabilityError<C>, | ||
| 107 | ), | ||
| 108 | Effect.withSpan("mcp.requireCapability"), | ||
| 109 | ); | ||
| 110 | |||
| 111 | const threadCallerRequired = (operation: string) => | ||
| 112 | new OrchestratorMcpFailure({ | ||
| 113 | code: "thread_credential_required", | ||
| 114 | message: `${operation} acts as the calling T3 thread, so it needs an agent running inside T3 Code. This MCP client signed in from outside a thread.`, | ||
| 115 | }); | ||
| 116 | |||
| 117 | /** A scope with a thread caller, for tools whose whole surface acts as the caller. */ | ||
| 118 | export type McpThreadInvocationScope = McpInvocationScope & { readonly thread: McpThreadCaller }; | ||
| 119 | |||
| 120 | export 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); |
| 48 | 48 | ||
| 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 | } |
| 160 | 160 | ||
| 161 | expect((yield* registry.resolve(token))?.threadId).toBe(threadId); | 161 | expect((yield* registry.resolve(token))?.thread.threadId).toBe(threadId); |
| 162 | }), | 162 | }), |
| 163 | ); | 163 | ); |
| 164 | 164 |
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`. Provider | 37 | * 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 its | 38 | * 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 | McpSessionRegistryShape | 49 | McpSessionRegistryShape |
| 50 | >()("t3/mcp/McpSessionRegistry") {} | 50 | >()("t3/mcp/McpSessionRegistry") {} |
| 51 | 51 | ||
| 52 | /** Registry credentials always belong to a provider session, so their scope has a thread. */ | ||
| 52 | interface CredentialRecord { | 53 | interface 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 | } |
| 57 | 58 | ||
| ... | @@ -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"; |
| 11 | import * as DateTime from "effect/DateTime"; | 13 | import * 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"; |
| 16 | 18 | ||
| 17 | import * as ProviderAdapterRegistry from "../orchestration-v2/ProviderAdapterRegistry.ts"; | 19 | import * as ProviderAdapterRegistry from "../orchestration-v2/ProviderAdapterRegistry.ts"; |
| 18 | import * as ProviderRegistry from "../provider/Services/ProviderRegistry.ts"; | 20 | import * as ProviderRegistry from "../provider/Services/ProviderRegistry.ts"; |
| 21 | import * as ProjectService from "../project/ProjectService.ts"; | ||
| 19 | import * as ScheduledTaskService from "../scheduledTasks/ScheduledTaskService.ts"; | 22 | import * as ScheduledTaskService from "../scheduledTasks/ScheduledTaskService.ts"; |
| 20 | import * as ThreadManagementService from "../orchestration-v2/ThreadManagementService.ts"; | 23 | import * as ThreadManagementService from "../orchestration-v2/ThreadManagementService.ts"; |
| 21 | import type * as McpInvocationContext from "./McpInvocationContext.ts"; | 24 | import type * as McpInvocationContext from "./McpInvocationContext.ts"; |
| ... | @@ -37,9 +40,13 @@ const parentInstanceId = ProviderInstanceId.make("codex"); | ... | @@ -37,9 +40,13 @@ const parentInstanceId = ProviderInstanceId.make("codex"); |
| 37 | 40 | ||
| 38 | const makeScope = (): McpInvocationContext.McpInvocationScope => ({ | 41 | const 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 === parentThreadId | 134 | 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 === parentThreadId | 198 | 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 | }); |
| 319 | 349 | ||
| 320 | it("readThread reaches a thread the user attached as context, but not one an agent attached", async () => { | 350 | it("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 |
| 441 | 470 | ||
| 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"]); | ||
| 447 | 477 | ||
| 448 | const denied = yield* service | 478 | 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); | ||
| 452 | 483 | ||
| 453 | const write = yield* service | 484 | // 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 |
| 22 | import * as ThreadManagementService from "../orchestration-v2/ThreadManagementService.ts"; | 22 | import * as ThreadManagementService from "../orchestration-v2/ThreadManagementService.ts"; |
| 23 | import * as ProviderRegistry from "../provider/Services/ProviderRegistry.ts"; | 23 | import * as ProviderRegistry from "../provider/Services/ProviderRegistry.ts"; |
| 24 | import { buildUnavailableProviderSnapshot } from "../provider/unavailableProviderSnapshot.ts"; | 24 | import { buildUnavailableProviderSnapshot } from "../provider/unavailableProviderSnapshot.ts"; |
| 25 | import * as ProjectService from "../project/ProjectService.ts"; | ||
| 25 | import * as ScheduledTaskService from "../scheduledTasks/ScheduledTaskService.ts"; | 26 | import * as ScheduledTaskService from "../scheduledTasks/ScheduledTaskService.ts"; |
| 26 | import type { McpInvocationScope } from "./McpInvocationContext.ts"; | 27 | import type { McpInvocationScope } from "./McpInvocationContext.ts"; |
| 27 | import * as OrchestratorMcpService from "./OrchestratorMcpService.ts"; | 28 | import * 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", () => { |
| 460 | 486 | ||
| 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 | ); |
| 618 | 649 | ||
| ... | @@ -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 | ); |
| 761 | 793 | ||
| ... | @@ -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 | ); |
| 854 | 887 | ||
| ... | @@ -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 | ); |
| 902 | 936 | ||
| ... | @@ -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 | ); |
| 1065 | 1100 |
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"; |
| 70 | import * as ThreadManagementService from "../orchestration-v2/ThreadManagementService.ts"; | 72 | import * as ThreadManagementService from "../orchestration-v2/ThreadManagementService.ts"; |
| 73 | import * as ProjectService from "../project/ProjectService.ts"; | ||
| 71 | import * as ProviderRegistry from "../provider/Services/ProviderRegistry.ts"; | 74 | import * as ProviderRegistry from "../provider/Services/ProviderRegistry.ts"; |
| 72 | import * as ScheduledTaskService from "../scheduledTasks/ScheduledTaskService.ts"; | 75 | import * as ScheduledTaskService from "../scheduledTasks/ScheduledTaskService.ts"; |
| 73 | import type { McpInvocationScope } from "./McpInvocationContext.ts"; | 76 | import { |
| 77 | type McpInvocationScope, | ||
| 78 | type McpThreadInvocationScope, | ||
| 79 | requireThreadScope, | ||
| 80 | } from "./McpInvocationContext.ts"; | ||
| 74 | 81 | ||
| 75 | const DEFAULT_WAIT_TIMEOUT_MS = 10 * 60 * 1_000; | 82 | const DEFAULT_WAIT_TIMEOUT_MS = 10 * 60 * 1_000; |
| 76 | const MAX_WAIT_TIMEOUT_MS = 60 * 60 * 1_000; | 83 | const 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); | ||
| 763 | 800 | ||
| 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)); |
| 819 | 856 | ||
| 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.threadId | 925 | caller.parent !== undefined && threadId === caller.parent.thread.id |
| 826 | ? parent | 926 | ? 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 | }); |
| 830 | 930 | ||
| 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 | }; |
| 848 | 967 | ||
| 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 | threadManagement | 973 | .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* threadManagement | 977 | 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 | }); |
| 877 | 986 | ||
| ... | @@ -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); |
| 1011 | 1120 | ||
| 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* threadManagement | 1143 | 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.providerInstanceId | 1260 | observingRun?.providerInstanceId === scope.thread.providerInstanceId |
| 1152 | ? observingRun.id | 1261 | ? 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 | }); |
| 1166 | 1275 | ||
| 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))); |
| 1175 | 1284 | ||
| 1176 | // Load a single scheduled task and enforce that it belongs to the calling | 1285 | /** |
| 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* scheduledTasks | 1298 | 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 | }); |
| 1199 | 1313 | ||
| 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 callers | 1354 | // 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* scheduledTasks | 1379 | 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: tasks | 1387 | 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 === undefined | 1409 | input.bindToCurrentThread === undefined |
| 1271 | ? existing.threadId | 1410 | ? existing.threadId |
| 1272 | : input.bindToCurrentThread | 1411 | : input.bindToCurrentThread && parent !== undefined |
| 1273 | ? scope.threadId | 1412 | ? parent.thread.id |
| 1274 | : null; | 1413 | : null; |
| 1275 | // Rebinding changes where runs execute, so the workspace strategy must | 1414 | // Rebinding changes where runs execute, so the workspace strategy must |
| 1276 | // follow: unbinding a root-strategy task would otherwise run loose | 1415 | // 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* scheduledTasks | 1450 | 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.runs | 1507 | 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.providerInstanceId | 1513 | 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.providerInstanceId | 1722 | 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* threadManagement | 1865 | 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); | ||
| 1851 | 2001 | ||
| 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* threadManagement | 2009 | 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* threadManagement | 2047 | 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* threadManagement | 2073 | 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.ProviderRegistry | 2116 | | ProviderRegistry.ProviderRegistry |
| 1963 | | ProviderAdapterRegistry.ProviderAdapterRegistryV2 | 2117 | | ProviderAdapterRegistry.ProviderAdapterRegistryV2 |
| 1964 | | ScheduledTaskService.ScheduledTaskService | 2118 | | 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"; |
| 73 | import { makeProviderRegistryLayer } from "../provider/testUtils/providerRegistryMock.ts"; | 73 | import { makeProviderRegistryLayer } from "../provider/testUtils/providerRegistryMock.ts"; |
| 74 | import * as ProjectService from "../project/ProjectService.ts"; | ||
| 74 | import * as ScheduledTaskService from "../scheduledTasks/ScheduledTaskService.ts"; | 75 | import * as ScheduledTaskService from "../scheduledTasks/ScheduledTaskService.ts"; |
| 75 | import * as McpHttpServer from "./McpHttpServer.ts"; | 76 | import * as McpHttpServer from "./McpHttpServer.ts"; |
| 76 | import * as McpInvocationContext from "./McpInvocationContext.ts"; | 77 | import * 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 | ); |
| 636 | 647 | ||
| ... | @@ -676,9 +687,13 @@ describe("orchestrator MCP toolkit", () => { | ... | @@ -676,9 +687,13 @@ describe("orchestrator MCP toolkit", () => { |
| 676 | 687 | ||
| 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(); | ||
| 2337 | 2351 | ||
| 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 | ); |
| 3553 | 3576 | ||
| ... | @@ -3602,9 +3625,13 @@ describe("orchestrator MCP toolkit", () => { | ... | @@ -3602,9 +3625,13 @@ describe("orchestrator MCP toolkit", () => { |
| 3602 | 3625 | ||
| 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 |
| 36 | 36 | ||
| 37 | const scope = { | 37 | const 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 |
| 702 | 709 | ||
| 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"; |
| 40 | import * as McpInvocationContext from "./McpInvocationContext.ts"; | 40 | import * as McpInvocationContext from "./McpInvocationContext.ts"; |
| 41 | 41 | ||
| 42 | export interface PreviewAutomationInvokeInput { | 42 | export 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 { |
| 103 | interface PreviewAutomationRequestErrorContext { | 104 | interface 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 | }; |
| 162 | 163 | ||
| 163 | const hostAssignmentKey = (scope: McpInvocationContext.McpInvocationScope): string => | 164 | const hostAssignmentKey = (scope: McpInvocationContext.McpThreadInvocationScope): string => |
| 164 | `${scope.environmentId}\u0000${scope.providerSessionId}`; | 165 | `${scope.environmentId}\u0000${scope.thread.providerSessionId}`; |
| 165 | 166 | ||
| 166 | const isPreviewTabId = Schema.is(PreviewTabId); | 167 | const isPreviewTabId = Schema.is(PreviewTabId); |
| 167 | 168 | ||
| ... | @@ -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"; |
| 12 | const threadId = ThreadId.make("thread:metadata-caller"); | 12 | const threadId = ThreadId.make("thread:metadata-caller"); |
| 13 | const scope: McpInvocationContext.McpInvocationScope = { | 13 | const 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 | |||
| 82 | it.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 | |||
| 126 | it.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"; |
| 16 | 16 | ||
| 17 | import * as ThreadManagementService from "../orchestration-v2/ThreadManagementService.ts"; | 17 | import * as ThreadManagementService from "../orchestration-v2/ThreadManagementService.ts"; |
| 18 | import type { McpInvocationScope } from "./McpInvocationContext.ts"; | 18 | import type { McpInvocationScope } from "./McpInvocationContext.ts"; |
| 19 | import { assertTargetWithinLimits } from "./threadAccess.ts"; | ||
| 19 | 20 | ||
| 20 | export class ThreadMetadataMcpService extends Context.Service< | 21 | export 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 | } |
| 148 | 149 | ||
| 149 | const parentShell = yield* threadManagement | 150 | 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* threadManagement | 157 | 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.threadId | 169 | } |
| 175 | ? parent | 170 | // Another thread may only be changed if it runs within the caller's own modes. |
| 176 | : yield* threadManagement | 171 | 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 === undefined | 210 | 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"; |
| 23 | import * as ProjectSetupScriptRunner from "../project/ProjectSetupScriptRunner.ts"; | 23 | import * as ProjectSetupScriptRunner from "../project/ProjectSetupScriptRunner.ts"; |
| 24 | import * as ServerSettings from "../serverSettings.ts"; | 24 | import * as ServerSettings from "../serverSettings.ts"; |
| 25 | import * as VcsStatusBroadcaster from "../vcs/VcsStatusBroadcaster.ts"; | 25 | import * as VcsStatusBroadcaster from "../vcs/VcsStatusBroadcaster.ts"; |
| 26 | import type { McpInvocationScope } from "./McpInvocationContext.ts"; | 26 | import type { McpInvocationScope, McpThreadInvocationScope } from "./McpInvocationContext.ts"; |
| 27 | 27 | ||
| 28 | export class WorktreeMcpService extends Context.Service< | 28 | export 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>(); |
| 72 | 72 | ||
| 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.void | 86 | ? 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 | ); |
| 79 | 90 | ||
| 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 | ); |
| 95 | 106 | ||
| 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 | ); |
| 117 | 128 | ||
| 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 | ); |
| 131 | 144 | ||
| 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 | ); |
| 141 | 154 | ||
| 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 | } |
| 154 | 167 | ||
| ... | @@ -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* threadManagement | 320 | 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* setupScriptRunner | 400 | 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 | }); |
| 431 | 444 | ||
| 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 the | 449 | // uninterruptibleMask: the guard acquisition and the registration of the |
| 436 | // releasing finalizer happen with no interruptible gap in between. An | 450 | // releasing finalizer happen with no interruptible gap in between. An |
| 437 | // interrupt landing between a bare add() and the start of an ensured | 451 | // 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 | ); |
| 458 | 474 | ||
| 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); |
| 464 | 481 |
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. |
| 2 | import { | 2 | import { |
| 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 | }); |
| 20 | 23 | ||
| 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 | */ | ||
| 29 | export interface CallerLimits { | ||
| 30 | readonly runtimeMode: RuntimeMode; | ||
| 31 | readonly interactionMode: ProviderInteractionMode; | ||
| 32 | } | ||
| 33 | |||
| 34 | export 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 | |||
| 21 | export const readCaller = Effect.fn("mcp.readCaller")(function* () { | 42 | export 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 | }); |
| 39 | 78 | ||
| 40 | function 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; | 83 | export 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 | |||
| 94 | function 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.providerInstanceId | 98 | 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 | */ | ||
| 58 | export const readMutationCaller = Effect.fn("mcp.readMutationCaller")(function* () { | 113 | export 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 | }); |
| 63 | 118 | ||
| 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 | */ | ||
| 124 | export 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. */ | ||
| 139 | export 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. */ | ||
| 152 | const 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. */ | ||
| 65 | export const readThread = Effect.fn("mcp.readThread")(function* < | 165 | export 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* threads | 169 | 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 | }); |
| 87 | 193 | ||
| 88 | export const readWritableThread = Effect.fn("mcp.readWritableThread")(function* < | 194 | export 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 | }); |
| 103 | 202 |
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", { |
| 47 | const AttachmentSendTool = Tool.make("t3_thread_send_attachments", { | 47 | const 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"; |
| 14 | import { McpSchema, McpServer, Tool } from "effect/unstable/ai"; | 14 | import { McpSchema, McpServer, Tool } from "effect/unstable/ai"; |
| 15 | 15 | ||
| 16 | import { OrchestratorProjectionError } from "../../orchestration-v2/Orchestrator.ts"; | 16 | import { OrchestratorProjectionError } from "../../orchestration-v2/Orchestrator.ts"; |
| 17 | import * as ProviderAdapterRegistry from "../../orchestration-v2/ProviderAdapterRegistry.ts"; | ||
| 17 | import * as ThreadManagement from "../../orchestration-v2/ThreadManagementService.ts"; | 18 | import * as ThreadManagement from "../../orchestration-v2/ThreadManagementService.ts"; |
| 19 | import * as ProjectService from "../../project/ProjectService.ts"; | ||
| 20 | import * as ProviderRegistry from "../../provider/Services/ProviderRegistry.ts"; | ||
| 21 | import * as ScheduledTaskService from "../../scheduledTasks/ScheduledTaskService.ts"; | ||
| 18 | import * as McpHttpServer from "../McpHttpServer.ts"; | 22 | import * as McpHttpServer from "../McpHttpServer.ts"; |
| 19 | import * as McpInvocationContext from "../McpInvocationContext.ts"; | 23 | import * as McpInvocationContext from "../McpInvocationContext.ts"; |
| 20 | import { OrchestratorToolkit } from "./orchestrator/tools.ts"; | 24 | import { 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", () => { |
| 78 | const threadId = ThreadId.make("mcp-core-thread"); | 82 | const threadId = ThreadId.make("mcp-core-thread"); |
| 79 | const scope: McpInvocationContext.McpInvocationScope = { | 83 | const 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 | |||
| 219 | const 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 | |||
| 230 | it.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 | |||
| 302 | it.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 | |||
| 333 | it.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 | |||
| 384 | function 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 | |||
| 410 | it.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 | } |
| 68 | 68 | ||
| 69 | const requireDeviceAccess = McpInvocationContext.requireMcpCapability("device").pipe( | 69 | const 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.sessions | 134 | 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 !== undefined | 212 | 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"; |
| 4 | import * as ThreadCommandExecutor from "../../../orchestration-v2/ThreadCommandExecutor.ts"; | 4 | import * as ThreadCommandExecutor from "../../../orchestration-v2/ThreadCommandExecutor.ts"; |
| 5 | import * as Settings from "../../../serverSettings.ts"; | 5 | import * as Settings from "../../../serverSettings.ts"; |
| 6 | import * as McpInvocationContext from "../../McpInvocationContext.ts"; | 6 | import * as McpInvocationContext from "../../McpInvocationContext.ts"; |
| 7 | import { readCaller, readMutationCaller, unavailable } from "../../threadAccess.ts"; | 7 | import { readCaller, readFullAccessCaller, unavailable } from "../../threadAccess.ts"; |
| 8 | import { EnvironmentToolkit } from "./tools.ts"; | 8 | import { EnvironmentToolkit } from "./tools.ts"; |
| 9 | 9 | ||
| 10 | export function preferences(settings: ServerSettings) { | 10 | export function preferences(settings: ServerSettings) { |
| ... | @@ -30,7 +30,11 @@ export function preferences(settings: ServerSettings) { | ... | @@ -30,7 +30,11 @@ export function preferences(settings: ServerSettings) { |
| 30 | } | 30 | } |
| 31 | const access = (writable = false) => | 31 | const 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 = [ |
| 44 | 45 | ||
| 45 | const OrchestratorCapabilitiesTool = Tool.make("orchestrator_capabilities", { | 46 | const 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", { |
| 57 | 58 | ||
| 58 | export const DelegateTaskTool = Tool.make("delegate_task", { | 59 | export 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", { |
| 70 | 71 | ||
| 71 | const TaskStatusTool = Tool.make("task_status", { | 72 | const 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", { |
| 84 | 85 | ||
| 85 | const TaskCancelTool = Tool.make("task_cancel", { | 86 | const 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", { |
| 96 | 97 | ||
| 97 | export const ScheduleTaskTool = Tool.make("schedule_task", { | 98 | export 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); |
| 109 | 110 | ||
| 110 | const ListScheduledTasksTool = Tool.make("list_scheduled_tasks", { | 111 | const 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", { |
| 146 | 148 | ||
| 147 | export const CreateThreadsTool = Tool.make("create_threads", { | 149 | export 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", { |
| 159 | 161 | ||
| 160 | const ThreadListTool = Tool.make("t3_thread_list", { | 162 | const 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", { |
| 173 | 175 | ||
| 174 | const ThreadReadTool = Tool.make("t3_thread_read", { | 176 | const 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", { |
| 187 | 189 | ||
| 188 | export const ThreadUpdateTool = Tool.make("t3_thread_update", { | 190 | export 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", { |
| 200 | 202 | ||
| 201 | const ThreadSendTool = Tool.make("t3_thread_send", { | 203 | const 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", { |
| 227 | 229 | ||
| 228 | const ThreadInterruptTool = Tool.make("t3_thread_interrupt", { | 230 | const 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.PreviewAutomationBroker | 61 | 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 @@ |
| 1 | import * as Effect from "effect/Effect"; | 1 | import * as Effect from "effect/Effect"; |
| 2 | import * as Preview from "../../../preview/Manager.ts"; | 2 | import * as Preview from "../../../preview/Manager.ts"; |
| 3 | import { requireMcpCapability } from "../../McpInvocationContext.ts"; | 3 | import { requireThreadMcpCapability } from "../../McpInvocationContext.ts"; |
| 4 | import { unavailable } from "../../threadAccess.ts"; | 4 | import { unavailable } from "../../threadAccess.ts"; |
| 5 | import { PreviewControlsToolkit } from "./tools.ts"; | 5 | import { PreviewControlsToolkit } from "./tools.ts"; |
| 6 | 6 | ||
| 7 | const access = Effect.gen(function* () { | 7 | const 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 | }); |
| 12 | export const PreviewControlsHandlersLive = PreviewControlsToolkit.toLayer({ | 12 | export 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* manager | 28 | 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"; |
| 9 | import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; | 10 | import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; |
| 10 | import * as NodeServices from "@effect/platform-node/NodeServices"; | 11 | import * 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 | |||
| 280 | const 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 | |||
| 335 | it.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"; |
| 6 | import * as Project from "../../../project/ProjectService.ts"; | 6 | import * as Project from "../../../project/ProjectService.ts"; |
| 7 | import * as ManagedProjectFolders from "../../../project/ManagedProjectFolders.ts"; | 7 | import * as ManagedProjectFolders from "../../../project/ManagedProjectFolders.ts"; |
| 8 | import * as Repositories from "../../../sourceControl/SourceControlRepositoryService.ts"; | 8 | import * as Repositories from "../../../sourceControl/SourceControlRepositoryService.ts"; |
| 9 | import { newCommandId, readCaller, readMutationCaller, unavailable } from "../../threadAccess.ts"; | 9 | import { resolveRuntimeMode } from "../../OrchestratorMcpService.ts"; |
| 10 | import { | ||
| 11 | newCommandId, | ||
| 12 | readCaller, | ||
| 13 | readFullAccessCaller, | ||
| 14 | readMutationCaller, | ||
| 15 | resolveProjectId, | ||
| 16 | unavailable, | ||
| 17 | } from "../../threadAccess.ts"; | ||
| 10 | import { ProjectToolkit } from "./tools.ts"; | 18 | import { ProjectToolkit } from "./tools.ts"; |
| 11 | 19 | ||
| 12 | function projectFailure(error: Project.ProjectServiceError) { | 20 | function 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 | }); |
| 27 | const mutation = Effect.gen(function* () { | 35 | const 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 | }); |
| 40 | export const ProjectHandlersLive = ProjectToolkit.toLayer({ | 41 | export 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 | )).projectId | 89 | )).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 === 0 | 116 | ...(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", { |
| 102 | const ThreadLaunchTool = Tool.make("t3_thread_launch", { | 102 | const 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"; |
| 26 | import * as Orchestrator from "../../../orchestration-v2/Orchestrator.ts"; | 26 | import * as Orchestrator from "../../../orchestration-v2/Orchestrator.ts"; |
| 27 | import * as ProjectService from "../../../project/ProjectService.ts"; | 27 | import * as ProjectService from "../../../project/ProjectService.ts"; |
| 28 | import * as McpInvocationContext from "../../McpInvocationContext.ts"; | 28 | import * as McpInvocationContext from "../../McpInvocationContext.ts"; |
| 29 | import { assertTargetWithinLimits } from "../../threadAccess.ts"; | ||
| 29 | import { | 30 | import { |
| 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 PullRequestUnlinkFailedError | 171 | | typeof PullRequestUnlinkFailedError |
| 169 | | typeof PullRequestListFailedError | 172 | | 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* engine | 181 | 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 | }); |
| 182 | 190 | ||
| 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"; |
| 8 | import * as Schema from "effect/Schema"; | 9 | import * as Schema from "effect/Schema"; |
| 9 | import * as Tool from "effect/unstable/ai/Tool"; | 10 | import * 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 | */ |
| 30 | export const PullRequestTargetInput = Schema.Struct({ | 31 | export 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 | } |
| 83 | 89 | ||
| 90 | export 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 | |||
| 99 | export 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 | |||
| 84 | export class PullRequestThreadNotFoundError extends Schema.TaggedError<PullRequestThreadNotFoundError>()( | 108 | export 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); |
| 246 | 272 | ||
| 247 | const ListThreadPullRequestsTool = Tool.make("list_thread_pull_requests", { | 273 | const 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"; |
| 13 | import { | 13 | import { |
| 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* ( |
| 78 | export const ThreadToolkitHandlersLive = ThreadToolkit.toLayer({ | 78 | export 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* scheduler | 91 | 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* threads | 123 | 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* threads | 140 | 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* threads | 187 | 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"; |
| 21 | import * as Crypto from "effect/Crypto"; | 22 | import * as Crypto from "effect/Crypto"; |
| 22 | import * as Schema from "effect/Schema"; | 23 | import * 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"; |
| 29 | 30 | ||
| 30 | const ThreadOrganizeTool = Tool.make("t3_thread_organize", { | 31 | const 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); |
| 92 | const QueueReadTool = Tool.make("t3_queue_read", { | 93 | const 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({ |
| 146 | const PendingRequestListTool = Tool.make("t3_pending_request_list", { | 147 | const 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", { |
| 173 | const ThreadConfigurationTool = Tool.make("t3_thread_configuration", { | 174 | const 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", { |
| 187 | const ThreadConfigureTool = Tool.make("t3_thread_configure", { | 188 | const 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); |
| 193 | 197 | ||
| 194 | const transferResult = Schema.Struct({ sequence: NonNegativeInt, targetThreadId: ThreadId }); | 198 | const transferResult = Schema.Struct({ sequence: NonNegativeInt, targetThreadId: ThreadId }); |
| 195 | const ThreadForkTool = Tool.make("t3_thread_fork", { | 199 | const 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", { |
| 205 | const ThreadMergeBackTool = Tool.make("t3_thread_merge_back", { | 210 | const 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); |
| 215 | const ThreadTransfersTool = Tool.make("t3_thread_transfers", { | 221 | const 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", { |
| 233 | const ThreadSearchTool = Tool.make("t3_thread_search", { | 239 | const 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", { |
| 244 | const ScheduledTaskRunTool = Tool.make("run_scheduled_task_now", { | 253 | const 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"; |
| 2 | import * as Option from "effect/Option"; | 2 | import * as Option from "effect/Option"; |
| 3 | import * as GitWorkflow from "../../../git/GitWorkflowService.ts"; | 3 | import * as GitWorkflow from "../../../git/GitWorkflowService.ts"; |
| 4 | import * as Project from "../../../project/ProjectService.ts"; | 4 | import * as Project from "../../../project/ProjectService.ts"; |
| 5 | import { readCaller, unavailable } from "../../threadAccess.ts"; | 5 | import { readThread, unavailable } from "../../threadAccess.ts"; |
| 6 | import * as Effect from "effect/Effect"; | 6 | import * as Effect from "effect/Effect"; |
| 7 | 7 | ||
| 8 | import * as McpInvocationContext from "../../McpInvocationContext.ts"; | 8 | import * 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* git | 33 | 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"; |
| 10 | import * as Schema from "effect/Schema"; | 11 | import * as Schema from "effect/Schema"; |
| 11 | import * as GitWorkflowService from "../../../git/GitWorkflowService.ts"; | 12 | import * as GitWorkflowService from "../../../git/GitWorkflowService.ts"; |
| ... | @@ -23,7 +24,7 @@ const dependencies = [ | ... | @@ -23,7 +24,7 @@ const dependencies = [ |
| 23 | 24 | ||
| 24 | const WorktreeHandoffTool = Tool.make("t3_worktree_handoff", { | 25 | const 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", { |
| 38 | 39 | ||
| 39 | const WorktreeStatusTool = Tool.make("t3_worktree_status", { | 40 | const 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 serializes | 43 | // No `parameters`: Tool.make defaults to Tool.EmptyParams, which serializes |
| 43 | // to a top-level `type: "object"` JSON Schema. An explicit empty | 44 | // to a top-level `type: "object"` JSON Schema. An explicit empty |
| 44 | // Schema.Struct({}) serializes to `anyOf: [object, array]`, which is not a | 45 | // 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", { |
| 56 | 57 | ||
| 57 | const WorktreeListTool = Tool.make("t3_worktree_list", { | 58 | const 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 | }); |
| 1322 | 1322 | ||
| 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 | }); |
| 1398 | 1398 | ||
| 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); |
| 1472 | 1472 | ||
| 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 through | 473 | // 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 | ]); |
| 281 | export type OrchestratorMcpThreadStatus = typeof OrchestratorMcpThreadStatus.Type; | 281 | export type OrchestratorMcpThreadStatus = typeof OrchestratorMcpThreadStatus.Type; |
| 282 | 282 | ||
| 283 | const 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 | |||
| 283 | export const OrchestratorMcpThreadListInput = Schema.Struct({ | 290 | export 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 |
| 316 | 324 | ||
| 317 | export const OrchestratorMcpThreadListResult = Schema.Struct({ | 325 | export 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({ |
| 475 | export type OrchestratorMcpProviderCapability = typeof OrchestratorMcpProviderCapability.Type; | 484 | export type OrchestratorMcpProviderCapability = typeof OrchestratorMcpProviderCapability.Type; |
| 476 | 485 | ||
| 477 | export const OrchestratorMcpCapabilitiesResult = Schema.Struct({ | 486 | export 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({ |
| 495 | export type OrchestratorMcpCapabilitiesResult = typeof OrchestratorMcpCapabilitiesResult.Type; | 506 | export type OrchestratorMcpCapabilitiesResult = typeof OrchestratorMcpCapabilitiesResult.Type; |
| 496 | 507 | ||
| 497 | export const OrchestratorMcpScheduleTaskInput = Schema.Struct({ | 508 | export 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 |
| 535 | export const OrchestratorMcpScheduleTaskResult = OrchestratorMcpScheduledTask; | 547 | export const OrchestratorMcpScheduleTaskResult = OrchestratorMcpScheduledTask; |
| 536 | export type OrchestratorMcpScheduleTaskResult = typeof OrchestratorMcpScheduleTaskResult.Type; | 548 | export type OrchestratorMcpScheduleTaskResult = typeof OrchestratorMcpScheduleTaskResult.Type; |
| 537 | 549 | ||
| 550 | export 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 | }); | ||
| 558 | export type OrchestratorMcpListScheduledTasksInput = | ||
| 559 | typeof OrchestratorMcpListScheduledTasksInput.Type; | ||
| 560 | |||
| 538 | export const OrchestratorMcpListScheduledTasksResult = Schema.Struct({ | 561 | export 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 | }); |
| 643 | export type PreviewAutomationResponse = typeof PreviewAutomationResponse.Type; | 643 | export type PreviewAutomationResponse = typeof PreviewAutomationResponse.Type; |
| 644 | 644 | ||
| 645 | // Thread fields are absent when the caller signed in from outside a T3 thread. | ||
| 645 | const McpCapabilityErrorFields = { | 646 | const 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 | }; |
| 651 | 652 | ||
| 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 |
| 80 | 80 | ||
| 81 | export const ThreadMetadataMcpUpdateInput = Schema.Struct({ | 81 | export 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 | }, |