| author | |
| committer | |
| log | 2f85686d9dca5ec98c892a2a26c321e09d746d71 |
| tree | 7621c9cfdc76cbe6a10f53d0c3856d81783741cd |
| parent | f21d6da51c9aa78ea12e7f13a378f55f1e63cb38 |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>48 files changed, 3043 insertions(+), 680 deletions(-)
apps/server/src/mcp/McpDeviceToolkit.test.ts+3| ... | ... | @@ -14,6 +14,7 @@ import * as ServerConfig from "../config.ts"; |
| 14 | 14 | import * as DeviceService from "../device/DeviceService.ts"; |
| 15 | 15 | import * as McpHttpServer from "./McpHttpServer.ts"; |
| 16 | 16 | import * as McpInvocationContext from "./McpInvocationContext.ts"; |
| 17 | import * as McpToolAccessTestkit from "./McpToolAccess.testkit.ts"; | |
| 17 | 18 | |
| 18 | 19 | const environmentId = EnvironmentId.make("environment-device-test"); |
| 19 | 20 | const threadId = ThreadId.make("thread-device-test"); |
| ... | ... | @@ -102,6 +103,7 @@ const layerDeviceServiceMock = Layer.mock(DeviceService.DeviceService)({ |
| 102 | 103 | |
| 103 | 104 | const layerTest = McpHttpServer.layerDeviceToolkit.pipe( |
| 104 | 105 | Layer.provideMerge(McpServer.McpServer.layer), |
| 106 | Layer.provideMerge(McpToolAccessTestkit.liveThreadsLayer), | |
| 105 | 107 | Layer.provideMerge(layerDeviceServiceMock), |
| 106 | 108 | Layer.provide(ServerConfig.layerTest(process.cwd(), { prefix: "t3-mcp-device-toolkit-test-" })), |
| 107 | 109 | Layer.provide(NodeServices.layer), |
| ... | ... | @@ -172,6 +174,7 @@ it.effect("rejects unavailable agent access before booting or opening a device", |
| 172 | 174 | Effect.provide( |
| 173 | 175 | McpHttpServer.layerDeviceToolkit.pipe( |
| 174 | 176 | Layer.provideMerge(McpServer.McpServer.layer), |
| 177 | Layer.provideMerge(McpToolAccessTestkit.liveThreadsLayer), | |
| 175 | 178 | Layer.provide(layerUnavailable), |
| 176 | 179 | Layer.provide(NodeServices.layer), |
| 177 | 180 | ), |
apps/server/src/mcp/McpHttpServer.test.ts+3| ... | ... | @@ -17,6 +17,7 @@ import { HttpBody, HttpClient, HttpRouter, HttpServerResponse } from "effect/htt |
| 17 | 17 | import * as ProjectService from "../project/ProjectService.ts"; |
| 18 | 18 | import * as ServerConfig from "../config.ts"; |
| 19 | 19 | import * as McpHttpServer from "./McpHttpServer.ts"; |
| 20 | import * as McpToolAccessTestkit from "./McpToolAccess.testkit.ts"; | |
| 20 | 21 | import * as McpInvocationContext from "./McpInvocationContext.ts"; |
| 21 | 22 | import * as PreviewAutomationBroker from "./PreviewAutomationBroker.ts"; |
| 22 | 23 | |
| ... | ... | @@ -51,12 +52,14 @@ const client = McpSchema.McpServerClient.of({ |
| 51 | 52 | }); |
| 52 | 53 | const layerTest = McpHttpServer.layerPreviewToolkit.pipe( |
| 53 | 54 | Layer.provideMerge(McpServer.McpServer.layer), |
| 55 | Layer.provideMerge(McpToolAccessTestkit.liveThreadsLayer), | |
| 54 | 56 | Layer.provideMerge(PreviewAutomationBroker.layer), |
| 55 | 57 | Layer.provideMerge(ServerConfig.layerTest(process.cwd(), { prefix: "t3-mcp-http-server-test-" })), |
| 56 | 58 | Layer.provideMerge(NodeServices.layer), |
| 57 | 59 | ); |
| 58 | 60 | const layerPullRequestsTest = McpHttpServer.layerPullRequestsToolkit.pipe( |
| 59 | 61 | Layer.provideMerge(McpServer.McpServer.layer), |
| 62 | Layer.provideMerge(McpToolAccessTestkit.liveThreadsLayer), | |
| 60 | 63 | Layer.provide( |
| 61 | 64 | Layer.mergeAll( |
| 62 | 65 | Layer.mock(ProjectService.ProjectService)({}), |
apps/server/src/mcp/McpHttpServer.ts+68-36| ... | ... | @@ -11,7 +11,7 @@ import * as Schema from "effect/Schema"; |
| 11 | 11 | import * as Sink from "effect/Sink"; |
| 12 | 12 | import * as Stream from "effect/Stream"; |
| 13 | 13 | import type * as Types from "effect/Types"; |
| 14 | import { AiError, McpProtocol, McpSchema, McpServer, Tool } from "effect/ai"; | |
| 14 | import { AiError, McpProtocol, McpSchema, McpServer, Tool, type Toolkit } from "effect/ai"; | |
| 15 | 15 | import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/http"; |
| 16 | 16 | import { OrchestratorMcpFailure, PreviewAutomationError } from "@t3tools/contracts"; |
| 17 | 17 | |
| ... | ... | @@ -19,7 +19,9 @@ import packageJson from "../../package.json" with { type: "json" }; |
| 19 | 19 | import * as ServerConfig from "../config.ts"; |
| 20 | 20 | import * as DeviceService from "../device/DeviceService.ts"; |
| 21 | 21 | import * as HtmlRender from "../htmlRender/HtmlRender.ts"; |
| 22 | import * as ThreadManagementService from "../orchestration-v2/ThreadManagementService.ts"; | |
| 22 | 23 | import * as McpInvocationContext from "./McpInvocationContext.ts"; |
| 24 | import * as McpToolAccess from "./McpToolAccess.ts"; | |
| 23 | 25 | import * as OrchestratorMcpService from "./OrchestratorMcpService.ts"; |
| 24 | 26 | import { PreviewControlsToolkit } from "./toolkits/previewControls/tools.ts"; |
| 25 | 27 | import * as PreviewControlsHandlers from "./toolkits/previewControls/handlers.ts"; |
| ... | ... | @@ -396,6 +398,7 @@ const previewSnapshotFailure = <E>(cause: Cause.Cause<E>) => { |
| 396 | 398 | const registerPreviewSnapshot = Effect.fn("McpHttpServer.registerPreviewSnapshot")(function* () { |
| 397 | 399 | const server = yield* McpServer.McpServer; |
| 398 | 400 | const broker = yield* PreviewAutomationBroker.PreviewAutomationBroker; |
| 401 | const threads = yield* ThreadManagementService.ThreadManagementService; | |
| 399 | 402 | // The MCP tool runner only supplies the client, so hand the save path its services here. |
| 400 | 403 | const saveServices = yield* Effect.context< |
| 401 | 404 | ServerConfig.ServerConfig | FileSystem.FileSystem | Path.Path | Crypto.Crypto |
| ... | ... | @@ -430,6 +433,7 @@ const registerPreviewSnapshot = Effect.fn("McpHttpServer.registerPreviewSnapshot |
| 430 | 433 | Stream.run(Sink.last()), |
| 431 | 434 | Effect.flatMap(Effect.fromOption), |
| 432 | 435 | Effect.provideService(PreviewAutomationBroker.PreviewAutomationBroker, broker), |
| 436 | Effect.provideService(ThreadManagementService.ThreadManagementService, threads), | |
| 433 | 437 | Effect.provideService(McpInvocationContext.McpInvocationContext, invocation), |
| 434 | 438 | Effect.flatMap(({ encodedResult }) => |
| 435 | 439 | Effect.gen(function* () { |
| ... | ... | @@ -655,6 +659,7 @@ const registerImageTool = <T extends Tool.Any, E, R>( |
| 655 | 659 | |
| 656 | 660 | const registerDeviceScreenshot = Effect.fn("McpHttpServer.registerDeviceScreenshot")(function* () { |
| 657 | 661 | const devices = yield* DeviceService.DeviceService; |
| 662 | const threads = yield* ThreadManagementService.ThreadManagementService; | |
| 658 | 663 | const built = yield* DeviceScreenshotToolkit; |
| 659 | 664 | yield* registerImageTool( |
| 660 | 665 | DeviceScreenshotTool, |
| ... | ... | @@ -662,7 +667,11 @@ const registerDeviceScreenshot = Effect.fn("McpHttpServer.registerDeviceScreensh |
| 662 | 667 | built |
| 663 | 668 | .handle("device_screenshot", payload) |
| 664 | 669 | .pipe(Stream.unwrap, Stream.run(Sink.last()), Effect.flatMap(Effect.fromOption)), |
| 665 | (effect) => effect.pipe(Effect.provideService(DeviceService.DeviceService, devices)), | |
| 670 | (effect) => | |
| 671 | effect.pipe( | |
| 672 | Effect.provideService(DeviceService.DeviceService, devices), | |
| 673 | Effect.provideService(ThreadManagementService.ThreadManagementService, threads), | |
| 674 | ), | |
| 666 | 675 | "screenshot", |
| 667 | 676 | "Device screenshot failed.", |
| 668 | 677 | ); |
| ... | ... | @@ -672,6 +681,7 @@ const isOrchestratorMcpFailure = Schema.is(OrchestratorMcpFailure); |
| 672 | 681 | |
| 673 | 682 | const registerHtmlPreview = Effect.fn("McpHttpServer.registerHtmlPreview")(function* () { |
| 674 | 683 | const htmlRender = yield* HtmlRender.HtmlRender; |
| 684 | const threads = yield* ThreadManagementService.ThreadManagementService; | |
| 675 | 685 | const built = yield* HtmlPreviewToolkit; |
| 676 | 686 | yield* registerImageTool( |
| 677 | 687 | HtmlPreviewTool, |
| ... | ... | @@ -679,7 +689,11 @@ const registerHtmlPreview = Effect.fn("McpHttpServer.registerHtmlPreview")(funct |
| 679 | 689 | built |
| 680 | 690 | .handle("html_preview", payload) |
| 681 | 691 | .pipe(Stream.unwrap, Stream.run(Sink.last()), Effect.flatMap(Effect.fromOption)), |
| 682 | (effect) => effect.pipe(Effect.provideService(HtmlRender.HtmlRender, htmlRender)), | |
| 692 | (effect) => | |
| 693 | effect.pipe( | |
| 694 | Effect.provideService(HtmlRender.HtmlRender, htmlRender), | |
| 695 | Effect.provideService(ThreadManagementService.ThreadManagementService, threads), | |
| 696 | ), | |
| 683 | 697 | "preview", |
| 684 | 698 | // Parameter errors and HTML render errors are both written by the server for the agent. |
| 685 | 699 | (error) => |
| ... | ... | @@ -689,17 +703,34 @@ const registerHtmlPreview = Effect.fn("McpHttpServer.registerHtmlPreview")(funct |
| 689 | 703 | ); |
| 690 | 704 | }); |
| 691 | 705 | |
| 706 | /** | |
| 707 | * `McpServer.toolkit` for handlers that declared their access (see | |
| 708 | * `McpToolAccess`). Every toolkit on `/mcp` registers through this. | |
| 709 | */ | |
| 710 | export const toolkitRegistration = <Tools extends Record<string, Tool.Any>, EX, RX>( | |
| 711 | toolkit: Toolkit.Toolkit<Tools>, | |
| 712 | handlers: McpToolAccess.HandlersLayer<Tools, EX, RX>, | |
| 713 | ) => McpServer.toolkit(toolkit).pipe(Layer.provide(McpToolAccess.HandlersLayer.layer(handlers))); | |
| 714 | ||
| 715 | /** A hand-registered tool, also only with handlers that declared their access. */ | |
| 716 | const imageToolRegistration = <Tools extends Record<string, Tool.Any>, A, E, R, EX, RX>( | |
| 717 | register: Effect.Effect<A, E, R>, | |
| 718 | handlers: McpToolAccess.HandlersLayer<Tools, EX, RX>, | |
| 719 | ) => Layer.effectDiscard(register).pipe(Layer.provide(McpToolAccess.HandlersLayer.layer(handlers))); | |
| 720 | ||
| 692 | 721 | export const layerHtmlToolkit = Layer.mergeAll( |
| 693 | McpServer.toolkit(HtmlRenderToolkit).pipe(Layer.provide(HtmlHandlers.layerRender)), | |
| 694 | Layer.effectDiscard(registerHtmlPreview()).pipe(Layer.provide(HtmlHandlers.layerPreview)), | |
| 722 | toolkitRegistration(HtmlRenderToolkit, HtmlHandlers.layerRender), | |
| 723 | imageToolRegistration(registerHtmlPreview(), HtmlHandlers.layerPreview), | |
| 695 | 724 | ).pipe(Layer.provide(HtmlRender.layer)); |
| 696 | 725 | |
| 697 | const layerPreviewStandardToolkitRegistration = McpServer.toolkit(PreviewStandardToolkit).pipe( | |
| 698 | Layer.provide(PreviewHandlers.layerStandard), | |
| 726 | const layerPreviewStandardToolkitRegistration = toolkitRegistration( | |
| 727 | PreviewStandardToolkit, | |
| 728 | PreviewHandlers.layerStandard, | |
| 699 | 729 | ); |
| 700 | 730 | |
| 701 | const layerPreviewSnapshotRegistration = Layer.effectDiscard(registerPreviewSnapshot()).pipe( | |
| 702 | Layer.provide(PreviewHandlers.layerSnapshot), | |
| 731 | const layerPreviewSnapshotRegistration = imageToolRegistration( | |
| 732 | registerPreviewSnapshot(), | |
| 733 | PreviewHandlers.layerSnapshot, | |
| 703 | 734 | ); |
| 704 | 735 | |
| 705 | 736 | export const layerPreviewToolkit = Layer.mergeAll( |
| ... | ... | @@ -707,47 +738,48 @@ export const layerPreviewToolkit = Layer.mergeAll( |
| 707 | 738 | layerPreviewSnapshotRegistration, |
| 708 | 739 | ); |
| 709 | 740 | |
| 710 | export const layerOrchestratorToolkit = McpServer.toolkit(OrchestratorToolkit).pipe( | |
| 711 | Layer.provide(OrchestratorHandlers.layer), | |
| 712 | Layer.provide(OrchestratorMcpService.layer), | |
| 713 | Layer.provide(ThreadMetadataMcpService.layer), | |
| 714 | ); | |
| 741 | export const layerOrchestratorToolkit = toolkitRegistration( | |
| 742 | OrchestratorToolkit, | |
| 743 | OrchestratorHandlers.layer, | |
| 744 | ).pipe(Layer.provide(OrchestratorMcpService.layer), Layer.provide(ThreadMetadataMcpService.layer)); | |
| 715 | 745 | |
| 716 | export const layerThreadToolkit = McpServer.toolkit(ThreadToolkit).pipe( | |
| 717 | Layer.provide(ThreadHandlers.layer), | |
| 718 | ); | |
| 746 | export const layerThreadToolkit = toolkitRegistration(ThreadToolkit, ThreadHandlers.layer); | |
| 719 | 747 | |
| 720 | const layerWorktreeToolkitRegistration = McpServer.toolkit(WorktreeToolkit).pipe( | |
| 721 | Layer.provide(WorktreeHandlers.layer), | |
| 722 | Layer.provide(WorktreeMcpService.layer), | |
| 723 | ); | |
| 748 | const layerWorktreeToolkitRegistration = toolkitRegistration( | |
| 749 | WorktreeToolkit, | |
| 750 | WorktreeHandlers.layer, | |
| 751 | ).pipe(Layer.provide(WorktreeMcpService.layer)); | |
| 724 | 752 | |
| 725 | const layerPreviewControlsRegistration = McpServer.toolkit(PreviewControlsToolkit).pipe( | |
| 726 | Layer.provide(PreviewControlsHandlers.layer), | |
| 753 | const layerPreviewControlsRegistration = toolkitRegistration( | |
| 754 | PreviewControlsToolkit, | |
| 755 | PreviewControlsHandlers.layer, | |
| 727 | 756 | ); |
| 728 | 757 | |
| 729 | const layerEnvironmentRegistration = McpServer.toolkit(EnvironmentToolkit).pipe( | |
| 730 | Layer.provide(EnvironmentHandlers.layer), | |
| 758 | const layerEnvironmentRegistration = toolkitRegistration( | |
| 759 | EnvironmentToolkit, | |
| 760 | EnvironmentHandlers.layer, | |
| 731 | 761 | ); |
| 732 | 762 | |
| 733 | const layerProjectRegistration = McpServer.toolkit(ProjectToolkit).pipe( | |
| 734 | Layer.provide(ProjectHandlers.layer), | |
| 735 | ); | |
| 763 | const layerProjectRegistration = toolkitRegistration(ProjectToolkit, ProjectHandlers.layer); | |
| 736 | 764 | |
| 737 | const layerAttachmentRegistration = McpServer.toolkit(AttachmentToolkit).pipe( | |
| 738 | Layer.provide(AttachmentHandlers.layer), | |
| 765 | const layerAttachmentRegistration = toolkitRegistration( | |
| 766 | AttachmentToolkit, | |
| 767 | AttachmentHandlers.layer, | |
| 739 | 768 | ); |
| 740 | 769 | |
| 741 | export const layerPullRequestsToolkit = McpServer.toolkit(PullRequestsToolkit).pipe( | |
| 742 | Layer.provide(PullRequestsHandlers.layer), | |
| 770 | export const layerPullRequestsToolkit = toolkitRegistration( | |
| 771 | PullRequestsToolkit, | |
| 772 | PullRequestsHandlers.layer, | |
| 743 | 773 | ); |
| 744 | 774 | |
| 745 | const layerDeviceStandardToolkitRegistration = McpServer.toolkit(DeviceStandardToolkit).pipe( | |
| 746 | Layer.provide(DeviceHandlers.layerStandard), | |
| 775 | const layerDeviceStandardToolkitRegistration = toolkitRegistration( | |
| 776 | DeviceStandardToolkit, | |
| 777 | DeviceHandlers.layerStandard, | |
| 747 | 778 | ); |
| 748 | 779 | |
| 749 | const layerDeviceScreenshotRegistration = Layer.effectDiscard(registerDeviceScreenshot()).pipe( | |
| 750 | Layer.provide(DeviceHandlers.layerScreenshot), | |
| 780 | const layerDeviceScreenshotRegistration = imageToolRegistration( | |
| 781 | registerDeviceScreenshot(), | |
| 782 | DeviceHandlers.layerScreenshot, | |
| 751 | 783 | ); |
| 752 | 784 | |
| 753 | 785 | export const layerDeviceToolkit = Layer.mergeAll( |
apps/server/src/mcp/McpToolAccess.race.test.ts created+200| ... | ... | @@ -0,0 +1,200 @@ |
| 1 | import { assert, it } from "@effect/vitest"; | |
| 2 | import { | |
| 3 | CommandId, | |
| 4 | EnvironmentId, | |
| 5 | OrchestratorMcpFailure, | |
| 6 | ProjectId, | |
| 7 | ProviderDriverKind, | |
| 8 | ProviderInstanceId, | |
| 9 | ThreadId, | |
| 10 | } from "@t3tools/contracts"; | |
| 11 | import * as Effect from "effect/Effect"; | |
| 12 | import * as Layer from "effect/Layer"; | |
| 13 | import * as Ref from "effect/Ref"; | |
| 14 | import * as Schema from "effect/Schema"; | |
| 15 | import { McpSchema, McpServer, Tool, Toolkit } from "effect/ai"; | |
| 16 | ||
| 17 | import * as SqlitePersistence from "../persistence/Sqlite.ts"; | |
| 18 | import { DispatchModeLimit } from "../orchestration-v2/DispatchModeLimit.ts"; | |
| 19 | import { CodexProviderCapabilitiesV2 } from "../orchestration-v2/Adapters/CodexAdapterV2.ts"; | |
| 20 | import * as Orchestrator from "../orchestration-v2/Orchestrator.ts"; | |
| 21 | import * as ProjectionStore from "../orchestration-v2/ProjectionStore.ts"; | |
| 22 | import type { ProviderAdapterV2Shape } from "../orchestration-v2/ProviderAdapter.ts"; | |
| 23 | import * as ProviderAdapterRegistry from "../orchestration-v2/ProviderAdapterRegistry.ts"; | |
| 24 | import * as ThreadManagement from "../orchestration-v2/ThreadManagementService.ts"; | |
| 25 | import * as ProviderReplayHarness from "../orchestration-v2/testkit/ProviderReplayHarness.ts"; | |
| 26 | import * as McpHttpServer from "./McpHttpServer.ts"; | |
| 27 | import * as McpInvocationContext from "./McpInvocationContext.ts"; | |
| 28 | import * as McpToolAccess from "./McpToolAccess.ts"; | |
| 29 | ||
| 30 | // A Supervised outside agent renames a thread through a `writesThreads` tool. | |
| 31 | // The thread's user raises it to full access after the tool's check but | |
| 32 | // before its write: the race the orchestrator closes under the thread's lock. | |
| 33 | ||
| 34 | const instanceId = ProviderInstanceId.make("codex"); | |
| 35 | const adapter = { | |
| 36 | instanceId, | |
| 37 | driver: ProviderDriverKind.make("codex"), | |
| 38 | getCapabilities: () => Effect.succeed(CodexProviderCapabilitiesV2), | |
| 39 | planSelectionTransition: () => Effect.succeed({ type: "apply_on_next_turn" as const }), | |
| 40 | openSession: () => Effect.die("No provider process needed for metadata commands"), | |
| 41 | } as ProviderAdapterV2Shape; | |
| 42 | const layerDatabase = SqlitePersistence.layerMemory; | |
| 43 | const layerOrchestrator = Layer.mergeAll( | |
| 44 | layerDatabase, | |
| 45 | ProjectionStore.layer.pipe(Layer.provide(layerDatabase)), | |
| 46 | ProviderReplayHarness.layerWithRegistry( | |
| 47 | { name: "mcp-mode-race" }, | |
| 48 | ProviderAdapterRegistry.layerFromAdapters([adapter]), | |
| 49 | { databaseLayer: layerDatabase, runEffectWorker: false }, | |
| 50 | ), | |
| 51 | ); | |
| 52 | ||
| 53 | const RenameTool = Tool.make("rename", { | |
| 54 | parameters: Schema.Struct({ threadId: ThreadId }), | |
| 55 | success: Schema.Struct({ renamed: Schema.Boolean }), | |
| 56 | failure: OrchestratorMcpFailure, | |
| 57 | failureMode: "return", | |
| 58 | dependencies: [ | |
| 59 | McpInvocationContext.McpInvocationContext, | |
| 60 | ThreadManagement.ThreadManagementService, | |
| 61 | ], | |
| 62 | }); | |
| 63 | const RenameToolkit = Toolkit.make(RenameTool); | |
| 64 | ||
| 65 | const supervisedClient: McpInvocationContext.McpInvocationScope = { | |
| 66 | environmentId: EnvironmentId.make("environment"), | |
| 67 | requestNamespace: "client:race", | |
| 68 | thread: undefined, | |
| 69 | client: { sessionId: "race", label: "Claude Code", runtimeModeCeiling: "approval-required" }, | |
| 70 | capabilities: new Set(["orchestration"]), | |
| 71 | issuedAt: 0, | |
| 72 | }; | |
| 73 | ||
| 74 | const mcpClient = McpSchema.McpServerClient.of({ | |
| 75 | clientId: 1, | |
| 76 | protocolVersion: "2025-06-18", | |
| 77 | clientCapabilities: {}, | |
| 78 | clientInfo: { name: "mcp-race", version: "1" }, | |
| 79 | initializePayload: { | |
| 80 | protocolVersion: "2025-06-18", | |
| 81 | capabilities: {}, | |
| 82 | clientInfo: { name: "mcp-race", version: "1" }, | |
| 83 | }, | |
| 84 | getClient: Effect.die("unused"), | |
| 85 | }); | |
| 86 | ||
| 87 | const decodeOutcome = Schema.decodeUnknownEffect( | |
| 88 | Schema.fromJsonString( | |
| 89 | Schema.Union([ | |
| 90 | Schema.Struct({ renamed: Schema.Boolean }), | |
| 91 | Schema.Struct({ code: Schema.String }), | |
| 92 | ]), | |
| 93 | ), | |
| 94 | ); | |
| 95 | ||
| 96 | /** Renames `threadId` as the Supervised client; the user may raise it in between. */ | |
| 97 | const renameRacingTheUser = (threadId: ThreadId, userRaises: boolean) => | |
| 98 | Effect.gen(function* () { | |
| 99 | const orchestrator = yield* Orchestrator.OrchestratorV2; | |
| 100 | const threads = yield* ThreadManagement.ThreadManagementService; | |
| 101 | const raised = yield* Ref.make(false); | |
| 102 | const handlers = McpToolAccess.toLayer(RenameToolkit, { | |
| 103 | rename: McpToolAccess.writesThreads( | |
| 104 | (input) => [input.threadId], | |
| 105 | (input) => | |
| 106 | Effect.gen(function* () { | |
| 107 | // The tool's own check has passed; the user acts before the write. | |
| 108 | if (userRaises) { | |
| 109 | // The user's own command carries no limit, so it lands. | |
| 110 | yield* orchestrator | |
| 111 | .dispatch({ | |
| 112 | type: "thread.runtime-mode.set", | |
| 113 | commandId: CommandId.make("user-raise"), | |
| 114 | threadId: input.threadId, | |
| 115 | runtimeMode: "full-access", | |
| 116 | }) | |
| 117 | .pipe(Effect.provideService(DispatchModeLimit, undefined), Effect.orDie); | |
| 118 | yield* Ref.set(raised, true); | |
| 119 | } | |
| 120 | yield* threads | |
| 121 | .dispatch({ | |
| 122 | type: "thread.metadata.update", | |
| 123 | commandId: CommandId.make(`agent-rename:${userRaises}`), | |
| 124 | threadId: input.threadId, | |
| 125 | title: "Renamed by the agent", | |
| 126 | }) | |
| 127 | .pipe( | |
| 128 | Effect.mapError( | |
| 129 | () => | |
| 130 | new OrchestratorMcpFailure({ | |
| 131 | code: "orchestration_error", | |
| 132 | message: "The rename failed.", | |
| 133 | }), | |
| 134 | ), | |
| 135 | ); | |
| 136 | return { renamed: true }; | |
| 137 | }), | |
| 138 | ), | |
| 139 | }); | |
| 140 | const outcome = yield* McpServer.McpServer.pipe( | |
| 141 | Effect.flatMap((server) => server.callTool({ name: "rename", arguments: { threadId } })), | |
| 142 | Effect.flatMap(({ content }) => | |
| 143 | decodeOutcome(content.map((part) => (part.type === "text" ? part.text : "")).join("")), | |
| 144 | ), | |
| 145 | Effect.provideService(McpInvocationContext.McpInvocationContext, supervisedClient), | |
| 146 | Effect.provideService(McpSchema.McpServerClient, mcpClient), | |
| 147 | Effect.provide( | |
| 148 | McpHttpServer.toolkitRegistration(RenameToolkit, handlers).pipe( | |
| 149 | Layer.provideMerge(McpServer.McpServer.layer), | |
| 150 | ), | |
| 151 | ), | |
| 152 | ); | |
| 153 | assert.equal(yield* Ref.get(raised), userRaises); | |
| 154 | return outcome; | |
| 155 | }); | |
| 156 | ||
| 157 | const createSupervisedThread = (threadId: ThreadId) => | |
| 158 | Effect.gen(function* () { | |
| 159 | const orchestrator = yield* Orchestrator.OrchestratorV2; | |
| 160 | yield* orchestrator.dispatch({ | |
| 161 | type: "thread.create", | |
| 162 | commandId: CommandId.make(`create:${threadId}`), | |
| 163 | threadId, | |
| 164 | projectId: ProjectId.make("project:mcp-race"), | |
| 165 | title: "Before", | |
| 166 | modelSelection: { instanceId, model: "gpt-5" }, | |
| 167 | runtimeMode: "approval-required", | |
| 168 | interactionMode: "default", | |
| 169 | branch: null, | |
| 170 | worktreePath: null, | |
| 171 | createdBy: "user", | |
| 172 | creationSource: "web", | |
| 173 | }); | |
| 174 | }); | |
| 175 | ||
| 176 | it.layer(layerOrchestrator)("writesThreads against a mode raise", (it) => { | |
| 177 | it.effect("refuses the write when the user raises the thread after the check", () => | |
| 178 | Effect.gen(function* () { | |
| 179 | const projections = yield* ProjectionStore.ProjectionStoreV2; | |
| 180 | const threadId = ThreadId.make("thread:race-raised"); | |
| 181 | yield* createSupervisedThread(threadId); | |
| 182 | const outcome = yield* renameRacingTheUser(threadId, true); | |
| 183 | assert.deepEqual(outcome, { code: "runtime_mode_escalation_denied" }); | |
| 184 | const shell = yield* projections.getThreadShell(threadId); | |
| 185 | assert.equal(shell?.runtimeMode, "full-access"); | |
| 186 | assert.equal(shell?.title, "Before"); | |
| 187 | }).pipe(Effect.provide(ThreadManagement.layer)), | |
| 188 | ); | |
| 189 | ||
| 190 | it.effect("lets the write through when the thread stays within the caller's modes", () => | |
| 191 | Effect.gen(function* () { | |
| 192 | const projections = yield* ProjectionStore.ProjectionStoreV2; | |
| 193 | const threadId = ThreadId.make("thread:race-steady"); | |
| 194 | yield* createSupervisedThread(threadId); | |
| 195 | const outcome = yield* renameRacingTheUser(threadId, false); | |
| 196 | assert.deepEqual(outcome, { renamed: true }); | |
| 197 | assert.equal((yield* projections.getThreadShell(threadId))?.title, "Renamed by the agent"); | |
| 198 | }).pipe(Effect.provide(ThreadManagement.layer)), | |
| 199 | ); | |
| 200 | }); |
apps/server/src/mcp/McpToolAccess.test.ts created+306| ... | ... | @@ -0,0 +1,306 @@ |
| 1 | import { expect, it } from "@effect/vitest"; | |
| 2 | import { | |
| 3 | EnvironmentId, | |
| 4 | OrchestratorMcpFailure, | |
| 5 | ProviderInstanceId, | |
| 6 | ProviderInteractionMode, | |
| 7 | RuntimeMode, | |
| 8 | ThreadId, | |
| 9 | } from "@t3tools/contracts"; | |
| 10 | import * as Effect from "effect/Effect"; | |
| 11 | import * as Layer from "effect/Layer"; | |
| 12 | import * as Schema from "effect/Schema"; | |
| 13 | import { McpSchema, McpServer, Tool, Toolkit } from "effect/ai"; | |
| 14 | ||
| 15 | import { OrchestratorProjectionError } from "../orchestration-v2/Orchestrator.ts"; | |
| 16 | import * as ThreadManagement from "../orchestration-v2/ThreadManagementService.ts"; | |
| 17 | import * as McpHttpServer from "./McpHttpServer.ts"; | |
| 18 | import * as McpInvocationContext from "./McpInvocationContext.ts"; | |
| 19 | import * as McpToolAccess from "./McpToolAccess.ts"; | |
| 20 | import { liveThreadShell } from "./McpToolAccess.testkit.ts"; | |
| 21 | ||
| 22 | const supervisedThreadId = ThreadId.make("thread:supervised"); | |
| 23 | const planThreadId = ThreadId.make("thread:plan"); | |
| 24 | const fullAccessThreadId = ThreadId.make("thread:full-access"); | |
| 25 | const endedThreadId = ThreadId.make("thread:ended"); | |
| 26 | ||
| 27 | const shells = new Map([ | |
| 28 | [supervisedThreadId, liveThreadShell(supervisedThreadId, { runtimeMode: "approval-required" })], | |
| 29 | [ | |
| 30 | planThreadId, | |
| 31 | liveThreadShell(planThreadId, { runtimeMode: "approval-required", interactionMode: "plan" }), | |
| 32 | ], | |
| 33 | [fullAccessThreadId, liveThreadShell(fullAccessThreadId)], | |
| 34 | [endedThreadId, liveThreadShell(endedThreadId, { activeRunId: null })], | |
| 35 | ]); | |
| 36 | ||
| 37 | const threadCaller = (threadId: ThreadId): McpInvocationContext.McpInvocationScope => ({ | |
| 38 | environmentId: EnvironmentId.make("environment"), | |
| 39 | requestNamespace: `provider:${threadId}`, | |
| 40 | thread: { | |
| 41 | threadId, | |
| 42 | providerSessionId: `provider:${threadId}`, | |
| 43 | providerInstanceId: ProviderInstanceId.make("codex"), | |
| 44 | }, | |
| 45 | client: undefined, | |
| 46 | capabilities: new Set(["orchestration"]), | |
| 47 | issuedAt: 0, | |
| 48 | }); | |
| 49 | ||
| 50 | const clientCaller = ( | |
| 51 | runtimeModeCeiling: RuntimeMode, | |
| 52 | ): McpInvocationContext.McpInvocationScope => ({ | |
| 53 | environmentId: EnvironmentId.make("environment"), | |
| 54 | requestNamespace: "client:session", | |
| 55 | thread: undefined, | |
| 56 | client: { sessionId: "session", label: "Claude Code", runtimeModeCeiling }, | |
| 57 | capabilities: new Set(["orchestration"]), | |
| 58 | issuedAt: 0, | |
| 59 | }); | |
| 60 | ||
| 61 | const mcpClient = McpSchema.McpServerClient.of({ | |
| 62 | clientId: 1, | |
| 63 | protocolVersion: "2025-06-18", | |
| 64 | clientCapabilities: {}, | |
| 65 | clientInfo: { name: "mcp-test", version: "1" }, | |
| 66 | initializePayload: { | |
| 67 | protocolVersion: "2025-06-18", | |
| 68 | capabilities: {}, | |
| 69 | clientInfo: { name: "mcp-test", version: "1" }, | |
| 70 | }, | |
| 71 | getClient: Effect.die("unused"), | |
| 72 | }); | |
| 73 | ||
| 74 | // One tool per declaration, each answering with the modes it was handed, so | |
| 75 | // the table below reads what the gate let through. | |
| 76 | const probe = { | |
| 77 | success: Schema.Struct({ ran: Schema.String }), | |
| 78 | failure: OrchestratorMcpFailure, | |
| 79 | failureMode: "return" as const, | |
| 80 | dependencies: [ | |
| 81 | McpInvocationContext.McpInvocationContext, | |
| 82 | ThreadManagement.ThreadManagementService, | |
| 83 | ], | |
| 84 | }; | |
| 85 | const ProbeToolkit = Toolkit.make( | |
| 86 | Tool.make("reads", probe), | |
| 87 | Tool.make("reads_as_caller", probe), | |
| 88 | Tool.make("acts_as_caller", probe), | |
| 89 | Tool.make("writes", probe), | |
| 90 | Tool.make("writes_threads", { | |
| 91 | ...probe, | |
| 92 | parameters: Schema.Struct({ threadId: Schema.optional(ThreadId) }), | |
| 93 | }), | |
| 94 | Tool.make("starts_threads", { | |
| 95 | ...probe, | |
| 96 | parameters: Schema.Struct({ | |
| 97 | runtimeMode: Schema.optional(RuntimeMode), | |
| 98 | interactionMode: Schema.optional(ProviderInteractionMode), | |
| 99 | }), | |
| 100 | }), | |
| 101 | Tool.make("writes_environment", probe), | |
| 102 | ); | |
| 103 | const ran = Effect.succeed({ ran: "ran" }); | |
| 104 | const probeHandlers: McpToolAccess.Handlers<typeof ProbeToolkit.tools> = { | |
| 105 | reads: McpToolAccess.reads(() => ran), | |
| 106 | reads_as_caller: McpToolAccess.readsAsCaller(() => ran), | |
| 107 | acts_as_caller: McpToolAccess.actsAsCaller(() => ran), | |
| 108 | writes: McpToolAccess.writes(() => ran), | |
| 109 | writes_threads: McpToolAccess.writesThreads( | |
| 110 | (input) => [input.threadId], | |
| 111 | () => ran, | |
| 112 | ), | |
| 113 | starts_threads: McpToolAccess.startsThreads( | |
| 114 | (input) => input, | |
| 115 | (_, modes) => Effect.succeed({ ran: `${modes.runtimeMode}/${modes.interactionMode}` }), | |
| 116 | ), | |
| 117 | writes_environment: McpToolAccess.writesEnvironment(() => ran), | |
| 118 | }; | |
| 119 | const layerProbeHandlers = McpToolAccess.toLayer(ProbeToolkit, probeHandlers); | |
| 120 | ||
| 121 | const unchecked = () => ran; | |
| 122 | ||
| 123 | // What the compiler refuses: each line below must fail to typecheck, and its | |
| 124 | // `@ts-expect-error` fails the build if one ever compiles. Never called. | |
| 125 | export const refusedAtCompileTime = () => { | |
| 126 | McpToolAccess.toLayer(ProbeToolkit, { | |
| 127 | ...probeHandlers, | |
| 128 | // @ts-expect-error a handler that skips its declaration | |
| 129 | reads: unchecked, | |
| 130 | }); | |
| 131 | McpToolAccess.toLayer(ProbeToolkit, { | |
| 132 | ...probeHandlers, | |
| 133 | // @ts-expect-error a declaration's fields copied onto an unchecked handler | |
| 134 | reads: Object.assign( | |
| 135 | unchecked, | |
| 136 | McpToolAccess.reads(() => ran), | |
| 137 | ), | |
| 138 | }); | |
| 139 | // @ts-expect-error a declaration built outside McpToolAccess | |
| 140 | McpToolAccess.Declaration.make(unchecked); | |
| 141 | // @ts-expect-error a declaration constructed directly | |
| 142 | new McpToolAccess.Declaration(unchecked); | |
| 143 | // @ts-expect-error a handlers layer built outside McpToolAccess | |
| 144 | McpToolAccess.HandlersLayer.make(Layer.empty); | |
| 145 | // The refused layer below types its error and services as unknown, which is fine here. | |
| 146 | // @effect-diagnostics-next-line anyUnknownInErrorContext:off | |
| 147 | McpHttpServer.toolkitRegistration( | |
| 148 | ProbeToolkit, | |
| 149 | // @ts-expect-error a handlers layer that skipped `McpToolAccess.toLayer` | |
| 150 | ProbeToolkit.toLayer({ | |
| 151 | reads: unchecked, | |
| 152 | reads_as_caller: unchecked, | |
| 153 | acts_as_caller: unchecked, | |
| 154 | writes: unchecked, | |
| 155 | writes_threads: unchecked, | |
| 156 | starts_threads: unchecked, | |
| 157 | writes_environment: unchecked, | |
| 158 | }), | |
| 159 | ); | |
| 160 | }; | |
| 161 | ||
| 162 | const probeServer = (threads: Layer.Layer<ThreadManagement.ThreadManagementService>) => | |
| 163 | McpHttpServer.toolkitRegistration(ProbeToolkit, layerProbeHandlers).pipe( | |
| 164 | Layer.provideMerge(McpServer.McpServer.layer), | |
| 165 | Layer.provideMerge(threads), | |
| 166 | ); | |
| 167 | ||
| 168 | /** A probe's answer, or the gate's refusal, as the result's JSON text. */ | |
| 169 | const decodeOutcome = Schema.decodeUnknownEffect( | |
| 170 | Schema.fromJsonString( | |
| 171 | Schema.Union([Schema.Struct({ ran: Schema.String }), Schema.Struct({ code: Schema.String })]), | |
| 172 | ), | |
| 173 | ); | |
| 174 | ||
| 175 | const call = ( | |
| 176 | tool: string, | |
| 177 | scope: McpInvocationContext.McpInvocationScope, | |
| 178 | args: Record<string, unknown> = {}, | |
| 179 | ) => | |
| 180 | McpServer.McpServer.pipe( | |
| 181 | Effect.flatMap((server) => server.callTool({ name: tool, arguments: args })), | |
| 182 | Effect.flatMap(({ content }) => | |
| 183 | decodeOutcome(content.map((part) => (part.type === "text" ? part.text : "")).join("")), | |
| 184 | ), | |
| 185 | Effect.map((outcome) => ("code" in outcome ? outcome.code : outcome.ran)), | |
| 186 | Effect.provideService(McpInvocationContext.McpInvocationContext, scope), | |
| 187 | Effect.provideService(McpSchema.McpServerClient, mcpClient), | |
| 188 | ); | |
| 189 | ||
| 190 | const supervised = threadCaller(supervisedThreadId); | |
| 191 | const plan = threadCaller(planThreadId); | |
| 192 | const fullAccess = threadCaller(fullAccessThreadId); | |
| 193 | const ended = threadCaller(endedThreadId); | |
| 194 | const supervisedClient = clientCaller("approval-required"); | |
| 195 | const fullAccessClient = clientCaller("full-access"); | |
| 196 | // A live full-access thread whose credential may use its browser but not control threads. | |
| 197 | const previewOnly = { ...fullAccess, capabilities: new Set(["preview"] as const) }; | |
| 198 | ||
| 199 | it.effect.each([ | |
| 200 | // Reads are open to every caller, even one whose turn ended. | |
| 201 | ["reads", ended, {}, "ran"], | |
| 202 | ["reads", supervisedClient, {}, "ran"], | |
| 203 | ||
| 204 | // What belongs to the calling thread needs one; changing it needs its live turn. | |
| 205 | ["reads_as_caller", ended, {}, "ran"], | |
| 206 | ["reads_as_caller", fullAccessClient, {}, "thread_credential_required"], | |
| 207 | ["acts_as_caller", supervised, {}, "ran"], | |
| 208 | ["acts_as_caller", ended, {}, "parent_not_active"], | |
| 209 | ["acts_as_caller", fullAccessClient, {}, "thread_credential_required"], | |
| 210 | ||
| 211 | // Any change needs a thread caller's live turn; a client has none to lose. | |
| 212 | ["writes", supervised, {}, "ran"], | |
| 213 | ["writes", ended, {}, "parent_not_active"], | |
| 214 | ["writes", supervisedClient, {}, "ran"], | |
| 215 | // Uploads, scheduled tasks, projects and settings need a credential that controls threads. | |
| 216 | ["writes", previewOnly, {}, "capability_denied"], | |
| 217 | ["writes_environment", previewOnly, {}, "capability_denied"], | |
| 218 | ||
| 219 | // Changing a thread: only one that runs within the caller's modes. | |
| 220 | ["writes_threads", supervised, {}, "ran"], | |
| 221 | ["writes_threads", supervised, { threadId: planThreadId }, "ran"], | |
| 222 | [ | |
| 223 | "writes_threads", | |
| 224 | supervised, | |
| 225 | { threadId: fullAccessThreadId }, | |
| 226 | "runtime_mode_escalation_denied", | |
| 227 | ], | |
| 228 | ["writes_threads", plan, { threadId: supervisedThreadId }, "interaction_mode_escalation_denied"], | |
| 229 | ["writes_threads", fullAccess, { threadId: supervisedThreadId }, "ran"], | |
| 230 | ["writes_threads", ended, { threadId: supervisedThreadId }, "parent_not_active"], | |
| 231 | ["writes_threads", supervisedClient, { threadId: supervisedThreadId }, "ran"], | |
| 232 | [ | |
| 233 | "writes_threads", | |
| 234 | supervisedClient, | |
| 235 | { threadId: fullAccessThreadId }, | |
| 236 | "runtime_mode_escalation_denied", | |
| 237 | ], | |
| 238 | // A thread that does not exist is the tool's to report. | |
| 239 | ["writes_threads", supervised, { threadId: "thread:missing" }, "ran"], | |
| 240 | ||
| 241 | // Starting a thread: the caller's own modes or narrower. | |
| 242 | ["starts_threads", supervised, {}, "approval-required/default"], | |
| 243 | ["starts_threads", supervised, { runtimeMode: "full-access" }, "runtime_mode_escalation_denied"], | |
| 244 | ["starts_threads", plan, {}, "approval-required/plan"], | |
| 245 | ["starts_threads", plan, { interactionMode: "default" }, "interaction_mode_escalation_denied"], | |
| 246 | ["starts_threads", fullAccess, { runtimeMode: "auto", interactionMode: "plan" }, "auto/plan"], | |
| 247 | ["starts_threads", ended, {}, "parent_not_active"], | |
| 248 | ["starts_threads", supervisedClient, {}, "approval-required/default"], | |
| 249 | ["starts_threads", supervisedClient, { runtimeMode: "auto" }, "runtime_mode_escalation_denied"], | |
| 250 | ["starts_threads", fullAccessClient, { runtimeMode: "full-access" }, "full-access/default"], | |
| 251 | ||
| 252 | // Projects and environment settings need full access. | |
| 253 | ["writes_environment", fullAccess, {}, "ran"], | |
| 254 | ["writes_environment", supervised, {}, "capability_denied"], | |
| 255 | ["writes_environment", plan, {}, "capability_denied"], | |
| 256 | ["writes_environment", fullAccessClient, {}, "ran"], | |
| 257 | ["writes_environment", supervisedClient, {}, "capability_denied"], | |
| 258 | ] as const)("%s from %o with %o: %s", ([tool, scope, args, expected]) => | |
| 259 | call(tool, scope, args).pipe( | |
| 260 | Effect.map((outcome) => expect(outcome).toBe(expected)), | |
| 261 | Effect.provide( | |
| 262 | probeServer( | |
| 263 | Layer.mock(ThreadManagement.ThreadManagementService)({ | |
| 264 | getThreadShell: (threadId) => Effect.succeed(shells.get(threadId) ?? null), | |
| 265 | }), | |
| 266 | ), | |
| 267 | ), | |
| 268 | ), | |
| 269 | ); | |
| 270 | ||
| 271 | it("refuses a declaration or handlers layer built outside McpToolAccess", () => { | |
| 272 | // Private constructors only bind the type checker; Reflect.construct reaches them anyway. | |
| 273 | expect(() => Reflect.construct(McpToolAccess.Declaration, [unchecked])).toThrow(TypeError); | |
| 274 | expect(() => Reflect.construct(McpToolAccess.HandlersLayer, [Layer.empty])).toThrow(TypeError); | |
| 275 | }); | |
| 276 | ||
| 277 | it("refuses a declaration or handlers layer that only wears another's fields", () => { | |
| 278 | // The types already refuse these; at runtime the private fields are missing too. | |
| 279 | const copiedDeclaration = Object.assign( | |
| 280 | unchecked, | |
| 281 | McpToolAccess.reads(() => ran), | |
| 282 | ); | |
| 283 | expect(() => | |
| 284 | McpToolAccess.toLayer(ProbeToolkit, { | |
| 285 | ...probeHandlers, | |
| 286 | // @ts-expect-error a declaration's fields copied onto an unchecked handler | |
| 287 | reads: copiedDeclaration, | |
| 288 | }), | |
| 289 | ).toThrow(TypeError); | |
| 290 | const copiedLayer = Object.assign(Layer.empty, layerProbeHandlers); | |
| 291 | expect(() => McpToolAccess.HandlersLayer.layer(copiedLayer)).toThrow(TypeError); | |
| 292 | }); | |
| 293 | ||
| 294 | it.effect("refuses a change when the calling thread cannot be read", () => | |
| 295 | call("writes", supervised).pipe( | |
| 296 | Effect.map((outcome) => expect(outcome).toBe("orchestration_error")), | |
| 297 | Effect.provide( | |
| 298 | probeServer( | |
| 299 | Layer.mock(ThreadManagement.ThreadManagementService)({ | |
| 300 | getThreadShell: (threadId) => | |
| 301 | Effect.fail(new OrchestratorProjectionError({ threadId, cause: "unreadable" })), | |
| 302 | }), | |
| 303 | ), | |
| 304 | ), | |
| 305 | ), | |
| 306 | ); |
apps/server/src/mcp/McpToolAccess.testkit.ts created+115| ... | ... | @@ -0,0 +1,115 @@ |
| 1 | import { | |
| 2 | type OrchestrationV2ThreadProjection, | |
| 3 | type OrchestrationV2ThreadShell, | |
| 4 | ProjectId, | |
| 5 | type ProviderInteractionMode, | |
| 6 | ProviderInstanceId, | |
| 7 | RunId, | |
| 8 | type RuntimeMode, | |
| 9 | type ThreadId, | |
| 10 | } from "@t3tools/contracts"; | |
| 11 | import * as DateTime from "effect/DateTime"; | |
| 12 | import * as Effect from "effect/Effect"; | |
| 13 | import * as Layer from "effect/Layer"; | |
| 14 | ||
| 15 | import * as ThreadManagement from "../orchestration-v2/ThreadManagementService.ts"; | |
| 16 | ||
| 17 | const EPOCH = DateTime.makeUnsafe("2026-01-01T00:00:00.000Z"); | |
| 18 | ||
| 19 | /** | |
| 20 | * A thread in the middle of a turn on the `codex` instance: the shape the | |
| 21 | * access gate reads for a thread caller, and for the threads a caller targets. | |
| 22 | */ | |
| 23 | export const liveThreadShell = ( | |
| 24 | id: ThreadId, | |
| 25 | modes: { | |
| 26 | readonly runtimeMode?: RuntimeMode; | |
| 27 | readonly interactionMode?: ProviderInteractionMode; | |
| 28 | readonly activeRunId?: RunId | null; | |
| 29 | } = {}, | |
| 30 | ): OrchestrationV2ThreadShell => { | |
| 31 | const providerInstanceId = ProviderInstanceId.make("codex"); | |
| 32 | return { | |
| 33 | id, | |
| 34 | projectId: ProjectId.make("project:mcp-test"), | |
| 35 | title: id, | |
| 36 | providerInstanceId, | |
| 37 | modelSelection: { instanceId: providerInstanceId, model: "gpt-5" }, | |
| 38 | runtimeMode: modes.runtimeMode ?? "full-access", | |
| 39 | interactionMode: modes.interactionMode ?? "default", | |
| 40 | branch: null, | |
| 41 | worktreePath: null, | |
| 42 | createdBy: "user", | |
| 43 | creationSource: "web", | |
| 44 | activeProviderThreadId: null, | |
| 45 | lineage: { rootThreadId: id, parentThreadId: null, relationshipToParent: null }, | |
| 46 | forkedFrom: null, | |
| 47 | latestRunId: null, | |
| 48 | activeRunId: modes.activeRunId === undefined ? RunId.make("run:mcp-test") : modes.activeRunId, | |
| 49 | status: "running", | |
| 50 | pendingRuntimeRequest: null, | |
| 51 | latestVisibleMessage: null, | |
| 52 | latestUserMessageAt: null, | |
| 53 | hasActionableProposedPlan: false, | |
| 54 | itemCount: 0, | |
| 55 | visibleItemCount: 0, | |
| 56 | createdAt: EPOCH, | |
| 57 | updatedAt: EPOCH, | |
| 58 | archivedAt: null, | |
| 59 | settledOverride: null, | |
| 60 | settledAt: null, | |
| 61 | lastVisitedAt: null, | |
| 62 | deletedAt: null, | |
| 63 | }; | |
| 64 | }; | |
| 65 | ||
| 66 | /** | |
| 67 | * `ThreadManagementService` that answers only the access gate's lookups, | |
| 68 | * every thread a live full-access one. For tests of a tool rather than of who | |
| 69 | * may call it. | |
| 70 | */ | |
| 71 | export const liveThreadsLayer = Layer.mock(ThreadManagement.ThreadManagementService)({ | |
| 72 | getThreadShell: (threadId) => Effect.succeed(liveThreadShell(threadId)), | |
| 73 | }); | |
| 74 | ||
| 75 | /** | |
| 76 | * The thread of `shell` with no runs, messages or other records: what a | |
| 77 | * caller looks like once its turn has ended. | |
| 78 | */ | |
| 79 | export const idleThreadProjection = ( | |
| 80 | shell: OrchestrationV2ThreadShell, | |
| 81 | ): OrchestrationV2ThreadProjection => { | |
| 82 | const { | |
| 83 | status: _status, | |
| 84 | activeRunId: _activeRunId, | |
| 85 | latestRunId: _latestRunId, | |
| 86 | pendingRuntimeRequest: _pendingRuntimeRequest, | |
| 87 | latestVisibleMessage: _latestVisibleMessage, | |
| 88 | latestUserMessageAt: _latestUserMessageAt, | |
| 89 | hasActionableProposedPlan: _hasActionableProposedPlan, | |
| 90 | itemCount: _itemCount, | |
| 91 | visibleItemCount: _visibleItemCount, | |
| 92 | lastVisitedAt, | |
| 93 | ...thread | |
| 94 | } = shell; | |
| 95 | return { | |
| 96 | thread: { ...thread, lastVisitedAt: lastVisitedAt ?? null }, | |
| 97 | runs: [], | |
| 98 | attempts: [], | |
| 99 | nodes: [], | |
| 100 | subagents: [], | |
| 101 | providerSessions: [], | |
| 102 | providerThreads: [], | |
| 103 | providerTurns: [], | |
| 104 | runtimeRequests: [], | |
| 105 | messages: [], | |
| 106 | plans: [], | |
| 107 | turnItems: [], | |
| 108 | checkpointScopes: [], | |
| 109 | checkpoints: [], | |
| 110 | contextHandoffs: [], | |
| 111 | contextTransfers: [], | |
| 112 | visibleTurnItems: [], | |
| 113 | updatedAt: shell.updatedAt, | |
| 114 | }; | |
| 115 | }; |
apps/server/src/mcp/McpToolAccess.ts created+295| ... | ... | @@ -0,0 +1,295 @@ |
| 1 | import { | |
| 2 | OrchestratorMcpFailure, | |
| 3 | type ProviderInteractionMode, | |
| 4 | type RuntimeMode, | |
| 5 | type ThreadId, | |
| 6 | } from "@t3tools/contracts"; | |
| 7 | import * as Effect from "effect/Effect"; | |
| 8 | import * as Ref from "effect/Ref"; | |
| 9 | import * as Struct from "effect/Struct"; | |
| 10 | import type * as Layer from "effect/Layer"; | |
| 11 | import type * as Scope from "effect/Scope"; | |
| 12 | import type { Tool, Toolkit } from "effect/ai"; | |
| 13 | ||
| 14 | import { | |
| 15 | DispatchModeLimit, | |
| 16 | type DispatchModeRefusal, | |
| 17 | } from "../orchestration-v2/DispatchModeLimit.ts"; | |
| 18 | import * as McpInvocationContext from "./McpInvocationContext.ts"; | |
| 19 | import { resolveInteractionMode, resolveRuntimeMode } from "./OrchestratorMcpService.ts"; | |
| 20 | import { | |
| 21 | assertFullAccess, | |
| 22 | assertLiveCaller, | |
| 23 | assertTargetWithinLimits, | |
| 24 | type Caller, | |
| 25 | loadCaller, | |
| 26 | readCaller, | |
| 27 | unavailable, | |
| 28 | } from "./threadAccess.ts"; | |
| 29 | ||
| 30 | // Only the classes below assign these, from their static blocks, so nothing | |
| 31 | // outside this module can build a declaration or a handlers layer. | |
| 32 | ||
| 33 | /** | |
| 34 | * Both constructors demand this. Their private constructors only stop the type | |
| 35 | * checker; this also stops `Reflect.construct` and friends at runtime. | |
| 36 | */ | |
| 37 | const builtHere: unique symbol = Symbol("t3/mcp/McpToolAccess/builtHere"); | |
| 38 | ||
| 39 | const refuseOutsideConstruction = (token: symbol) => { | |
| 40 | if (token !== builtHere) { | |
| 41 | throw new TypeError("Only McpToolAccess builds MCP tool declarations and handler layers."); | |
| 42 | } | |
| 43 | }; | |
| 44 | ||
| 45 | /** Builds a declaration; only the declaration functions below call it. */ | |
| 46 | let declare: <P, A, E, R>( | |
| 47 | handle: (params: P) => Effect.Effect<A, E, R>, | |
| 48 | ) => Declaration<(params: P) => Effect.Effect<A, E, R>>; | |
| 49 | ||
| 50 | type CheckedHandlerOf<D> = D extends Declaration<infer Handler> ? Handler : never; | |
| 51 | ||
| 52 | /** Turns each declaration back into the handler it checks, keeping its type. */ | |
| 53 | interface CheckedHandler extends Struct.Lambda { | |
| 54 | <Handler>(declaration: Declaration<Handler>): Handler; | |
| 55 | readonly "~lambda.out": CheckedHandlerOf<this["~lambda.in"]>; | |
| 56 | } | |
| 57 | let checkedHandler: CheckedHandler; | |
| 58 | ||
| 59 | /** Only `toLayer` below builds one. */ | |
| 60 | let handlersLayer: <Tools extends Record<string, Tool.Any>, EX, RX>( | |
| 61 | layer: Layer.Layer<Tool.HandlersFor<Tools>, EX, RX>, | |
| 62 | ) => HandlersLayer<Tools, EX, RX>; | |
| 63 | ||
| 64 | /** | |
| 65 | * Who may call a T3 MCP tool. Every handler is built by one of the | |
| 66 | * declarations below, which say what the tool does. `toLayer` accepts only | |
| 67 | * declarations, and `/mcp` registers only layers `toLayer` built, so a tool | |
| 68 | * without a decision here does not compile. Both are nominal classes, so a | |
| 69 | * handler or layer cannot pass for one by copying its fields. Effect's own | |
| 70 | * registration functions accept any handler, so the | |
| 71 | * `t3code/no-raw-mcp-registration` lint rule keeps Effect's `McpServer` inside | |
| 72 | * McpHttpServer. | |
| 73 | * | |
| 74 | * Parameters choose the target; the caller sets the limits: a thread caller | |
| 75 | * its own runtime and interaction modes, an outside client the ceiling it was | |
| 76 | * approved with. Nothing a caller starts or changes may run with broader | |
| 77 | * modes, and a thread caller changes things only while its own run is live. | |
| 78 | * A refusal is an `OrchestratorMcpFailure`, so the compiler requires it in the | |
| 79 | * tool's failure schema, and `ThreadManagementService` in its dependencies. | |
| 80 | * | |
| 81 | * The declaration checks the caller before the handler runs. Handlers still | |
| 82 | * check what only they can see, such as a queued run belonging to its thread. | |
| 83 | */ | |
| 84 | export class Declaration<out Handler> { | |
| 85 | // A private field makes the class nominal, and only this module can build | |
| 86 | // one: its constructor is private and the static block hands the only way | |
| 87 | // in to module-scoped functions. Copying a declaration's fields onto | |
| 88 | // anything else fails to typecheck and, at runtime, to run. | |
| 89 | readonly #handle: Handler; | |
| 90 | private constructor(token: typeof builtHere, handle: Handler) { | |
| 91 | refuseOutsideConstruction(token); | |
| 92 | this.#handle = handle; | |
| 93 | } | |
| 94 | static { | |
| 95 | declare = (handle) => new Declaration(builtHere, handle); | |
| 96 | checkedHandler = Struct.lambda<CheckedHandler>((declaration) => declaration.#handle); | |
| 97 | } | |
| 98 | } | |
| 99 | ||
| 100 | /** | |
| 101 | * The caller of a tool that changes something. Tools that act for a | |
| 102 | * capability of their own (preview, device, worktree, pull requests) check it | |
| 103 | * themselves. | |
| 104 | */ | |
| 105 | const writingCaller = loadCaller().pipe(Effect.tap(assertLiveCaller)); | |
| 106 | ||
| 107 | /** The same, for tools whose only capability is controlling threads. */ | |
| 108 | const orchestratingCaller = readCaller().pipe(Effect.tap(assertLiveCaller)); | |
| 109 | ||
| 110 | const requireThreadCaller = McpInvocationContext.McpInvocationContext.pipe( | |
| 111 | Effect.flatMap((scope) => McpInvocationContext.requireThreadScope(scope, "This tool")), | |
| 112 | ); | |
| 113 | ||
| 114 | /** Changes nothing, so every caller may call it. */ | |
| 115 | export const reads = <P, A, E, R>(handle: (params: P) => Effect.Effect<A, E, R>) => | |
| 116 | declare((params: P) => handle(params)); | |
| 117 | ||
| 118 | /** Reads what belongs to the calling T3 thread, such as its preview tabs or devices. */ | |
| 119 | export const readsAsCaller = <P, A, E, R>(handle: (params: P) => Effect.Effect<A, E, R>) => | |
| 120 | declare((params: P) => requireThreadCaller.pipe(Effect.flatMap(() => handle(params)))); | |
| 121 | ||
| 122 | /** | |
| 123 | * Acts as the calling T3 thread (its subagents, preview tabs, devices, | |
| 124 | * worktree) while that thread's run is live. Only an agent running inside a | |
| 125 | * T3 thread has one. | |
| 126 | */ | |
| 127 | export const actsAsCaller = <P, A, E, R>(handle: (params: P) => Effect.Effect<A, E, R>) => | |
| 128 | declare((params: P) => | |
| 129 | requireThreadCaller.pipe( | |
| 130 | Effect.flatMap(() => writingCaller), | |
| 131 | Effect.flatMap(() => handle(params)), | |
| 132 | ), | |
| 133 | ); | |
| 134 | ||
| 135 | /** Changes something that belongs to no thread, such as a pending upload or a scheduled task. */ | |
| 136 | export const writes = <P, A, E, R>(handle: (params: P) => Effect.Effect<A, E, R>) => | |
| 137 | declare((params: P) => orchestratingCaller.pipe(Effect.flatMap(() => handle(params)))); | |
| 138 | ||
| 139 | /** | |
| 140 | * Changes the threads `threads` names. An omitted id is the caller's own | |
| 141 | * thread; any other thread must run within the caller's modes, checked here | |
| 142 | * and again by the orchestrator when it applies each command. A thread that | |
| 143 | * does not exist is the handler's to report. | |
| 144 | */ | |
| 145 | export const writesThreads = <P, A, E, R>( | |
| 146 | threads: (params: P) => ReadonlyArray<ThreadId | undefined>, | |
| 147 | handle: (params: P) => Effect.Effect<A, E, R>, | |
| 148 | ) => | |
| 149 | declare((params: P) => | |
| 150 | Effect.gen(function* () { | |
| 151 | const caller = yield* writingCaller; | |
| 152 | for (const threadId of threads(params)) { | |
| 153 | if (threadId === undefined || threadId === caller.scope.thread?.threadId) continue; | |
| 154 | const target = yield* caller.threads | |
| 155 | .getThreadShell(threadId) | |
| 156 | .pipe(Effect.mapError(unavailable)); | |
| 157 | if (target !== null && target.deletedAt === null) { | |
| 158 | yield* assertTargetWithinLimits(caller.limits, target); | |
| 159 | } | |
| 160 | } | |
| 161 | // The target's user can raise its modes after the check above; the | |
| 162 | // orchestrator checks again under the thread's lock and records its | |
| 163 | // refusal here, since handlers wrap dispatch errors their own way. | |
| 164 | const refused = yield* Ref.make<DispatchModeRefusal | undefined>(undefined); | |
| 165 | return yield* handle(params).pipe( | |
| 166 | Effect.provideService(DispatchModeLimit, { ...caller.limits, refused }), | |
| 167 | Effect.catch((error) => | |
| 168 | Effect.flatMap(Ref.get(refused), (refusal) => | |
| 169 | Effect.fail<E | OrchestratorMcpFailure>( | |
| 170 | refusal === undefined ? error : escalationDenied(refusal), | |
| 171 | ), | |
| 172 | ), | |
| 173 | ), | |
| 174 | ); | |
| 175 | }), | |
| 176 | ); | |
| 177 | ||
| 178 | /** How an agent hears that a thread it targets was raised above its modes mid-call. */ | |
| 179 | const escalationDenied = (refusal: DispatchModeRefusal) => | |
| 180 | new OrchestratorMcpFailure({ | |
| 181 | code: | |
| 182 | refusal.mode === "runtime" | |
| 183 | ? "runtime_mode_escalation_denied" | |
| 184 | : "interaction_mode_escalation_denied", | |
| 185 | message: `Thread ${refusal.threadId} now runs in ${refusal.runtimeMode}/${refusal.interactionMode} mode, above this caller's. Its user changed it while this call ran.`, | |
| 186 | }); | |
| 187 | ||
| 188 | /** The modes a started thread runs with: those requested, else the caller's own. */ | |
| 189 | export interface StartedModes { | |
| 190 | readonly runtimeMode: RuntimeMode; | |
| 191 | readonly interactionMode: ProviderInteractionMode; | |
| 192 | } | |
| 193 | ||
| 194 | /** Starts threads with the modes `modes` requests, which may not be broader than the caller's. */ | |
| 195 | export const startsThreads = <P, A, E, R>( | |
| 196 | modes: (params: P) => { | |
| 197 | readonly runtimeMode?: RuntimeMode | undefined; | |
| 198 | readonly interactionMode?: ProviderInteractionMode | undefined; | |
| 199 | }, | |
| 200 | handle: (params: P, modes: StartedModes) => Effect.Effect<A, E, R>, | |
| 201 | ) => | |
| 202 | declare((params: P) => | |
| 203 | Effect.gen(function* () { | |
| 204 | const { limits } = yield* writingCaller; | |
| 205 | const requested = modes(params); | |
| 206 | const started: StartedModes = { | |
| 207 | runtimeMode: yield* resolveRuntimeMode(limits.runtimeMode, requested.runtimeMode), | |
| 208 | interactionMode: yield* resolveInteractionMode( | |
| 209 | limits.interactionMode, | |
| 210 | requested.interactionMode, | |
| 211 | ), | |
| 212 | }; | |
| 213 | return yield* handle(params, started); | |
| 214 | }), | |
| 215 | ); | |
| 216 | ||
| 217 | const fullAccessRequired = | |
| 218 | "Changing projects or environment settings needs a live full-access/default calling thread or a full-access client."; | |
| 219 | ||
| 220 | /** | |
| 221 | * Changes projects or environment settings, which needs a full-access/default | |
| 222 | * caller. `check` re-checks the caller wherever the handler waits before | |
| 223 | * writing, such as for a lock, since the caller's modes can change meanwhile. | |
| 224 | */ | |
| 225 | export const writesEnvironment = <P, A, E, R>( | |
| 226 | handle: ( | |
| 227 | params: P, | |
| 228 | check: Effect.Effect<Caller, OrchestratorMcpFailure, CheckServices>, | |
| 229 | ) => Effect.Effect<A, E, R>, | |
| 230 | ) => { | |
| 231 | const check = orchestratingCaller.pipe( | |
| 232 | Effect.tap((caller) => assertFullAccess(caller, fullAccessRequired)), | |
| 233 | ); | |
| 234 | return declare((params: P) => check.pipe(Effect.flatMap(() => handle(params, check)))); | |
| 235 | }; | |
| 236 | ||
| 237 | /** What a declaration's own check needs. */ | |
| 238 | type CheckServices = Effect.Services<typeof orchestratingCaller>; | |
| 239 | ||
| 240 | /** | |
| 241 | * Each handler of `Handlers`, built by one of the declarations above. A | |
| 242 | * declaration is never callable, which rules out a handler function wearing a | |
| 243 | * declaration's fields. | |
| 244 | */ | |
| 245 | type Declarations<Handlers> = { | |
| 246 | readonly [Name in keyof Handlers]: Declaration<Handlers[Name]> & NotCallable; | |
| 247 | }; | |
| 248 | ||
| 249 | /** Anything but a function. */ | |
| 250 | type NotCallable = { readonly call?: never } & { readonly apply?: never }; | |
| 251 | ||
| 252 | /** A toolkit's handlers, each built by one of the declarations above. */ | |
| 253 | export type Handlers<Tools extends Record<string, Tool.Any>> = Declarations< | |
| 254 | Toolkit.HandlersFrom<Tools> | |
| 255 | >; | |
| 256 | ||
| 257 | /** | |
| 258 | * A toolkit's handler layer built by `toLayer`, the only kind `/mcp` | |
| 259 | * registers. Like a declaration it is nominal, so nothing else passes for one. | |
| 260 | */ | |
| 261 | export class HandlersLayer<Tools extends Record<string, Tool.Any>, EX = never, RX = never> { | |
| 262 | readonly #layer: Layer.Layer<Tool.HandlersFor<Tools>, EX, RX>; | |
| 263 | private constructor( | |
| 264 | token: typeof builtHere, | |
| 265 | layer: Layer.Layer<Tool.HandlersFor<Tools>, EX, RX>, | |
| 266 | ) { | |
| 267 | refuseOutsideConstruction(token); | |
| 268 | this.#layer = layer; | |
| 269 | } | |
| 270 | static { | |
| 271 | handlersLayer = (layer) => new HandlersLayer(builtHere, layer); | |
| 272 | } | |
| 273 | /** The handlers, for registering this toolkit on the MCP server. */ | |
| 274 | static layer<Tools extends Record<string, Tool.Any>, EX, RX>( | |
| 275 | handlers: HandlersLayer<Tools, EX, RX>, | |
| 276 | ) { | |
| 277 | return handlers.#layer; | |
| 278 | } | |
| 279 | } | |
| 280 | ||
| 281 | const checkedHandlers = <Handlers>(declarations: Declarations<Handlers>): Handlers => | |
| 282 | Struct.map(declarations, checkedHandler); | |
| 283 | ||
| 284 | /** `Toolkit.toLayer` for handlers that all declare their access. */ | |
| 285 | export const toLayer = <Tools extends Record<string, Tool.Any>, EX = never, RX = never>( | |
| 286 | toolkit: Toolkit.Toolkit<Tools>, | |
| 287 | build: Handlers<Tools> | Effect.Effect<Handlers<Tools>, EX, RX>, | |
| 288 | ): HandlersLayer<Tools, EX, Exclude<RX, Scope.Scope>> => | |
| 289 | handlersLayer( | |
| 290 | toolkit.toLayer( | |
| 291 | Effect.isEffect(build) | |
| 292 | ? Effect.map(build, checkedHandlers<Toolkit.HandlersFrom<Tools>>) | |
| 293 | : checkedHandlers(build), | |
| 294 | ), | |
| 295 | ); |
apps/server/src/mcp/OrchestratorMcpService.test.ts+494-1| ... | ... | @@ -1,8 +1,12 @@ |
| 1 | 1 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 2 | 2 | import { assert, describe, it } from "@effect/vitest"; |
| 3 | 3 | import { |
| 4 | CommandId, | |
| 4 | 5 | EnvironmentId, |
| 5 | 6 | NodeId, |
| 7 | type OrchestrationV2ThreadShell, | |
| 8 | type ScheduledTask, | |
| 9 | ScheduledTaskId, | |
| 6 | 10 | ProjectId, |
| 7 | 11 | ProviderDriverKind, |
| 8 | 12 | ProviderInstanceId, |
| ... | ... | @@ -16,7 +20,14 @@ import * as Effect from "effect/Effect"; |
| 16 | 20 | import * as Layer from "effect/Layer"; |
| 17 | 21 | import * as Ref from "effect/Ref"; |
| 18 | 22 | |
| 19 | import { OrchestratorProjectionError } from "../orchestration-v2/Orchestrator.ts"; | |
| 23 | import { | |
| 24 | DispatchModeLimit, | |
| 25 | type DispatchModeRefusal, | |
| 26 | } from "../orchestration-v2/DispatchModeLimit.ts"; | |
| 27 | import { | |
| 28 | OrchestratorProjectionError, | |
| 29 | OrchestratorThreadAboveModeLimitError, | |
| 30 | } from "../orchestration-v2/Orchestrator.ts"; | |
| 20 | 31 | import type { ProviderAdapterV2Shape } from "../orchestration-v2/ProviderAdapter.ts"; |
| 21 | 32 | import * as ProviderAdapterRegistry from "../orchestration-v2/ProviderAdapterRegistry.ts"; |
| 22 | 33 | import * as ThreadManagementService from "../orchestration-v2/ThreadManagementService.ts"; |
| ... | ... | @@ -26,6 +37,7 @@ import * as ProjectService from "../project/ProjectService.ts"; |
| 26 | 37 | import * as ScheduledTaskService from "../scheduledTasks/ScheduledTaskService.ts"; |
| 27 | 38 | import * as SecretRequests from "../secrets/SecretRequests.ts"; |
| 28 | 39 | import type { McpInvocationScope } from "./McpInvocationContext.ts"; |
| 40 | import { idleThreadProjection, liveThreadShell } from "./McpToolAccess.testkit.ts"; | |
| 29 | 41 | import * as OrchestratorMcpService from "./OrchestratorMcpService.ts"; |
| 30 | 42 | |
| 31 | 43 | describe("OrchestratorMcpService", () => { |
| ... | ... | @@ -283,6 +295,8 @@ describe("OrchestratorMcpService", () => { |
| 283 | 295 | Layer.mock(ThreadManagementService.ThreadManagementService)({ |
| 284 | 296 | getThreadRecords: (threadId) => |
| 285 | 297 | Effect.succeed(threadId === parentThreadId ? parentProjection : childProjection), |
| 298 | // The child still runs within the parent's modes. | |
| 299 | getThreadShell: (threadId) => Effect.succeed(liveThreadShell(threadId)), | |
| 286 | 300 | dispatch: (command) => |
| 287 | 301 | Ref.update(dispatched, (commands) => [...commands, command]).pipe( |
| 288 | 302 | Effect.as({} as never), |
| ... | ... | @@ -357,6 +371,8 @@ describe("OrchestratorMcpService", () => { |
| 357 | 371 | Layer.mock(ThreadManagementService.ThreadManagementService)({ |
| 358 | 372 | getThreadRecords: (threadId) => |
| 359 | 373 | Effect.succeed(threadId === parentThreadId ? parentProjection : childProjection), |
| 374 | // The child still runs within the parent's modes. | |
| 375 | getThreadShell: (threadId) => Effect.succeed(liveThreadShell(threadId)), | |
| 360 | 376 | dispatch: (command) => |
| 361 | 377 | Ref.update(dispatched, (commands) => [...commands, command]).pipe( |
| 362 | 378 | Effect.andThen(Effect.fail(new Error("simulated stop failure") as never)), |
| ... | ... | @@ -434,6 +450,8 @@ describe("OrchestratorMcpService", () => { |
| 434 | 450 | Layer.mock(ThreadManagementService.ThreadManagementService)({ |
| 435 | 451 | getThreadRecords: (threadId) => |
| 436 | 452 | Effect.succeed(threadId === parentThreadId ? parentProjection : childProjection), |
| 453 | // The child still runs within the parent's modes. | |
| 454 | getThreadShell: (threadId) => Effect.succeed(liveThreadShell(threadId)), | |
| 437 | 455 | dispatch: (command) => |
| 438 | 456 | Ref.update(dispatched, (commands) => [...commands, command]).pipe( |
| 439 | 457 | Effect.andThen( |
| ... | ... | @@ -479,6 +497,258 @@ describe("OrchestratorMcpService", () => { |
| 479 | 497 | }).pipe(Effect.provide(OrchestratorMcpService.layer.pipe(Layer.provide(layerDependencies)))); |
| 480 | 498 | }), |
| 481 | 499 | ); |
| 500 | ||
| 501 | it.effect("refuses to cancel a task whose child now runs above the parent's modes", () => | |
| 502 | Effect.gen(function* () { | |
| 503 | const parentThreadId = ThreadId.make("thread:mcp-cancel-dispose-failed-parent"); | |
| 504 | const childThreadId = ThreadId.make("thread:mcp-cancel-dispose-failed-child"); | |
| 505 | const childRunId = RunId.make("run:mcp-cancel-dispose-failed-child"); | |
| 506 | const taskId = NodeId.make("node:mcp-cancel-dispose-failed-task"); | |
| 507 | const dispatched = yield* Ref.make<ReadonlyArray<unknown>>([]); | |
| 508 | // The parent runs Supervised in plan mode. | |
| 509 | const parentProjection = { | |
| 510 | thread: { id: parentThreadId, runtimeMode: "approval-required", interactionMode: "plan" }, | |
| 511 | runs: [], | |
| 512 | contextTransfers: [], | |
| 513 | subagents: [ | |
| 514 | { | |
| 515 | id: taskId, | |
| 516 | threadId: parentThreadId, | |
| 517 | origin: "app_owned", | |
| 518 | childThreadId, | |
| 519 | driver: "codex", | |
| 520 | model: "gpt-5.6-terra", | |
| 521 | result: null, | |
| 522 | completionDelivery: { state: "pending" }, | |
| 523 | }, | |
| 524 | ], | |
| 525 | } as unknown as OrchestrationV2ThreadProjection; | |
| 526 | const childProjection = { | |
| 527 | thread: { id: childThreadId }, | |
| 528 | runs: [{ id: childRunId, status: "running" }], | |
| 529 | contextTransfers: [], | |
| 530 | messages: [], | |
| 531 | subagents: [], | |
| 532 | providerThreads: [], | |
| 533 | } as unknown as OrchestrationV2ThreadProjection; | |
| 534 | const layerDependencies = Layer.mergeAll( | |
| 535 | NodeServices.layer, | |
| 536 | Layer.mock(ThreadManagementService.ThreadManagementService)({ | |
| 537 | getThreadRecords: (threadId) => | |
| 538 | Effect.succeed(threadId === parentThreadId ? parentProjection : childProjection), | |
| 539 | // Its user has since raised the child to full access. | |
| 540 | getThreadShell: (threadId) => Effect.succeed(liveThreadShell(threadId)), | |
| 541 | dispatch: (command) => | |
| 542 | Ref.update(dispatched, (commands) => [...commands, command]).pipe( | |
| 543 | Effect.andThen( | |
| 544 | command.type === "delegated_task.completion-delivery.dispose" | |
| 545 | ? Effect.fail(new Error("simulated disposal failure") as never) | |
| 546 | : Effect.succeed({} as never), | |
| 547 | ), | |
| 548 | ), | |
| 549 | stopDelegatedTasks: () => Effect.void, | |
| 550 | }), | |
| 551 | Layer.mock(ProviderRegistry.ProviderRegistry)({ getProviders: Effect.succeed([]) }), | |
| 552 | Layer.mock(ProviderAdapterRegistry.ProviderAdapterRegistryV2)({ | |
| 553 | list: () => Effect.succeed([]), | |
| 554 | }), | |
| 555 | Layer.mock(ProjectService.ProjectService)({}), | |
| 556 | Layer.mock(SecretRequests.SecretRequests)({}), | |
| 557 | Layer.mock(ScheduledTaskService.ScheduledTaskService)({}), | |
| 558 | ); | |
| 559 | const scope: McpInvocationScope = { | |
| 560 | environmentId: EnvironmentId.make("environment:mcp-cancel-dispose-failed"), | |
| 561 | requestNamespace: "provider-session:mcp-cancel-dispose-failed", | |
| 562 | thread: { | |
| 563 | threadId: parentThreadId, | |
| 564 | providerSessionId: "provider-session:mcp-cancel-dispose-failed", | |
| 565 | providerInstanceId: ProviderInstanceId.make("codex"), | |
| 566 | }, | |
| 567 | client: undefined, | |
| 568 | capabilities: new Set(["orchestration"]), | |
| 569 | issuedAt: 1, | |
| 570 | }; | |
| 571 | ||
| 572 | yield* Effect.gen(function* () { | |
| 573 | const service = yield* OrchestratorMcpService.OrchestratorMcpService; | |
| 574 | const error = yield* service | |
| 575 | .cancelTask(scope, { taskId, clientRequestId: "cancel-above-modes" }) | |
| 576 | .pipe(Effect.flip); | |
| 577 | assert.equal(error.code, "runtime_mode_escalation_denied"); | |
| 578 | assert.deepEqual(yield* Ref.get(dispatched), []); | |
| 579 | }).pipe(Effect.provide(OrchestratorMcpService.layer.pipe(Layer.provide(layerDependencies)))); | |
| 580 | }), | |
| 581 | ); | |
| 582 | ||
| 583 | /** | |
| 584 | * A Supervised parent cancels its Supervised child, under which a task | |
| 585 | * now runs at full access. Reports what the cancel did. | |
| 586 | */ | |
| 587 | const cancelOverRaisedGrandchild = (child: { | |
| 588 | readonly deleted: boolean; | |
| 589 | /** The grandchild passes the check, and its user raises it before the stop reaches it. */ | |
| 590 | readonly raisedDuringStop?: boolean; | |
| 591 | }) => | |
| 592 | Effect.gen(function* () { | |
| 593 | const parentThreadId = ThreadId.make("thread:mcp-cancel-grandchild-parent"); | |
| 594 | const childThreadId = ThreadId.make("thread:mcp-cancel-grandchild-child"); | |
| 595 | const grandchildThreadId = ThreadId.make("thread:mcp-cancel-grandchild-grandchild"); | |
| 596 | const taskId = NodeId.make("node:mcp-cancel-grandchild-task"); | |
| 597 | const dispatched = yield* Ref.make<ReadonlyArray<unknown>>([]); | |
| 598 | const stoppedBelow = yield* Ref.make(false); | |
| 599 | const appOwnedTask = (id: string, threadId: ThreadId, childId: ThreadId) => ({ | |
| 600 | id: NodeId.make(id), | |
| 601 | threadId, | |
| 602 | origin: "app_owned", | |
| 603 | childThreadId: childId, | |
| 604 | driver: "codex", | |
| 605 | model: "gpt-5.6-terra", | |
| 606 | result: null, | |
| 607 | completionDelivery: { state: "pending" }, | |
| 608 | }); | |
| 609 | // A Supervised parent delegated a Supervised child, which delegated a task of its own. | |
| 610 | const projections = new Map([ | |
| 611 | [ | |
| 612 | parentThreadId, | |
| 613 | { | |
| 614 | thread: { | |
| 615 | id: parentThreadId, | |
| 616 | runtimeMode: "approval-required", | |
| 617 | interactionMode: "default", | |
| 618 | }, | |
| 619 | runs: [], | |
| 620 | contextTransfers: [], | |
| 621 | subagents: [appOwnedTask(taskId, parentThreadId, childThreadId)], | |
| 622 | }, | |
| 623 | ], | |
| 624 | [ | |
| 625 | childThreadId, | |
| 626 | { | |
| 627 | thread: { id: childThreadId }, | |
| 628 | runs: [{ id: RunId.make("run:mcp-cancel-grandchild-child"), status: "running" }], | |
| 629 | contextTransfers: [], | |
| 630 | messages: [], | |
| 631 | subagents: [ | |
| 632 | appOwnedTask("node:mcp-cancel-grandchild-below", childThreadId, grandchildThreadId), | |
| 633 | ], | |
| 634 | providerThreads: [], | |
| 635 | }, | |
| 636 | ], | |
| 637 | [ | |
| 638 | grandchildThreadId, | |
| 639 | { | |
| 640 | thread: { id: grandchildThreadId }, | |
| 641 | runs: [], | |
| 642 | contextTransfers: [], | |
| 643 | messages: [], | |
| 644 | subagents: [], | |
| 645 | providerThreads: [], | |
| 646 | }, | |
| 647 | ], | |
| 648 | ]) as unknown as ReadonlyMap<ThreadId, OrchestrationV2ThreadProjection>; | |
| 649 | const layerDependencies = Layer.mergeAll( | |
| 650 | NodeServices.layer, | |
| 651 | Layer.mock(ThreadManagementService.ThreadManagementService)({ | |
| 652 | getThreadRecords: (threadId) => Effect.succeed(projections.get(threadId)!), | |
| 653 | // The child still runs Supervised; its user has since raised the task under it | |
| 654 | // to full access. | |
| 655 | getThreadShell: (threadId) => | |
| 656 | Effect.succeed( | |
| 657 | threadId === grandchildThreadId && child.raisedDuringStop !== true | |
| 658 | ? liveThreadShell(threadId) | |
| 659 | : threadId === childThreadId && child.deleted | |
| 660 | ? null | |
| 661 | : liveThreadShell(threadId, { runtimeMode: "approval-required" }), | |
| 662 | ), | |
| 663 | dispatch: (command) => | |
| 664 | Ref.update(dispatched, (commands) => [...commands, command]).pipe( | |
| 665 | Effect.as({} as never), | |
| 666 | ), | |
| 667 | // Stands in for the orchestrator, which finds the grandchild raised | |
| 668 | // under its lock, when the stop runs under the parent's limit. | |
| 669 | stopDelegatedTasks: () => | |
| 670 | Effect.gen(function* () { | |
| 671 | const limit = yield* DispatchModeLimit; | |
| 672 | if (child.raisedDuringStop === true && limit?.refused !== undefined) { | |
| 673 | const refusal: DispatchModeRefusal = { | |
| 674 | threadId: grandchildThreadId, | |
| 675 | mode: "runtime", | |
| 676 | runtimeMode: "full-access", | |
| 677 | interactionMode: "default", | |
| 678 | }; | |
| 679 | yield* Ref.set(limit.refused, refusal); | |
| 680 | return yield* new OrchestratorThreadAboveModeLimitError({ | |
| 681 | commandId: CommandId.make("stop-grandchild"), | |
| 682 | threadId: grandchildThreadId, | |
| 683 | mode: "runtime", | |
| 684 | runtimeMode: "full-access", | |
| 685 | interactionMode: "default", | |
| 686 | }); | |
| 687 | } | |
| 688 | yield* Ref.set(stoppedBelow, true); | |
| 689 | }), | |
| 690 | }), | |
| 691 | Layer.mock(ProviderRegistry.ProviderRegistry)({ getProviders: Effect.succeed([]) }), | |
| 692 | Layer.mock(ProviderAdapterRegistry.ProviderAdapterRegistryV2)({ | |
| 693 | list: () => Effect.succeed([]), | |
| 694 | }), | |
| 695 | Layer.mock(ProjectService.ProjectService)({}), | |
| 696 | Layer.mock(SecretRequests.SecretRequests)({}), | |
| 697 | Layer.mock(ScheduledTaskService.ScheduledTaskService)({}), | |
| 698 | ); | |
| 699 | const scope: McpInvocationScope = { | |
| 700 | environmentId: EnvironmentId.make("environment:mcp-cancel-grandchild"), | |
| 701 | requestNamespace: "provider-session:mcp-cancel-grandchild", | |
| 702 | thread: { | |
| 703 | threadId: parentThreadId, | |
| 704 | providerSessionId: "provider-session:mcp-cancel-grandchild", | |
| 705 | providerInstanceId: ProviderInstanceId.make("codex"), | |
| 706 | }, | |
| 707 | client: undefined, | |
| 708 | capabilities: new Set(["orchestration"]), | |
| 709 | issuedAt: 1, | |
| 710 | }; | |
| 711 | ||
| 712 | return yield* Effect.gen(function* () { | |
| 713 | const service = yield* OrchestratorMcpService.OrchestratorMcpService; | |
| 714 | const error = yield* service | |
| 715 | .cancelTask(scope, { taskId, clientRequestId: "cancel-grandchild-above-modes" }) | |
| 716 | .pipe(Effect.flip); | |
| 717 | return { | |
| 718 | code: error.code, | |
| 719 | dispatched: yield* Ref.get(dispatched), | |
| 720 | stoppedBelow: yield* Ref.get(stoppedBelow), | |
| 721 | }; | |
| 722 | }).pipe(Effect.provide(OrchestratorMcpService.layer.pipe(Layer.provide(layerDependencies)))); | |
| 723 | }); | |
| 724 | ||
| 725 | it.effect("refuses to cancel a task when a task under it now runs above the parent's modes", () => | |
| 726 | Effect.gen(function* () { | |
| 727 | assert.deepEqual(yield* cancelOverRaisedGrandchild({ deleted: false }), { | |
| 728 | code: "runtime_mode_escalation_denied", | |
| 729 | dispatched: [], | |
| 730 | stoppedBelow: false, | |
| 731 | }); | |
| 732 | }), | |
| 733 | ); | |
| 734 | ||
| 735 | it.effect("reports a task under the child that its user raises while it is being stopped", () => | |
| 736 | Effect.gen(function* () { | |
| 737 | const outcome = yield* cancelOverRaisedGrandchild({ deleted: false, raisedDuringStop: true }); | |
| 738 | assert.equal(outcome.code, "runtime_mode_escalation_denied"); | |
| 739 | assert.isFalse(outcome.stoppedBelow); | |
| 740 | }), | |
| 741 | ); | |
| 742 | ||
| 743 | it.effect("checks the tasks under a deleted child before cancelling it", () => | |
| 744 | Effect.gen(function* () { | |
| 745 | assert.deepEqual(yield* cancelOverRaisedGrandchild({ deleted: true }), { | |
| 746 | code: "runtime_mode_escalation_denied", | |
| 747 | dispatched: [], | |
| 748 | stoppedBelow: false, | |
| 749 | }); | |
| 750 | }), | |
| 751 | ); | |
| 482 | 752 | }); |
| 483 | 753 | |
| 484 | 754 | describe("OrchestratorMcpService provider resolution", () => { |
| ... | ... | @@ -1294,4 +1564,227 @@ describe("OrchestratorMcpService provider resolution", () => { |
| 1294 | 1564 | } |
| 1295 | 1565 | }), |
| 1296 | 1566 | ); |
| 1567 | ||
| 1568 | describe("scheduled tasks at modes above the caller's", () => { | |
| 1569 | const projectId = ProjectId.make("project:scheduled"); | |
| 1570 | const boundThreadId = ThreadId.make("thread:scheduled-bound"); | |
| 1571 | const task = (overrides: Partial<ScheduledTask>): ScheduledTask => ({ | |
| 1572 | id: ScheduledTaskId.make("scheduled-task:webhook"), | |
| 1573 | title: "On push", | |
| 1574 | prompt: "Do {{body.instruction}}", | |
| 1575 | enabled: true, | |
| 1576 | schedule: { type: "webhook", signature: null }, | |
| 1577 | projectId, | |
| 1578 | threadId: null, | |
| 1579 | workspaceStrategy: { type: "root" }, | |
| 1580 | modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5" }, | |
| 1581 | runtimeMode: "approval-required", | |
| 1582 | interactionMode: "default", | |
| 1583 | createdBy: "agent", | |
| 1584 | creationSource: "mcp", | |
| 1585 | createdAt: "2026-10-05T00:00:00.000Z", | |
| 1586 | updatedAt: "2026-10-05T00:00:00.000Z", | |
| 1587 | nextRunAt: null, | |
| 1588 | lastRunAt: null, | |
| 1589 | lastRunStatus: "never", | |
| 1590 | lastRunError: null, | |
| 1591 | runCount: 0, | |
| 1592 | webhook: { | |
| 1593 | path: "/hooks/scheduled-task/secret", | |
| 1594 | url: "https://t3.example/hooks/secret", | |
| 1595 | hasSecret: false, | |
| 1596 | }, | |
| 1597 | ...overrides, | |
| 1598 | }); | |
| 1599 | const supervisedClient: McpInvocationScope = { | |
| 1600 | environmentId: EnvironmentId.make("environment:scheduled"), | |
| 1601 | requestNamespace: "client:scheduled", | |
| 1602 | thread: undefined, | |
| 1603 | client: { | |
| 1604 | sessionId: "scheduled", | |
| 1605 | label: "Claude Code", | |
| 1606 | runtimeModeCeiling: "approval-required", | |
| 1607 | }, | |
| 1608 | capabilities: new Set(["orchestration"]), | |
| 1609 | issuedAt: 1, | |
| 1610 | }; | |
| 1611 | const service = ( | |
| 1612 | tasks: ReadonlyArray<ScheduledTask>, | |
| 1613 | boundThread: OrchestrationV2ThreadShell | null, | |
| 1614 | upserted: Ref.Ref<number>, | |
| 1615 | ) => | |
| 1616 | OrchestratorMcpService.layer.pipe( | |
| 1617 | Layer.provide( | |
| 1618 | Layer.mergeAll( | |
| 1619 | NodeServices.layer, | |
| 1620 | Layer.mock(ThreadManagementService.ThreadManagementService)({ | |
| 1621 | getThreadShell: (threadId) => | |
| 1622 | Effect.succeed(threadId === boundThreadId ? boundThread : null), | |
| 1623 | }), | |
| 1624 | Layer.mock(ProviderRegistry.ProviderRegistry)({ getProviders: Effect.succeed([]) }), | |
| 1625 | Layer.mock(ProviderAdapterRegistry.ProviderAdapterRegistryV2)({ | |
| 1626 | list: () => Effect.succeed([]), | |
| 1627 | }), | |
| 1628 | Layer.mock(ProjectService.ProjectService)({}), | |
| 1629 | Layer.mock(SecretRequests.SecretRequests)({}), | |
| 1630 | Layer.mock(ScheduledTaskService.ScheduledTaskService)({ | |
| 1631 | list: () => Effect.succeed({ tasks }), | |
| 1632 | upsert: () => | |
| 1633 | Ref.update(upserted, (count) => count + 1).pipe(Effect.as({ task: tasks[0]! })), | |
| 1634 | }), | |
| 1635 | ), | |
| 1636 | ), | |
| 1637 | ); | |
| 1638 | ||
| 1639 | it.effect("hides a webhook URL from a caller below the task's modes", () => | |
| 1640 | Effect.gen(function* () { | |
| 1641 | const upserted = yield* Ref.make(0); | |
| 1642 | const listed = yield* OrchestratorMcpService.OrchestratorMcpService.pipe( | |
| 1643 | Effect.flatMap((mcp) => mcp.listScheduledTasks(supervisedClient, { projectId })), | |
| 1644 | Effect.provide( | |
| 1645 | service( | |
| 1646 | [ | |
| 1647 | task({ runtimeMode: "full-access" }), | |
| 1648 | task({ id: ScheduledTaskId.make("scheduled-task:supervised") }), | |
| 1649 | ], | |
| 1650 | null, | |
| 1651 | upserted, | |
| 1652 | ), | |
| 1653 | ), | |
| 1654 | ); | |
| 1655 | assert.deepEqual( | |
| 1656 | listed.tasks.map((summary) => summary.webhookUrl), | |
| 1657 | [undefined, "https://t3.example/hooks/secret"], | |
| 1658 | ); | |
| 1659 | }), | |
| 1660 | ); | |
| 1661 | ||
| 1662 | it.effect("hides a webhook URL from a thread whose turn has ended", () => | |
| 1663 | Effect.gen(function* () { | |
| 1664 | const callerId = ThreadId.make("thread:scheduled-ended"); | |
| 1665 | const shell = liveThreadShell(callerId, { activeRunId: null }); | |
| 1666 | const listed = yield* OrchestratorMcpService.OrchestratorMcpService.pipe( | |
| 1667 | Effect.flatMap((mcp) => | |
| 1668 | mcp.listScheduledTasks( | |
| 1669 | { | |
| 1670 | ...supervisedClient, | |
| 1671 | requestNamespace: "provider:scheduled-ended", | |
| 1672 | thread: { | |
| 1673 | threadId: callerId, | |
| 1674 | providerSessionId: "provider:scheduled-ended", | |
| 1675 | providerInstanceId: shell.providerInstanceId, | |
| 1676 | }, | |
| 1677 | client: undefined, | |
| 1678 | }, | |
| 1679 | { projectId }, | |
| 1680 | ), | |
| 1681 | ), | |
| 1682 | Effect.provide( | |
| 1683 | OrchestratorMcpService.layer.pipe( | |
| 1684 | Layer.provide( | |
| 1685 | Layer.mergeAll( | |
| 1686 | NodeServices.layer, | |
| 1687 | Layer.mock(ThreadManagementService.ThreadManagementService)({ | |
| 1688 | getThreadShell: () => Effect.succeed(null), | |
| 1689 | // Its turn ended: no run is active. | |
| 1690 | getThreadRecords: () => Effect.succeed(idleThreadProjection(shell)), | |
| 1691 | }), | |
| 1692 | Layer.mock(ProviderRegistry.ProviderRegistry)({ | |
| 1693 | getProviders: Effect.succeed([]), | |
| 1694 | }), | |
| 1695 | Layer.mock(ProviderAdapterRegistry.ProviderAdapterRegistryV2)({ | |
| 1696 | list: () => Effect.succeed([]), | |
| 1697 | }), | |
| 1698 | Layer.mock(ProjectService.ProjectService)({}), | |
| 1699 | Layer.mock(SecretRequests.SecretRequests)({}), | |
| 1700 | Layer.mock(ScheduledTaskService.ScheduledTaskService)({ | |
| 1701 | list: () => Effect.succeed({ tasks: [task({})] }), | |
| 1702 | }), | |
| 1703 | ), | |
| 1704 | ), | |
| 1705 | ), | |
| 1706 | ), | |
| 1707 | ); | |
| 1708 | assert.equal(listed.tasks[0]?.webhookUrl, undefined); | |
| 1709 | }), | |
| 1710 | ); | |
| 1711 | ||
| 1712 | it.effect("checks a bound task against its thread's current modes", () => | |
| 1713 | Effect.gen(function* () { | |
| 1714 | const upserted = yield* Ref.make(0); | |
| 1715 | // Scheduled while the thread was Supervised; the thread now runs in full access. | |
| 1716 | const bound = task({ threadId: boundThreadId }); | |
| 1717 | const layer = service( | |
| 1718 | [bound], | |
| 1719 | liveThreadShell(boundThreadId, { runtimeMode: "full-access" }), | |
| 1720 | upserted, | |
| 1721 | ); | |
| 1722 | const mcp = yield* OrchestratorMcpService.OrchestratorMcpService.pipe( | |
| 1723 | Effect.provide(layer), | |
| 1724 | ); | |
| 1725 | const listed = yield* mcp.listScheduledTasks(supervisedClient, { projectId }); | |
| 1726 | assert.equal(listed.tasks[0]?.webhookUrl, undefined); | |
| 1727 | const error = yield* mcp | |
| 1728 | .updateScheduledTask(supervisedClient, { | |
| 1729 | scheduledTaskId: bound.id, | |
| 1730 | prompt: "Something else", | |
| 1731 | }) | |
| 1732 | .pipe(Effect.flip); | |
| 1733 | assert.equal(error.code, "runtime_mode_escalation_denied"); | |
| 1734 | assert.equal(yield* Ref.get(upserted), 0); | |
| 1735 | }), | |
| 1736 | ); | |
| 1737 | ||
| 1738 | it.effect("reports a saved task even when its bound thread cannot be read", () => | |
| 1739 | Effect.gen(function* () { | |
| 1740 | const upserted = yield* Ref.make(0); | |
| 1741 | const bound = task({ threadId: boundThreadId }); | |
| 1742 | const lookups = yield* Ref.make(0); | |
| 1743 | // The edit's own check reads the thread; the read after the save fails. | |
| 1744 | const mcp = yield* OrchestratorMcpService.OrchestratorMcpService.pipe( | |
| 1745 | Effect.provide( | |
| 1746 | OrchestratorMcpService.layer.pipe( | |
| 1747 | Layer.provide( | |
| 1748 | Layer.mergeAll( | |
| 1749 | NodeServices.layer, | |
| 1750 | Layer.mock(ThreadManagementService.ThreadManagementService)({ | |
| 1751 | getThreadShell: (threadId) => | |
| 1752 | Ref.getAndUpdate(lookups, (count) => count + 1).pipe( | |
| 1753 | Effect.flatMap((count) => | |
| 1754 | count === 0 | |
| 1755 | ? Effect.succeed( | |
| 1756 | liveThreadShell(threadId, { runtimeMode: "approval-required" }), | |
| 1757 | ) | |
| 1758 | : Effect.fail(new OrchestratorProjectionError({ threadId })), | |
| 1759 | ), | |
| 1760 | ), | |
| 1761 | }), | |
| 1762 | Layer.mock(ProviderRegistry.ProviderRegistry)({ | |
| 1763 | getProviders: Effect.succeed([]), | |
| 1764 | }), | |
| 1765 | Layer.mock(ProviderAdapterRegistry.ProviderAdapterRegistryV2)({ | |
| 1766 | list: () => Effect.succeed([]), | |
| 1767 | }), | |
| 1768 | Layer.mock(ProjectService.ProjectService)({}), | |
| 1769 | Layer.mock(SecretRequests.SecretRequests)({}), | |
| 1770 | Layer.mock(ScheduledTaskService.ScheduledTaskService)({ | |
| 1771 | list: () => Effect.succeed({ tasks: [bound] }), | |
| 1772 | upsert: () => | |
| 1773 | Ref.update(upserted, (count) => count + 1).pipe(Effect.as({ task: bound })), | |
| 1774 | }), | |
| 1775 | ), | |
| 1776 | ), | |
| 1777 | ), | |
| 1778 | ), | |
| 1779 | ); | |
| 1780 | const updated = yield* mcp.updateScheduledTask(supervisedClient, { | |
| 1781 | scheduledTaskId: bound.id, | |
| 1782 | prompt: "Something else", | |
| 1783 | }); | |
| 1784 | assert.equal(yield* Ref.get(upserted), 1); | |
| 1785 | assert.equal(updated.scheduledTaskId, bound.id); | |
| 1786 | assert.equal(updated.webhookUrl, undefined); | |
| 1787 | }), | |
| 1788 | ); | |
| 1789 | }); | |
| 1297 | 1790 | }); |
apps/server/src/mcp/OrchestratorMcpService.ts+134-15| ... | ... | @@ -66,6 +66,7 @@ import * as Effect from "effect/Effect"; |
| 66 | 66 | import * as Exit from "effect/Exit"; |
| 67 | 67 | import * as Layer from "effect/Layer"; |
| 68 | 68 | import * as Option from "effect/Option"; |
| 69 | import * as Ref from "effect/Ref"; | |
| 69 | 70 | import * as Schema from "effect/Schema"; |
| 70 | 71 | import * as Stream from "effect/Stream"; |
| 71 | 72 | |
| ... | ... | @@ -74,6 +75,10 @@ import { |
| 74 | 75 | subagentResultForRun, |
| 75 | 76 | delegatedTaskProgress, |
| 76 | 77 | } from "../orchestration-v2/SubagentProjection.ts"; |
| 78 | import { | |
| 79 | DispatchModeLimit, | |
| 80 | type DispatchModeRefusal, | |
| 81 | } from "../orchestration-v2/DispatchModeLimit.ts"; | |
| 77 | 82 | import * as ThreadManagementService from "../orchestration-v2/ThreadManagementService.ts"; |
| 78 | 83 | import * as ProjectService from "../project/ProjectService.ts"; |
| 79 | 84 | import * as ProviderRegistry from "../provider/ProviderRegistry.ts"; |
| ... | ... | @@ -225,7 +230,12 @@ function scheduledTaskWorkspaceStrategy( |
| 225 | 230 | : { type: "worktree", baseRef: "main", startFromOrigin: true }; |
| 226 | 231 | } |
| 227 | 232 | |
| 228 | function scheduledTaskSummary(task: ScheduledTask): OrchestratorMcpScheduledTask { | |
| 233 | /** | |
| 234 | * A scheduled task as an agent sees it. `mayRun` says whether the caller may | |
| 235 | * run it: a webhook's URL carries the secret that starts the task's runs, so | |
| 236 | * only such a caller sees it. | |
| 237 | */ | |
| 238 | function scheduledTaskSummary(task: ScheduledTask, mayRun: boolean): OrchestratorMcpScheduledTask { | |
| 229 | 239 | return { |
| 230 | 240 | scheduledTaskId: task.id, |
| 231 | 241 | title: task.title, |
| ... | ... | @@ -237,7 +247,7 @@ function scheduledTaskSummary(task: ScheduledTask): OrchestratorMcpScheduledTask |
| 237 | 247 | nextRunAt: task.nextRunAt, |
| 238 | 248 | lastRunStatus: task.lastRunStatus, |
| 239 | 249 | // A bare path is not a URL anyone can call, so agents never get one to share. |
| 240 | ...(task.webhook?.url == null ? {} : { webhookUrl: task.webhook.url }), | |
| 250 | ...(task.webhook?.url == null || !mayRun ? {} : { webhookUrl: task.webhook.url }), | |
| 241 | 251 | ...(task.webhook === undefined |
| 242 | 252 | ? {} |
| 243 | 253 | : { webhookSignature: task.webhook.hasSecret ? "set" : "none" }), |
| ... | ... | @@ -1364,9 +1374,65 @@ const make = Effect.gen(function* () { |
| 1364 | 1374 | }).pipe(Effect.timeoutOption(Duration.millis(timeoutMs)), Effect.map(Option.flatten)); |
| 1365 | 1375 | |
| 1366 | 1376 | /** |
| 1367 | * A scheduled task the caller may change: one whose modes are no broader | |
| 1368 | * than the caller's own, so editing its prompt cannot run work above the | |
| 1369 | * caller's limits. | |
| 1377 | * The modes a task's runs execute at: its own, or for a task bound to a | |
| 1378 | * thread, also that thread's modes as they are now, since its runs are | |
| 1379 | * messages to that thread. | |
| 1380 | */ | |
| 1381 | const scheduledTaskRunModes = (task: ScheduledTask) => | |
| 1382 | Effect.gen(function* () { | |
| 1383 | const modes = [{ runtimeMode: task.runtimeMode, interactionMode: task.interactionMode }]; | |
| 1384 | if (task.threadId === null) return modes; | |
| 1385 | const bound = yield* threadManagement | |
| 1386 | .getThreadShell(task.threadId) | |
| 1387 | .pipe(Effect.mapError(threadManagementFailure)); | |
| 1388 | return bound === null || bound.deletedAt !== null ? modes : [...modes, bound]; | |
| 1389 | }); | |
| 1390 | ||
| 1391 | const withinLimits = ( | |
| 1392 | limits: { | |
| 1393 | readonly runtimeMode: RuntimeMode; | |
| 1394 | readonly interactionMode: ProviderInteractionMode; | |
| 1395 | }, | |
| 1396 | modes: { readonly runtimeMode: RuntimeMode; readonly interactionMode: ProviderInteractionMode }, | |
| 1397 | ) => | |
| 1398 | runtimeModeRank(modes.runtimeMode) <= runtimeModeRank(limits.runtimeMode) && | |
| 1399 | interactionModeRank(modes.interactionMode) <= interactionModeRank(limits.interactionMode); | |
| 1400 | ||
| 1401 | /** | |
| 1402 | * A task as the caller may see it. Its webhook URL starts runs, so only a | |
| 1403 | * caller that may start one sees it: a thread caller with a live turn, at | |
| 1404 | * modes covering every mode the task runs at. | |
| 1405 | */ | |
| 1406 | const summarizeScheduledTask = ( | |
| 1407 | scope: McpInvocationScope, | |
| 1408 | caller: { | |
| 1409 | readonly parent: Pick<OrchestrationV2ThreadProjection, "thread" | "runs"> | undefined; | |
| 1410 | readonly limits: { | |
| 1411 | readonly runtimeMode: RuntimeMode; | |
| 1412 | readonly interactionMode: ProviderInteractionMode; | |
| 1413 | }; | |
| 1414 | }, | |
| 1415 | task: ScheduledTask, | |
| 1416 | ) => | |
| 1417 | Effect.gen(function* () { | |
| 1418 | const live = | |
| 1419 | caller.parent === undefined || | |
| 1420 | Exit.isSuccess(yield* Effect.exit(assertLiveCaller(scope, caller.parent))); | |
| 1421 | // This runs after a save, so a failed lookup of the bound thread hides | |
| 1422 | // the webhook URL instead of reporting a saved task as an error. | |
| 1423 | const modes = yield* scheduledTaskRunModes(task).pipe(Effect.option); | |
| 1424 | return scheduledTaskSummary( | |
| 1425 | task, | |
| 1426 | live && | |
| 1427 | Option.isSome(modes) && | |
| 1428 | modes.value.every((mode) => withinLimits(caller.limits, mode)), | |
| 1429 | ); | |
| 1430 | }); | |
| 1431 | ||
| 1432 | /** | |
| 1433 | * A scheduled task the caller may change: one whose runs execute at modes no | |
| 1434 | * broader than the caller's own, so editing its prompt cannot run work above | |
| 1435 | * the caller's limits. | |
| 1370 | 1436 | */ |
| 1371 | 1437 | const loadScheduledTask = ( |
| 1372 | 1438 | scheduledTaskId: ScheduledTask["id"], |
| ... | ... | @@ -1387,8 +1453,10 @@ const make = Effect.gen(function* () { |
| 1387 | 1453 | if (task === undefined) { |
| 1388 | 1454 | return yield* failure("task_not_found", `Scheduled task ${scheduledTaskId} was not found.`); |
| 1389 | 1455 | } |
| 1390 | yield* resolveRuntimeMode(limits.runtimeMode, task.runtimeMode); | |
| 1391 | yield* resolveInteractionMode(limits.interactionMode, task.interactionMode); | |
| 1456 | for (const modes of yield* scheduledTaskRunModes(task)) { | |
| 1457 | yield* resolveRuntimeMode(limits.runtimeMode, modes.runtimeMode); | |
| 1458 | yield* resolveInteractionMode(limits.interactionMode, modes.interactionMode); | |
| 1459 | } | |
| 1392 | 1460 | return task; |
| 1393 | 1461 | }); |
| 1394 | 1462 | |
| ... | ... | @@ -1451,11 +1519,11 @@ const make = Effect.gen(function* () { |
| 1451 | 1519 | failure("orchestration_error", `Could not schedule task: ${error.message}`), |
| 1452 | 1520 | ), |
| 1453 | 1521 | ); |
| 1454 | return scheduledTaskSummary(task); | |
| 1522 | return yield* summarizeScheduledTask(scope, { parent, limits }, task); | |
| 1455 | 1523 | }), |
| 1456 | 1524 | listScheduledTasks: (scope, input) => |
| 1457 | 1525 | Effect.gen(function* () { |
| 1458 | const { parent } = yield* loadCaller(scope); | |
| 1526 | const { parent, limits } = yield* loadCaller(scope); | |
| 1459 | 1527 | const projectId = input.projectId ?? parent?.thread.projectId; |
| 1460 | 1528 | const { tasks } = yield* scheduledTasks |
| 1461 | 1529 | .list() |
| ... | ... | @@ -1465,9 +1533,10 @@ const make = Effect.gen(function* () { |
| 1465 | 1533 | ), |
| 1466 | 1534 | ); |
| 1467 | 1535 | return { |
| 1468 | tasks: tasks | |
| 1469 | .filter((task) => projectId === undefined || task.projectId === projectId) | |
| 1470 | .map(scheduledTaskSummary), | |
| 1536 | tasks: yield* Effect.forEach( | |
| 1537 | tasks.filter((task) => projectId === undefined || task.projectId === projectId), | |
| 1538 | (task) => summarizeScheduledTask(scope, { parent, limits }, task), | |
| 1539 | ), | |
| 1471 | 1540 | }; |
| 1472 | 1541 | }), |
| 1473 | 1542 | updateScheduledTask: (scope, input) => |
| ... | ... | @@ -1521,7 +1590,7 @@ const make = Effect.gen(function* () { |
| 1521 | 1590 | failure("orchestration_error", `Could not update scheduled task: ${error.message}`), |
| 1522 | 1591 | ), |
| 1523 | 1592 | ); |
| 1524 | return scheduledTaskSummary(task); | |
| 1593 | return yield* summarizeScheduledTask(scope, { parent, limits }, task); | |
| 1525 | 1594 | }), |
| 1526 | 1595 | deleteScheduledTask: (scope, input) => |
| 1527 | 1596 | Effect.gen(function* () { |
| ... | ... | @@ -1851,6 +1920,32 @@ const make = Effect.gen(function* () { |
| 1851 | 1920 | const current = yield* readTask(scope, input.taskId); |
| 1852 | 1921 | const key = yield* requestKey(input.clientRequestId); |
| 1853 | 1922 | const parentProjection = yield* loadProjection(scope.thread.threadId); |
| 1923 | // Cancelling stops the child and every task under it, each a write to a | |
| 1924 | // thread its user may have raised above the parent's modes since it was | |
| 1925 | // delegated. All of them are checked before anything is stopped. | |
| 1926 | const assertStoppable = (threadId: ThreadId): Effect.Effect<void, OrchestratorMcpFailure> => | |
| 1927 | Effect.gen(function* () { | |
| 1928 | const shell = yield* threadManagement | |
| 1929 | .getThreadShell(threadId) | |
| 1930 | .pipe(Effect.mapError(threadManagementFailure)); | |
| 1931 | // A deleted thread takes no stop, but the tasks under it still do. | |
| 1932 | if (shell !== null && shell.deletedAt === null) { | |
| 1933 | yield* resolveRuntimeMode(parentProjection.thread.runtimeMode, shell.runtimeMode); | |
| 1934 | yield* resolveInteractionMode( | |
| 1935 | parentProjection.thread.interactionMode, | |
| 1936 | shell.interactionMode, | |
| 1937 | ); | |
| 1938 | } | |
| 1939 | const { subagents } = yield* threadManagement | |
| 1940 | .getThreadRecords(threadId, ["subagents"]) | |
| 1941 | .pipe(Effect.mapError(threadManagementFailure)); | |
| 1942 | for (const task of subagents) { | |
| 1943 | if (task.origin === "app_owned" && task.childThreadId !== null) { | |
| 1944 | yield* assertStoppable(task.childThreadId); | |
| 1945 | } | |
| 1946 | } | |
| 1947 | }); | |
| 1948 | yield* assertStoppable(current.childThreadId); | |
| 1854 | 1949 | const parentTask = parentProjection.subagents.find( |
| 1855 | 1950 | (task) => task.id === input.taskId && task.origin === "app_owned", |
| 1856 | 1951 | ); |
| ... | ... | @@ -1910,8 +2005,32 @@ const make = Effect.gen(function* () { |
| 1910 | 2005 | ), |
| 1911 | 2006 | ); |
| 1912 | 2007 | }); |
| 2008 | // Checked above; a user raising one of these threads meanwhile is | |
| 2009 | // caught again under that thread's lock, which leaves it running. | |
| 2010 | const refused = yield* Ref.make<DispatchModeRefusal | undefined>(undefined); | |
| 2011 | const stopWithinLimit = stopChild.pipe( | |
| 2012 | Effect.provideService(DispatchModeLimit, { | |
| 2013 | runtimeMode: parentProjection.thread.runtimeMode, | |
| 2014 | interactionMode: parentProjection.thread.interactionMode, | |
| 2015 | refused, | |
| 2016 | }), | |
| 2017 | Effect.catch((error) => | |
| 2018 | Effect.flatMap(Ref.get(refused), (refusal) => | |
| 2019 | Effect.fail( | |
| 2020 | refusal === undefined | |
| 2021 | ? error | |
| 2022 | : failure( | |
| 2023 | refusal.mode === "runtime" | |
| 2024 | ? "runtime_mode_escalation_denied" | |
| 2025 | : "interaction_mode_escalation_denied", | |
| 2026 | `Thread ${refusal.threadId} now runs in ${refusal.runtimeMode}/${refusal.interactionMode} mode, above this thread's; its user changed it while the task was being cancelled.`, | |
| 2027 | ), | |
| 2028 | ), | |
| 2029 | ), | |
| 2030 | ), | |
| 2031 | ); | |
| 1913 | 2032 | if (isTerminalTaskStatus(current.status)) { |
| 1914 | yield* stopChild; | |
| 2033 | yield* stopWithinLimit; | |
| 1915 | 2034 | yield* disposeCompletionDelivery; |
| 1916 | 2035 | return { |
| 1917 | 2036 | taskId: input.taskId, |
| ... | ... | @@ -1930,7 +2049,7 @@ const make = Effect.gen(function* () { |
| 1930 | 2049 | `Delegated task ${input.taskId} has no interruptible child run.`, |
| 1931 | 2050 | ); |
| 1932 | 2051 | } |
| 1933 | yield* stopChild; | |
| 2052 | yield* stopWithinLimit; | |
| 1934 | 2053 | yield* disposeCompletionDelivery.pipe( |
| 1935 | 2054 | Effect.catchCause((cause) => |
| 1936 | 2055 | Effect.logWarning("orchestrator-mcp.cancel-task.delivery-dispose-failed", { |
apps/server/src/mcp/ThreadMetadataMcpService.test.ts-88| ... | ... | @@ -78,91 +78,3 @@ it.effect("keeps calling-thread storage failures as orchestration errors", () => |
| 78 | 78 | expect(error.code).toBe("orchestration_error"); |
| 79 | 79 | }), |
| 80 | 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(layerService((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(layerService((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-37| ... | ... | @@ -16,7 +16,6 @@ import * as Layer from "effect/Layer"; |
| 16 | 16 | |
| 17 | 17 | import * as ThreadManagementService from "../orchestration-v2/ThreadManagementService.ts"; |
| 18 | 18 | import type { McpInvocationScope } from "./McpInvocationContext.ts"; |
| 19 | import { assertTargetWithinLimits } from "./threadAccess.ts"; | |
| 20 | 19 | |
| 21 | 20 | export class ThreadMetadataMcpService extends Context.Service< |
| 22 | 21 | ThreadMetadataMcpService, |
| ... | ... | @@ -167,42 +166,6 @@ const make = Effect.gen(function* () { |
| 167 | 166 | if (shell === null || shell.deletedAt !== null) { |
| 168 | 167 | return yield* failure("thread_not_found", `Thread ${threadId} was not found.`); |
| 169 | 168 | } |
| 170 | // Another thread may only be changed if it runs within the caller's own modes. | |
| 171 | if (threadId !== scope.thread?.threadId) { | |
| 172 | const limits = | |
| 173 | scope.thread === undefined | |
| 174 | ? { | |
| 175 | runtimeMode: scope.client?.runtimeModeCeiling ?? ("approval-required" as const), | |
| 176 | interactionMode: "default" as const, | |
| 177 | } | |
| 178 | : yield* threadManagement.getThreadShell(scope.thread.threadId).pipe( | |
| 179 | Effect.mapError((error) => | |
| 180 | failure( | |
| 181 | "orchestration_error", | |
| 182 | `Unable to locate calling thread: ${errorMessage(error)}`, | |
| 183 | ), | |
| 184 | ), | |
| 185 | Effect.flatMap((caller) => | |
| 186 | caller === null | |
| 187 | ? Effect.fail(failure("thread_not_found", "The calling thread was not found.")) | |
| 188 | : // Like every other cross-thread write, a thread caller needs its live run. | |
| 189 | caller.archivedAt !== null || | |
| 190 | caller.activeRunId === null || | |
| 191 | caller.providerInstanceId !== scope.thread?.providerInstanceId | |
| 192 | ? Effect.fail( | |
| 193 | failure( | |
| 194 | "parent_not_active", | |
| 195 | "The calling provider no longer owns an active thread run.", | |
| 196 | ), | |
| 197 | ) | |
| 198 | : Effect.succeed({ | |
| 199 | runtimeMode: caller.runtimeMode, | |
| 200 | interactionMode: caller.interactionMode, | |
| 201 | }), | |
| 202 | ), | |
| 203 | ); | |
| 204 | yield* assertTargetWithinLimits(limits, shell); | |
| 205 | } | |
| 206 | 169 | const target = yield* threadManagement |
| 207 | 170 | .getProjectThreadRecords({ projectId: shell.projectId, threadId }, []) |
| 208 | 171 | .pipe(Effect.mapError(threadLookupFailure)); |
apps/server/src/mcp/threadAccess.ts+22-37| ... | ... | @@ -60,6 +60,15 @@ export const readCaller = Effect.fn("mcp.readCaller")(function* () { |
| 60 | 60 | message: "This credential cannot control threads.", |
| 61 | 61 | }); |
| 62 | 62 | } |
| 63 | return yield* loadCaller(); | |
| 64 | }); | |
| 65 | ||
| 66 | /** | |
| 67 | * The caller and its limits, whichever tools its credential grants. Tools | |
| 68 | * check their own capability; `McpToolAccess` uses this for every tool. | |
| 69 | */ | |
| 70 | export const loadCaller = Effect.fn("mcp.loadCaller")(function* () { | |
| 71 | const scope = yield* McpInvocationContext.McpInvocationContext; | |
| 63 | 72 | const threads = yield* ThreadManagement.ThreadManagementService; |
| 64 | 73 | if (scope.thread === undefined) { |
| 65 | 74 | return { |
| ... | ... | @@ -104,7 +113,12 @@ export const assertTargetWithinLimits = ( |
| 104 | 113 | Effect.asVoid, |
| 105 | 114 | ); |
| 106 | 115 | |
| 107 | function assertLiveCaller({ caller, scope }: Caller) { | |
| 116 | /** | |
| 117 | * A thread caller acts only while it owns a live run of a thread that is not | |
| 118 | * archived, so a provider token that outlived its session cannot act. An | |
| 119 | * OAuth client has no run; its session and ceiling are its authority. | |
| 120 | */ | |
| 121 | export function assertLiveCaller({ caller, scope }: Caller) { | |
| 108 | 122 | if (caller === undefined) return Effect.void; |
| 109 | 123 | return caller.archivedAt !== null || |
| 110 | 124 | caller.activeRunId === null || |
| ... | ... | @@ -119,34 +133,14 @@ function assertLiveCaller({ caller, scope }: Caller) { |
| 119 | 133 | } |
| 120 | 134 | |
| 121 | 135 | /** |
| 122 | * Mutations from a thread need that thread's live run, so an agent whose turn | |
| 123 | * ended cannot keep acting. An OAuth client has no run; its session and | |
| 124 | * ceiling are its authority. | |
| 125 | */ | |
| 126 | export const readMutationCaller = Effect.fn("mcp.readMutationCaller")(function* () { | |
| 127 | const context = yield* readCaller(); | |
| 128 | yield* assertLiveCaller(context); | |
| 129 | return context; | |
| 130 | }); | |
| 131 | ||
| 132 | /** | |
| 133 | * Actions that change the environment itself (projects, preferences, launching | |
| 134 | * outside a project) need full access: a thread caller in full-access/default | |
| 135 | * mode, or a client approved with a full-access ceiling. | |
| 136 | * Actions that change the environment itself (projects, preferences) need full | |
| 137 | * access: a thread caller in full-access/default mode, or a client approved | |
| 138 | * with a full-access ceiling. | |
| 136 | 139 | */ |
| 137 | export const readFullAccessCaller = Effect.fn("mcp.readFullAccessCaller")(function* ( | |
| 138 | message: string, | |
| 139 | ) { | |
| 140 | const context = yield* readMutationCaller(); | |
| 141 | if ( | |
| 142 | (context.caller !== undefined && context.caller.archivedAt !== null) || | |
| 143 | context.limits.runtimeMode !== "full-access" || | |
| 144 | context.limits.interactionMode !== "default" | |
| 145 | ) { | |
| 146 | return yield* new OrchestratorMcpFailure({ code: "capability_denied", message }); | |
| 147 | } | |
| 148 | return context; | |
| 149 | }); | |
| 140 | export const assertFullAccess = (context: Caller, message: string) => | |
| 141 | context.limits.runtimeMode === "full-access" && context.limits.interactionMode === "default" | |
| 142 | ? Effect.void | |
| 143 | : Effect.fail(new OrchestratorMcpFailure({ code: "capability_denied", message })); | |
| 150 | 144 | |
| 151 | 145 | /** A target project: the one passed, else the calling thread's. */ |
| 152 | 146 | export const resolveProjectId = (context: Caller, projectId: ProjectId | undefined) => |
| ... | ... | @@ -204,15 +198,6 @@ export const readThread = Effect.fn("mcp.readThread")(function* < |
| 204 | 198 | return { ...context, projection }; |
| 205 | 199 | }); |
| 206 | 200 | |
| 207 | export const readWritableThread = Effect.fn("mcp.readWritableThread")(function* < | |
| 208 | K extends ProjectionRecordField = never, | |
| 209 | >(threadId?: ThreadId, fields: ReadonlyArray<K> = []) { | |
| 210 | const context = yield* readThread(threadId, fields); | |
| 211 | yield* assertLiveCaller(context); | |
| 212 | yield* assertTargetWithinLimits(context.limits, context.projection.thread); | |
| 213 | return context; | |
| 214 | }); | |
| 215 | ||
| 216 | 201 | export const newCommandId = Effect.fn("mcp.newCommandId")(function* () { |
| 217 | 202 | const crypto = yield* Crypto.Crypto; |
| 218 | 203 | return CommandId.make(`mcp:${yield* crypto.randomUUIDv4.pipe(Effect.orDie)}`); |
apps/server/src/mcp/toolkits/attachment/handlers.ts+51-59| ... | ... | @@ -3,12 +3,8 @@ import * as Effect from "effect/Effect"; |
| 3 | 3 | import * as Upload from "../../../assets/AttachmentUpload.ts"; |
| 4 | 4 | import * as Claims from "../../../orchestration-v2/AttachmentClaims.ts"; |
| 5 | 5 | import * as ThreadMessageIntake from "../../../orchestration-v2/ThreadMessageIntake.ts"; |
| 6 | import { | |
| 7 | newCommandId, | |
| 8 | readMutationCaller, | |
| 9 | readWritableThread, | |
| 10 | unavailable, | |
| 11 | } from "../../threadAccess.ts"; | |
| 6 | import * as McpToolAccess from "../../McpToolAccess.ts"; | |
| 7 | import { newCommandId, readThread, unavailable } from "../../threadAccess.ts"; | |
| 12 | 8 | import { AttachmentToolkit } from "./tools.ts"; |
| 13 | 9 | |
| 14 | 10 | export function resolveAttachmentReferences( |
| ... | ... | @@ -31,57 +27,53 @@ export function resolveAttachmentReferences( |
| 31 | 27 | }); |
| 32 | 28 | } |
| 33 | 29 | |
| 34 | export const layer = AttachmentToolkit.toLayer({ | |
| 35 | t3_attachment_prepare_upload: (input) => | |
| 36 | Effect.gen(function* () { | |
| 37 | yield* readMutationCaller(); | |
| 38 | return yield* Upload.issueAttachmentUploadUrl(input.upload).pipe( | |
| 39 | Effect.mapError(unavailable), | |
| 40 | ); | |
| 41 | }), | |
| 42 | t3_attachment_discard: (input) => | |
| 43 | Effect.gen(function* () { | |
| 44 | yield* readMutationCaller(); | |
| 45 | yield* Upload.deletePendingAttachment(input.attachmentId); | |
| 46 | return {}; | |
| 47 | }), | |
| 48 | t3_thread_send_attachments: (input) => | |
| 49 | Effect.gen(function* () { | |
| 50 | const { caller, projection } = yield* readWritableThread(input.threadId, ["messages"]); | |
| 51 | if (projection.thread.archivedAt !== null) | |
| 52 | return yield* new OrchestratorMcpFailure({ | |
| 53 | code: "invalid_request", | |
| 54 | message: "Unarchive the target thread before sending attachments.", | |
| 55 | }); | |
| 56 | const attachments = yield* resolveAttachmentReferences( | |
| 57 | input.attachments, | |
| 58 | projection.messages.flatMap((message) => message.attachments), | |
| 59 | ); | |
| 60 | const commandId = yield* newCommandId(); | |
| 61 | const messageId = MessageId.make(commandId); | |
| 62 | const result = yield* ThreadMessageIntake.sendToThread({ | |
| 63 | projectId: projection.thread.projectId, | |
| 64 | threadId: projection.thread.id, | |
| 65 | commandId, | |
| 66 | messageId, | |
| 67 | ...(caller === undefined ? {} : { senderThreadId: caller.id }), | |
| 68 | text: input.message ?? "", | |
| 69 | attachments, | |
| 70 | mode: "auto", | |
| 71 | createdBy: "agent", | |
| 72 | creationSource: "mcp", | |
| 73 | }).pipe( | |
| 74 | Effect.mapError((error) => | |
| 75 | error._tag === "AttachmentClaimError" | |
| 76 | ? new OrchestratorMcpFailure({ code: "orchestration_error", message: error.message }) | |
| 77 | : unavailable(), | |
| 78 | ), | |
| 79 | ); | |
| 80 | return { | |
| 81 | threadId: projection.thread.id, | |
| 82 | messageId, | |
| 83 | runId: result.run.id, | |
| 84 | status: result.run.status, | |
| 85 | }; | |
| 86 | }), | |
| 30 | export const layer = McpToolAccess.toLayer(AttachmentToolkit, { | |
| 31 | t3_attachment_prepare_upload: McpToolAccess.writes((input) => | |
| 32 | Upload.issueAttachmentUploadUrl(input.upload).pipe(Effect.mapError(unavailable)), | |
| 33 | ), | |
| 34 | t3_attachment_discard: McpToolAccess.writes((input) => | |
| 35 | Upload.deletePendingAttachment(input.attachmentId).pipe(Effect.as({})), | |
| 36 | ), | |
| 37 | t3_thread_send_attachments: McpToolAccess.writesThreads( | |
| 38 | (input) => [input.threadId], | |
| 39 | (input) => | |
| 40 | Effect.gen(function* () { | |
| 41 | const { caller, projection } = yield* readThread(input.threadId, ["messages"]); | |
| 42 | if (projection.thread.archivedAt !== null) | |
| 43 | return yield* new OrchestratorMcpFailure({ | |
| 44 | code: "invalid_request", | |
| 45 | message: "Unarchive the target thread before sending attachments.", | |
| 46 | }); | |
| 47 | const attachments = yield* resolveAttachmentReferences( | |
| 48 | input.attachments, | |
| 49 | projection.messages.flatMap((message) => message.attachments), | |
| 50 | ); | |
| 51 | const commandId = yield* newCommandId(); | |
| 52 | const messageId = MessageId.make(commandId); | |
| 53 | const result = yield* ThreadMessageIntake.sendToThread({ | |
| 54 | projectId: projection.thread.projectId, | |
| 55 | threadId: projection.thread.id, | |
| 56 | commandId, | |
| 57 | messageId, | |
| 58 | ...(caller === undefined ? {} : { senderThreadId: caller.id }), | |
| 59 | text: input.message ?? "", | |
| 60 | attachments, | |
| 61 | mode: "auto", | |
| 62 | createdBy: "agent", | |
| 63 | creationSource: "mcp", | |
| 64 | }).pipe( | |
| 65 | Effect.mapError((error) => | |
| 66 | error._tag === "AttachmentClaimError" | |
| 67 | ? new OrchestratorMcpFailure({ code: "orchestration_error", message: error.message }) | |
| 68 | : unavailable(), | |
| 69 | ), | |
| 70 | ); | |
| 71 | return { | |
| 72 | threadId: projection.thread.id, | |
| 73 | messageId, | |
| 74 | runId: result.run.id, | |
| 75 | status: result.run.status, | |
| 76 | }; | |
| 77 | }), | |
| 78 | ), | |
| 87 | 79 | }); |
apps/server/src/mcp/toolkits/core.test.ts+6-9| ... | ... | @@ -36,6 +36,7 @@ import * as SecretRequests from "../../secrets/SecretRequests.ts"; |
| 36 | 36 | import * as ScheduledTaskService from "../../scheduledTasks/ScheduledTaskService.ts"; |
| 37 | 37 | import * as McpHttpServer from "../McpHttpServer.ts"; |
| 38 | 38 | import * as McpInvocationContext from "../McpInvocationContext.ts"; |
| 39 | import * as McpToolAccessTestkit from "../McpToolAccess.testkit.ts"; | |
| 39 | 40 | import { dispatchFailure } from "../threadAccess.ts"; |
| 40 | 41 | import { OrchestratorToolkit } from "./orchestrator/tools.ts"; |
| 41 | 42 | import { PreviewToolkit } from "./preview/tools.ts"; |
| ... | ... | @@ -131,7 +132,7 @@ const client = McpSchema.McpServerClient.of({ |
| 131 | 132 | getClient: Effect.die("unused"), |
| 132 | 133 | }); |
| 133 | 134 | |
| 134 | it.effect("checks capability before accessing services through the production registration", () => | |
| 135 | it.effect("checks capability through the production registration", () => | |
| 135 | 136 | Effect.gen(function* () { |
| 136 | 137 | const server = yield* McpServer.McpServer; |
| 137 | 138 | expect(server.tools.some(({ tool }) => tool.name === "t3_thread_organize")).toBe(true); |
| ... | ... | @@ -152,7 +153,7 @@ it.effect("checks capability before accessing services through the production re |
| 152 | 153 | McpHttpServer.layerThreadToolkit.pipe( |
| 153 | 154 | Layer.provideMerge(McpServer.McpServer.layer), |
| 154 | 155 | Layer.provide(NodeCrypto.layer), |
| 155 | Layer.provide(Layer.mock(ThreadManagement.ThreadManagementService)({})), | |
| 156 | Layer.provide(McpToolAccessTestkit.liveThreadsLayer), | |
| 156 | 157 | ), |
| 157 | 158 | ), |
| 158 | 159 | ), |
| ... | ... | @@ -453,12 +454,8 @@ it.effect("a client caller targets any thread within its ceiling and cannot act |
| 453 | 454 | Layer.provide(NodeCrypto.layer), |
| 454 | 455 | Layer.provide( |
| 455 | 456 | Layer.mock(ThreadManagement.ThreadManagementService)({ |
| 456 | getThreadShell: () => | |
| 457 | Effect.succeed({ | |
| 458 | id: ThreadId.make("other-project-thread"), | |
| 459 | projectId: "other-project", | |
| 460 | deletedAt: null, | |
| 461 | } as never), | |
| 457 | getThreadShell: (id) => | |
| 458 | Effect.succeed(McpToolAccessTestkit.liveThreadShell(id, { runtimeMode: "auto" })), | |
| 462 | 459 | getProjectThreadRecords: () => |
| 463 | 460 | Effect.succeed({ |
| 464 | 461 | thread: { |
| ... | ... | @@ -469,7 +466,7 @@ it.effect("a client caller targets any thread within its ceiling and cannot act |
| 469 | 466 | deletedAt: null, |
| 470 | 467 | }, |
| 471 | 468 | } as never), |
| 472 | dispatch: () => Effect.succeed({ sequence: 7 } as never), | |
| 469 | dispatch: () => Effect.succeed({ sequence: 7, storedEvents: [] }), | |
| 473 | 470 | }), |
| 474 | 471 | ), |
| 475 | 472 | ), |
apps/server/src/mcp/toolkits/device/handlers.ts+12-7| ... | ... | @@ -16,6 +16,7 @@ import { nodeRuntimeUnavailableMessage } from "@t3tools/shared/nodeRuntime"; |
| 16 | 16 | |
| 17 | 17 | import * as DeviceService from "../../../device/DeviceService.ts"; |
| 18 | 18 | import * as McpInvocationContext from "../../McpInvocationContext.ts"; |
| 19 | import * as McpToolAccess from "../../McpToolAccess.ts"; | |
| 19 | 20 | import { DeviceScreenshotToolkit, DeviceStandardToolkit, DeviceToolkit } from "./tools.ts"; |
| 20 | 21 | |
| 21 | 22 | /** The flags that pin every agent-device command to one device. */ |
| ... | ... | @@ -119,7 +120,7 @@ const pickDevice = ( |
| 119 | 120 | const toolError = (error: DeviceError | DeviceToolUnavailableError) => error; |
| 120 | 121 | |
| 121 | 122 | const handlers = { |
| 122 | device_list: (input) => | |
| 123 | device_list: McpToolAccess.readsAsCaller((input) => | |
| 123 | 124 | Effect.gen(function* () { |
| 124 | 125 | const scope = yield* requireDeviceAccess; |
| 125 | 126 | const devices = yield* DeviceService.DeviceService; |
| ... | ... | @@ -145,7 +146,8 @@ const handlers = { |
| 145 | 146 | open, |
| 146 | 147 | }; |
| 147 | 148 | }).pipe(Effect.mapError(toolError)), |
| 148 | device_open: (input) => | |
| 149 | ), | |
| 150 | device_open: McpToolAccess.actsAsCaller((input) => | |
| 149 | 151 | Effect.gen(function* () { |
| 150 | 152 | const scope = yield* requireDeviceAccess; |
| 151 | 153 | const devices = yield* DeviceService.DeviceService; |
| ... | ... | @@ -203,7 +205,8 @@ const handlers = { |
| 203 | 205 | quickStart: agentDeviceQuickStart(device, targetArgs, command), |
| 204 | 206 | }; |
| 205 | 207 | }).pipe(Effect.mapError(toolError)), |
| 206 | device_screenshot: (input) => | |
| 208 | ), | |
| 209 | device_screenshot: McpToolAccess.readsAsCaller((input) => | |
| 207 | 210 | Effect.gen(function* () { |
| 208 | 211 | const scope = yield* requireDeviceAccess; |
| 209 | 212 | const devices = yield* DeviceService.DeviceService; |
| ... | ... | @@ -229,7 +232,8 @@ const handlers = { |
| 229 | 232 | }, |
| 230 | 233 | }; |
| 231 | 234 | }).pipe(Effect.mapError(toolError)), |
| 232 | device_close: (input) => | |
| 235 | ), | |
| 236 | device_close: McpToolAccess.actsAsCaller((input) => | |
| 233 | 237 | Effect.gen(function* () { |
| 234 | 238 | const scope = yield* requireDeviceAccess; |
| 235 | 239 | const devices = yield* DeviceService.DeviceService; |
| ... | ... | @@ -241,7 +245,8 @@ const handlers = { |
| 241 | 245 | }); |
| 242 | 246 | return {}; |
| 243 | 247 | }).pipe(Effect.mapError(toolError)), |
| 244 | } satisfies Parameters<typeof DeviceToolkit.toLayer>[0]; | |
| 248 | ), | |
| 249 | } satisfies McpToolAccess.Handlers<typeof DeviceToolkit.tools>; | |
| 245 | 250 | |
| 246 | 251 | /** Width and height from the IHDR chunk; a PNG that lacks one reports 0×0. */ |
| 247 | 252 | export function pngDimensions(png: Uint8Array): { width: number; height: number } { |
| ... | ... | @@ -258,8 +263,8 @@ export function pngDimensions(png: Uint8Array): { width: number; height: number |
| 258 | 263 | |
| 259 | 264 | const { device_screenshot, ...standardHandlers } = handlers; |
| 260 | 265 | |
| 261 | export const layerStandard = DeviceStandardToolkit.toLayer(standardHandlers); | |
| 266 | export const layerStandard = McpToolAccess.toLayer(DeviceStandardToolkit, standardHandlers); | |
| 262 | 267 | |
| 263 | export const layerScreenshot = DeviceScreenshotToolkit.toLayer({ | |
| 268 | export const layerScreenshot = McpToolAccess.toLayer(DeviceScreenshotToolkit, { | |
| 264 | 269 | device_screenshot, |
| 265 | 270 | }); |
apps/server/src/mcp/toolkits/device/tools.ts+14-5| ... | ... | @@ -1,4 +1,5 @@ |
| 1 | 1 | import { |
| 2 | OrchestratorMcpFailure, | |
| 2 | 3 | DeviceToolCloseInput, |
| 3 | 4 | DeviceToolError, |
| 4 | 5 | DeviceToolListResult, |
| ... | ... | @@ -15,8 +16,16 @@ import { Tool, Toolkit } from "effect/ai"; |
| 15 | 16 | |
| 16 | 17 | import * as McpInvocationContext from "../../McpInvocationContext.ts"; |
| 17 | 18 | import * as DeviceService from "../../../device/DeviceService.ts"; |
| 19 | import * as ThreadManagementService from "../../../orchestration-v2/ThreadManagementService.ts"; | |
| 18 | 20 | |
| 19 | const dependencies = [McpInvocationContext.McpInvocationContext, DeviceService.DeviceService]; | |
| 21 | const dependencies = [ | |
| 22 | McpInvocationContext.McpInvocationContext, | |
| 23 | ThreadManagementService.ThreadManagementService, | |
| 24 | DeviceService.DeviceService, | |
| 25 | ]; | |
| 26 | ||
| 27 | /** What a device tool fails with, including the access gate's refusal. */ | |
| 28 | const DeviceToolFailure = Schema.Union([DeviceToolError, OrchestratorMcpFailure]); | |
| 20 | 29 | |
| 21 | 30 | /** |
| 22 | 31 | * Deliberately a small surface: lifecycle, visibility for the user, and one |
| ... | ... | @@ -36,7 +45,7 @@ const DeviceListTool = Tool.make("device_list", { |
| 36 | 45 | ), |
| 37 | 46 | }), |
| 38 | 47 | success: DeviceToolListResult, |
| 39 | failure: DeviceToolError, | |
| 48 | failure: DeviceToolFailure, | |
| 40 | 49 | dependencies, |
| 41 | 50 | }) |
| 42 | 51 | .annotate(Tool.Title, "List devices") |
| ... | ... | @@ -50,7 +59,7 @@ const DeviceOpenTool = Tool.make("device_open", { |
| 50 | 59 | "Open a simulator or emulator for this thread: boots it if needed, starts its live stream, and shows it in the user's Device panel so they can watch. Returns the agent-device CLI invocation pinned to the device; drive the device with that CLI afterwards.", |
| 51 | 60 | parameters: DeviceToolOpenInput, |
| 52 | 61 | success: DeviceToolOpenResult, |
| 53 | failure: DeviceToolError, | |
| 62 | failure: DeviceToolFailure, | |
| 54 | 63 | dependencies: [...dependencies, FileSystem.FileSystem, Path.Path, ServerConfig.ServerConfig], |
| 55 | 64 | }) |
| 56 | 65 | .annotate(Tool.Title, "Open device") |
| ... | ... | @@ -64,7 +73,7 @@ export const DeviceScreenshotTool = Tool.make("device_screenshot", { |
| 64 | 73 | "Capture the current screen of an open device as a PNG image. Use it to see what the user sees; for taps and text use the agent-device CLI.", |
| 65 | 74 | parameters: DeviceToolTargetInput, |
| 66 | 75 | success: DeviceToolScreenshotResult, |
| 67 | failure: DeviceToolError, | |
| 76 | failure: DeviceToolFailure, | |
| 68 | 77 | dependencies, |
| 69 | 78 | }) |
| 70 | 79 | .annotate(Tool.Title, "Screenshot device") |
| ... | ... | @@ -80,7 +89,7 @@ const DeviceCloseTool = Tool.make("device_close", { |
| 80 | 89 | success: Schema.Record(Schema.String, Schema.Never).annotate({ |
| 81 | 90 | description: "The device was closed.", |
| 82 | 91 | }), |
| 83 | failure: DeviceToolError, | |
| 92 | failure: DeviceToolFailure, | |
| 84 | 93 | dependencies, |
| 85 | 94 | }) |
| 86 | 95 | .annotate(Tool.Title, "Close device") |
apps/server/src/mcp/toolkits/environment/handlers.test.ts created+100| ... | ... | @@ -0,0 +1,100 @@ |
| 1 | import { expect, it } from "@effect/vitest"; | |
| 2 | import { | |
| 3 | DEFAULT_SERVER_SETTINGS, | |
| 4 | EnvironmentId, | |
| 5 | ProviderInstanceId, | |
| 6 | ThreadId, | |
| 7 | type OrchestrationV2ThreadShell, | |
| 8 | } from "@t3tools/contracts"; | |
| 9 | import * as Deferred from "effect/Deferred"; | |
| 10 | import * as Effect from "effect/Effect"; | |
| 11 | import * as Fiber from "effect/Fiber"; | |
| 12 | import * as Layer from "effect/Layer"; | |
| 13 | import * as Ref from "effect/Ref"; | |
| 14 | import * as Stream from "effect/Stream"; | |
| 15 | ||
| 16 | import * as Environment from "../../../environment/ServerEnvironment.ts"; | |
| 17 | import * as ThreadCommandExecutor from "../../../orchestration-v2/ThreadCommandExecutor.ts"; | |
| 18 | import * as ThreadManagement from "../../../orchestration-v2/ThreadManagementService.ts"; | |
| 19 | import * as Settings from "../../../serverSettings.ts"; | |
| 20 | import * as McpInvocationContext from "../../McpInvocationContext.ts"; | |
| 21 | import * as McpToolAccess from "../../McpToolAccess.ts"; | |
| 22 | import { liveThreadShell } from "../../McpToolAccess.testkit.ts"; | |
| 23 | import * as EnvironmentHandlers from "./handlers.ts"; | |
| 24 | import { EnvironmentToolkit } from "./tools.ts"; | |
| 25 | ||
| 26 | const environmentId = EnvironmentId.make("environment:preferences"); | |
| 27 | const threadId = ThreadId.make("thread:preferences"); | |
| 28 | ||
| 29 | it.effect("refuses a preferences update when the caller's turn ends while it waits", () => | |
| 30 | Effect.gen(function* () { | |
| 31 | const caller = yield* Ref.make<OrchestrationV2ThreadShell>(liveThreadShell(threadId)); | |
| 32 | const updates = yield* Ref.make(0); | |
| 33 | // Completes once the declaration's own check has read the caller. | |
| 34 | const checked = yield* Deferred.make<void>(); | |
| 35 | const layerDependencies = Layer.mergeAll( | |
| 36 | ThreadCommandExecutor.layer, | |
| 37 | Layer.succeed(McpInvocationContext.McpInvocationContext, { | |
| 38 | environmentId, | |
| 39 | requestNamespace: "provider:preferences", | |
| 40 | thread: { | |
| 41 | threadId, | |
| 42 | providerSessionId: "provider:preferences", | |
| 43 | providerInstanceId: ProviderInstanceId.make("codex"), | |
| 44 | }, | |
| 45 | client: undefined, | |
| 46 | capabilities: new Set(["orchestration" as const]), | |
| 47 | issuedAt: 0, | |
| 48 | }), | |
| 49 | Layer.mock(ThreadManagement.ThreadManagementService)({ | |
| 50 | getThreadShell: () => | |
| 51 | Ref.get(caller).pipe(Effect.tap(() => Deferred.succeed(checked, undefined))), | |
| 52 | }), | |
| 53 | Layer.mock(Environment.ServerEnvironment)({ | |
| 54 | getDescriptor: Effect.succeed({ | |
| 55 | environmentId, | |
| 56 | label: "Test", | |
| 57 | platform: { os: "linux", arch: "x64" }, | |
| 58 | serverVersion: "0.0.0", | |
| 59 | capabilities: { repositoryIdentity: false }, | |
| 60 | }), | |
| 61 | }), | |
| 62 | Layer.mock(Settings.ServerSettingsService)({ | |
| 63 | getSettings: Effect.succeed(DEFAULT_SERVER_SETTINGS), | |
| 64 | updateSettings: () => | |
| 65 | Ref.update(updates, (count) => count + 1).pipe(Effect.as(DEFAULT_SERVER_SETTINGS)), | |
| 66 | }), | |
| 67 | ); | |
| 68 | yield* Effect.gen(function* () { | |
| 69 | const toolkit = yield* EnvironmentToolkit; | |
| 70 | const executor = yield* ThreadCommandExecutor.ThreadCommandExecutor; | |
| 71 | const held = yield* Deferred.make<void>(); | |
| 72 | const release = yield* Deferred.make<void>(); | |
| 73 | // A turn-completion command holds the thread's lock. | |
| 74 | const holder = yield* executor | |
| 75 | .withLock( | |
| 76 | threadId, | |
| 77 | Deferred.succeed(held, undefined).pipe(Effect.andThen(Deferred.await(release))), | |
| 78 | ) | |
| 79 | .pipe(Effect.forkChild); | |
| 80 | yield* Deferred.await(held); | |
| 81 | const update = yield* toolkit | |
| 82 | .handle("t3_environment_preferences_update", { newWorktreesStartFromOrigin: true }) | |
| 83 | .pipe(Stream.unwrap, Stream.runCollect, Effect.forkChild); | |
| 84 | // The update passed its first check and waits for the lock; the turn then ends. | |
| 85 | yield* Deferred.await(checked); | |
| 86 | yield* Ref.update(caller, (shell) => ({ ...shell, activeRunId: null })); | |
| 87 | yield* Deferred.succeed(release, undefined); | |
| 88 | yield* Fiber.join(holder); | |
| 89 | const result = yield* Fiber.join(update); | |
| 90 | expect(result.at(-1)?.result).toMatchObject({ code: "parent_not_active" }); | |
| 91 | expect(yield* Ref.get(updates)).toBe(0); | |
| 92 | }).pipe( | |
| 93 | Effect.provide( | |
| 94 | McpToolAccess.HandlersLayer.layer(EnvironmentHandlers.layer).pipe( | |
| 95 | Layer.provideMerge(layerDependencies), | |
| 96 | ), | |
| 97 | ), | |
| 98 | ); | |
| 99 | }), | |
| 100 | ); |
apps/server/src/mcp/toolkits/environment/handlers.ts+22-22| ... | ... | @@ -4,7 +4,8 @@ import * as Environment from "../../../environment/ServerEnvironment.ts"; |
| 4 | 4 | import * as ThreadCommandExecutor from "../../../orchestration-v2/ThreadCommandExecutor.ts"; |
| 5 | 5 | import * as Settings from "../../../serverSettings.ts"; |
| 6 | 6 | import * as McpInvocationContext from "../../McpInvocationContext.ts"; |
| 7 | import { readCaller, readFullAccessCaller, unavailable } from "../../threadAccess.ts"; | |
| 7 | import * as McpToolAccess from "../../McpToolAccess.ts"; | |
| 8 | import { readCaller, unavailable } from "../../threadAccess.ts"; | |
| 8 | 9 | import { EnvironmentToolkit } from "./tools.ts"; |
| 9 | 10 | |
| 10 | 11 | export function preferences(settings: ServerSettings) { |
| ... | ... | @@ -28,26 +29,21 @@ export function preferences(settings: ServerSettings) { |
| 28 | 29 | }, |
| 29 | 30 | }; |
| 30 | 31 | } |
| 31 | const access = (writable = false) => | |
| 32 | Effect.gen(function* () { | |
| 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(); | |
| 38 | const environment = yield* Environment.ServerEnvironment; | |
| 39 | const descriptor = yield* environment.getDescriptor; | |
| 40 | if (descriptor.environmentId !== context.scope.environmentId) | |
| 41 | return yield* new OrchestratorMcpFailure({ | |
| 42 | code: "capability_denied", | |
| 43 | message: "This credential belongs to another environment.", | |
| 44 | }); | |
| 45 | return { ...context, descriptor, settings: yield* Settings.ServerSettingsService }; | |
| 46 | }); | |
| 47 | export const layer = EnvironmentToolkit.toLayer({ | |
| 48 | t3_environment_read: () => | |
| 32 | const access = Effect.gen(function* () { | |
| 33 | const context = yield* readCaller(); | |
| 34 | const environment = yield* Environment.ServerEnvironment; | |
| 35 | const descriptor = yield* environment.getDescriptor; | |
| 36 | if (descriptor.environmentId !== context.scope.environmentId) | |
| 37 | return yield* new OrchestratorMcpFailure({ | |
| 38 | code: "capability_denied", | |
| 39 | message: "This credential belongs to another environment.", | |
| 40 | }); | |
| 41 | return { ...context, descriptor, settings: yield* Settings.ServerSettingsService }; | |
| 42 | }); | |
| 43 | export const layer = McpToolAccess.toLayer(EnvironmentToolkit, { | |
| 44 | t3_environment_read: McpToolAccess.reads(() => | |
| 49 | 45 | Effect.gen(function* () { |
| 50 | const { descriptor, settings } = yield* access(); | |
| 46 | const { descriptor, settings } = yield* access; | |
| 51 | 47 | const current = yield* settings.getSettings.pipe(Effect.mapError(unavailable)); |
| 52 | 48 | return { |
| 53 | 49 | environmentId: descriptor.environmentId, |
| ... | ... | @@ -57,12 +53,15 @@ export const layer = EnvironmentToolkit.toLayer({ |
| 57 | 53 | preferences: preferences(current), |
| 58 | 54 | }; |
| 59 | 55 | }), |
| 60 | t3_environment_preferences_update: (patch) => | |
| 56 | ), | |
| 57 | t3_environment_preferences_update: McpToolAccess.writesEnvironment((patch, check) => | |
| 61 | 58 | Effect.gen(function* () { |
| 62 | 59 | const scope = yield* McpInvocationContext.McpInvocationContext; |
| 63 | 60 | const executor = yield* ThreadCommandExecutor.ThreadCommandExecutor; |
| 64 | 61 | const update = Effect.gen(function* () { |
| 65 | const { settings } = yield* access(true); | |
| 62 | // The turn may have ended, or the thread's modes changed, while this waited for the lock. | |
| 63 | yield* check; | |
| 64 | const { settings } = yield* access; | |
| 66 | 65 | return preferences( |
| 67 | 66 | yield* settings.updateSettings(patch).pipe(Effect.mapError(unavailable)), |
| 68 | 67 | ); |
| ... | ... | @@ -72,4 +71,5 @@ export const layer = EnvironmentToolkit.toLayer({ |
| 72 | 71 | ? update |
| 73 | 72 | : executor.withLock(scope.thread.threadId, update); |
| 74 | 73 | }), |
| 74 | ), | |
| 75 | 75 | }); |
apps/server/src/mcp/toolkits/html/handlers.ts+12-15| ... | ... | @@ -3,7 +3,7 @@ import * as Effect from "effect/Effect"; |
| 3 | 3 | |
| 4 | 4 | import * as HtmlRender from "../../../htmlRender/HtmlRender.ts"; |
| 5 | 5 | import * as McpInvocationContext from "../../McpInvocationContext.ts"; |
| 6 | import { readMutationCaller } from "../../threadAccess.ts"; | |
| 6 | import * as McpToolAccess from "../../McpToolAccess.ts"; | |
| 7 | 7 | import { HtmlPreviewToolkit, HtmlRenderToolkit, type HtmlToolkit } from "./tools.ts"; |
| 8 | 8 | |
| 9 | 9 | const INVALID_PAGE_ERRORS = new Set([ |
| ... | ... | @@ -20,14 +20,10 @@ const toFailure = (error: { readonly _tag: string; readonly message: string }) = |
| 20 | 20 | }); |
| 21 | 21 | |
| 22 | 22 | const handlers = { |
| 23 | html_preview: (input) => | |
| 23 | // The headless browser runs on the host and can open local files, so only | |
| 24 | // agents T3 launched, which already work on this machine, get it. | |
| 25 | html_preview: McpToolAccess.readsAsCaller((input) => | |
| 24 | 26 | Effect.gen(function* () { |
| 25 | // The headless browser runs on the host and can open local files, so | |
| 26 | // only agents T3 launched, which already work on this machine, get it. | |
| 27 | yield* McpInvocationContext.requireThreadScope( | |
| 28 | yield* McpInvocationContext.McpInvocationContext, | |
| 29 | "html_preview", | |
| 30 | ); | |
| 31 | 27 | const htmlRender = yield* HtmlRender.HtmlRender; |
| 32 | 28 | const { png, ...preview } = yield* htmlRender.preview(input).pipe(Effect.mapError(toFailure)); |
| 33 | 29 | return { |
| ... | ... | @@ -40,11 +36,11 @@ const handlers = { |
| 40 | 36 | }, |
| 41 | 37 | }; |
| 42 | 38 | }), |
| 43 | html_render: (input) => | |
| 39 | ), | |
| 40 | // The page is stored in the calling thread, so it needs that thread's live run. | |
| 41 | html_render: McpToolAccess.actsAsCaller((input) => | |
| 44 | 42 | Effect.gen(function* () { |
| 45 | // The page is stored in the calling thread, so it needs that thread's | |
| 46 | // live run, like any other write. | |
| 47 | const { scope } = yield* readMutationCaller(); | |
| 43 | const scope = yield* McpInvocationContext.McpInvocationContext; | |
| 48 | 44 | const { thread } = yield* McpInvocationContext.requireThreadScope(scope, "html_render"); |
| 49 | 45 | const htmlRender = yield* HtmlRender.HtmlRender; |
| 50 | 46 | const reference = yield* htmlRender |
| ... | ... | @@ -56,12 +52,13 @@ const handlers = { |
| 56 | 52 | "Shown to the reader above your reply. Don't mention or describe the page; reply with only what it doesn't already say.", |
| 57 | 53 | }; |
| 58 | 54 | }), |
| 59 | } satisfies Parameters<typeof HtmlToolkit.toLayer>[0]; | |
| 55 | ), | |
| 56 | } satisfies McpToolAccess.Handlers<typeof HtmlToolkit.tools>; | |
| 60 | 57 | |
| 61 | export const layerPreview = HtmlPreviewToolkit.toLayer({ | |
| 58 | export const layerPreview = McpToolAccess.toLayer(HtmlPreviewToolkit, { | |
| 62 | 59 | html_preview: handlers.html_preview, |
| 63 | 60 | }); |
| 64 | 61 | |
| 65 | export const layerRender = HtmlRenderToolkit.toLayer({ | |
| 62 | export const layerRender = McpToolAccess.toLayer(HtmlRenderToolkit, { | |
| 66 | 63 | html_render: handlers.html_render, |
| 67 | 64 | }); |
apps/server/src/mcp/toolkits/html/tools.ts+5-1| ... | ... | @@ -56,7 +56,11 @@ export const HtmlPreviewTool = Tool.make("html_preview", { |
| 56 | 56 | }), |
| 57 | 57 | }), |
| 58 | 58 | failure: OrchestratorMcpFailure, |
| 59 | dependencies: [McpInvocationContext.McpInvocationContext, HtmlRender.HtmlRender], | |
| 59 | dependencies: [ | |
| 60 | McpInvocationContext.McpInvocationContext, | |
| 61 | ThreadManagementService.ThreadManagementService, | |
| 62 | HtmlRender.HtmlRender, | |
| 63 | ], | |
| 60 | 64 | }) |
| 61 | 65 | .annotate(Tool.Title, "Preview HTML") |
| 62 | 66 | .annotate(Tool.Readonly, true) |
apps/server/src/mcp/toolkits/orchestrator/handlers.ts+67-38| ... | ... | @@ -2,106 +2,135 @@ import { OrchestratorToolkit } from "./tools.ts"; |
| 2 | 2 | import * as Effect from "effect/Effect"; |
| 3 | 3 | |
| 4 | 4 | import * as McpInvocationContext from "../../McpInvocationContext.ts"; |
| 5 | import * as McpToolAccess from "../../McpToolAccess.ts"; | |
| 5 | 6 | import * as OrchestratorMcpService from "../../OrchestratorMcpService.ts"; |
| 6 | 7 | import * as ThreadMetadataMcpService from "../../ThreadMetadataMcpService.ts"; |
| 7 | 8 | |
| 8 | 9 | const handlers = { |
| 9 | orchestrator_capabilities: () => | |
| 10 | orchestrator_capabilities: McpToolAccess.reads(() => | |
| 10 | 11 | Effect.gen(function* () { |
| 11 | 12 | const scope = yield* McpInvocationContext.McpInvocationContext; |
| 12 | 13 | const service = yield* OrchestratorMcpService.OrchestratorMcpService; |
| 13 | 14 | return yield* service.capabilities(scope); |
| 14 | 15 | }), |
| 15 | delegate_task: (input) => | |
| 16 | ), | |
| 17 | delegate_task: McpToolAccess.actsAsCaller((input) => | |
| 16 | 18 | Effect.gen(function* () { |
| 17 | 19 | const scope = yield* McpInvocationContext.McpInvocationContext; |
| 18 | 20 | const service = yield* OrchestratorMcpService.OrchestratorMcpService; |
| 19 | 21 | return yield* service.delegateTask(scope, input); |
| 20 | 22 | }), |
| 21 | task_status: ({ taskId }) => | |
| 23 | ), | |
| 24 | task_status: McpToolAccess.actsAsCaller(({ taskId }) => | |
| 22 | 25 | Effect.gen(function* () { |
| 23 | 26 | const scope = yield* McpInvocationContext.McpInvocationContext; |
| 24 | 27 | const service = yield* OrchestratorMcpService.OrchestratorMcpService; |
| 25 | 28 | return yield* service.taskStatus(scope, taskId); |
| 26 | 29 | }), |
| 27 | task_cancel: (input) => | |
| 30 | ), | |
| 31 | task_cancel: McpToolAccess.actsAsCaller((input) => | |
| 28 | 32 | Effect.gen(function* () { |
| 29 | 33 | const scope = yield* McpInvocationContext.McpInvocationContext; |
| 30 | 34 | const service = yield* OrchestratorMcpService.OrchestratorMcpService; |
| 31 | 35 | return yield* service.cancelTask(scope, input); |
| 32 | 36 | }), |
| 33 | schedule_task: (input) => | |
| 34 | Effect.gen(function* () { | |
| 35 | const scope = yield* McpInvocationContext.McpInvocationContext; | |
| 36 | const service = yield* OrchestratorMcpService.OrchestratorMcpService; | |
| 37 | return yield* service.scheduleTask(scope, input); | |
| 38 | }), | |
| 39 | list_scheduled_tasks: (input) => | |
| 37 | ), | |
| 38 | schedule_task: McpToolAccess.startsThreads( | |
| 39 | // A scheduled task runs with the caller's own modes. | |
| 40 | () => ({}), | |
| 41 | (input) => | |
| 42 | Effect.gen(function* () { | |
| 43 | const scope = yield* McpInvocationContext.McpInvocationContext; | |
| 44 | const service = yield* OrchestratorMcpService.OrchestratorMcpService; | |
| 45 | return yield* service.scheduleTask(scope, input); | |
| 46 | }), | |
| 47 | ), | |
| 48 | list_scheduled_tasks: McpToolAccess.reads((input) => | |
| 40 | 49 | Effect.gen(function* () { |
| 41 | 50 | const scope = yield* McpInvocationContext.McpInvocationContext; |
| 42 | 51 | const service = yield* OrchestratorMcpService.OrchestratorMcpService; |
| 43 | 52 | return yield* service.listScheduledTasks(scope, input); |
| 44 | 53 | }), |
| 45 | update_scheduled_task: (input) => | |
| 54 | ), | |
| 55 | update_scheduled_task: McpToolAccess.writes((input) => | |
| 46 | 56 | Effect.gen(function* () { |
| 47 | 57 | const scope = yield* McpInvocationContext.McpInvocationContext; |
| 48 | 58 | const service = yield* OrchestratorMcpService.OrchestratorMcpService; |
| 49 | 59 | return yield* service.updateScheduledTask(scope, input); |
| 50 | 60 | }), |
| 51 | delete_scheduled_task: (input) => | |
| 61 | ), | |
| 62 | delete_scheduled_task: McpToolAccess.writes((input) => | |
| 52 | 63 | Effect.gen(function* () { |
| 53 | 64 | const scope = yield* McpInvocationContext.McpInvocationContext; |
| 54 | 65 | const service = yield* OrchestratorMcpService.OrchestratorMcpService; |
| 55 | 66 | return yield* service.deleteScheduledTask(scope, input); |
| 56 | 67 | }), |
| 57 | request_secret: (input) => | |
| 68 | ), | |
| 69 | request_secret: McpToolAccess.actsAsCaller((input) => | |
| 58 | 70 | Effect.gen(function* () { |
| 59 | 71 | const scope = yield* McpInvocationContext.McpInvocationContext; |
| 60 | 72 | const service = yield* OrchestratorMcpService.OrchestratorMcpService; |
| 61 | 73 | return yield* service.requestSecret(scope, input); |
| 62 | 74 | }), |
| 63 | create_threads: (input) => | |
| 75 | ), | |
| 76 | create_threads: McpToolAccess.actsAsCaller((input) => | |
| 64 | 77 | Effect.gen(function* () { |
| 65 | 78 | const scope = yield* McpInvocationContext.McpInvocationContext; |
| 66 | 79 | const service = yield* OrchestratorMcpService.OrchestratorMcpService; |
| 67 | 80 | return yield* service.createThreads(scope, input); |
| 68 | 81 | }), |
| 69 | t3_thread_list: (input) => | |
| 82 | ), | |
| 83 | t3_thread_list: McpToolAccess.reads((input) => | |
| 70 | 84 | Effect.gen(function* () { |
| 71 | 85 | const scope = yield* McpInvocationContext.McpInvocationContext; |
| 72 | 86 | const service = yield* OrchestratorMcpService.OrchestratorMcpService; |
| 73 | 87 | return yield* service.listThreads(scope, input); |
| 74 | 88 | }), |
| 75 | t3_thread_read: (input) => | |
| 89 | ), | |
| 90 | // Reading a child's finished result also acknowledges its delivery to the | |
| 91 | // reader's own thread. That is bookkeeping on the caller's own subagent, not | |
| 92 | // a change to anything it reads, so this stays a read. | |
| 93 | t3_thread_read: McpToolAccess.reads((input) => | |
| 76 | 94 | Effect.gen(function* () { |
| 77 | 95 | const scope = yield* McpInvocationContext.McpInvocationContext; |
| 78 | 96 | const service = yield* OrchestratorMcpService.OrchestratorMcpService; |
| 79 | 97 | return yield* service.readThread(scope, input); |
| 80 | 98 | }), |
| 81 | t3_thread_update: (input) => | |
| 82 | Effect.gen(function* () { | |
| 83 | const scope = yield* McpInvocationContext.McpInvocationContext; | |
| 84 | const service = yield* ThreadMetadataMcpService.ThreadMetadataMcpService; | |
| 85 | return yield* service.update(scope, input); | |
| 86 | }), | |
| 87 | t3_thread_send: (input) => | |
| 88 | Effect.gen(function* () { | |
| 89 | const scope = yield* McpInvocationContext.McpInvocationContext; | |
| 90 | const service = yield* OrchestratorMcpService.OrchestratorMcpService; | |
| 91 | return yield* service.sendToThread(scope, input); | |
| 92 | }), | |
| 93 | t3_thread_wait: (input) => | |
| 99 | ), | |
| 100 | t3_thread_update: McpToolAccess.writesThreads( | |
| 101 | (input) => [input.threadId], | |
| 102 | (input) => | |
| 103 | Effect.gen(function* () { | |
| 104 | const scope = yield* McpInvocationContext.McpInvocationContext; | |
| 105 | const service = yield* ThreadMetadataMcpService.ThreadMetadataMcpService; | |
| 106 | return yield* service.update(scope, input); | |
| 107 | }), | |
| 108 | ), | |
| 109 | t3_thread_send: McpToolAccess.writesThreads( | |
| 110 | (input) => [input.threadId], | |
| 111 | (input) => | |
| 112 | Effect.gen(function* () { | |
| 113 | const scope = yield* McpInvocationContext.McpInvocationContext; | |
| 114 | const service = yield* OrchestratorMcpService.OrchestratorMcpService; | |
| 115 | return yield* service.sendToThread(scope, input); | |
| 116 | }), | |
| 117 | ), | |
| 118 | t3_thread_wait: McpToolAccess.reads((input) => | |
| 94 | 119 | Effect.gen(function* () { |
| 95 | 120 | const scope = yield* McpInvocationContext.McpInvocationContext; |
| 96 | 121 | const service = yield* OrchestratorMcpService.OrchestratorMcpService; |
| 97 | 122 | return yield* service.waitForThread(scope, input); |
| 98 | 123 | }), |
| 99 | t3_thread_interrupt: (input) => | |
| 100 | Effect.gen(function* () { | |
| 101 | const scope = yield* McpInvocationContext.McpInvocationContext; | |
| 102 | const service = yield* OrchestratorMcpService.OrchestratorMcpService; | |
| 103 | return yield* service.interruptThread(scope, input); | |
| 104 | }), | |
| 105 | } satisfies Parameters<typeof OrchestratorToolkit.toLayer>[0]; | |
| 124 | ), | |
| 125 | t3_thread_interrupt: McpToolAccess.writesThreads( | |
| 126 | (input) => [input.threadId], | |
| 127 | (input) => | |
| 128 | Effect.gen(function* () { | |
| 129 | const scope = yield* McpInvocationContext.McpInvocationContext; | |
| 130 | const service = yield* OrchestratorMcpService.OrchestratorMcpService; | |
| 131 | return yield* service.interruptThread(scope, input); | |
| 132 | }), | |
| 133 | ), | |
| 134 | } satisfies McpToolAccess.Handlers<typeof OrchestratorToolkit.tools>; | |
| 106 | 135 | |
| 107 | export const layer = OrchestratorToolkit.toLayer(handlers); | |
| 136 | export const layer = McpToolAccess.toLayer(OrchestratorToolkit, handlers); |
apps/server/src/mcp/toolkits/orchestrator/tools.ts+3| ... | ... | @@ -32,16 +32,19 @@ import { |
| 32 | 32 | } from "@t3tools/contracts"; |
| 33 | 33 | import { Tool, Toolkit } from "effect/ai"; |
| 34 | 34 | |
| 35 | import * as ThreadManagementService from "../../../orchestration-v2/ThreadManagementService.ts"; | |
| 35 | 36 | import * as McpInvocationContext from "../../McpInvocationContext.ts"; |
| 36 | 37 | import * as OrchestratorMcpService from "../../OrchestratorMcpService.ts"; |
| 37 | 38 | import * as ThreadMetadataMcpService from "../../ThreadMetadataMcpService.ts"; |
| 38 | 39 | |
| 39 | 40 | const dependencies = [ |
| 40 | 41 | McpInvocationContext.McpInvocationContext, |
| 42 | ThreadManagementService.ThreadManagementService, | |
| 41 | 43 | OrchestratorMcpService.OrchestratorMcpService, |
| 42 | 44 | ]; |
| 43 | 45 | const threadMetadataDependencies = [ |
| 44 | 46 | McpInvocationContext.McpInvocationContext, |
| 47 | ThreadManagementService.ThreadManagementService, | |
| 45 | 48 | ThreadMetadataMcpService.ThreadMetadataMcpService, |
| 46 | 49 | ]; |
| 47 | 50 |
apps/server/src/mcp/toolkits/preview/handlers.ts+48-23| ... | ... | @@ -29,6 +29,7 @@ import { |
| 29 | 29 | import { resolveAttachmentRelativePath } from "../../../attachmentPaths.ts"; |
| 30 | 30 | import * as ServerConfig from "../../../config.ts"; |
| 31 | 31 | import * as McpInvocationContext from "../../McpInvocationContext.ts"; |
| 32 | import * as McpToolAccess from "../../McpToolAccess.ts"; | |
| 32 | 33 | import * as PreviewAutomationBroker from "../../PreviewAutomationBroker.ts"; |
| 33 | 34 | import { PreviewSnapshotToolkit, PreviewStandardToolkit, PreviewToolkit } from "./tools.ts"; |
| 34 | 35 | |
| ... | ... | @@ -189,41 +190,64 @@ export const claimPreviewRecording = Effect.fn("PreviewToolkit.claimRecording")( |
| 189 | 190 | }); |
| 190 | 191 | |
| 191 | 192 | const handlers = { |
| 192 | preview_dialog: (input) => invokeTargeted<PreviewAutomationStatus>("dialog", input), | |
| 193 | preview_status: (input) => invokeTargeted<PreviewAutomationStatus>("status", input ?? {}), | |
| 194 | preview_open: (input) => | |
| 193 | preview_dialog: McpToolAccess.actsAsCaller((input) => | |
| 194 | invokeTargeted<PreviewAutomationStatus>("dialog", input), | |
| 195 | ), | |
| 196 | preview_status: McpToolAccess.readsAsCaller((input) => | |
| 197 | invokeTargeted<PreviewAutomationStatus>("status", input ?? {}), | |
| 198 | ), | |
| 199 | preview_open: McpToolAccess.actsAsCaller((input) => | |
| 195 | 200 | invokeTargeted<PreviewAutomationStatus>("open", normalizePreviewOpenInput(input)), |
| 196 | preview_navigate: (input) => | |
| 201 | ), | |
| 202 | preview_navigate: McpToolAccess.actsAsCaller((input) => | |
| 197 | 203 | invokeTargeted<PreviewAutomationStatus>("navigate", input, input.timeoutMs), |
| 198 | preview_resize: (input) => | |
| 204 | ), | |
| 205 | preview_resize: McpToolAccess.actsAsCaller((input) => | |
| 199 | 206 | invokeTargeted<PreviewAutomationResizeResult>("resize", input, input.timeoutMs), |
| 200 | preview_set_appearance: (input) => | |
| 207 | ), | |
| 208 | preview_set_appearance: McpToolAccess.actsAsCaller((input) => | |
| 201 | 209 | invokeTargeted<PreviewAutomationSetColorSchemeResult>("setColorScheme", input), |
| 202 | preview_snapshot: (input) => { | |
| 210 | ), | |
| 211 | preview_snapshot: McpToolAccess.readsAsCaller((input) => { | |
| 203 | 212 | // Output selection and saving are MCP-only; the browser still produces a complete snapshot. |
| 204 | 213 | const { includeImage: _includeImage, save: _save, ...operationInput } = input ?? {}; |
| 205 | 214 | return invokeTargeted<PreviewAutomationSnapshot>("snapshot", operationInput); |
| 206 | }, | |
| 207 | preview_click: (input) => invokeTargeted<object>("click", input, input.timeoutMs), | |
| 208 | preview_type: (input) => invokeTargeted<object>("type", input, input.timeoutMs), | |
| 209 | preview_hover: (input) => invokeTargeted<object>("hover", input, input.timeoutMs), | |
| 210 | preview_select: (input) => | |
| 215 | }), | |
| 216 | preview_click: McpToolAccess.actsAsCaller((input) => | |
| 217 | invokeTargeted<object>("click", input, input.timeoutMs), | |
| 218 | ), | |
| 219 | preview_type: McpToolAccess.actsAsCaller((input) => | |
| 220 | invokeTargeted<object>("type", input, input.timeoutMs), | |
| 221 | ), | |
| 222 | preview_hover: McpToolAccess.actsAsCaller((input) => | |
| 223 | invokeTargeted<object>("hover", input, input.timeoutMs), | |
| 224 | ), | |
| 225 | preview_select: McpToolAccess.actsAsCaller((input) => | |
| 211 | 226 | invokeTargeted<PreviewAutomationSelectResult>("select", input, input.timeoutMs), |
| 212 | preview_drag: (input) => invokeTargeted<object>("drag", input, input.timeoutMs), | |
| 213 | preview_upload: (input) => invokeTargeted<object>("upload", input, input.timeoutMs), | |
| 214 | preview_press: (input) => invokeTargeted<object>("press", input), | |
| 215 | preview_scroll: (input) => invokeTargeted<object>("scroll", input), | |
| 216 | preview_evaluate: ({ tabId, ...input }) => | |
| 227 | ), | |
| 228 | preview_drag: McpToolAccess.actsAsCaller((input) => | |
| 229 | invokeTargeted<object>("drag", input, input.timeoutMs), | |
| 230 | ), | |
| 231 | preview_upload: McpToolAccess.actsAsCaller((input) => | |
| 232 | invokeTargeted<object>("upload", input, input.timeoutMs), | |
| 233 | ), | |
| 234 | preview_press: McpToolAccess.actsAsCaller((input) => invokeTargeted<object>("press", input)), | |
| 235 | preview_scroll: McpToolAccess.actsAsCaller((input) => invokeTargeted<object>("scroll", input)), | |
| 236 | preview_evaluate: McpToolAccess.actsAsCaller(({ tabId, ...input }) => | |
| 217 | 237 | invoke<unknown>("evaluate", input, undefined, tabId).pipe( |
| 218 | 238 | Effect.map(({ result, toolIcon }) => ({ |
| 219 | 239 | value: result ?? null, |
| 220 | 240 | ...(toolIcon ? { toolIcon } : {}), |
| 221 | 241 | })), |
| 222 | 242 | ), |
| 223 | preview_wait_for: (input) => invokeTargeted<object>("waitFor", input, input.timeoutMs), | |
| 224 | preview_recording_start: (input) => | |
| 243 | ), | |
| 244 | preview_wait_for: McpToolAccess.readsAsCaller((input) => | |
| 245 | invokeTargeted<object>("waitFor", input, input.timeoutMs), | |
| 246 | ), | |
| 247 | preview_recording_start: McpToolAccess.actsAsCaller((input) => | |
| 225 | 248 | invokeTargeted<PreviewAutomationRecordingStatus>("recordingStart", input ?? {}), |
| 226 | preview_recording_stop: (input) => | |
| 249 | ), | |
| 250 | preview_recording_stop: McpToolAccess.actsAsCaller((input) => | |
| 227 | 251 | Effect.gen(function* () { |
| 228 | 252 | const scope = yield* McpInvocationContext.requireThreadMcpCapability("preview"); |
| 229 | 253 | const { tabId, ...operationInput } = input; |
| ... | ... | @@ -236,12 +260,13 @@ const handlers = { |
| 236 | 260 | const artifact = yield* claimPreviewRecording(scope.thread.threadId, response.result); |
| 237 | 261 | return { ...artifact, ...(response.toolIcon ? { toolIcon: response.toolIcon } : {}) }; |
| 238 | 262 | }), |
| 239 | } satisfies Parameters<typeof PreviewToolkit.toLayer>[0]; | |
| 263 | ), | |
| 264 | } satisfies McpToolAccess.Handlers<typeof PreviewToolkit.tools>; | |
| 240 | 265 | |
| 241 | 266 | const { preview_snapshot, ...standardHandlers } = handlers; |
| 242 | 267 | |
| 243 | export const layerStandard = PreviewStandardToolkit.toLayer(standardHandlers); | |
| 268 | export const layerStandard = McpToolAccess.toLayer(PreviewStandardToolkit, standardHandlers); | |
| 244 | 269 | |
| 245 | export const layerSnapshot = PreviewSnapshotToolkit.toLayer({ | |
| 270 | export const layerSnapshot = McpToolAccess.toLayer(PreviewSnapshotToolkit, { | |
| 246 | 271 | preview_snapshot, |
| 247 | 272 | }); |
apps/server/src/mcp/toolkits/preview/tools.ts+25-19| ... | ... | @@ -1,4 +1,5 @@ |
| 1 | 1 | import { |
| 2 | OrchestratorMcpFailure, | |
| 2 | 3 | ToolActivityIcon, |
| 3 | 4 | PreviewAutomationClickInput, |
| 4 | 5 | PreviewAutomationDialogInput, |
| ... | ... | @@ -32,12 +33,17 @@ import { Tool, Toolkit } from "effect/ai"; |
| 32 | 33 | import * as McpInvocationContext from "../../McpInvocationContext.ts"; |
| 33 | 34 | import * as PreviewAutomationBroker from "../../PreviewAutomationBroker.ts"; |
| 34 | 35 | import * as ServerConfig from "../../../config.ts"; |
| 36 | import * as ThreadManagementService from "../../../orchestration-v2/ThreadManagementService.ts"; | |
| 35 | 37 | |
| 36 | 38 | const dependencies = [ |
| 37 | 39 | McpInvocationContext.McpInvocationContext, |
| 40 | ThreadManagementService.ThreadManagementService, | |
| 38 | 41 | PreviewAutomationBroker.PreviewAutomationBroker, |
| 39 | 42 | ]; |
| 40 | 43 | |
| 44 | /** What a browser action fails with, including the access gate's refusal. */ | |
| 45 | const PreviewToolFailure = Schema.Union([PreviewAutomationError, OrchestratorMcpFailure]); | |
| 46 | ||
| 41 | 47 | const presentationFields = { toolIcon: Schema.optional(ToolActivityIcon) }; |
| 42 | 48 | |
| 43 | 49 | const PreviewActionResult = Schema.Struct(presentationFields).annotate({ |
| ... | ... | @@ -61,7 +67,7 @@ const PreviewStatusTool = Tool.make("preview_status", { |
| 61 | 67 | "Report whether a collaborative browser tab is automation-capable, including its control owner, pending dialog, URL, title, visibility, loading state, viewport mode, and measured CSS-pixel size. Pass tabId to inspect a specific tab; omit it to use this agent session's current tab.", |
| 62 | 68 | parameters: PreviewAutomationTabTargetInput, |
| 63 | 69 | success: PreviewAutomationStatus, |
| 64 | failure: PreviewAutomationError, | |
| 70 | failure: PreviewToolFailure, | |
| 65 | 71 | dependencies, |
| 66 | 72 | }) |
| 67 | 73 | .annotate(Tool.Title, "Get preview status") |
| ... | ... | @@ -75,7 +81,7 @@ const PreviewOpenTool = browserTool( |
| 75 | 81 | "Initialize a collaborative browser tab and open its thread-bound inline preview by default. Set open=false for background-only automation. Pass tabId to reuse a specific existing tab, set reuseExistingTab=false to create another tab, or omit both to use this agent session's current tab. Parallel subagents sharing a provider session must each open with reuseExistingTab=false and pass their returned tabId on every call. Server tabs use isolated storage and cannot be operated by a different agent session.", |
| 76 | 82 | parameters: PreviewAutomationOpenInput, |
| 77 | 83 | success: PreviewAutomationStatus, |
| 78 | failure: PreviewAutomationError, | |
| 84 | failure: PreviewToolFailure, | |
| 79 | 85 | dependencies, |
| 80 | 86 | }) |
| 81 | 87 | .annotate(Tool.Title, "Open browser preview") |
| ... | ... | @@ -88,7 +94,7 @@ const PreviewDialogTool = browserTool( |
| 88 | 94 | "Accept or dismiss the server browser dialog reported by preview_status. For a prompt, supply promptText when accepting. Requires this agent to own the tab. Desktop hosts may not support this operation.", |
| 89 | 95 | parameters: PreviewAutomationDialogInput, |
| 90 | 96 | success: PreviewAutomationStatus, |
| 91 | failure: PreviewAutomationError, | |
| 97 | failure: PreviewToolFailure, | |
| 92 | 98 | dependencies, |
| 93 | 99 | }).annotate(Tool.Title, "Resolve browser dialog"), |
| 94 | 100 | ); |
| ... | ... | @@ -99,7 +105,7 @@ const PreviewNavigateTool = safeBrowserTool( |
| 99 | 105 | "Navigate a collaborative browser tab. Pass tabId to target a specific tab, plus {url:'https://t3.chat'} for a website or {target:{kind:'environment-port',port:5173}} for a dev server. Exactly one of url or target is required.", |
| 100 | 106 | parameters: PreviewAutomationNavigateInput, |
| 101 | 107 | success: PreviewAutomationStatus, |
| 102 | failure: PreviewAutomationError, | |
| 108 | failure: PreviewToolFailure, | |
| 103 | 109 | dependencies, |
| 104 | 110 | }).annotate(Tool.Title, "Navigate browser preview"), |
| 105 | 111 | ); |
| ... | ... | @@ -110,7 +116,7 @@ const PreviewResizeTool = safeBrowserTool( |
| 110 | 116 | "Resize a collaborative browser tab, optionally selected by tabId. Use {mode:'fill'}, {mode:'freeform',width:1024,height:768}, or {mode:'preset',preset:'iphone-12-pro',orientation:'portrait'}. This changes CSS layout breakpoints without changing the desktop browser user agent.", |
| 111 | 117 | parameters: PreviewAutomationResizeInput, |
| 112 | 118 | success: Schema.Struct({ ...PreviewAutomationResizeResult.fields, ...presentationFields }), |
| 113 | failure: PreviewAutomationError, | |
| 119 | failure: PreviewToolFailure, | |
| 114 | 120 | dependencies, |
| 115 | 121 | }) |
| 116 | 122 | .annotate(Tool.Title, "Resize browser viewport") |
| ... | ... | @@ -126,7 +132,7 @@ const PreviewSetAppearanceTool = safeBrowserTool( |
| 126 | 132 | ...PreviewAutomationSetColorSchemeResult.fields, |
| 127 | 133 | ...presentationFields, |
| 128 | 134 | }), |
| 129 | failure: PreviewAutomationError, | |
| 135 | failure: PreviewToolFailure, | |
| 130 | 136 | dependencies, |
| 131 | 137 | }) |
| 132 | 138 | .annotate(Tool.Title, "Set preview appearance") |
| ... | ... | @@ -153,7 +159,7 @@ export const PreviewSnapshotTool = readonlyBrowserTool( |
| 153 | 159 | ), |
| 154 | 160 | }), |
| 155 | 161 | success: PreviewAutomationSnapshot, |
| 156 | failure: PreviewAutomationError, | |
| 162 | failure: PreviewToolFailure, | |
| 157 | 163 | dependencies, |
| 158 | 164 | }).annotate(Tool.Title, "Inspect browser page"), |
| 159 | 165 | ); |
| ... | ... | @@ -164,7 +170,7 @@ const PreviewClickTool = browserTool( |
| 164 | 170 | "Click exactly one target in the tab selected by tabId, or this agent session's current tab when omitted. Prefer a Playwright locator; selector accepts legacy CSS; x and y must be supplied together. Set button=right for a context menu or clickCount=2 for a double-click; server browser tabs only.", |
| 165 | 171 | parameters: PreviewAutomationClickInput, |
| 166 | 172 | success: PreviewActionResult, |
| 167 | failure: PreviewAutomationError, | |
| 173 | failure: PreviewToolFailure, | |
| 168 | 174 | dependencies, |
| 169 | 175 | }).annotate(Tool.Title, "Click preview page"), |
| 170 | 176 | ); |
| ... | ... | @@ -175,7 +181,7 @@ const PreviewTypeTool = browserTool( |
| 175 | 181 | "Insert literal text into one input in the tab selected by tabId, or this agent session's current tab when omitted. Prefer a Playwright locator; set clear=true to replace existing text.", |
| 176 | 182 | parameters: PreviewAutomationTypeInput, |
| 177 | 183 | success: PreviewActionResult, |
| 178 | failure: PreviewAutomationError, | |
| 184 | failure: PreviewToolFailure, | |
| 179 | 185 | dependencies, |
| 180 | 186 | }).annotate(Tool.Title, "Type into preview page"), |
| 181 | 187 | ); |
| ... | ... | @@ -186,7 +192,7 @@ const PreviewHoverTool = safeBrowserTool( |
| 186 | 192 | "Move the mouse over exactly one target in the tab selected by tabId, or this agent session's current tab when omitted, to reveal hover menus and tooltips. Server browser tabs only.", |
| 187 | 193 | parameters: PreviewAutomationHoverInput, |
| 188 | 194 | success: PreviewActionResult, |
| 189 | failure: PreviewAutomationError, | |
| 195 | failure: PreviewToolFailure, | |
| 190 | 196 | dependencies, |
| 191 | 197 | }).annotate(Tool.Title, "Hover preview page"), |
| 192 | 198 | ); |
| ... | ... | @@ -200,7 +206,7 @@ const PreviewSelectTool = browserTool( |
| 200 | 206 | ...PreviewAutomationSelectResult.fields, |
| 201 | 207 | ...presentationFields, |
| 202 | 208 | }), |
| 203 | failure: PreviewAutomationError, | |
| 209 | failure: PreviewToolFailure, | |
| 204 | 210 | dependencies, |
| 205 | 211 | }).annotate(Tool.Title, "Select preview option"), |
| 206 | 212 | ); |
| ... | ... | @@ -211,7 +217,7 @@ const PreviewDragTool = browserTool( |
| 211 | 217 | "Drag one element onto another in the tab selected by tabId, or this agent session's current tab when omitted. Server browser tabs only.", |
| 212 | 218 | parameters: PreviewAutomationDragInput, |
| 213 | 219 | success: PreviewActionResult, |
| 214 | failure: PreviewAutomationError, | |
| 220 | failure: PreviewToolFailure, | |
| 215 | 221 | dependencies, |
| 216 | 222 | }).annotate(Tool.Title, "Drag in preview page"), |
| 217 | 223 | ); |
| ... | ... | @@ -222,7 +228,7 @@ const PreviewUploadTool = browserTool( |
| 222 | 228 | "Give files on the environment to the page in the tab selected by tabId, or this agent session's current tab when omitted. After clicking an upload control, preview_status reports the open fileChooser; call this with absolute paths to answer it, or with an empty list to cancel. Pass a locator for an <input type=file> to set its files without a picker. Server browser tabs only.", |
| 223 | 229 | parameters: PreviewAutomationUploadInput, |
| 224 | 230 | success: PreviewActionResult, |
| 225 | failure: PreviewAutomationError, | |
| 231 | failure: PreviewToolFailure, | |
| 226 | 232 | dependencies, |
| 227 | 233 | }).annotate(Tool.Title, "Upload files to preview page"), |
| 228 | 234 | ); |
| ... | ... | @@ -233,7 +239,7 @@ const PreviewPressTool = browserTool( |
| 233 | 239 | "Press one keyboard key in the tab selected by tabId, or this agent session's current tab when omitted. Examples: {key:'Enter'}, {key:'Escape'}, or {key:'a',modifiers:['Meta']}.", |
| 234 | 240 | parameters: PreviewAutomationPressInput, |
| 235 | 241 | success: PreviewActionResult, |
| 236 | failure: PreviewAutomationError, | |
| 242 | failure: PreviewToolFailure, | |
| 237 | 243 | dependencies, |
| 238 | 244 | }).annotate(Tool.Title, "Press key in preview page"), |
| 239 | 245 | ); |
| ... | ... | @@ -244,7 +250,7 @@ const PreviewScrollTool = safeBrowserTool( |
| 244 | 250 | "Scroll the tab selected by tabId, or this agent session's current tab when omitted. Positive deltaY scrolls down and positive deltaX scrolls right; a locator/selector targets a container.", |
| 245 | 251 | parameters: PreviewAutomationScrollInput, |
| 246 | 252 | success: PreviewActionResult, |
| 247 | failure: PreviewAutomationError, | |
| 253 | failure: PreviewToolFailure, | |
| 248 | 254 | dependencies, |
| 249 | 255 | }).annotate(Tool.Title, "Scroll preview page"), |
| 250 | 256 | ); |
| ... | ... | @@ -267,7 +273,7 @@ const PreviewEvaluateTool = browserTool( |
| 267 | 273 | "Evaluate JavaScript in the tab selected by tabId, or this agent session's current tab when omitted. Returns {value} with a serializable result up to 64 KB; the expression may mutate page state.", |
| 268 | 274 | parameters: PreviewAutomationEvaluateInput, |
| 269 | 275 | success: PreviewEvaluateResult, |
| 270 | failure: PreviewAutomationError, | |
| 276 | failure: PreviewToolFailure, | |
| 271 | 277 | dependencies, |
| 272 | 278 | }).annotate(Tool.Title, "Evaluate JavaScript in preview"), |
| 273 | 279 | ); |
| ... | ... | @@ -278,7 +284,7 @@ const PreviewWaitForTool = readonlyBrowserTool( |
| 278 | 284 | "Wait in the tab selected by tabId, or this agent session's current tab when omitted, until all supplied locator, selector, text, and URL conditions match.", |
| 279 | 285 | parameters: PreviewAutomationWaitForInput, |
| 280 | 286 | success: PreviewActionResult, |
| 281 | failure: PreviewAutomationError, | |
| 287 | failure: PreviewToolFailure, | |
| 282 | 288 | dependencies, |
| 283 | 289 | }).annotate(Tool.Title, "Wait for preview page condition"), |
| 284 | 290 | ); |
| ... | ... | @@ -289,7 +295,7 @@ const PreviewRecordingStartTool = safeBrowserTool( |
| 289 | 295 | "Start recording the collaborative browser tab selected by tabId, or this agent session's current tab when omitted.", |
| 290 | 296 | parameters: PreviewAutomationTabTargetInput, |
| 291 | 297 | success: Schema.Struct({ ...PreviewAutomationRecordingStatus.fields, ...presentationFields }), |
| 292 | failure: PreviewAutomationError, | |
| 298 | failure: PreviewToolFailure, | |
| 293 | 299 | dependencies, |
| 294 | 300 | }).annotate(Tool.Title, "Start browser recording"), |
| 295 | 301 | ); |
| ... | ... | @@ -300,7 +306,7 @@ const PreviewRecordingStopTool = safeBrowserTool( |
| 300 | 306 | "Stop recording the collaborative browser tab selected by tabId, or this agent session's current tab when omitted, and transfer the compressed recording once (up to 50 MiB) to an evidence file readable in this agent's environment. Returns its environment-local path after transfer succeeds.", |
| 301 | 307 | parameters: PreviewAutomationTabTargetInput, |
| 302 | 308 | success: Schema.Struct({ ...PreviewAutomationRecordingArtifact.fields, ...presentationFields }), |
| 303 | failure: PreviewAutomationError, | |
| 309 | failure: PreviewToolFailure, | |
| 304 | 310 | dependencies: [...dependencies, FileSystem.FileSystem, ServerConfig.ServerConfig], |
| 305 | 311 | }).annotate(Tool.Title, "Stop browser recording"), |
| 306 | 312 | ); |
apps/server/src/mcp/toolkits/previewControls/handlers.test.ts+8-1| ... | ... | @@ -17,6 +17,8 @@ import * as ServerConfig from "../../../config.ts"; |
| 17 | 17 | import * as Preview from "../../../preview/Manager.ts"; |
| 18 | 18 | import * as ServerSettings from "../../../serverSettings.ts"; |
| 19 | 19 | import * as McpInvocationContext from "../../McpInvocationContext.ts"; |
| 20 | import * as McpToolAccess from "../../McpToolAccess.ts"; | |
| 21 | import * as McpToolAccessTestkit from "../../McpToolAccess.testkit.ts"; | |
| 20 | 22 | import * as PreviewAutomationBroker from "../../PreviewAutomationBroker.ts"; |
| 21 | 23 | import * as PreviewControlsHandlers from "./handlers.ts"; |
| 22 | 24 | import { PreviewControlsToolkit } from "./tools.ts"; |
| ... | ... | @@ -64,12 +66,17 @@ it.effect.each([ |
| 64 | 66 | PreviewAutomationBroker.layer.pipe(Layer.provide(NodeServices.layer)), |
| 65 | 67 | Layer.succeed(Preview.PreviewManager, manager), |
| 66 | 68 | Layer.succeed(McpInvocationContext.McpInvocationContext, scope), |
| 69 | McpToolAccessTestkit.liveThreadsLayer, | |
| 67 | 70 | Layer.mock(ServerSettings.ServerSettingsService)({ |
| 68 | 71 | getSettings: Effect.succeed(settings), |
| 69 | 72 | }), |
| 70 | 73 | ); |
| 71 | 74 | const toolkit = yield* PreviewControlsToolkit.pipe( |
| 72 | Effect.provide(PreviewControlsHandlers.layer.pipe(Layer.provide(layerDependencies))), | |
| 75 | Effect.provide( | |
| 76 | McpToolAccess.HandlersLayer.layer(PreviewControlsHandlers.layer).pipe( | |
| 77 | Layer.provide(layerDependencies), | |
| 78 | ), | |
| 79 | ), | |
| 73 | 80 | ); |
| 74 | 81 | const listed = yield* toolkit |
| 75 | 82 | .handle("t3_preview_list", {}) |
apps/server/src/mcp/toolkits/previewControls/handlers.ts+6-3| ... | ... | @@ -1,6 +1,7 @@ |
| 1 | 1 | import * as Effect from "effect/Effect"; |
| 2 | 2 | import * as Preview from "../../../preview/Manager.ts"; |
| 3 | 3 | import { requireThreadMcpCapability } from "../../McpInvocationContext.ts"; |
| 4 | import * as McpToolAccess from "../../McpToolAccess.ts"; | |
| 4 | 5 | import { unavailable } from "../../threadAccess.ts"; |
| 5 | 6 | import { PreviewControlsToolkit } from "./tools.ts"; |
| 6 | 7 | import * as PreviewAutomationBroker from "../../PreviewAutomationBroker.ts"; |
| ... | ... | @@ -10,8 +11,8 @@ const access = Effect.gen(function* () { |
| 10 | 11 | const scope = yield* requireThreadMcpCapability("preview"); |
| 11 | 12 | return { scope, manager: yield* Preview.PreviewManager }; |
| 12 | 13 | }); |
| 13 | export const layer = PreviewControlsToolkit.toLayer({ | |
| 14 | t3_preview_list: (input) => | |
| 14 | export const layer = McpToolAccess.toLayer(PreviewControlsToolkit, { | |
| 15 | t3_preview_list: McpToolAccess.readsAsCaller((input) => | |
| 15 | 16 | Effect.gen(function* () { |
| 16 | 17 | const { scope, manager } = yield* access; |
| 17 | 18 | const result = yield* manager.list({ threadId: scope.thread.threadId }); |
| ... | ... | @@ -23,7 +24,8 @@ export const layer = PreviewControlsToolkit.toLayer({ |
| 23 | 24 | nextCursor: end < result.sessions.length ? end : null, |
| 24 | 25 | }; |
| 25 | 26 | }), |
| 26 | t3_preview_close: (input) => | |
| 27 | ), | |
| 28 | t3_preview_close: McpToolAccess.actsAsCaller((input) => | |
| 27 | 29 | Effect.gen(function* () { |
| 28 | 30 | const { scope, manager } = yield* access; |
| 29 | 31 | const { sessions } = yield* manager.list({ threadId: scope.thread.threadId }); |
| ... | ... | @@ -41,4 +43,5 @@ export const layer = PreviewControlsToolkit.toLayer({ |
| 41 | 43 | .pipe(Effect.mapError(unavailable)); |
| 42 | 44 | return {}; |
| 43 | 45 | }), |
| 46 | ), | |
| 44 | 47 | }); |
apps/server/src/mcp/toolkits/previewControls/tools.ts+2| ... | ... | @@ -7,6 +7,7 @@ import { |
| 7 | 7 | } from "@t3tools/contracts"; |
| 8 | 8 | import * as Schema from "effect/Schema"; |
| 9 | 9 | import { Tool, Toolkit } from "effect/ai"; |
| 10 | import * as ThreadManagementService from "../../../orchestration-v2/ThreadManagementService.ts"; | |
| 10 | 11 | import * as PreviewManager from "../../../preview/Manager.ts"; |
| 11 | 12 | import * as PreviewAutomationBroker from "../../PreviewAutomationBroker.ts"; |
| 12 | 13 | import * as McpInvocationContext from "../../McpInvocationContext.ts"; |
| ... | ... | @@ -16,6 +17,7 @@ const shared = { |
| 16 | 17 | failureMode: "return" as const, |
| 17 | 18 | dependencies: [ |
| 18 | 19 | McpInvocationContext.McpInvocationContext, |
| 20 | ThreadManagementService.ThreadManagementService, | |
| 19 | 21 | PreviewManager.PreviewManager, |
| 20 | 22 | PreviewAutomationBroker.PreviewAutomationBroker, |
| 21 | 23 | ], |
apps/server/src/mcp/toolkits/project/handlers.test.ts+101-7| ... | ... | @@ -10,6 +10,8 @@ import { |
| 10 | 10 | import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; |
| 11 | 11 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 12 | 12 | import * as Effect from "effect/Effect"; |
| 13 | import * as FileSystem from "effect/FileSystem"; | |
| 14 | import * as Path from "effect/Path"; | |
| 13 | 15 | import * as Layer from "effect/Layer"; |
| 14 | 16 | import * as Option from "effect/Option"; |
| 15 | 17 | import * as Stream from "effect/Stream"; |
| ... | ... | @@ -19,7 +21,10 @@ import * as ThreadManagement from "../../../orchestration-v2/ThreadManagementSer |
| 19 | 21 | import * as ServerConfig from "../../../config.ts"; |
| 20 | 22 | import * as Project from "../../../project/ProjectService.ts"; |
| 21 | 23 | import * as ManagedProjectFolders from "../../../project/ManagedProjectFolders.ts"; |
| 24 | import * as GitVcsDriver from "../../../vcs/GitVcsDriver.ts"; | |
| 25 | import * as VcsProcess from "../../../vcs/VcsProcess.ts"; | |
| 22 | 26 | import * as McpInvocationContext from "../../McpInvocationContext.ts"; |
| 27 | import * as McpToolAccess from "../../McpToolAccess.ts"; | |
| 23 | 28 | import * as ProjectHandlers from "./handlers.ts"; |
| 24 | 29 | import { ProjectToolkit } from "./tools.ts"; |
| 25 | 30 | |
| ... | ... | @@ -73,13 +78,18 @@ it.effect("attributes a launched thread's first message to the calling thread", |
| 73 | 78 | }), |
| 74 | 79 | Layer.mock(Project.ProjectService)({}), |
| 75 | 80 | Layer.mock(ManagedProjectFolders.ManagedProjectFolders)({ namedProjectsRoot: "/projects" }), |
| 81 | Layer.mock(GitVcsDriver.GitVcsDriver)({}), | |
| 76 | 82 | NodeServices.layer, |
| 77 | 83 | ServerConfig.layerTest(process.cwd(), { prefix: "t3-source-link-" }).pipe( |
| 78 | 84 | Layer.provide(NodeServices.layer), |
| 79 | 85 | ), |
| 80 | 86 | ); |
| 81 | 87 | const toolkit = yield* ProjectToolkit.pipe( |
| 82 | Effect.provide(ProjectHandlers.layer.pipe(Layer.provide(layerDependencies))), | |
| 88 | Effect.provide( | |
| 89 | McpToolAccess.HandlersLayer.layer(ProjectHandlers.layer).pipe( | |
| 90 | Layer.provide(layerDependencies), | |
| 91 | ), | |
| 92 | ), | |
| 83 | 93 | ); |
| 84 | 94 | const result = yield* toolkit |
| 85 | 95 | .handle("t3_thread_launch", { title: "Audit", message: "Review the change" }) |
| ... | ... | @@ -142,13 +152,18 @@ it.effect("launches a scratch thread into the Scratch project", () => |
| 142 | 152 | namedProjectsRoot: "/projects", |
| 143 | 153 | ensureScratchProject: Effect.succeed({ projectId: scratchProjectId }), |
| 144 | 154 | }), |
| 155 | Layer.mock(GitVcsDriver.GitVcsDriver)({}), | |
| 145 | 156 | NodeServices.layer, |
| 146 | 157 | ServerConfig.layerTest(process.cwd(), { prefix: "t3-scratch-launch-" }).pipe( |
| 147 | 158 | Layer.provide(NodeServices.layer), |
| 148 | 159 | ), |
| 149 | 160 | ); |
| 150 | 161 | const toolkit = yield* ProjectToolkit.pipe( |
| 151 | Effect.provide(ProjectHandlers.layer.pipe(Layer.provide(layerDependencies))), | |
| 162 | Effect.provide( | |
| 163 | McpToolAccess.HandlersLayer.layer(ProjectHandlers.layer).pipe( | |
| 164 | Layer.provide(layerDependencies), | |
| 165 | ), | |
| 166 | ), | |
| 152 | 167 | ); |
| 153 | 168 | const handle = (params: Parameters<typeof toolkit.handle<"t3_thread_launch">>[1]) => |
| 154 | 169 | toolkit |
| ... | ... | @@ -240,13 +255,18 @@ it.effect("starts a project from just a title when workspaceRoot is omitted", () |
| 240 | 255 | }; |
| 241 | 256 | }), |
| 242 | 257 | }), |
| 258 | Layer.mock(GitVcsDriver.GitVcsDriver)({}), | |
| 243 | 259 | NodeServices.layer, |
| 244 | 260 | ServerConfig.layerTest(process.cwd(), { prefix: "t3-named-project-" }).pipe( |
| 245 | 261 | Layer.provide(NodeServices.layer), |
| 246 | 262 | ), |
| 247 | 263 | ); |
| 248 | 264 | const toolkit = yield* ProjectToolkit.pipe( |
| 249 | Effect.provide(ProjectHandlers.layer.pipe(Layer.provide(layerDependencies))), | |
| 265 | Effect.provide( | |
| 266 | McpToolAccess.HandlersLayer.layer(ProjectHandlers.layer).pipe( | |
| 267 | Layer.provide(layerDependencies), | |
| 268 | ), | |
| 269 | ), | |
| 250 | 270 | ); |
| 251 | 271 | const handle = (params: Parameters<typeof toolkit.handle<"t3_project_create">>[1]) => |
| 252 | 272 | toolkit |
| ... | ... | @@ -280,6 +300,7 @@ it.effect("starts a project from just a title when workspaceRoot is omitted", () |
| 280 | 300 | const clientLaunchHarness = (input: { |
| 281 | 301 | readonly runtimeModeCeiling: "approval-required" | "auto-accept-edits" | "auto" | "full-access"; |
| 282 | 302 | readonly launched: Array<ThreadLaunch.ThreadLaunchInput>; |
| 303 | readonly workspaceRoot?: string; | |
| 283 | 304 | }) => { |
| 284 | 305 | const projectId = ProjectId.make("project:client-target"); |
| 285 | 306 | const modelSelection = { instanceId: ProviderInstanceId.make("claude"), model: "claude-opus" }; |
| ... | ... | @@ -319,15 +340,25 @@ const clientLaunchHarness = (input: { |
| 319 | 340 | getById: (id) => |
| 320 | 341 | Effect.succeed( |
| 321 | 342 | id === projectId |
| 322 | ? Option.some({ id, defaultModelSelection: modelSelection } as unknown as ProjectRecord) | |
| 343 | ? Option.some({ | |
| 344 | id, | |
| 345 | workspaceRoot: input.workspaceRoot ?? "/projects/client-target", | |
| 346 | defaultModelSelection: modelSelection, | |
| 347 | } as unknown as ProjectRecord) | |
| 323 | 348 | : Option.none(), |
| 324 | 349 | ), |
| 325 | 350 | }), |
| 326 | 351 | Layer.mock(ManagedProjectFolders.ManagedProjectFolders)({ namedProjectsRoot: "/projects" }), |
| 327 | 352 | NodeServices.layer, |
| 328 | ServerConfig.layerTest(process.cwd(), { prefix: "t3-client-launch-" }).pipe( | |
| 329 | Layer.provide(NodeServices.layer), | |
| 353 | ).pipe( | |
| 354 | Layer.provideMerge(GitVcsDriver.layer), | |
| 355 | Layer.provideMerge(VcsProcess.layer), | |
| 356 | Layer.provideMerge( | |
| 357 | ServerConfig.layerTest(process.cwd(), { prefix: "t3-client-launch-" }).pipe( | |
| 358 | Layer.provide(NodeServices.layer), | |
| 359 | ), | |
| 330 | 360 | ), |
| 361 | Layer.provideMerge(NodeServices.layer), | |
| 331 | 362 | ); |
| 332 | 363 | return { projectId, modelSelection, dependencies: layerDependencies }; |
| 333 | 364 | }; |
| ... | ... | @@ -340,7 +371,9 @@ it.effect("a client launches at its ceiling with the project's default model", ( |
| 340 | 371 | launched, |
| 341 | 372 | }); |
| 342 | 373 | const toolkit = yield* ProjectToolkit.pipe( |
| 343 | Effect.provide(ProjectHandlers.layer.pipe(Layer.provide(dependencies))), | |
| 374 | Effect.provide( | |
| 375 | McpToolAccess.HandlersLayer.layer(ProjectHandlers.layer).pipe(Layer.provide(dependencies)), | |
| 376 | ), | |
| 344 | 377 | ); |
| 345 | 378 | const handle = (params: Parameters<typeof toolkit.handle<"t3_thread_launch">>[1]) => |
| 346 | 379 | toolkit |
| ... | ... | @@ -360,3 +393,64 @@ it.effect("a client launches at its ceiling with the project's default model", ( |
| 360 | 393 | expect(launched).toHaveLength(1); |
| 361 | 394 | }), |
| 362 | 395 | ); |
| 396 | ||
| 397 | it.effect("a launch binds only an existing checkout that is one of the project's worktrees", () => | |
| 398 | Effect.gen(function* () { | |
| 399 | const fileSystem = yield* FileSystem.FileSystem; | |
| 400 | const path = yield* Path.Path; | |
| 401 | const root = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-launch-worktree-" }); | |
| 402 | const repo = path.join(root, "repo"); | |
| 403 | const worktree = path.join(root, "feature"); | |
| 404 | const outside = path.join(root, "outside"); | |
| 405 | yield* fileSystem.makeDirectory(repo); | |
| 406 | yield* fileSystem.makeDirectory(outside); | |
| 407 | ||
| 408 | const launched: Array<ThreadLaunch.ThreadLaunchInput> = []; | |
| 409 | const { projectId, dependencies } = clientLaunchHarness({ | |
| 410 | runtimeModeCeiling: "auto", | |
| 411 | launched, | |
| 412 | workspaceRoot: repo, | |
| 413 | }); | |
| 414 | const git = yield* GitVcsDriver.GitVcsDriver.pipe(Effect.provide(dependencies)); | |
| 415 | for (const args of [ | |
| 416 | ["init", "-b", "main"], | |
| 417 | ["-c", "user.email=t@t", "-c", "user.name=t", "commit", "--allow-empty", "-m", "init"], | |
| 418 | ["worktree", "add", "-b", "feature", worktree], | |
| 419 | ]) { | |
| 420 | yield* git.execute({ operation: "test.setupRepo", cwd: repo, args }); | |
| 421 | } | |
| 422 | const toolkit = yield* ProjectToolkit.pipe( | |
| 423 | Effect.provide( | |
| 424 | McpToolAccess.HandlersLayer.layer(ProjectHandlers.layer).pipe(Layer.provide(dependencies)), | |
| 425 | ), | |
| 426 | ); | |
| 427 | const launchInto = (worktreePath: string) => | |
| 428 | toolkit | |
| 429 | .handle("t3_thread_launch", { | |
| 430 | title: "Fix", | |
| 431 | projectId, | |
| 432 | workspaceStrategy: { type: "existing_worktree", worktreePath }, | |
| 433 | }) | |
| 434 | .pipe(Stream.unwrap, Stream.runCollect, Effect.provide(dependencies)); | |
| 435 | ||
| 436 | expect((yield* launchInto(worktree)).at(-1)?.result).toMatchObject({ projectId }); | |
| 437 | expect((yield* launchInto(repo)).at(-1)?.result).toMatchObject({ projectId }); | |
| 438 | for (const elsewhere of [outside, path.join(worktree, "..", "outside"), "/"]) { | |
| 439 | expect((yield* launchInto(elsewhere)).at(-1)?.result).toMatchObject({ | |
| 440 | code: "invalid_request", | |
| 441 | }); | |
| 442 | } | |
| 443 | expect(launched.map((launch) => launch.workspaceStrategy)).toEqual([ | |
| 444 | { type: "existing_worktree", worktreePath: worktree }, | |
| 445 | { type: "existing_worktree", worktreePath: repo }, | |
| 446 | ]); | |
| 447 | ||
| 448 | // A removed worktree stays listed as prunable until `git worktree prune`; | |
| 449 | // whatever directory is later made at its path is not one of the project's. | |
| 450 | yield* fileSystem.remove(worktree, { recursive: true }); | |
| 451 | yield* fileSystem.makeDirectory(worktree); | |
| 452 | expect((yield* launchInto(worktree)).at(-1)?.result).toMatchObject({ | |
| 453 | code: "invalid_request", | |
| 454 | }); | |
| 455 | }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), | |
| 456 | ); |
apps/server/src/mcp/toolkits/project/handlers.ts+142-126| ... | ... | @@ -1,20 +1,15 @@ |
| 1 | 1 | import { MessageId, ThreadId, OrchestratorMcpFailure, ProjectId } from "@t3tools/contracts"; |
| 2 | 2 | import * as Effect from "effect/Effect"; |
| 3 | import * as FileSystem from "effect/FileSystem"; | |
| 3 | 4 | import * as Option from "effect/Option"; |
| 4 | 5 | import * as ThreadMessageIntake from "../../../orchestration-v2/ThreadMessageIntake.ts"; |
| 5 | 6 | import * as Claims from "../../../orchestration-v2/AttachmentClaims.ts"; |
| 6 | 7 | import * as Project from "../../../project/ProjectService.ts"; |
| 7 | 8 | import * as ManagedProjectFolders from "../../../project/ManagedProjectFolders.ts"; |
| 8 | 9 | import * as Repositories from "../../../sourceControl/SourceControlRepositoryService.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 * as GitVcsDriver from "../../../vcs/GitVcsDriver.ts"; | |
| 11 | import * as McpToolAccess from "../../McpToolAccess.ts"; | |
| 12 | import { newCommandId, readCaller, resolveProjectId, unavailable } from "../../threadAccess.ts"; | |
| 18 | 13 | import { ProjectToolkit } from "./tools.ts"; |
| 19 | 14 | |
| 20 | 15 | function projectFailure(error: Project.ProjectServiceError) { |
| ... | ... | @@ -28,121 +23,137 @@ function projectFailure(error: Project.ProjectServiceError) { |
| 28 | 23 | return new OrchestratorMcpFailure({ code: "invalid_request", message }); |
| 29 | 24 | } |
| 30 | 25 | |
| 26 | /** | |
| 27 | * An existing checkout a launch may bind: one of the project's own git | |
| 28 | * worktrees. Without this check a launch could point an agent at any directory | |
| 29 | * on the machine. | |
| 30 | */ | |
| 31 | const assertProjectWorktree = Effect.fn("mcp.assertProjectWorktree")(function* ( | |
| 32 | workspaceRoot: string, | |
| 33 | worktreePath: string, | |
| 34 | ) { | |
| 35 | const git = yield* GitVcsDriver.GitVcsDriver; | |
| 36 | const fileSystem = yield* FileSystem.FileSystem; | |
| 37 | const real = (path: string) => fileSystem.realPath(path).pipe(Effect.orElseSucceed(() => path)); | |
| 38 | const worktrees = yield* git.listWorktreePaths(workspaceRoot).pipe( | |
| 39 | Effect.flatMap((paths) => Effect.forEach(paths, real)), | |
| 40 | Effect.orElseSucceed((): ReadonlyArray<string> => []), | |
| 41 | ); | |
| 42 | if (!worktrees.includes(yield* real(worktreePath))) | |
| 43 | return yield* new OrchestratorMcpFailure({ | |
| 44 | code: "invalid_request", | |
| 45 | message: | |
| 46 | "worktreePath must be one of the project's git worktrees. t3_worktree_list shows them.", | |
| 47 | }); | |
| 48 | }); | |
| 49 | ||
| 31 | 50 | const access = Effect.gen(function* () { |
| 32 | 51 | yield* readCaller(); |
| 33 | 52 | return yield* Project.ProjectService; |
| 34 | 53 | }); |
| 35 | const mutation = Effect.gen(function* () { | |
| 36 | yield* readFullAccessCaller( | |
| 37 | "Project changes require a live full-access/default calling thread or a full-access client.", | |
| 38 | ); | |
| 39 | return yield* Project.ProjectService; | |
| 40 | }); | |
| 41 | export const layer = ProjectToolkit.toLayer({ | |
| 42 | t3_thread_launch: (input) => | |
| 43 | Effect.gen(function* () { | |
| 44 | const context = yield* readMutationCaller(); | |
| 45 | const { caller, limits } = context; | |
| 46 | // A thread caller launches only as itself (full-access/default), as before. A client | |
| 47 | // launches anything up to its ceiling. | |
| 48 | if ( | |
| 49 | caller !== undefined && | |
| 50 | (caller.runtimeMode !== "full-access" || caller.interactionMode !== "default") | |
| 51 | ) | |
| 52 | return yield* new OrchestratorMcpFailure({ | |
| 53 | code: "capability_denied", | |
| 54 | message: "Project launches require a full-access/default calling thread.", | |
| 55 | }); | |
| 56 | const runtimeMode = yield* resolveRuntimeMode( | |
| 57 | limits.runtimeMode, | |
| 58 | input.runtimeMode ?? caller?.runtimeMode, | |
| 59 | ); | |
| 60 | const commandId = yield* newCommandId(); | |
| 61 | const threadId = ThreadId.make(commandId); | |
| 62 | const messageId = MessageId.make(commandId); | |
| 63 | const attachments = input.attachments ?? []; | |
| 64 | if (attachments.some((attachment) => !Claims.attachmentIsPendingUpload(attachment))) | |
| 65 | return yield* new OrchestratorMcpFailure({ | |
| 66 | code: "invalid_request", | |
| 67 | message: "A new thread accepts only pending attachment uploads.", | |
| 68 | }); | |
| 69 | if ( | |
| 70 | input.scratch === true && | |
| 71 | (input.projectId !== undefined || input.workspaceStrategy !== undefined) | |
| 72 | ) | |
| 73 | return yield* new OrchestratorMcpFailure({ | |
| 74 | code: "invalid_request", | |
| 75 | message: | |
| 76 | "scratch:true picks its own project and folder; omit projectId and workspaceStrategy.", | |
| 77 | }); | |
| 78 | const projectId = | |
| 79 | input.scratch === true | |
| 80 | ? (yield* ManagedProjectFolders.ManagedProjectFolders.pipe( | |
| 81 | Effect.flatMap((folders) => folders.ensureScratchProject), | |
| 82 | Effect.mapError( | |
| 83 | (error) => | |
| 84 | new OrchestratorMcpFailure({ | |
| 85 | code: "orchestration_error", | |
| 86 | message: error.message, | |
| 87 | }), | |
| 88 | ), | |
| 89 | )).projectId | |
| 90 | : yield* resolveProjectId(context, input.projectId); | |
| 91 | const modelSelection = | |
| 92 | input.modelSelection ?? | |
| 93 | caller?.modelSelection ?? | |
| 94 | (yield* Project.ProjectService.pipe( | |
| 54 | export const layer = McpToolAccess.toLayer(ProjectToolkit, { | |
| 55 | t3_thread_launch: McpToolAccess.startsThreads( | |
| 56 | (input) => input, | |
| 57 | (input, { runtimeMode, interactionMode }) => | |
| 58 | Effect.gen(function* () { | |
| 59 | const context = yield* readCaller(); | |
| 60 | const { caller } = context; | |
| 61 | const commandId = yield* newCommandId(); | |
| 62 | const threadId = ThreadId.make(commandId); | |
| 63 | const messageId = MessageId.make(commandId); | |
| 64 | const attachments = input.attachments ?? []; | |
| 65 | if (attachments.some((attachment) => !Claims.attachmentIsPendingUpload(attachment))) | |
| 66 | return yield* new OrchestratorMcpFailure({ | |
| 67 | code: "invalid_request", | |
| 68 | message: "A new thread accepts only pending attachment uploads.", | |
| 69 | }); | |
| 70 | if ( | |
| 71 | input.scratch === true && | |
| 72 | (input.projectId !== undefined || input.workspaceStrategy !== undefined) | |
| 73 | ) | |
| 74 | return yield* new OrchestratorMcpFailure({ | |
| 75 | code: "invalid_request", | |
| 76 | message: | |
| 77 | "scratch:true picks its own project and folder; omit projectId and workspaceStrategy.", | |
| 78 | }); | |
| 79 | const projectId = | |
| 80 | input.scratch === true | |
| 81 | ? (yield* ManagedProjectFolders.ManagedProjectFolders.pipe( | |
| 82 | Effect.flatMap((folders) => folders.ensureScratchProject), | |
| 83 | Effect.mapError( | |
| 84 | (error) => | |
| 85 | new OrchestratorMcpFailure({ | |
| 86 | code: "orchestration_error", | |
| 87 | message: error.message, | |
| 88 | }), | |
| 89 | ), | |
| 90 | )).projectId | |
| 91 | : yield* resolveProjectId(context, input.projectId); | |
| 92 | const readProject = Project.ProjectService.pipe( | |
| 95 | 93 | Effect.flatMap((projects) => projects.getById(projectId)), |
| 96 | 94 | Effect.mapError(unavailable), |
| 97 | Effect.map((project) => | |
| 98 | Option.getOrUndefined(Option.flatMapNullishOr(project, (p) => p.defaultModelSelection)), | |
| 95 | Effect.map(Option.getOrUndefined), | |
| 96 | ); | |
| 97 | if (input.workspaceStrategy?.type === "existing_worktree") { | |
| 98 | const project = yield* readProject; | |
| 99 | if (project === undefined) | |
| 100 | return yield* new OrchestratorMcpFailure({ | |
| 101 | code: "invalid_request", | |
| 102 | message: "The project was not found.", | |
| 103 | }); | |
| 104 | yield* assertProjectWorktree(project.workspaceRoot, input.workspaceStrategy.worktreePath); | |
| 105 | } | |
| 106 | const modelSelection = | |
| 107 | input.modelSelection ?? | |
| 108 | caller?.modelSelection ?? | |
| 109 | (yield* readProject)?.defaultModelSelection ?? | |
| 110 | undefined; | |
| 111 | if (modelSelection === undefined) | |
| 112 | return yield* new OrchestratorMcpFailure({ | |
| 113 | code: "invalid_request", | |
| 114 | message: | |
| 115 | "Pass modelSelection: the project has no default model. orchestrator_capabilities lists providers and models.", | |
| 116 | }); | |
| 117 | const result = yield* ThreadMessageIntake.launchThread({ | |
| 118 | commandId, | |
| 119 | threadId, | |
| 120 | projectId, | |
| 121 | title: input.title, | |
| 122 | modelSelection, | |
| 123 | runtimeMode, | |
| 124 | interactionMode, | |
| 125 | workspaceStrategy: input.workspaceStrategy ?? { type: "root" }, | |
| 126 | ...(input.message === undefined && attachments.length === 0 | |
| 127 | ? {} | |
| 128 | : { | |
| 129 | initialMessage: { | |
| 130 | messageId, | |
| 131 | ...(caller === undefined ? {} : { senderThreadId: caller.id }), | |
| 132 | text: input.message ?? "", | |
| 133 | attachments, | |
| 134 | }, | |
| 135 | }), | |
| 136 | createdBy: "agent", | |
| 137 | creationSource: "mcp", | |
| 138 | }).pipe( | |
| 139 | Effect.mapError((error) => | |
| 140 | error._tag === "AttachmentClaimError" | |
| 141 | ? new OrchestratorMcpFailure({ code: "orchestration_error", message: error.message }) | |
| 142 | : unavailable(), | |
| 99 | 143 | ), |
| 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 | }); | |
| 107 | const result = yield* ThreadMessageIntake.launchThread({ | |
| 108 | commandId, | |
| 109 | threadId, | |
| 110 | projectId, | |
| 111 | title: input.title, | |
| 112 | modelSelection, | |
| 113 | runtimeMode, | |
| 114 | interactionMode: input.interactionMode ?? caller?.interactionMode ?? "default", | |
| 115 | workspaceStrategy: input.workspaceStrategy ?? { type: "root" }, | |
| 116 | ...(input.message === undefined && attachments.length === 0 | |
| 117 | ? {} | |
| 118 | : { | |
| 119 | initialMessage: { | |
| 120 | messageId, | |
| 121 | ...(caller === undefined ? {} : { senderThreadId: caller.id }), | |
| 122 | text: input.message ?? "", | |
| 123 | attachments, | |
| 124 | }, | |
| 125 | }), | |
| 126 | createdBy: "agent", | |
| 127 | creationSource: "mcp", | |
| 128 | }).pipe( | |
| 129 | Effect.mapError((error) => | |
| 130 | error._tag === "AttachmentClaimError" | |
| 131 | ? new OrchestratorMcpFailure({ code: "orchestration_error", message: error.message }) | |
| 132 | : unavailable(), | |
| 133 | ), | |
| 134 | ); | |
| 135 | const thread = result.projection.thread; | |
| 136 | const run = result.projection.runs.find((run) => run.userMessageId === messageId); | |
| 137 | return { | |
| 138 | threadId: thread.id, | |
| 139 | projectId: thread.projectId, | |
| 140 | modelSelection: thread.modelSelection, | |
| 141 | runId: run?.id ?? null, | |
| 142 | status: run?.status ?? null, | |
| 143 | }; | |
| 144 | }), | |
| 145 | t3_project_list: (input) => | |
| 144 | ); | |
| 145 | const thread = result.projection.thread; | |
| 146 | const run = result.projection.runs.find((run) => run.userMessageId === messageId); | |
| 147 | return { | |
| 148 | threadId: thread.id, | |
| 149 | projectId: thread.projectId, | |
| 150 | modelSelection: thread.modelSelection, | |
| 151 | runId: run?.id ?? null, | |
| 152 | status: run?.status ?? null, | |
| 153 | }; | |
| 154 | }), | |
| 155 | ), | |
| 156 | t3_project_list: McpToolAccess.reads((input) => | |
| 146 | 157 | Effect.gen(function* () { |
| 147 | 158 | const projects = yield* access; |
| 148 | 159 | const snapshot = yield* projects.snapshot.pipe(Effect.mapError(unavailable)); |
| ... | ... | @@ -151,7 +162,8 @@ export const layer = ProjectToolkit.toLayer({ |
| 151 | 162 | end = start + (input.limit ?? 20); |
| 152 | 163 | return { projects: rows.slice(start, end), nextCursor: end < rows.length ? end : null }; |
| 153 | 164 | }), |
| 154 | t3_project_read: (input) => | |
| 165 | ), | |
| 166 | t3_project_read: McpToolAccess.reads((input) => | |
| 155 | 167 | Effect.gen(function* () { |
| 156 | 168 | const projects = yield* access; |
| 157 | 169 | const result = yield* projects.getById(input.projectId).pipe(Effect.mapError(unavailable)); |
| ... | ... | @@ -162,9 +174,10 @@ export const layer = ProjectToolkit.toLayer({ |
| 162 | 174 | }); |
| 163 | 175 | return result.value; |
| 164 | 176 | }), |
| 165 | t3_project_create: ({ workspaceRoot, ...input }) => | |
| 177 | ), | |
| 178 | t3_project_create: McpToolAccess.writesEnvironment(({ workspaceRoot, ...input }) => | |
| 166 | 179 | Effect.gen(function* () { |
| 167 | const projects = yield* mutation; | |
| 180 | const projects = yield* Project.ProjectService; | |
| 168 | 181 | if (workspaceRoot === undefined) { |
| 169 | 182 | // Project creation records no model default (only an update does), so |
| 170 | 183 | // reject what this mode would otherwise drop silently. |
| ... | ... | @@ -205,23 +218,25 @@ export const layer = ProjectToolkit.toLayer({ |
| 205 | 218 | .create({ ...input, workspaceRoot, commandId, projectId: ProjectId.make(commandId) }) |
| 206 | 219 | .pipe(Effect.mapError(projectFailure)); |
| 207 | 220 | }), |
| 208 | t3_project_update: (input) => | |
| 221 | ), | |
| 222 | t3_project_update: McpToolAccess.writesEnvironment((input) => | |
| 209 | 223 | Effect.gen(function* () { |
| 210 | const projects = yield* mutation; | |
| 224 | const projects = yield* Project.ProjectService; | |
| 211 | 225 | return yield* projects |
| 212 | 226 | .update({ ...input, commandId: yield* newCommandId() }) |
| 213 | 227 | .pipe(Effect.mapError(projectFailure)); |
| 214 | 228 | }), |
| 215 | t3_project_delete: (input) => | |
| 229 | ), | |
| 230 | t3_project_delete: McpToolAccess.writesEnvironment((input) => | |
| 216 | 231 | Effect.gen(function* () { |
| 217 | const projects = yield* mutation; | |
| 232 | const projects = yield* Project.ProjectService; | |
| 218 | 233 | return yield* projects |
| 219 | 234 | .delete({ ...input, commandId: yield* newCommandId() }) |
| 220 | 235 | .pipe(Effect.mapError(projectFailure)); |
| 221 | 236 | }), |
| 222 | t3_project_clone: (input) => | |
| 237 | ), | |
| 238 | t3_project_clone: McpToolAccess.writesEnvironment((input) => | |
| 223 | 239 | Effect.gen(function* () { |
| 224 | yield* mutation; | |
| 225 | 240 | const repositories = yield* Repositories.SourceControlRepositoryService; |
| 226 | 241 | return yield* repositories.cloneRepository(input).pipe( |
| 227 | 242 | Effect.mapError( |
| ... | ... | @@ -233,4 +248,5 @@ export const layer = ProjectToolkit.toLayer({ |
| 233 | 248 | ), |
| 234 | 249 | ); |
| 235 | 250 | }), |
| 251 | ), | |
| 236 | 252 | }); |
apps/server/src/mcp/toolkits/project/tools.ts+4-2| ... | ... | @@ -27,6 +27,7 @@ import * as ProjectService from "../../../project/ProjectService.ts"; |
| 27 | 27 | import * as ManagedProjectFolders from "../../../project/ManagedProjectFolders.ts"; |
| 28 | 28 | import * as ThreadManagementService from "../../../orchestration-v2/ThreadManagementService.ts"; |
| 29 | 29 | import * as SourceControlRepositoryService from "../../../sourceControl/SourceControlRepositoryService.ts"; |
| 30 | import * as GitVcsDriver from "../../../vcs/GitVcsDriver.ts"; | |
| 30 | 31 | import * as McpInvocationContext from "../../McpInvocationContext.ts"; |
| 31 | 32 | |
| 32 | 33 | const shared = { |
| ... | ... | @@ -102,7 +103,7 @@ const ProjectCloneTool = Tool.make("t3_project_clone", { |
| 102 | 103 | const ThreadLaunchTool = Tool.make("t3_thread_launch", { |
| 103 | 104 | ...shared, |
| 104 | 105 | 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 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 | '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. The new thread may not run with broader runtime or interaction modes than the caller: the calling T3 thread\'s own modes, or the permission mode an outside agent was approved with.', | |
| 106 | 107 | parameters: Schema.Struct({ |
| 107 | 108 | projectId: Schema.optional(ProjectId), |
| 108 | 109 | scratch: Schema.optional( |
| ... | ... | @@ -118,7 +119,7 @@ const ThreadLaunchTool = Tool.make("t3_thread_launch", { |
| 118 | 119 | workspaceStrategy: Schema.optional( |
| 119 | 120 | OrchestrationV2ThreadLaunchWorkspaceStrategy.annotate({ |
| 120 | 121 | description: |
| 121 | "Choose where this thread runs before starting its agent: worktree creates and binds a new checkout from baseRef; existing_worktree binds worktreePath; root uses the project checkout. Omitted means root, not the caller's worktree. For a PR stack use the parent branch as baseRef and startFromOrigin:false. Uncommitted changes are not copied.", | |
| 122 | "Choose where this thread runs before starting its agent: worktree creates and binds a new checkout from baseRef; existing_worktree binds worktreePath, which must be one of the project's git worktrees; root uses the project checkout. Omitted means root, not the caller's worktree. For a PR stack use the parent branch as baseRef and startFromOrigin:false. Uncommitted changes are not copied.", | |
| 122 | 123 | }), |
| 123 | 124 | ), |
| 124 | 125 | message: Schema.optional( |
| ... | ... | @@ -140,6 +141,7 @@ const ThreadLaunchTool = Tool.make("t3_thread_launch", { |
| 140 | 141 | ...shared.dependencies, |
| 141 | 142 | ThreadLaunchService.ThreadLaunchService, |
| 142 | 143 | ManagedProjectFolders.ManagedProjectFolders, |
| 144 | GitVcsDriver.GitVcsDriver, | |
| 143 | 145 | FileSystem.FileSystem, |
| 144 | 146 | ServerConfig.ServerConfig, |
| 145 | 147 | ], |
apps/server/src/mcp/toolkits/pullRequests/handlers.test.ts+8-1| ... | ... | @@ -23,6 +23,8 @@ import { |
| 23 | 23 | } from "../../../orchestration-v2/testkit/pullRequestFixtures.ts"; |
| 24 | 24 | import * as ProjectService from "../../../project/ProjectService.ts"; |
| 25 | 25 | import * as McpInvocationContext from "../../McpInvocationContext.ts"; |
| 26 | import * as McpToolAccess from "../../McpToolAccess.ts"; | |
| 27 | import * as McpToolAccessTestkit from "../../McpToolAccess.testkit.ts"; | |
| 26 | 28 | import { listThreadPullRequests } from "./handlers.ts"; |
| 27 | 29 | import * as PullRequestsHandlers from "./handlers.ts"; |
| 28 | 30 | import { PullRequestLinkFailedError, PullRequestsToolkit } from "./tools.ts"; |
| ... | ... | @@ -163,9 +165,14 @@ const makeHarness = Effect.fn("makePullRequestsToolkitHarness")(function* ( |
| 163 | 165 | dispatch, |
| 164 | 166 | }), |
| 165 | 167 | Layer.succeed(Crypto.Crypto, testCrypto), |
| 168 | McpToolAccessTestkit.liveThreadsLayer, | |
| 166 | 169 | ); |
| 167 | 170 | const toolkit = yield* PullRequestsToolkit.pipe( |
| 168 | Effect.provide(PullRequestsHandlers.layer.pipe(Layer.provide(layerDependencies))), | |
| 171 | Effect.provide( | |
| 172 | McpToolAccess.HandlersLayer.layer(PullRequestsHandlers.layer).pipe( | |
| 173 | Layer.provide(layerDependencies), | |
| 174 | ), | |
| 175 | ), | |
| 169 | 176 | ); |
| 170 | 177 | const call = <Name extends keyof typeof PullRequestsToolkit.tools>( |
| 171 | 178 | name: Name, |
apps/server/src/mcp/toolkits/pullRequests/handlers.ts+20-54| ... | ... | @@ -26,7 +26,7 @@ import * as Option from "effect/Option"; |
| 26 | 26 | import * as Orchestrator from "../../../orchestration-v2/Orchestrator.ts"; |
| 27 | 27 | import * as ProjectService from "../../../project/ProjectService.ts"; |
| 28 | 28 | import * as McpInvocationContext from "../../McpInvocationContext.ts"; |
| 29 | import { assertTargetWithinLimits } from "../../threadAccess.ts"; | |
| 29 | import * as McpToolAccess from "../../McpToolAccess.ts"; | |
| 30 | 30 | import { |
| 31 | 31 | type ListThreadPullRequestsResult, |
| 32 | 32 | PullRequestLinkFailedError, |
| ... | ... | @@ -40,7 +40,6 @@ import { |
| 40 | 40 | PullRequestWatchFailedError, |
| 41 | 41 | PullRequestWatchFromSubagentError, |
| 42 | 42 | PullRequestThreadNotFoundError, |
| 43 | PullRequestThreadAboveLimitsError, | |
| 44 | 43 | PullRequestThreadRequiredError, |
| 45 | 44 | PullRequestsToolkit, |
| 46 | 45 | type ThreadPullRequestEntry, |
| ... | ... | @@ -189,47 +188,6 @@ const make = Effect.gen(function* () { |
| 189 | 188 | return thread.value; |
| 190 | 189 | }); |
| 191 | 190 | |
| 192 | /** | |
| 193 | * A thread whose pull requests the caller may change: its own, or one that | |
| 194 | * runs within the caller's modes. | |
| 195 | */ | |
| 196 | const requireWritableThread = Effect.fn("PullRequestsToolkit.requireWritableThread")(function* ( | |
| 197 | Failure: | |
| 198 | | typeof PullRequestLinkFailedError | |
| 199 | | typeof PullRequestUnlinkFailedError | |
| 200 | | typeof PullRequestWatchFailedError, | |
| 201 | requested: ThreadId | undefined, | |
| 202 | ) { | |
| 203 | const thread = yield* requireThread(Failure, requested); | |
| 204 | const scope = yield* McpInvocationContext.McpInvocationContext; | |
| 205 | if (thread.id === scope.thread?.threadId) return thread; | |
| 206 | const limits = | |
| 207 | scope.thread === undefined | |
| 208 | ? { | |
| 209 | runtimeMode: scope.client?.runtimeModeCeiling ?? ("approval-required" as const), | |
| 210 | interactionMode: "default" as const, | |
| 211 | } | |
| 212 | : yield* engine.getThreadShell(scope.thread.threadId).pipe( | |
| 213 | Effect.mapError((cause) => new Failure({ cause })), | |
| 214 | Effect.map((caller) => | |
| 215 | // A thread caller changes other threads only while its own run is live. | |
| 216 | caller === null || | |
| 217 | caller.archivedAt !== null || | |
| 218 | caller.activeRunId === null || | |
| 219 | caller.providerInstanceId !== scope.thread?.providerInstanceId | |
| 220 | ? undefined | |
| 221 | : { runtimeMode: caller.runtimeMode, interactionMode: caller.interactionMode }, | |
| 222 | ), | |
| 223 | ); | |
| 224 | if (limits === undefined) { | |
| 225 | return yield* new PullRequestThreadAboveLimitsError({ threadId: thread.id }); | |
| 226 | } | |
| 227 | yield* assertTargetWithinLimits(limits, thread).pipe( | |
| 228 | Effect.mapError(() => new PullRequestThreadAboveLimitsError({ threadId: thread.id })), | |
| 229 | ); | |
| 230 | return thread; | |
| 231 | }); | |
| 232 | ||
| 233 | 191 | const projectOf = ( |
| 234 | 192 | thread: OrchestrationV2ThreadShell, |
| 235 | 193 | Failure: |
| ... | ... | @@ -267,7 +225,7 @@ const make = Effect.gen(function* () { |
| 267 | 225 | input: PullRequestTargetInput, |
| 268 | 226 | watching: boolean, |
| 269 | 227 | ) { |
| 270 | const thread = yield* requireWritableThread(PullRequestWatchFailedError, input.threadId); | |
| 228 | const thread = yield* requireThread(PullRequestWatchFailedError, input.threadId); | |
| 271 | 229 | const project = yield* projectOf(thread, PullRequestWatchFailedError); |
| 272 | 230 | const target = yield* resolveTarget(input, project); |
| 273 | 231 | const watchedLink = (shell: OrchestrationV2ThreadShell) => |
| ... | ... | @@ -306,10 +264,15 @@ const make = Effect.gen(function* () { |
| 306 | 264 | }; |
| 307 | 265 | }); |
| 308 | 266 | |
| 309 | return PullRequestsToolkit.of({ | |
| 310 | link_pull_request: (input) => | |
| 267 | /** A tool that changes `threadId`, or the caller's own thread when it is omitted. */ | |
| 268 | const writesThread = <P extends { readonly threadId?: ThreadId | undefined }, A, E, R>( | |
| 269 | handle: (params: P) => Effect.Effect<A, E, R>, | |
| 270 | ) => McpToolAccess.writesThreads((params: P) => [params.threadId], handle); | |
| 271 | ||
| 272 | return { | |
| 273 | link_pull_request: writesThread((input) => | |
| 311 | 274 | Effect.gen(function* () { |
| 312 | const thread = yield* requireWritableThread(PullRequestLinkFailedError, input.threadId); | |
| 275 | const thread = yield* requireThread(PullRequestLinkFailedError, input.threadId); | |
| 313 | 276 | const project = yield* projectOf(thread, PullRequestLinkFailedError); |
| 314 | 277 | const target = yield* resolveTarget(input, project); |
| 315 | 278 | const existing = threadPullRequestsOf(thread).find((link) => |
| ... | ... | @@ -337,9 +300,10 @@ const make = Effect.gen(function* () { |
| 337 | 300 | ); |
| 338 | 301 | return { ...target, alreadyLinked }; |
| 339 | 302 | }), |
| 340 | unlink_pull_request: (input) => | |
| 303 | ), | |
| 304 | unlink_pull_request: writesThread((input) => | |
| 341 | 305 | Effect.gen(function* () { |
| 342 | const thread = yield* requireWritableThread(PullRequestUnlinkFailedError, input.threadId); | |
| 306 | const thread = yield* requireThread(PullRequestUnlinkFailedError, input.threadId); | |
| 343 | 307 | const project = yield* projectOf(thread, PullRequestUnlinkFailedError); |
| 344 | 308 | const target = yield* resolveTarget(input, project); |
| 345 | 309 | if (!threadPullRequestsOf(thread).some((link) => threadPullRequestKeysEqual(link, target))) |
| ... | ... | @@ -370,13 +334,15 @@ const make = Effect.gen(function* () { |
| 370 | 334 | wasLinked, |
| 371 | 335 | }; |
| 372 | 336 | }), |
| 373 | list_thread_pull_requests: (input) => | |
| 337 | ), | |
| 338 | list_thread_pull_requests: McpToolAccess.reads((input) => | |
| 374 | 339 | requireThread(PullRequestListFailedError, input.threadId).pipe( |
| 375 | 340 | Effect.map(listThreadPullRequests), |
| 376 | 341 | ), |
| 377 | watch_pull_request: (input) => setWatching(input, true), | |
| 378 | unwatch_pull_request: (input) => setWatching(input, false), | |
| 379 | }); | |
| 342 | ), | |
| 343 | watch_pull_request: writesThread((input) => setWatching(input, true)), | |
| 344 | unwatch_pull_request: writesThread((input) => setWatching(input, false)), | |
| 345 | } satisfies McpToolAccess.Handlers<typeof PullRequestsToolkit.tools>; | |
| 380 | 346 | }); |
| 381 | 347 | |
| 382 | export const layer = PullRequestsToolkit.toLayer(make); | |
| 348 | export const layer = McpToolAccess.toLayer(PullRequestsToolkit, make); |
apps/server/src/mcp/toolkits/pullRequests/tools.ts+4-10| ... | ... | @@ -1,5 +1,6 @@ |
| 1 | 1 | import { |
| 2 | 2 | McpCapabilityUnavailableError, |
| 3 | OrchestratorMcpFailure, | |
| 3 | 4 | PositiveInt, |
| 4 | 5 | PullRequestState, |
| 5 | 6 | ThreadPullRequestLinkSource, |
| ... | ... | @@ -12,10 +13,12 @@ import * as Toolkit from "effect/ai/Toolkit"; |
| 12 | 13 | |
| 13 | 14 | import * as McpInvocationContext from "../../McpInvocationContext.ts"; |
| 14 | 15 | import * as Orchestrator from "../../../orchestration-v2/Orchestrator.ts"; |
| 16 | import * as ThreadManagementService from "../../../orchestration-v2/ThreadManagementService.ts"; | |
| 15 | 17 | import * as ProjectService from "../../../project/ProjectService.ts"; |
| 16 | 18 | |
| 17 | 19 | const dependencies = [ |
| 18 | 20 | McpInvocationContext.McpInvocationContext, |
| 21 | ThreadManagementService.ThreadManagementService, | |
| 19 | 22 | Orchestrator.OrchestratorV2, |
| 20 | 23 | ProjectService.ProjectService, |
| 21 | 24 | ]; |
| ... | ... | @@ -96,15 +99,6 @@ export class PullRequestThreadRequiredError extends Schema.TaggedError<PullReque |
| 96 | 99 | } |
| 97 | 100 | } |
| 98 | 101 | |
| 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 | ||
| 108 | 102 | export class PullRequestThreadNotFoundError extends Schema.TaggedError<PullRequestThreadNotFoundError>()( |
| 109 | 103 | "PullRequestThreadNotFoundError", |
| 110 | 104 | { threadId: Schema.String }, |
| ... | ... | @@ -169,12 +163,12 @@ export class PullRequestListFailedError extends Schema.TaggedError<PullRequestLi |
| 169 | 163 | } |
| 170 | 164 | |
| 171 | 165 | export const PullRequestToolError = Schema.Union([ |
| 166 | OrchestratorMcpFailure, | |
| 172 | 167 | McpCapabilityUnavailableError, |
| 173 | 168 | PullRequestUrlInvalidError, |
| 174 | 169 | PullRequestTargetIncompleteError, |
| 175 | 170 | PullRequestHostRequiredError, |
| 176 | 171 | PullRequestThreadRequiredError, |
| 177 | PullRequestThreadAboveLimitsError, | |
| 178 | 172 | PullRequestThreadNotFoundError, |
| 179 | 173 | PullRequestLinkFailedError, |
| 180 | 174 | PullRequestUnlinkFailedError, |
apps/server/src/mcp/toolkits/thread/handlers.ts+69-54| ... | ... | @@ -10,13 +10,12 @@ import { |
| 10 | 10 | import * as Effect from "effect/Effect"; |
| 11 | 11 | import { modelSelectionCommandType } from "@t3tools/shared/model"; |
| 12 | 12 | |
| 13 | import * as McpToolAccess from "../../McpToolAccess.ts"; | |
| 13 | 14 | import { |
| 14 | 15 | dispatchFailure, |
| 15 | 16 | newCommandId, |
| 16 | 17 | readCaller, |
| 17 | readFullAccessCaller, | |
| 18 | 18 | readThread, |
| 19 | readWritableThread, | |
| 20 | 19 | unavailable, |
| 21 | 20 | } from "../../threadAccess.ts"; |
| 22 | 21 | import * as ThreadSearch from "../../../orchestration-v2/ThreadSearch.ts"; |
| ... | ... | @@ -43,23 +42,18 @@ const dispatch = Effect.fn("mcp.dispatchThreadCommand")(function* ( |
| 43 | 42 | threadId: ThreadId | undefined, |
| 44 | 43 | command: (common: { commandId: CommandId; threadId: ThreadId }) => OrchestrationV2Command, |
| 45 | 44 | ) { |
| 46 | const { threads, projection } = yield* readWritableThread(threadId); | |
| 45 | const { threads, projection } = yield* readThread(threadId); | |
| 47 | 46 | const result = yield* threads |
| 48 | 47 | .dispatch(command({ commandId: yield* newCommandId(), threadId: projection.thread.id })) |
| 49 | 48 | .pipe(Effect.mapError(dispatchFailure)); |
| 50 | 49 | return { sequence: result.sequence }; |
| 51 | 50 | }); |
| 52 | 51 | |
| 53 | const readQuestion = Effect.fn("mcp.readQuestion")(function* ( | |
| 54 | input: { | |
| 55 | threadId?: ThreadId | undefined; | |
| 56 | requestId: RuntimeRequestId; | |
| 57 | }, | |
| 58 | writable = false, | |
| 59 | ) { | |
| 60 | const context = yield* writable | |
| 61 | ? readWritableThread(input.threadId, ["runtimeRequests", "turnItems"]) | |
| 62 | : readThread(input.threadId, ["runtimeRequests", "turnItems"]); | |
| 52 | const readQuestion = Effect.fn("mcp.readQuestion")(function* (input: { | |
| 53 | threadId?: ThreadId | undefined; | |
| 54 | requestId: RuntimeRequestId; | |
| 55 | }) { | |
| 56 | const context = yield* readThread(input.threadId, ["runtimeRequests", "turnItems"]); | |
| 63 | 57 | const request = context.projection.runtimeRequests.find( |
| 64 | 58 | (request) => |
| 65 | 59 | request.id === input.requestId && |
| ... | ... | @@ -76,12 +70,14 @@ const readQuestion = Effect.fn("mcp.readQuestion")(function* ( |
| 76 | 70 | }); |
| 77 | 71 | return { ...context, request, item }; |
| 78 | 72 | }); |
| 79 | export const layer = ThreadToolkit.toLayer({ | |
| 80 | run_scheduled_task_now: (input) => | |
| 73 | /** A tool that changes `threadId`, or the caller's own thread when it is omitted. */ | |
| 74 | const writesThread = <P extends { readonly threadId?: ThreadId | undefined }, A, E, R>( | |
| 75 | handle: (params: P) => Effect.Effect<A, E, R>, | |
| 76 | ) => McpToolAccess.writesThreads((params: P) => [params.threadId], handle); | |
| 77 | ||
| 78 | export const layer = McpToolAccess.toLayer(ThreadToolkit, { | |
| 79 | run_scheduled_task_now: McpToolAccess.writesEnvironment((input) => | |
| 81 | 80 | Effect.gen(function* () { |
| 82 | yield* readFullAccessCaller( | |
| 83 | "Running a scheduled task requires a live full-access/default thread or a full-access client.", | |
| 84 | ); | |
| 85 | 81 | const scheduler = yield* ScheduledTasks.ScheduledTaskService; |
| 86 | 82 | const { tasks } = yield* scheduler.list().pipe(Effect.mapError(unavailable)); |
| 87 | 83 | if (!tasks.some((task) => task.id === input.taskId)) |
| ... | ... | @@ -100,7 +96,8 @@ export const layer = ThreadToolkit.toLayer({ |
| 100 | 96 | nextRunAt: task.nextRunAt, |
| 101 | 97 | }; |
| 102 | 98 | }), |
| 103 | t3_thread_search: (input) => | |
| 99 | ), | |
| 100 | t3_thread_search: McpToolAccess.reads((input) => | |
| 104 | 101 | Effect.gen(function* () { |
| 105 | 102 | const { caller } = yield* readCaller(); |
| 106 | 103 | const { projectId: requested, ...query } = input; |
| ... | ... | @@ -116,9 +113,10 @@ export const layer = ThreadToolkit.toLayer({ |
| 116 | 113 | : result.matches.filter((match) => match.projectId === projectId), |
| 117 | 114 | }; |
| 118 | 115 | }), |
| 119 | t3_thread_fork: (input) => | |
| 116 | ), | |
| 117 | t3_thread_fork: writesThread((input) => | |
| 120 | 118 | Effect.gen(function* () { |
| 121 | const { threads, projection } = yield* readWritableThread(input.threadId); | |
| 119 | const { threads, projection } = yield* readThread(input.threadId); | |
| 122 | 120 | const commandId = yield* newCommandId(); |
| 123 | 121 | const targetThreadId = ThreadId.make(`${commandId}:fork`); |
| 124 | 122 | const result = yield* threads |
| ... | ... | @@ -135,24 +133,28 @@ export const layer = ThreadToolkit.toLayer({ |
| 135 | 133 | .pipe(Effect.mapError(dispatchFailure)); |
| 136 | 134 | return { sequence: result.sequence, targetThreadId }; |
| 137 | 135 | }), |
| 138 | t3_thread_merge_back: (input) => | |
| 139 | Effect.gen(function* () { | |
| 140 | const context = yield* readWritableThread(input.targetThreadId); | |
| 141 | const source = yield* readWritableThread(input.sourceThreadId); | |
| 142 | const result = yield* context.threads | |
| 143 | .dispatch({ | |
| 144 | type: "thread.merge_back", | |
| 145 | commandId: yield* newCommandId(), | |
| 146 | sourceThreadId: source.projection.thread.id, | |
| 147 | targetThreadId: input.targetThreadId, | |
| 148 | sourcePoint: input.sourcePoint, | |
| 149 | createdBy: "agent", | |
| 150 | creationSource: "mcp", | |
| 151 | }) | |
| 152 | .pipe(Effect.mapError(dispatchFailure)); | |
| 153 | return { sequence: result.sequence, targetThreadId: input.targetThreadId }; | |
| 154 | }), | |
| 155 | t3_thread_transfers: (input) => | |
| 136 | ), | |
| 137 | t3_thread_merge_back: McpToolAccess.writesThreads( | |
| 138 | (input) => [input.targetThreadId, input.sourceThreadId], | |
| 139 | (input) => | |
| 140 | Effect.gen(function* () { | |
| 141 | const context = yield* readThread(input.targetThreadId); | |
| 142 | const source = yield* readThread(input.sourceThreadId); | |
| 143 | const result = yield* context.threads | |
| 144 | .dispatch({ | |
| 145 | type: "thread.merge_back", | |
| 146 | commandId: yield* newCommandId(), | |
| 147 | sourceThreadId: source.projection.thread.id, | |
| 148 | targetThreadId: input.targetThreadId, | |
| 149 | sourcePoint: input.sourcePoint, | |
| 150 | createdBy: "agent", | |
| 151 | creationSource: "mcp", | |
| 152 | }) | |
| 153 | .pipe(Effect.mapError(dispatchFailure)); | |
| 154 | return { sequence: result.sequence, targetThreadId: input.targetThreadId }; | |
| 155 | }), | |
| 156 | ), | |
| 157 | t3_thread_transfers: McpToolAccess.reads((input) => | |
| 156 | 158 | Effect.gen(function* () { |
| 157 | 159 | const { projection } = yield* readThread(input.threadId, ["contextTransfers"]); |
| 158 | 160 | return { |
| ... | ... | @@ -166,7 +168,8 @@ export const layer = ThreadToolkit.toLayer({ |
| 166 | 168 | ), |
| 167 | 169 | }; |
| 168 | 170 | }), |
| 169 | t3_thread_configuration: (input) => | |
| 171 | ), | |
| 172 | t3_thread_configuration: McpToolAccess.reads((input) => | |
| 170 | 173 | Effect.gen(function* () { |
| 171 | 174 | const { |
| 172 | 175 | projection: { thread }, |
| ... | ... | @@ -178,12 +181,13 @@ export const layer = ThreadToolkit.toLayer({ |
| 178 | 181 | interactionMode: thread.interactionMode, |
| 179 | 182 | }; |
| 180 | 183 | }), |
| 181 | t3_thread_configure: (input) => | |
| 184 | ), | |
| 185 | t3_thread_configure: writesThread((input) => | |
| 182 | 186 | Effect.gen(function* () { |
| 183 | 187 | const { |
| 184 | 188 | threads, |
| 185 | 189 | projection: { thread }, |
| 186 | } = yield* readWritableThread(input.threadId); | |
| 190 | } = yield* readThread(input.threadId); | |
| 187 | 191 | const type = modelSelectionCommandType(thread.providerInstanceId, input.modelSelection); |
| 188 | 192 | const result = yield* threads |
| 189 | 193 | .dispatch({ |
| ... | ... | @@ -195,7 +199,8 @@ export const layer = ThreadToolkit.toLayer({ |
| 195 | 199 | .pipe(Effect.mapError(dispatchFailure)); |
| 196 | 200 | return { sequence: result.sequence }; |
| 197 | 201 | }), |
| 198 | t3_pending_request_list: (input) => | |
| 202 | ), | |
| 203 | t3_pending_request_list: McpToolAccess.reads((input) => | |
| 199 | 204 | Effect.gen(function* () { |
| 200 | 205 | const { projection } = yield* readThread(input.threadId, ["runtimeRequests"]); |
| 201 | 206 | return { |
| ... | ... | @@ -204,14 +209,16 @@ export const layer = ThreadToolkit.toLayer({ |
| 204 | 209 | .map((request) => request.id), |
| 205 | 210 | }; |
| 206 | 211 | }), |
| 207 | t3_pending_request_read: (input) => | |
| 212 | ), | |
| 213 | t3_pending_request_read: McpToolAccess.reads((input) => | |
| 208 | 214 | Effect.gen(function* () { |
| 209 | 215 | const { item } = yield* readQuestion(input); |
| 210 | 216 | return { requestId: input.requestId, questions: item.questions }; |
| 211 | 217 | }), |
| 212 | t3_pending_request_respond: (input) => | |
| 218 | ), | |
| 219 | t3_pending_request_respond: writesThread((input) => | |
| 213 | 220 | Effect.gen(function* () { |
| 214 | const { threads, projection } = yield* readQuestion(input, true); | |
| 221 | const { threads, projection } = yield* readQuestion(input); | |
| 215 | 222 | const result = yield* threads |
| 216 | 223 | .dispatch({ |
| 217 | 224 | type: "runtime-request.respond", |
| ... | ... | @@ -223,7 +230,8 @@ export const layer = ThreadToolkit.toLayer({ |
| 223 | 230 | .pipe(Effect.mapError(dispatchFailure)); |
| 224 | 231 | return { sequence: result.sequence }; |
| 225 | 232 | }), |
| 226 | t3_queue_list: (input) => | |
| 233 | ), | |
| 234 | t3_queue_list: McpToolAccess.reads((input) => | |
| 227 | 235 | Effect.gen(function* () { |
| 228 | 236 | const { projection } = yield* readThread(input.threadId, ["runs", "messages"]); |
| 229 | 237 | const runs = queuedRunsInDeliveryOrder(projection); |
| ... | ... | @@ -237,7 +245,8 @@ export const layer = ThreadToolkit.toLayer({ |
| 237 | 245 | nextCursor: end < runs.length ? end : null, |
| 238 | 246 | }; |
| 239 | 247 | }), |
| 240 | t3_queue_read: (input) => | |
| 248 | ), | |
| 249 | t3_queue_read: McpToolAccess.reads((input) => | |
| 241 | 250 | Effect.gen(function* () { |
| 242 | 251 | const { projection } = yield* readThread(input.threadId, ["runs", "messages"]); |
| 243 | 252 | const entry = queueEntry(projection, input.queuedRunId, 16000); |
| ... | ... | @@ -249,36 +258,41 @@ export const layer = ThreadToolkit.toLayer({ |
| 249 | 258 | })) |
| 250 | 259 | ); |
| 251 | 260 | }), |
| 252 | t3_queue_edit: (input) => | |
| 261 | ), | |
| 262 | t3_queue_edit: writesThread((input) => | |
| 253 | 263 | dispatch(input.threadId, (common) => ({ |
| 254 | 264 | ...common, |
| 255 | 265 | type: "queued-run.edit", |
| 256 | 266 | runId: input.queuedRunId, |
| 257 | 267 | text: input.text, |
| 258 | 268 | })), |
| 259 | t3_queue_cancel: (input) => | |
| 269 | ), | |
| 270 | t3_queue_cancel: writesThread((input) => | |
| 260 | 271 | dispatch(input.threadId, (common) => ({ |
| 261 | 272 | ...common, |
| 262 | 273 | type: "queued-run.cancel", |
| 263 | 274 | runId: input.queuedRunId, |
| 264 | 275 | })), |
| 265 | t3_queue_reorder: (input) => | |
| 276 | ), | |
| 277 | t3_queue_reorder: writesThread((input) => | |
| 266 | 278 | dispatch(input.threadId, (common) => ({ |
| 267 | 279 | ...common, |
| 268 | 280 | type: "queued-run.reorder", |
| 269 | 281 | runId: input.queuedRunId, |
| 270 | 282 | beforeRunId: input.beforeRunId, |
| 271 | 283 | })), |
| 272 | t3_queue_promote_to_steer: (input) => | |
| 284 | ), | |
| 285 | t3_queue_promote_to_steer: writesThread((input) => | |
| 273 | 286 | dispatch(input.threadId, (common) => ({ |
| 274 | 287 | ...common, |
| 275 | 288 | type: "queued-message.promote-to-steer", |
| 276 | 289 | queuedRunId: input.queuedRunId, |
| 277 | 290 | targetRunId: input.targetRunId, |
| 278 | 291 | })), |
| 279 | t3_thread_organize: (input) => | |
| 292 | ), | |
| 293 | t3_thread_organize: writesThread((input) => | |
| 280 | 294 | Effect.gen(function* () { |
| 281 | const { threads, projection } = yield* readWritableThread(input.threadId); | |
| 295 | const { threads, projection } = yield* readThread(input.threadId); | |
| 282 | 296 | const common = { commandId: yield* newCommandId(), threadId: projection.thread.id }; |
| 283 | 297 | let command: OrchestrationV2Command; |
| 284 | 298 | switch (input.action) { |
| ... | ... | @@ -304,4 +318,5 @@ export const layer = ThreadToolkit.toLayer({ |
| 304 | 318 | const result = yield* threads.dispatch(command).pipe(Effect.mapError(dispatchFailure)); |
| 305 | 319 | return { sequence: result.sequence }; |
| 306 | 320 | }), |
| 321 | ), | |
| 307 | 322 | }); |
apps/server/src/mcp/toolkits/worktree/handlers.ts+9-5| ... | ... | @@ -6,11 +6,12 @@ import { readThread, unavailable } from "../../threadAccess.ts"; |
| 6 | 6 | import * as Effect from "effect/Effect"; |
| 7 | 7 | |
| 8 | 8 | import * as McpInvocationContext from "../../McpInvocationContext.ts"; |
| 9 | import * as McpToolAccess from "../../McpToolAccess.ts"; | |
| 9 | 10 | import * as WorktreeMcpService from "../../WorktreeMcpService.ts"; |
| 10 | 11 | import { WorktreeToolkit } from "./tools.ts"; |
| 11 | 12 | |
| 12 | 13 | const handlers = { |
| 13 | t3_worktree_list: (input) => | |
| 14 | t3_worktree_list: McpToolAccess.reads((input) => | |
| 14 | 15 | Effect.gen(function* () { |
| 15 | 16 | const context = yield* McpInvocationContext.McpInvocationContext; |
| 16 | 17 | if (!context.capabilities.has("worktree")) |
| ... | ... | @@ -34,18 +35,21 @@ const handlers = { |
| 34 | 35 | .listRefs({ ...refs, cwd: thread.worktreePath ?? project.value.workspaceRoot }) |
| 35 | 36 | .pipe(Effect.mapError(unavailable)); |
| 36 | 37 | }), |
| 37 | t3_worktree_handoff: (input) => | |
| 38 | ), | |
| 39 | t3_worktree_handoff: McpToolAccess.actsAsCaller((input) => | |
| 38 | 40 | Effect.gen(function* () { |
| 39 | 41 | const scope = yield* McpInvocationContext.McpInvocationContext; |
| 40 | 42 | const service = yield* WorktreeMcpService.WorktreeMcpService; |
| 41 | 43 | return yield* service.handoff(scope, input); |
| 42 | 44 | }), |
| 43 | t3_worktree_status: () => | |
| 45 | ), | |
| 46 | t3_worktree_status: McpToolAccess.readsAsCaller(() => | |
| 44 | 47 | Effect.gen(function* () { |
| 45 | 48 | const scope = yield* McpInvocationContext.McpInvocationContext; |
| 46 | 49 | const service = yield* WorktreeMcpService.WorktreeMcpService; |
| 47 | 50 | return yield* service.status(scope); |
| 48 | 51 | }), |
| 49 | } satisfies Parameters<typeof WorktreeToolkit.toLayer>[0]; | |
| 52 | ), | |
| 53 | } satisfies McpToolAccess.Handlers<typeof WorktreeToolkit.tools>; | |
| 50 | 54 | |
| 51 | export const layer = WorktreeToolkit.toLayer(handlers); | |
| 55 | export const layer = McpToolAccess.toLayer(WorktreeToolkit, handlers); |
apps/server/src/mcp/toolkits/worktree/tools.ts+6-2| ... | ... | @@ -19,15 +19,19 @@ import * as WorktreeMcpService from "../../WorktreeMcpService.ts"; |
| 19 | 19 | |
| 20 | 20 | const dependencies = [ |
| 21 | 21 | McpInvocationContext.McpInvocationContext, |
| 22 | ThreadManagementService.ThreadManagementService, | |
| 22 | 23 | WorktreeMcpService.WorktreeMcpService, |
| 23 | 24 | ]; |
| 24 | 25 | |
| 26 | /** What handoff and status fail with, including the access gate's refusal. */ | |
| 27 | const WorktreeToolFailure = Schema.Union([WorktreeMcpFailure, OrchestratorMcpFailure]); | |
| 28 | ||
| 25 | 29 | const WorktreeHandoffTool = Tool.make("t3_worktree_handoff", { |
| 26 | 30 | description: |
| 27 | 31 | "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.", |
| 28 | 32 | parameters: WorktreeMcpHandoffInput, |
| 29 | 33 | success: WorktreeMcpHandoffResult, |
| 30 | failure: WorktreeMcpFailure, | |
| 34 | failure: WorktreeToolFailure, | |
| 31 | 35 | failureMode: "return", |
| 32 | 36 | dependencies, |
| 33 | 37 | }) |
| ... | ... | @@ -45,7 +49,7 @@ const WorktreeStatusTool = Tool.make("t3_worktree_status", { |
| 45 | 49 | // Schema.Struct({}) serializes to `anyOf: [object, array]`, which is not a |
| 46 | 50 | // valid MCP tool input schema and makes clients reject the whole server. |
| 47 | 51 | success: WorktreeMcpStatusResult, |
| 48 | failure: WorktreeMcpFailure, | |
| 52 | failure: WorktreeToolFailure, | |
| 49 | 53 | failureMode: "return", |
| 50 | 54 | dependencies, |
| 51 | 55 | }) |
apps/server/src/orchestration-v2/DispatchModeLimit.test.ts created+167| ... | ... | @@ -0,0 +1,167 @@ |
| 1 | import { assert, it } from "@effect/vitest"; | |
| 2 | import { | |
| 3 | CommandId, | |
| 4 | ProjectId, | |
| 5 | ProviderDriverKind, | |
| 6 | ProviderInstanceId, | |
| 7 | ThreadId, | |
| 8 | } from "@t3tools/contracts"; | |
| 9 | import * as Effect from "effect/Effect"; | |
| 10 | import * as Layer from "effect/Layer"; | |
| 11 | import * as SqlitePersistence from "../persistence/Sqlite.ts"; | |
| 12 | import { CodexProviderCapabilitiesV2 } from "./Adapters/CodexAdapterV2.ts"; | |
| 13 | import { DispatchModeLimit } from "./DispatchModeLimit.ts"; | |
| 14 | import * as Orchestrator from "./Orchestrator.ts"; | |
| 15 | import * as ProjectionStore from "./ProjectionStore.ts"; | |
| 16 | import type { ProviderAdapterV2Shape } from "./ProviderAdapter.ts"; | |
| 17 | import * as ProviderAdapterRegistry from "./ProviderAdapterRegistry.ts"; | |
| 18 | import * as ProviderReplayHarness from "./testkit/ProviderReplayHarness.ts"; | |
| 19 | ||
| 20 | const instanceId = ProviderInstanceId.make("codex"); | |
| 21 | const adapter = { | |
| 22 | instanceId, | |
| 23 | driver: ProviderDriverKind.make("codex"), | |
| 24 | getCapabilities: () => Effect.succeed(CodexProviderCapabilitiesV2), | |
| 25 | planSelectionTransition: () => Effect.succeed({ type: "apply_on_next_turn" as const }), | |
| 26 | openSession: () => Effect.die("No provider process needed for metadata commands"), | |
| 27 | } as ProviderAdapterV2Shape; | |
| 28 | const layerDatabase = SqlitePersistence.layerMemory; | |
| 29 | const layerTest = Layer.mergeAll( | |
| 30 | layerDatabase, | |
| 31 | ProjectionStore.layer.pipe(Layer.provide(layerDatabase)), | |
| 32 | ProviderReplayHarness.layerWithRegistry( | |
| 33 | { name: "dispatch-mode-limit" }, | |
| 34 | ProviderAdapterRegistry.layerFromAdapters([adapter]), | |
| 35 | { databaseLayer: layerDatabase, runEffectWorker: false }, | |
| 36 | ), | |
| 37 | ); | |
| 38 | ||
| 39 | const supervised = { runtimeMode: "approval-required", interactionMode: "default" } as const; | |
| 40 | ||
| 41 | const createThread = (threadId: ThreadId) => | |
| 42 | Effect.gen(function* () { | |
| 43 | const orchestrator = yield* Orchestrator.OrchestratorV2; | |
| 44 | yield* orchestrator.dispatch({ | |
| 45 | type: "thread.create", | |
| 46 | commandId: CommandId.make(`create:${threadId}`), | |
| 47 | threadId, | |
| 48 | projectId: ProjectId.make("project:dispatch-mode-limit"), | |
| 49 | title: "Before", | |
| 50 | modelSelection: { instanceId, model: "gpt-5" }, | |
| 51 | ...supervised, | |
| 52 | branch: null, | |
| 53 | worktreePath: null, | |
| 54 | createdBy: "user", | |
| 55 | creationSource: "web", | |
| 56 | }); | |
| 57 | }); | |
| 58 | ||
| 59 | it.layer(layerTest)("DispatchModeLimit", (it) => { | |
| 60 | it.effect("refuses a limited command on a thread its user raised, and records nothing", () => | |
| 61 | Effect.gen(function* () { | |
| 62 | const orchestrator = yield* Orchestrator.OrchestratorV2; | |
| 63 | const projections = yield* ProjectionStore.ProjectionStoreV2; | |
| 64 | const threadId = ThreadId.make("thread:limit-raised"); | |
| 65 | yield* createThread(threadId); | |
| 66 | // The user raises the thread; the user's own commands have no limit. | |
| 67 | yield* orchestrator.dispatch({ | |
| 68 | type: "thread.runtime-mode.set", | |
| 69 | commandId: CommandId.make("raise"), | |
| 70 | threadId, | |
| 71 | runtimeMode: "full-access", | |
| 72 | }); | |
| 73 | const rename = { | |
| 74 | type: "thread.metadata.update", | |
| 75 | commandId: CommandId.make("rename"), | |
| 76 | threadId, | |
| 77 | title: "After", | |
| 78 | } as const; | |
| 79 | const refused = yield* orchestrator | |
| 80 | .dispatch(rename) | |
| 81 | .pipe(Effect.provideService(DispatchModeLimit, supervised), Effect.flip); | |
| 82 | assert.equal(refused._tag, "OrchestratorThreadAboveModeLimitError"); | |
| 83 | assert.equal((yield* projections.getThreadShell(threadId))?.title, "Before"); | |
| 84 | ||
| 85 | // Refused before planning, so no receipt holds the command id: once the | |
| 86 | // user lowers the thread again, the same command goes through. | |
| 87 | yield* orchestrator.dispatch({ | |
| 88 | type: "thread.runtime-mode.set", | |
| 89 | commandId: CommandId.make("lower"), | |
| 90 | threadId, | |
| 91 | runtimeMode: "approval-required", | |
| 92 | }); | |
| 93 | yield* orchestrator | |
| 94 | .dispatch(rename) | |
| 95 | .pipe(Effect.provideService(DispatchModeLimit, supervised)); | |
| 96 | assert.equal((yield* projections.getThreadShell(threadId))?.title, "After"); | |
| 97 | }), | |
| 98 | ); | |
| 99 | ||
| 100 | it.effect("names the interaction mode when that is what was raised", () => | |
| 101 | Effect.gen(function* () { | |
| 102 | const orchestrator = yield* Orchestrator.OrchestratorV2; | |
| 103 | const threadId = ThreadId.make("thread:limit-plan"); | |
| 104 | yield* createThread(threadId); | |
| 105 | const refused = yield* orchestrator | |
| 106 | .dispatch({ | |
| 107 | type: "thread.metadata.update", | |
| 108 | commandId: CommandId.make("rename-plan"), | |
| 109 | threadId, | |
| 110 | title: "After", | |
| 111 | }) | |
| 112 | .pipe( | |
| 113 | Effect.provideService(DispatchModeLimit, { | |
| 114 | runtimeMode: "full-access", | |
| 115 | interactionMode: "plan", | |
| 116 | }), | |
| 117 | Effect.flip, | |
| 118 | ); | |
| 119 | assert.ok(refused._tag === "OrchestratorThreadAboveModeLimitError"); | |
| 120 | assert.equal(refused.mode, "interaction"); | |
| 121 | }), | |
| 122 | ); | |
| 123 | ||
| 124 | it.effect("refuses to fork a source thread its user raised", () => | |
| 125 | Effect.gen(function* () { | |
| 126 | const orchestrator = yield* Orchestrator.OrchestratorV2; | |
| 127 | const projections = yield* ProjectionStore.ProjectionStoreV2; | |
| 128 | const sourceThreadId = ThreadId.make("thread:limit-fork-source"); | |
| 129 | const targetThreadId = ThreadId.make("thread:limit-fork-target"); | |
| 130 | yield* createThread(sourceThreadId); | |
| 131 | yield* orchestrator.dispatch({ | |
| 132 | type: "thread.runtime-mode.set", | |
| 133 | commandId: CommandId.make("raise-source"), | |
| 134 | threadId: sourceThreadId, | |
| 135 | runtimeMode: "full-access", | |
| 136 | }); | |
| 137 | const fork = { | |
| 138 | type: "thread.fork", | |
| 139 | commandId: CommandId.make("fork"), | |
| 140 | sourceThreadId, | |
| 141 | targetThreadId, | |
| 142 | sourcePoint: { type: "latest_stable" }, | |
| 143 | createdBy: "agent", | |
| 144 | creationSource: "mcp", | |
| 145 | } as const; | |
| 146 | const refused = yield* orchestrator | |
| 147 | .dispatch(fork) | |
| 148 | .pipe(Effect.provideService(DispatchModeLimit, supervised), Effect.flip); | |
| 149 | assert.ok(refused._tag === "OrchestratorThreadAboveModeLimitError"); | |
| 150 | assert.equal(refused.threadId, sourceThreadId); | |
| 151 | assert.isNull(yield* projections.getThreadShell(targetThreadId)); | |
| 152 | ||
| 153 | // The refusal records no receipt: once the source is lowered, the same | |
| 154 | // command is planned again (and fails only for want of a finished run). | |
| 155 | yield* orchestrator.dispatch({ | |
| 156 | type: "thread.runtime-mode.set", | |
| 157 | commandId: CommandId.make("lower-source"), | |
| 158 | threadId: sourceThreadId, | |
| 159 | runtimeMode: "approval-required", | |
| 160 | }); | |
| 161 | const retried = yield* orchestrator | |
| 162 | .dispatch(fork) | |
| 163 | .pipe(Effect.provideService(DispatchModeLimit, supervised), Effect.flip); | |
| 164 | assert.equal(retried._tag, "OrchestratorDispatchError"); | |
| 165 | }), | |
| 166 | ); | |
| 167 | }); |
apps/server/src/orchestration-v2/DispatchModeLimit.ts created+52| ... | ... | @@ -0,0 +1,52 @@ |
| 1 | import type { ProviderInteractionMode, RuntimeMode, ThreadId } from "@t3tools/contracts"; | |
| 2 | import * as Context from "effect/Context"; | |
| 3 | import type * as Ref from "effect/Ref"; | |
| 4 | ||
| 5 | export interface DispatchModes { | |
| 6 | readonly runtimeMode: RuntimeMode; | |
| 7 | readonly interactionMode: ProviderInteractionMode; | |
| 8 | } | |
| 9 | ||
| 10 | /** A thread the orchestrator refused to touch because it ran above the limit. */ | |
| 11 | export interface DispatchModeRefusal extends DispatchModes { | |
| 12 | readonly threadId: ThreadId; | |
| 13 | readonly mode: "runtime" | "interaction"; | |
| 14 | } | |
| 15 | ||
| 16 | /** | |
| 17 | * The broadest modes a command may touch, for commands an MCP caller sends. | |
| 18 | * A caller checks its target's modes before dispatching, but the target's | |
| 19 | * user can raise them meanwhile; the orchestrator re-checks this limit inside | |
| 20 | * the thread's command lock, where nothing else can change them. Unset for | |
| 21 | * the user's own commands, which have no limit. | |
| 22 | * | |
| 23 | * The orchestrator also records its refusal in `refused`, so the sender can | |
| 24 | * report it however the code in between wrapped the dispatch error. | |
| 25 | */ | |
| 26 | export interface DispatchModeLimitValue extends DispatchModes { | |
| 27 | readonly refused?: Ref.Ref<DispatchModeRefusal | undefined>; | |
| 28 | } | |
| 29 | ||
| 30 | export const DispatchModeLimit = Context.Reference<DispatchModeLimitValue | undefined>( | |
| 31 | "t3/orchestration-v2/DispatchModeLimit", | |
| 32 | { defaultValue: () => undefined }, | |
| 33 | ); | |
| 34 | ||
| 35 | const runtimeModeRank: Record<RuntimeMode, number> = { | |
| 36 | "approval-required": 0, | |
| 37 | "auto-accept-edits": 1, | |
| 38 | auto: 2, | |
| 39 | "full-access": 3, | |
| 40 | }; | |
| 41 | const interactionModeRank: Record<ProviderInteractionMode, number> = { plan: 0, default: 1 }; | |
| 42 | ||
| 43 | /** Which of `modes` is broader than `limit`, if either. */ | |
| 44 | export const exceededDispatchModeLimit = ( | |
| 45 | limit: DispatchModes, | |
| 46 | modes: DispatchModes, | |
| 47 | ): "runtime" | "interaction" | undefined => | |
| 48 | runtimeModeRank[modes.runtimeMode] > runtimeModeRank[limit.runtimeMode] | |
| 49 | ? "runtime" | |
| 50 | : interactionModeRank[modes.interactionMode] > interactionModeRank[limit.interactionMode] | |
| 51 | ? "interaction" | |
| 52 | : undefined; |
apps/server/src/orchestration-v2/Orchestrator.ts+69| ... | ... | @@ -46,8 +46,10 @@ import { |
| 46 | 46 | latestProviderTurnForAttempt, |
| 47 | 47 | orchestrationV2RunWorkStartedAt, |
| 48 | 48 | ProviderInstanceId, |
| 49 | ProviderInteractionMode, | |
| 49 | 50 | type ProviderSessionId, |
| 50 | 51 | RunId, |
| 52 | RuntimeMode, | |
| 51 | 53 | ThreadLinkedPullRequest, |
| 52 | 54 | ThreadId, |
| 53 | 55 | type TurnItemId, |
| ... | ... | @@ -86,6 +88,7 @@ import * as EffectOutbox from "./EffectOutbox.ts"; |
| 86 | 88 | import type { OrchestrationEffectRequestV2, PendingOrchestrationEffectV2 } from "./EffectOutbox.ts"; |
| 87 | 89 | import { IdAllocatorV2 } from "./IdAllocator.ts"; |
| 88 | 90 | import * as ThreadCommandExecutor from "./ThreadCommandExecutor.ts"; |
| 91 | import { DispatchModeLimit, exceededDispatchModeLimit } from "./DispatchModeLimit.ts"; | |
| 89 | 92 | import { |
| 90 | 93 | applyToProjection, |
| 91 | 94 | emptyProjection, |
| ... | ... | @@ -187,6 +190,22 @@ export class OrchestratorSubagentThreadReadOnlyError extends Schema.TaggedError< |
| 187 | 190 | } |
| 188 | 191 | } |
| 189 | 192 | |
| 193 | /** The command's thread runs above the modes its sender may touch (see `DispatchModeLimit`). */ | |
| 194 | export class OrchestratorThreadAboveModeLimitError extends Schema.TaggedError<OrchestratorThreadAboveModeLimitError>()( | |
| 195 | "OrchestratorThreadAboveModeLimitError", | |
| 196 | { | |
| 197 | commandId: CommandId, | |
| 198 | threadId: ThreadId, | |
| 199 | mode: Schema.Literals(["runtime", "interaction"]), | |
| 200 | runtimeMode: RuntimeMode, | |
| 201 | interactionMode: ProviderInteractionMode, | |
| 202 | }, | |
| 203 | ) { | |
| 204 | override get message(): string { | |
| 205 | return `Thread ${this.threadId} now runs in ${this.runtimeMode}/${this.interactionMode} mode, above what this caller may change.`; | |
| 206 | } | |
| 207 | } | |
| 208 | ||
| 190 | 209 | export class OrchestratorCommandPreviouslyRejectedError extends Schema.TaggedError<OrchestratorCommandPreviouslyRejectedError>()( |
| 191 | 210 | "OrchestratorCommandPreviouslyRejectedError", |
| 192 | 211 | { |
| ... | ... | @@ -236,6 +255,7 @@ export const OrchestratorV2Error = Schema.Union([ |
| 236 | 255 | OrchestratorCommandPreviouslyRejectedError, |
| 237 | 256 | OrchestratorCommandIdConflictError, |
| 238 | 257 | OrchestratorSubagentThreadReadOnlyError, |
| 258 | OrchestratorThreadAboveModeLimitError, | |
| 239 | 259 | ]); |
| 240 | 260 | export type OrchestratorV2Error = typeof OrchestratorV2Error.Type; |
| 241 | 261 | |
| ... | ... | @@ -3418,6 +3438,33 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio |
| 3418 | 3438 | }, |
| 3419 | 3439 | ); |
| 3420 | 3440 | |
| 3441 | /** Fails when a limited sender's command would touch a thread running above its limit. */ | |
| 3442 | const refuseAboveDispatchModeLimit = Effect.fn("orchestrationV2.dispatch.refuseAboveModeLimit")( | |
| 3443 | function* ( | |
| 3444 | command: OrchestrationV2ServerCommand, | |
| 3445 | threadId: ThreadId, | |
| 3446 | modes: { | |
| 3447 | readonly runtimeMode: RuntimeMode; | |
| 3448 | readonly interactionMode: ProviderInteractionMode; | |
| 3449 | }, | |
| 3450 | ) { | |
| 3451 | const limit = yield* DispatchModeLimit; | |
| 3452 | const exceeded = limit === undefined ? undefined : exceededDispatchModeLimit(limit, modes); | |
| 3453 | if (limit === undefined || exceeded === undefined) return; | |
| 3454 | const refusal = { | |
| 3455 | threadId, | |
| 3456 | mode: exceeded, | |
| 3457 | runtimeMode: modes.runtimeMode, | |
| 3458 | interactionMode: modes.interactionMode, | |
| 3459 | }; | |
| 3460 | if (limit.refused !== undefined) yield* Ref.set(limit.refused, refusal); | |
| 3461 | return yield* new OrchestratorThreadAboveModeLimitError({ | |
| 3462 | commandId: command.commandId, | |
| 3463 | ...refusal, | |
| 3464 | }); | |
| 3465 | }, | |
| 3466 | ); | |
| 3467 | ||
| 3421 | 3468 | const dispatchThreadFork = Effect.fn("orchestrationV2.dispatch.threadFork")(function* ( |
| 3422 | 3469 | command: Extract<OrchestrationV2Command, { readonly type: "thread.fork" }>, |
| 3423 | 3470 | events: Ref.Ref<Array<OrchestrationV2DomainEvent>>, |
| ... | ... | @@ -3448,6 +3495,9 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio |
| 3448 | 3495 | }), |
| 3449 | 3496 | ), |
| 3450 | 3497 | ); |
| 3498 | // The source is not under this command's lock, so check the modes this | |
| 3499 | // command copies, not an earlier read the source's user could outrun. | |
| 3500 | yield* refuseAboveDispatchModeLimit(command, command.sourceThreadId, sourceProjection.thread); | |
| 3451 | 3501 | |
| 3452 | 3502 | const sourceRun = runForSourcePoint(sourceProjection, command.sourcePoint); |
| 3453 | 3503 | |
| ... | ... | @@ -3536,6 +3586,9 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio |
| 3536 | 3586 | }), |
| 3537 | 3587 | ), |
| 3538 | 3588 | ); |
| 3589 | // The source is not under this command's lock, so check the modes this | |
| 3590 | // command copies, not an earlier read the source's user could outrun. | |
| 3591 | yield* refuseAboveDispatchModeLimit(command, command.sourceThreadId, sourceProjection.thread); | |
| 3539 | 3592 | const targetProjection = yield* projectionStore |
| 3540 | 3593 | .getThreadRecords(command.targetThreadId, ["contextTransfers"]) |
| 3541 | 3594 | .pipe( |
| ... | ... | @@ -10404,6 +10457,19 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio |
| 10404 | 10457 | } satisfies OrchestratorV2DispatchResult; |
| 10405 | 10458 | } |
| 10406 | 10459 | |
| 10460 | // A limited sender checked these modes before dispatching; the thread's | |
| 10461 | // user may have raised them since, and only here can they not change. | |
| 10462 | const limit = yield* DispatchModeLimit; | |
| 10463 | if (limit !== undefined) { | |
| 10464 | // A fork or merge-back source is not under this lock: its dispatch | |
| 10465 | // checks the copy it reads instead. | |
| 10466 | const threadId = commandThreadId(command); | |
| 10467 | const shell = yield* projectionStore | |
| 10468 | .getThreadShell(threadId) | |
| 10469 | .pipe(Effect.mapError((cause) => new OrchestratorProjectionError({ threadId, cause }))); | |
| 10470 | if (shell !== null) yield* refuseAboveDispatchModeLimit(command, threadId, shell); | |
| 10471 | } | |
| 10472 | ||
| 10407 | 10473 | const plan = yield* dispatchOnce(command).pipe( |
| 10408 | 10474 | Effect.flatMap((planned) => |
| 10409 | 10475 | // A settle that finds the provider already ended everything, or a |
| ... | ... | @@ -10423,6 +10489,9 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio |
| 10423 | 10489 | ), |
| 10424 | 10490 | Effect.catch((cause) => |
| 10425 | 10491 | Effect.gen(function* () { |
| 10492 | // Refused like the check above: nothing recorded, so the same command | |
| 10493 | // can go through once the thread's user lowers it again. | |
| 10494 | if (cause._tag === "OrchestratorThreadAboveModeLimitError") return yield* cause; | |
| 10426 | 10495 | const rejectedAt = yield* DateTime.now; |
| 10427 | 10496 | const receipt = yield* eventSink |
| 10428 | 10497 | .commitRejectedCommand({ |
apps/server/src/orchestration-v2/ThreadMessageIntake.ts+1| ... | ... | @@ -23,6 +23,7 @@ function dispatchWasNotAccepted( |
| 23 | 23 | case "OrchestratorCommandPreviouslyRejectedError": |
| 24 | 24 | case "OrchestratorCommandIdConflictError": |
| 25 | 25 | case "OrchestratorSubagentThreadReadOnlyError": |
| 26 | case "OrchestratorThreadAboveModeLimitError": | |
| 26 | 27 | return true; |
| 27 | 28 | default: |
| 28 | 29 | return false; |
apps/server/src/provider/T3OrchestrationInstructions.ts+1-1| ... | ... | @@ -20,7 +20,7 @@ For independent implementation or a PR stack in its own worktree, use \`t3_threa |
| 20 | 20 | - Existing worktree: \`{"title":"Continue cleanup","workspaceStrategy":{"type":"existing_worktree","worktreePath":"/absolute/path/to/worktree","branch":"feature/ui-cleanup"},"message":"Continue the cleanup."}\` |
| 21 | 21 | - Project's main checkout: \`workspaceStrategy:{"type":"root"}\`. Omitting workspaceStrategy also selects root; it does not inherit the caller's worktree. |
| 22 | 22 | |
| 23 | For stacked work, set \`baseRef\` to the intended parent branch and \`startFromOrigin:false\` to use its local commits. Use \`startFromOrigin:true\` when you intend to fetch and start from origin. Uncommitted edits are not copied. Use \`t3_worktree_list\` to discover existing checkout paths. Project, model selection, and modes inherit unless supplied; launch requires a full-access/default caller. | |
| 23 | For stacked work, set \`baseRef\` to the intended parent branch and \`startFromOrigin:false\` to use its local commits. Use \`startFromOrigin:true\` when you intend to fetch and start from origin. Uncommitted edits are not copied. Use \`t3_worktree_list\` to discover existing checkout paths. Project, model selection, and modes inherit unless supplied; a launched thread may not run with broader modes than yours. | |
| 24 | 24 | |
| 25 | 25 | \`t3_thread_launch\` is the single-thread launch tool. Use \`create_threads\` only for a batch of threads intentionally sharing the caller's checkout: it always inherits the caller's project, branch, and worktree and has no workspace override. Asking an agent to run \`git worktree add\` or \`cd\` in its prompt does not update T3's thread binding. Select the workspace in the launch call instead. \`t3_worktree_handoff\` moves the calling thread, not another thread, and cannot move a thread already attached to a worktree. |
| 26 | 26 |
apps/server/src/vcs/GitVcsDriver.ts+5| ... | ... | @@ -398,6 +398,11 @@ export class GitVcsDriver extends Context.Service< |
| 398 | 398 | readonly pruneWorktrees: (input: { |
| 399 | 399 | readonly cwd: string; |
| 400 | 400 | }) => Effect.Effect<void, GitCommandError>; |
| 401 | /** | |
| 402 | * Absolute paths of every live worktree of the repository at `cwd`, the | |
| 403 | * main checkout included. Worktrees whose directory is gone are left out. | |
| 404 | */ | |
| 405 | readonly listWorktreePaths: (cwd: string) => Effect.Effect<string[], GitCommandError>; | |
| 401 | 406 | readonly deleteLocalBranch: ( |
| 402 | 407 | input: GitDeleteLocalBranchInput, |
| 403 | 408 | ) => Effect.Effect<void, GitCommandError>; |
apps/server/src/vcs/GitVcsDriverCore.ts+21| ... | ... | @@ -3954,6 +3954,26 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* |
| 3954 | 3954 | }), |
| 3955 | 3955 | ); |
| 3956 | 3956 | |
| 3957 | const listWorktreePaths: GitVcsDriver.GitVcsDriver["Service"]["listWorktreePaths"] = (cwd) => | |
| 3958 | runGitStdout("GitVcsDriver.listWorktreePaths", cwd, [ | |
| 3959 | "worktree", | |
| 3960 | "list", | |
| 3961 | "--porcelain", | |
| 3962 | "-z", | |
| 3963 | ]).pipe( | |
| 3964 | // One record per worktree, each ended by an empty field. A `prunable` | |
| 3965 | // record's directory is gone, and another checkout may now sit there. | |
| 3966 | Effect.map((stdout) => | |
| 3967 | stdout.split("\0\0").flatMap((record) => { | |
| 3968 | const fields = record.split("\0"); | |
| 3969 | const worktree = fields.find((field) => field.startsWith("worktree ")); | |
| 3970 | return worktree === undefined || fields.some((field) => field.startsWith("prunable")) | |
| 3971 | ? [] | |
| 3972 | : [path.resolve(cwd, worktree.slice("worktree ".length))]; | |
| 3973 | }), | |
| 3974 | ), | |
| 3975 | ); | |
| 3976 | ||
| 3957 | 3977 | const withListRefsInvalidation = <A, E>( |
| 3958 | 3978 | cwd: string, |
| 3959 | 3979 | effect: Effect.Effect<A, E>, |
| ... | ... | @@ -4024,5 +4044,6 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* |
| 4024 | 4044 | switchRef: (input) => withListRefsInvalidation(input.cwd, switchRef(input)), |
| 4025 | 4045 | initRepo: initRepoWithListRefsInvalidation, |
| 4026 | 4046 | listLocalBranchNames, |
| 4047 | listWorktreePaths, | |
| 4027 | 4048 | }); |
| 4028 | 4049 | }); |
docs/orchestration-v2/orchestrator-mcp-server.md+2-2| ... | ... | @@ -325,8 +325,8 @@ this binding. |
| 325 | 325 | Pass the task in `message`. Project, model, and modes inherit when omitted; |
| 326 | 326 | workspace does not. `scratch: true` launches without a project, in a folder of |
| 327 | 327 | its own under the environment's Scratch project. For stacked PRs, use the parent branch as `baseRef` with |
| 328 | `startFromOrigin: false`. Launch requires a full-access/default caller and has | |
| 329 | no retry key, so inspect existing threads after a failed or lost response before | |
| 328 | `startFromOrigin: false`. The new thread may not run with broader runtime or | |
| 329 | interaction modes than the caller. Launch has no retry key, so inspect existing threads after a failed or lost response before | |
| 330 | 330 | launching again. `create_threads` remains the batch option for a shared checkout. |
| 331 | 331 | |
| 332 | 332 | ### `t3_thread_list` |
oxlint-plugin-t3code/index.ts+2| ... | ... | @@ -7,6 +7,7 @@ import noInlineSchemaCompile from "./rules/no-inline-schema-compile.ts"; |
| 7 | 7 | import noManualEffectRuntimeInTests from "./rules/no-manual-effect-runtime-in-tests.ts"; |
| 8 | 8 | import noMobileUniwindThemeEscapeHatches from "./rules/no-mobile-uniwind-theme-escape-hatches.ts"; |
| 9 | 9 | import noNativeTitleTooltip from "./rules/no-native-title-tooltip.ts"; |
| 10 | import noRawMcpRegistration from "./rules/no-raw-mcp-registration.ts"; | |
| 10 | 11 | import noTestInLoop from "./rules/no-test-in-loop.ts"; |
| 11 | 12 | import noUnscopedHas from "./rules/no-unscoped-has.ts"; |
| 12 | 13 | import preferCatchTags from "./rules/prefer-catch-tags.ts"; |
| ... | ... | @@ -24,6 +25,7 @@ export default definePlugin({ |
| 24 | 25 | "no-manual-effect-runtime-in-tests": noManualEffectRuntimeInTests, |
| 25 | 26 | "no-mobile-uniwind-theme-escape-hatches": noMobileUniwindThemeEscapeHatches, |
| 26 | 27 | "no-native-title-tooltip": noNativeTitleTooltip, |
| 28 | "no-raw-mcp-registration": noRawMcpRegistration, | |
| 27 | 29 | "no-test-in-loop": noTestInLoop, |
| 28 | 30 | "no-unscoped-has": noUnscopedHas, |
| 29 | 31 | "prefer-catch-tags": preferCatchTags, |
oxlint-plugin-t3code/rules/no-raw-mcp-registration.test.ts created+185| ... | ... | @@ -0,0 +1,185 @@ |
| 1 | import { assert, describe } from "@effect/vitest"; | |
| 2 | ||
| 3 | import { createOxlintRuleHarness } from "../test/utils.ts"; | |
| 4 | ||
| 5 | const rule = createOxlintRuleHarness("t3code/no-raw-mcp-registration"); | |
| 6 | const testFile = createOxlintRuleHarness("t3code/no-raw-mcp-registration", { | |
| 7 | filename: "server.test.ts", | |
| 8 | }); | |
| 9 | ||
| 10 | describe("t3code/no-raw-mcp-registration", () => { | |
| 11 | rule.valid( | |
| 12 | "allows the rest of effect/ai and type-only McpServer imports", | |
| 13 | ` | |
| 14 | import { McpSchema, Tool, Toolkit } from "effect/ai"; | |
| 15 | import type { McpServer } from "effect/ai"; | |
| 16 | import { type McpServer as Server } from "effect/ai"; | |
| 17 | export type Service = McpServer.McpServer | Server.McpServer; | |
| 18 | export const used = [McpSchema, Tool, Toolkit]; | |
| 19 | `, | |
| 20 | ); | |
| 21 | ||
| 22 | rule.invalid( | |
| 23 | "reports importing McpServer from effect/ai", | |
| 24 | ` | |
| 25 | import { McpServer } from "effect/ai"; | |
| 26 | export const server = McpServer.McpServer; | |
| 27 | `, | |
| 28 | (output) => { | |
| 29 | assert.match(output, /Only McpHttpServer may use Effect's McpServer/); | |
| 30 | }, | |
| 31 | ); | |
| 32 | ||
| 33 | rule.invalid( | |
| 34 | "reports importing McpServer under another name", | |
| 35 | ` | |
| 36 | import { McpServer as Server } from "effect/ai"; | |
| 37 | export const registration = Server.toolkit(SomeToolkit); | |
| 38 | `, | |
| 39 | ); | |
| 40 | ||
| 41 | rule.invalid( | |
| 42 | "reports a namespace import of effect/ai", | |
| 43 | ` | |
| 44 | import * as Ai from "effect/ai"; | |
| 45 | export const registration = Ai.McpServer.toolkit(SomeToolkit); | |
| 46 | `, | |
| 47 | ); | |
| 48 | ||
| 49 | rule.invalid( | |
| 50 | "reports importing the McpServer module directly", | |
| 51 | ` | |
| 52 | import { toolkit } from "effect/ai/McpServer"; | |
| 53 | export const registration = toolkit(SomeToolkit); | |
| 54 | `, | |
| 55 | ); | |
| 56 | ||
| 57 | rule.invalid( | |
| 58 | "reports re-exporting McpServer", | |
| 59 | ` | |
| 60 | export { McpServer as Server } from "effect/ai"; | |
| 61 | `, | |
| 62 | ); | |
| 63 | ||
| 64 | rule.invalid( | |
| 65 | "reports re-exporting all of effect/ai", | |
| 66 | ` | |
| 67 | export * from "effect/ai"; | |
| 68 | `, | |
| 69 | ); | |
| 70 | ||
| 71 | rule.invalid( | |
| 72 | "reports a dynamic import of the McpServer module", | |
| 73 | ` | |
| 74 | export const load = () => import("effect/ai/McpServer"); | |
| 75 | `, | |
| 76 | ); | |
| 77 | ||
| 78 | rule.invalid( | |
| 79 | "reports a dynamic import written as a template", | |
| 80 | ` | |
| 81 | export const load = () => import(\`effect/ai/McpServer\`); | |
| 82 | `, | |
| 83 | ); | |
| 84 | ||
| 85 | rule.invalid( | |
| 86 | "reports McpServer.toolkit", | |
| 87 | ` | |
| 88 | export const registration = McpServer.toolkit(SomeToolkit); | |
| 89 | `, | |
| 90 | (output) => { | |
| 91 | assert.match(output, /McpServer\.toolkit registers on \/mcp/); | |
| 92 | }, | |
| 93 | ); | |
| 94 | ||
| 95 | rule.invalid( | |
| 96 | "reports McpServer.resource and McpServer.prompt", | |
| 97 | ` | |
| 98 | export const resource = McpServer.resource({ uri: "t3://x", name: "x", content: "x" }); | |
| 99 | export const prompt = McpServer.prompt({ name: "x", content: () => "x" }); | |
| 100 | `, | |
| 101 | ); | |
| 102 | ||
| 103 | rule.invalid( | |
| 104 | "reports addTool on the McpServer service", | |
| 105 | ` | |
| 106 | import * as Effect from "effect/Effect"; | |
| 107 | export const register = Effect.gen(function* () { | |
| 108 | const server = yield* Service; | |
| 109 | yield* server.addTool({ tool, annotations, handle: () => Effect.die("unchecked") }); | |
| 110 | }); | |
| 111 | `, | |
| 112 | (output) => { | |
| 113 | assert.match(output, /\.addTool registers on \/mcp/); | |
| 114 | }, | |
| 115 | ); | |
| 116 | ||
| 117 | rule.invalid( | |
| 118 | "reports a registration method read without calling it", | |
| 119 | ` | |
| 120 | export const add = server.addTool; | |
| 121 | `, | |
| 122 | ); | |
| 123 | ||
| 124 | rule.invalid( | |
| 125 | "reports a registration function taken by destructuring", | |
| 126 | ` | |
| 127 | const { toolkit } = McpServer; | |
| 128 | export const registration = toolkit(SomeToolkit); | |
| 129 | `, | |
| 130 | ); | |
| 131 | ||
| 132 | rule.invalid( | |
| 133 | "reports a registration method taken by destructuring", | |
| 134 | ` | |
| 135 | import * as Effect from "effect/Effect"; | |
| 136 | export const register = Effect.gen(function* () { | |
| 137 | const { addTool } = yield* Service; | |
| 138 | yield* addTool({ tool, annotations, handle: () => Effect.die("unchecked") }); | |
| 139 | }); | |
| 140 | `, | |
| 141 | ); | |
| 142 | ||
| 143 | testFile.valid( | |
| 144 | "lets tests import McpServer to build a server", | |
| 145 | ` | |
| 146 | import { McpServer } from "effect/ai"; | |
| 147 | export const layer = McpServer.McpServer.layer; | |
| 148 | `, | |
| 149 | ); | |
| 150 | ||
| 151 | testFile.invalid( | |
| 152 | "reports a registration in a test", | |
| 153 | ` | |
| 154 | import { McpServer } from "effect/ai"; | |
| 155 | export const registration = McpServer.toolkit(SomeToolkit); | |
| 156 | `, | |
| 157 | ); | |
| 158 | ||
| 159 | testFile.invalid( | |
| 160 | "reports a test importing McpServer under another name", | |
| 161 | ` | |
| 162 | import { McpServer as Server } from "effect/ai"; | |
| 163 | export const registration = Server.toolkit(SomeToolkit); | |
| 164 | `, | |
| 165 | (output) => { | |
| 166 | assert.match(output, /Tests import McpServer only as/); | |
| 167 | }, | |
| 168 | ); | |
| 169 | ||
| 170 | testFile.invalid( | |
| 171 | "reports a test importing effect/ai as a namespace", | |
| 172 | ` | |
| 173 | import * as Ai from "effect/ai"; | |
| 174 | export const registration = Ai.McpServer.toolkit(SomeToolkit); | |
| 175 | `, | |
| 176 | ); | |
| 177 | ||
| 178 | testFile.invalid( | |
| 179 | "reports a test importing the McpServer module directly", | |
| 180 | ` | |
| 181 | import { toolkit } from "effect/ai/McpServer"; | |
| 182 | export const registration = toolkit(SomeToolkit); | |
| 183 | `, | |
| 184 | ); | |
| 185 | }); |
oxlint-plugin-t3code/rules/no-raw-mcp-registration.ts created+148| ... | ... | @@ -0,0 +1,148 @@ |
| 1 | import { defineRule } from "@oxlint/plugins"; | |
| 2 | import * as Option from "effect/Option"; | |
| 3 | ||
| 4 | import { getPropertyName, isIdentifier, unwrapExpression } from "../utils.ts"; | |
| 5 | ||
| 6 | // Effect's MCP server, and the modules that re-export it. | |
| 7 | const MCP_SERVER_MODULE = "effect/ai/McpServer"; | |
| 8 | const AI_MODULES = new Set(["effect/ai", "effect/ai/index"]); | |
| 9 | // Registering anything straight on the MCP server skips McpToolAccess, where | |
| 10 | // every T3 MCP tool declares who may call it. These are the module functions... | |
| 11 | const MODULE_REGISTRATIONS = new Set([ | |
| 12 | "toolkit", | |
| 13 | "registerToolkit", | |
| 14 | "resource", | |
| 15 | "registerResource", | |
| 16 | "prompt", | |
| 17 | "registerPrompt", | |
| 18 | ]); | |
| 19 | // ...and the methods on the McpServer service itself. | |
| 20 | const SERVICE_REGISTRATIONS = new Set([ | |
| 21 | "addTool", | |
| 22 | "addResource", | |
| 23 | "addResourceTemplate", | |
| 24 | "addPrompt", | |
| 25 | ]); | |
| 26 | const TEST_FILE_PATTERN = /\.(?:test|spec)\.[cm]?[jt]sx?$/u; | |
| 27 | ||
| 28 | const HOW = | |
| 29 | "Build the handlers with McpToolAccess.toLayer and register them through McpHttpServer's toolkitRegistration."; | |
| 30 | const registrationMessage = (name: string) => | |
| 31 | `${name} registers on /mcp without the access checks McpToolAccess declares. ${HOW}`; | |
| 32 | const importMessage = `Only McpHttpServer may use Effect's McpServer: a registration anywhere else skips the access checks McpToolAccess declares. ${HOW}`; | |
| 33 | const testImportMessage = | |
| 34 | 'Tests import McpServer only as `import { McpServer } from "effect/ai"`, so a registration on it is reported.'; | |
| 35 | ||
| 36 | /** A module specifier: a string, or a template with nothing substituted. */ | |
| 37 | const literalString = (node: unknown): Option.Option<string> => | |
| 38 | Option.flatMap(unwrapExpression(node), (expression) => { | |
| 39 | if (expression.type === "Literal" && typeof expression.value === "string") { | |
| 40 | return Option.some(expression.value); | |
| 41 | } | |
| 42 | if (expression.type === "TemplateLiteral" && expression.expressions.length === 0) { | |
| 43 | return Option.fromNullishOr(expression.quasis[0]?.value.cooked); | |
| 44 | } | |
| 45 | return Option.none(); | |
| 46 | }); | |
| 47 | ||
| 48 | const namesMcpServer = (node: unknown) => | |
| 49 | Option.getOrUndefined(getPropertyName(node)) === "McpServer"; | |
| 50 | ||
| 51 | const exposesMcpServer = (source: Option.Option<string>) => | |
| 52 | Option.isSome(source) && (source.value === MCP_SERVER_MODULE || AI_MODULES.has(source.value)); | |
| 53 | ||
| 54 | /** | |
| 55 | * Keeps every registration on `/mcp` inside McpHttpServer, whose helpers | |
| 56 | * accept only handlers McpToolAccess built; the lint config exempts that file. | |
| 57 | * Elsewhere it reports runtime imports of Effect's McpServer, under any name | |
| 58 | * or path, and reading a registration method or function in any form. Tests | |
| 59 | * may import McpServer to build a server, but only under its own name, so a | |
| 60 | * registration on it is still reported. It guards against a registration that | |
| 61 | * skips the checks by accident; deliberately working around it needs a | |
| 62 | * reviewed change here. | |
| 63 | */ | |
| 64 | export default defineRule({ | |
| 65 | meta: { | |
| 66 | type: "problem", | |
| 67 | docs: { | |
| 68 | description: | |
| 69 | "Disallow registering on the MCP server without McpToolAccess; tools must declare who may call them.", | |
| 70 | }, | |
| 71 | }, | |
| 72 | create(context) { | |
| 73 | const isTest = TEST_FILE_PATTERN.test(context.filename); | |
| 74 | return { | |
| 75 | ImportDeclaration(node) { | |
| 76 | if (node.importKind === "type") return; | |
| 77 | const source = literalString(node.source); | |
| 78 | if (Option.isNone(source)) return; | |
| 79 | const exposes = node.specifiers.some((specifier) => { | |
| 80 | if (specifier.type !== "ImportSpecifier") return exposesMcpServer(source); | |
| 81 | if (specifier.importKind === "type") return false; | |
| 82 | if (source.value === MCP_SERVER_MODULE) return true; | |
| 83 | return ( | |
| 84 | AI_MODULES.has(source.value) && | |
| 85 | namesMcpServer(specifier.imported) && | |
| 86 | // A test's `McpServer.toolkit(...)` is reported below by name. | |
| 87 | !(isTest && namesMcpServer(specifier.local)) | |
| 88 | ); | |
| 89 | }); | |
| 90 | if (exposes) context.report({ node, message: isTest ? testImportMessage : importMessage }); | |
| 91 | }, | |
| 92 | ExportNamedDeclaration(node) { | |
| 93 | if (node.source === null || node.exportKind === "type") return; | |
| 94 | const source = literalString(node.source); | |
| 95 | if (Option.isNone(source)) return; | |
| 96 | const exposes = node.specifiers.some( | |
| 97 | (specifier) => | |
| 98 | specifier.exportKind !== "type" && | |
| 99 | (source.value === MCP_SERVER_MODULE || | |
| 100 | (AI_MODULES.has(source.value) && namesMcpServer(specifier.local))), | |
| 101 | ); | |
| 102 | if (exposes) context.report({ node, message: importMessage }); | |
| 103 | }, | |
| 104 | ExportAllDeclaration(node) { | |
| 105 | if (node.exportKind === "type") return; | |
| 106 | if (exposesMcpServer(literalString(node.source))) { | |
| 107 | context.report({ node, message: importMessage }); | |
| 108 | } | |
| 109 | }, | |
| 110 | ImportExpression(node) { | |
| 111 | if (exposesMcpServer(literalString(node.source))) { | |
| 112 | context.report({ node, message: importMessage }); | |
| 113 | } | |
| 114 | }, | |
| 115 | MemberExpression(node) { | |
| 116 | const name = getPropertyName(node.property); | |
| 117 | if (Option.isNone(name)) return; | |
| 118 | if (SERVICE_REGISTRATIONS.has(name.value)) { | |
| 119 | context.report({ node, message: registrationMessage(`.${name.value}`) }); | |
| 120 | } else if ( | |
| 121 | MODULE_REGISTRATIONS.has(name.value) && | |
| 122 | isIdentifier(unwrapExpression(node.object), "McpServer") | |
| 123 | ) { | |
| 124 | context.report({ node, message: registrationMessage(`McpServer.${name.value}`) }); | |
| 125 | } | |
| 126 | }, | |
| 127 | // `const { addTool } = server`, `const { toolkit } = McpServer` | |
| 128 | ObjectPattern(node) { | |
| 129 | const fromMcpServer = | |
| 130 | node.parent.type === "VariableDeclarator" && | |
| 131 | isIdentifier(unwrapExpression(node.parent.init), "McpServer"); | |
| 132 | for (const property of node.properties) { | |
| 133 | if (property.type !== "Property") continue; | |
| 134 | const name = getPropertyName(property.key); | |
| 135 | if (Option.isNone(name)) continue; | |
| 136 | if (SERVICE_REGISTRATIONS.has(name.value)) { | |
| 137 | context.report({ node: property, message: registrationMessage(`.${name.value}`) }); | |
| 138 | } else if (fromMcpServer && MODULE_REGISTRATIONS.has(name.value)) { | |
| 139 | context.report({ | |
| 140 | node: property, | |
| 141 | message: registrationMessage(`McpServer.${name.value}`), | |
| 142 | }); | |
| 143 | } | |
| 144 | } | |
| 145 | }, | |
| 146 | }; | |
| 147 | }, | |
| 148 | }); |
vite.config.ts+6| ... | ... | @@ -167,6 +167,7 @@ export default defineConfig({ |
| 167 | 167 | "t3code/no-inline-schema-compile": "warn", |
| 168 | 168 | "t3code/no-manual-effect-runtime-in-tests": "error", |
| 169 | 169 | "t3code/no-native-title-tooltip": "error", |
| 170 | "t3code/no-raw-mcp-registration": "error", | |
| 170 | 171 | "t3code/no-test-in-loop": "error", |
| 171 | 172 | "t3code/no-unscoped-has": "error", |
| 172 | 173 | "t3code/namespace-node-imports": "error", |
| ... | ... | @@ -179,6 +180,11 @@ export default defineConfig({ |
| 179 | 180 | files: ["packages/shared/src/hostProcess.ts"], |
| 180 | 181 | rules: { "t3code/no-global-process-runtime": "off" }, |
| 181 | 182 | }, |
| 183 | { | |
| 184 | // The registration helpers that only accept handlers built by McpToolAccess. | |
| 185 | files: ["apps/server/src/mcp/McpHttpServer.ts"], | |
| 186 | rules: { "t3code/no-raw-mcp-registration": "off" }, | |
| 187 | }, | |
| 182 | 188 | { |
| 183 | 189 | files: ["apps/web/src/**"], |
| 184 | 190 | excludeFiles: ["apps/web/src/components/ui/**"], |