diff --git a/apps/server/src/mcp/McpDeviceToolkit.test.ts b/apps/server/src/mcp/McpDeviceToolkit.test.ts index 60b90ca84f1f63cb0063a243466a0e8b311cdbea..3c2f4e5905ebee96fd71b02534922eb77edf16f5 100644 --- a/apps/server/src/mcp/McpDeviceToolkit.test.ts +++ b/apps/server/src/mcp/McpDeviceToolkit.test.ts @@ -14,6 +14,7 @@ import * as ServerConfig from "../config.ts"; import * as DeviceService from "../device/DeviceService.ts"; import * as McpHttpServer from "./McpHttpServer.ts"; import * as McpInvocationContext from "./McpInvocationContext.ts"; +import * as McpToolAccessTestkit from "./McpToolAccess.testkit.ts"; const environmentId = EnvironmentId.make("environment-device-test"); const threadId = ThreadId.make("thread-device-test"); @@ -102,6 +103,7 @@ const layerDeviceServiceMock = Layer.mock(DeviceService.DeviceService)({ const layerTest = McpHttpServer.layerDeviceToolkit.pipe( Layer.provideMerge(McpServer.McpServer.layer), + Layer.provideMerge(McpToolAccessTestkit.liveThreadsLayer), Layer.provideMerge(layerDeviceServiceMock), Layer.provide(ServerConfig.layerTest(process.cwd(), { prefix: "t3-mcp-device-toolkit-test-" })), Layer.provide(NodeServices.layer), @@ -172,6 +174,7 @@ it.effect("rejects unavailable agent access before booting or opening a device", Effect.provide( McpHttpServer.layerDeviceToolkit.pipe( Layer.provideMerge(McpServer.McpServer.layer), + Layer.provideMerge(McpToolAccessTestkit.liveThreadsLayer), Layer.provide(layerUnavailable), Layer.provide(NodeServices.layer), ), diff --git a/apps/server/src/mcp/McpHttpServer.test.ts b/apps/server/src/mcp/McpHttpServer.test.ts index 591b36548b2eef31f0491dcb9dbf552504e22027..e7bcbdea8e5ef9985a1715096171db63db3a1d01 100644 --- a/apps/server/src/mcp/McpHttpServer.test.ts +++ b/apps/server/src/mcp/McpHttpServer.test.ts @@ -17,6 +17,7 @@ import { HttpBody, HttpClient, HttpRouter, HttpServerResponse } from "effect/htt import * as ProjectService from "../project/ProjectService.ts"; import * as ServerConfig from "../config.ts"; import * as McpHttpServer from "./McpHttpServer.ts"; +import * as McpToolAccessTestkit from "./McpToolAccess.testkit.ts"; import * as McpInvocationContext from "./McpInvocationContext.ts"; import * as PreviewAutomationBroker from "./PreviewAutomationBroker.ts"; @@ -51,12 +52,14 @@ const client = McpSchema.McpServerClient.of({ }); const layerTest = McpHttpServer.layerPreviewToolkit.pipe( Layer.provideMerge(McpServer.McpServer.layer), + Layer.provideMerge(McpToolAccessTestkit.liveThreadsLayer), Layer.provideMerge(PreviewAutomationBroker.layer), Layer.provideMerge(ServerConfig.layerTest(process.cwd(), { prefix: "t3-mcp-http-server-test-" })), Layer.provideMerge(NodeServices.layer), ); const layerPullRequestsTest = McpHttpServer.layerPullRequestsToolkit.pipe( Layer.provideMerge(McpServer.McpServer.layer), + Layer.provideMerge(McpToolAccessTestkit.liveThreadsLayer), Layer.provide( Layer.mergeAll( Layer.mock(ProjectService.ProjectService)({}), diff --git a/apps/server/src/mcp/McpHttpServer.ts b/apps/server/src/mcp/McpHttpServer.ts index 24f719c224baabffb0378677818e6956e755dde4..79f7f6079dadbdb9289864cc7a08e9df5b101d29 100644 --- a/apps/server/src/mcp/McpHttpServer.ts +++ b/apps/server/src/mcp/McpHttpServer.ts @@ -11,7 +11,7 @@ import * as Schema from "effect/Schema"; import * as Sink from "effect/Sink"; import * as Stream from "effect/Stream"; import type * as Types from "effect/Types"; -import { AiError, McpProtocol, McpSchema, McpServer, Tool } from "effect/ai"; +import { AiError, McpProtocol, McpSchema, McpServer, Tool, type Toolkit } from "effect/ai"; import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/http"; import { OrchestratorMcpFailure, PreviewAutomationError } from "@t3tools/contracts"; @@ -19,7 +19,9 @@ import packageJson from "../../package.json" with { type: "json" }; import * as ServerConfig from "../config.ts"; import * as DeviceService from "../device/DeviceService.ts"; import * as HtmlRender from "../htmlRender/HtmlRender.ts"; +import * as ThreadManagementService from "../orchestration-v2/ThreadManagementService.ts"; import * as McpInvocationContext from "./McpInvocationContext.ts"; +import * as McpToolAccess from "./McpToolAccess.ts"; import * as OrchestratorMcpService from "./OrchestratorMcpService.ts"; import { PreviewControlsToolkit } from "./toolkits/previewControls/tools.ts"; import * as PreviewControlsHandlers from "./toolkits/previewControls/handlers.ts"; @@ -396,6 +398,7 @@ const previewSnapshotFailure = (cause: Cause.Cause) => { const registerPreviewSnapshot = Effect.fn("McpHttpServer.registerPreviewSnapshot")(function* () { const server = yield* McpServer.McpServer; const broker = yield* PreviewAutomationBroker.PreviewAutomationBroker; + const threads = yield* ThreadManagementService.ThreadManagementService; // The MCP tool runner only supplies the client, so hand the save path its services here. const saveServices = yield* Effect.context< ServerConfig.ServerConfig | FileSystem.FileSystem | Path.Path | Crypto.Crypto @@ -430,6 +433,7 @@ const registerPreviewSnapshot = Effect.fn("McpHttpServer.registerPreviewSnapshot Stream.run(Sink.last()), Effect.flatMap(Effect.fromOption), Effect.provideService(PreviewAutomationBroker.PreviewAutomationBroker, broker), + Effect.provideService(ThreadManagementService.ThreadManagementService, threads), Effect.provideService(McpInvocationContext.McpInvocationContext, invocation), Effect.flatMap(({ encodedResult }) => Effect.gen(function* () { @@ -655,6 +659,7 @@ const registerImageTool = ( const registerDeviceScreenshot = Effect.fn("McpHttpServer.registerDeviceScreenshot")(function* () { const devices = yield* DeviceService.DeviceService; + const threads = yield* ThreadManagementService.ThreadManagementService; const built = yield* DeviceScreenshotToolkit; yield* registerImageTool( DeviceScreenshotTool, @@ -662,7 +667,11 @@ const registerDeviceScreenshot = Effect.fn("McpHttpServer.registerDeviceScreensh built .handle("device_screenshot", payload) .pipe(Stream.unwrap, Stream.run(Sink.last()), Effect.flatMap(Effect.fromOption)), - (effect) => effect.pipe(Effect.provideService(DeviceService.DeviceService, devices)), + (effect) => + effect.pipe( + Effect.provideService(DeviceService.DeviceService, devices), + Effect.provideService(ThreadManagementService.ThreadManagementService, threads), + ), "screenshot", "Device screenshot failed.", ); @@ -672,6 +681,7 @@ const isOrchestratorMcpFailure = Schema.is(OrchestratorMcpFailure); const registerHtmlPreview = Effect.fn("McpHttpServer.registerHtmlPreview")(function* () { const htmlRender = yield* HtmlRender.HtmlRender; + const threads = yield* ThreadManagementService.ThreadManagementService; const built = yield* HtmlPreviewToolkit; yield* registerImageTool( HtmlPreviewTool, @@ -679,7 +689,11 @@ const registerHtmlPreview = Effect.fn("McpHttpServer.registerHtmlPreview")(funct built .handle("html_preview", payload) .pipe(Stream.unwrap, Stream.run(Sink.last()), Effect.flatMap(Effect.fromOption)), - (effect) => effect.pipe(Effect.provideService(HtmlRender.HtmlRender, htmlRender)), + (effect) => + effect.pipe( + Effect.provideService(HtmlRender.HtmlRender, htmlRender), + Effect.provideService(ThreadManagementService.ThreadManagementService, threads), + ), "preview", // Parameter errors and HTML render errors are both written by the server for the agent. (error) => @@ -689,17 +703,34 @@ const registerHtmlPreview = Effect.fn("McpHttpServer.registerHtmlPreview")(funct ); }); +/** + * `McpServer.toolkit` for handlers that declared their access (see + * `McpToolAccess`). Every toolkit on `/mcp` registers through this. + */ +export const toolkitRegistration = , EX, RX>( + toolkit: Toolkit.Toolkit, + handlers: McpToolAccess.HandlersLayer, +) => McpServer.toolkit(toolkit).pipe(Layer.provide(McpToolAccess.HandlersLayer.layer(handlers))); + +/** A hand-registered tool, also only with handlers that declared their access. */ +const imageToolRegistration = , A, E, R, EX, RX>( + register: Effect.Effect, + handlers: McpToolAccess.HandlersLayer, +) => Layer.effectDiscard(register).pipe(Layer.provide(McpToolAccess.HandlersLayer.layer(handlers))); + export const layerHtmlToolkit = Layer.mergeAll( - McpServer.toolkit(HtmlRenderToolkit).pipe(Layer.provide(HtmlHandlers.layerRender)), - Layer.effectDiscard(registerHtmlPreview()).pipe(Layer.provide(HtmlHandlers.layerPreview)), + toolkitRegistration(HtmlRenderToolkit, HtmlHandlers.layerRender), + imageToolRegistration(registerHtmlPreview(), HtmlHandlers.layerPreview), ).pipe(Layer.provide(HtmlRender.layer)); -const layerPreviewStandardToolkitRegistration = McpServer.toolkit(PreviewStandardToolkit).pipe( - Layer.provide(PreviewHandlers.layerStandard), +const layerPreviewStandardToolkitRegistration = toolkitRegistration( + PreviewStandardToolkit, + PreviewHandlers.layerStandard, ); -const layerPreviewSnapshotRegistration = Layer.effectDiscard(registerPreviewSnapshot()).pipe( - Layer.provide(PreviewHandlers.layerSnapshot), +const layerPreviewSnapshotRegistration = imageToolRegistration( + registerPreviewSnapshot(), + PreviewHandlers.layerSnapshot, ); export const layerPreviewToolkit = Layer.mergeAll( @@ -707,47 +738,48 @@ export const layerPreviewToolkit = Layer.mergeAll( layerPreviewSnapshotRegistration, ); -export const layerOrchestratorToolkit = McpServer.toolkit(OrchestratorToolkit).pipe( - Layer.provide(OrchestratorHandlers.layer), - Layer.provide(OrchestratorMcpService.layer), - Layer.provide(ThreadMetadataMcpService.layer), -); +export const layerOrchestratorToolkit = toolkitRegistration( + OrchestratorToolkit, + OrchestratorHandlers.layer, +).pipe(Layer.provide(OrchestratorMcpService.layer), Layer.provide(ThreadMetadataMcpService.layer)); -export const layerThreadToolkit = McpServer.toolkit(ThreadToolkit).pipe( - Layer.provide(ThreadHandlers.layer), -); +export const layerThreadToolkit = toolkitRegistration(ThreadToolkit, ThreadHandlers.layer); -const layerWorktreeToolkitRegistration = McpServer.toolkit(WorktreeToolkit).pipe( - Layer.provide(WorktreeHandlers.layer), - Layer.provide(WorktreeMcpService.layer), -); +const layerWorktreeToolkitRegistration = toolkitRegistration( + WorktreeToolkit, + WorktreeHandlers.layer, +).pipe(Layer.provide(WorktreeMcpService.layer)); -const layerPreviewControlsRegistration = McpServer.toolkit(PreviewControlsToolkit).pipe( - Layer.provide(PreviewControlsHandlers.layer), +const layerPreviewControlsRegistration = toolkitRegistration( + PreviewControlsToolkit, + PreviewControlsHandlers.layer, ); -const layerEnvironmentRegistration = McpServer.toolkit(EnvironmentToolkit).pipe( - Layer.provide(EnvironmentHandlers.layer), +const layerEnvironmentRegistration = toolkitRegistration( + EnvironmentToolkit, + EnvironmentHandlers.layer, ); -const layerProjectRegistration = McpServer.toolkit(ProjectToolkit).pipe( - Layer.provide(ProjectHandlers.layer), -); +const layerProjectRegistration = toolkitRegistration(ProjectToolkit, ProjectHandlers.layer); -const layerAttachmentRegistration = McpServer.toolkit(AttachmentToolkit).pipe( - Layer.provide(AttachmentHandlers.layer), +const layerAttachmentRegistration = toolkitRegistration( + AttachmentToolkit, + AttachmentHandlers.layer, ); -export const layerPullRequestsToolkit = McpServer.toolkit(PullRequestsToolkit).pipe( - Layer.provide(PullRequestsHandlers.layer), +export const layerPullRequestsToolkit = toolkitRegistration( + PullRequestsToolkit, + PullRequestsHandlers.layer, ); -const layerDeviceStandardToolkitRegistration = McpServer.toolkit(DeviceStandardToolkit).pipe( - Layer.provide(DeviceHandlers.layerStandard), +const layerDeviceStandardToolkitRegistration = toolkitRegistration( + DeviceStandardToolkit, + DeviceHandlers.layerStandard, ); -const layerDeviceScreenshotRegistration = Layer.effectDiscard(registerDeviceScreenshot()).pipe( - Layer.provide(DeviceHandlers.layerScreenshot), +const layerDeviceScreenshotRegistration = imageToolRegistration( + registerDeviceScreenshot(), + DeviceHandlers.layerScreenshot, ); export const layerDeviceToolkit = Layer.mergeAll( diff --git a/apps/server/src/mcp/McpToolAccess.race.test.ts b/apps/server/src/mcp/McpToolAccess.race.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..baffc00659ebe9b97c2ff7335c41be0d91e0edbc --- /dev/null +++ b/apps/server/src/mcp/McpToolAccess.race.test.ts @@ -0,0 +1,200 @@ +import { assert, it } from "@effect/vitest"; +import { + CommandId, + EnvironmentId, + OrchestratorMcpFailure, + ProjectId, + ProviderDriverKind, + ProviderInstanceId, + ThreadId, +} from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Ref from "effect/Ref"; +import * as Schema from "effect/Schema"; +import { McpSchema, McpServer, Tool, Toolkit } from "effect/ai"; + +import * as SqlitePersistence from "../persistence/Sqlite.ts"; +import { DispatchModeLimit } from "../orchestration-v2/DispatchModeLimit.ts"; +import { CodexProviderCapabilitiesV2 } from "../orchestration-v2/Adapters/CodexAdapterV2.ts"; +import * as Orchestrator from "../orchestration-v2/Orchestrator.ts"; +import * as ProjectionStore from "../orchestration-v2/ProjectionStore.ts"; +import type { ProviderAdapterV2Shape } from "../orchestration-v2/ProviderAdapter.ts"; +import * as ProviderAdapterRegistry from "../orchestration-v2/ProviderAdapterRegistry.ts"; +import * as ThreadManagement from "../orchestration-v2/ThreadManagementService.ts"; +import * as ProviderReplayHarness from "../orchestration-v2/testkit/ProviderReplayHarness.ts"; +import * as McpHttpServer from "./McpHttpServer.ts"; +import * as McpInvocationContext from "./McpInvocationContext.ts"; +import * as McpToolAccess from "./McpToolAccess.ts"; + +// A Supervised outside agent renames a thread through a `writesThreads` tool. +// The thread's user raises it to full access after the tool's check but +// before its write: the race the orchestrator closes under the thread's lock. + +const instanceId = ProviderInstanceId.make("codex"); +const adapter = { + instanceId, + driver: ProviderDriverKind.make("codex"), + getCapabilities: () => Effect.succeed(CodexProviderCapabilitiesV2), + planSelectionTransition: () => Effect.succeed({ type: "apply_on_next_turn" as const }), + openSession: () => Effect.die("No provider process needed for metadata commands"), +} as ProviderAdapterV2Shape; +const layerDatabase = SqlitePersistence.layerMemory; +const layerOrchestrator = Layer.mergeAll( + layerDatabase, + ProjectionStore.layer.pipe(Layer.provide(layerDatabase)), + ProviderReplayHarness.layerWithRegistry( + { name: "mcp-mode-race" }, + ProviderAdapterRegistry.layerFromAdapters([adapter]), + { databaseLayer: layerDatabase, runEffectWorker: false }, + ), +); + +const RenameTool = Tool.make("rename", { + parameters: Schema.Struct({ threadId: ThreadId }), + success: Schema.Struct({ renamed: Schema.Boolean }), + failure: OrchestratorMcpFailure, + failureMode: "return", + dependencies: [ + McpInvocationContext.McpInvocationContext, + ThreadManagement.ThreadManagementService, + ], +}); +const RenameToolkit = Toolkit.make(RenameTool); + +const supervisedClient: McpInvocationContext.McpInvocationScope = { + environmentId: EnvironmentId.make("environment"), + requestNamespace: "client:race", + thread: undefined, + client: { sessionId: "race", label: "Claude Code", runtimeModeCeiling: "approval-required" }, + capabilities: new Set(["orchestration"]), + issuedAt: 0, +}; + +const mcpClient = McpSchema.McpServerClient.of({ + clientId: 1, + protocolVersion: "2025-06-18", + clientCapabilities: {}, + clientInfo: { name: "mcp-race", version: "1" }, + initializePayload: { + protocolVersion: "2025-06-18", + capabilities: {}, + clientInfo: { name: "mcp-race", version: "1" }, + }, + getClient: Effect.die("unused"), +}); + +const decodeOutcome = Schema.decodeUnknownEffect( + Schema.fromJsonString( + Schema.Union([ + Schema.Struct({ renamed: Schema.Boolean }), + Schema.Struct({ code: Schema.String }), + ]), + ), +); + +/** Renames `threadId` as the Supervised client; the user may raise it in between. */ +const renameRacingTheUser = (threadId: ThreadId, userRaises: boolean) => + Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const threads = yield* ThreadManagement.ThreadManagementService; + const raised = yield* Ref.make(false); + const handlers = McpToolAccess.toLayer(RenameToolkit, { + rename: McpToolAccess.writesThreads( + (input) => [input.threadId], + (input) => + Effect.gen(function* () { + // The tool's own check has passed; the user acts before the write. + if (userRaises) { + // The user's own command carries no limit, so it lands. + yield* orchestrator + .dispatch({ + type: "thread.runtime-mode.set", + commandId: CommandId.make("user-raise"), + threadId: input.threadId, + runtimeMode: "full-access", + }) + .pipe(Effect.provideService(DispatchModeLimit, undefined), Effect.orDie); + yield* Ref.set(raised, true); + } + yield* threads + .dispatch({ + type: "thread.metadata.update", + commandId: CommandId.make(`agent-rename:${userRaises}`), + threadId: input.threadId, + title: "Renamed by the agent", + }) + .pipe( + Effect.mapError( + () => + new OrchestratorMcpFailure({ + code: "orchestration_error", + message: "The rename failed.", + }), + ), + ); + return { renamed: true }; + }), + ), + }); + const outcome = yield* McpServer.McpServer.pipe( + Effect.flatMap((server) => server.callTool({ name: "rename", arguments: { threadId } })), + Effect.flatMap(({ content }) => + decodeOutcome(content.map((part) => (part.type === "text" ? part.text : "")).join("")), + ), + Effect.provideService(McpInvocationContext.McpInvocationContext, supervisedClient), + Effect.provideService(McpSchema.McpServerClient, mcpClient), + Effect.provide( + McpHttpServer.toolkitRegistration(RenameToolkit, handlers).pipe( + Layer.provideMerge(McpServer.McpServer.layer), + ), + ), + ); + assert.equal(yield* Ref.get(raised), userRaises); + return outcome; + }); + +const createSupervisedThread = (threadId: ThreadId) => + Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + yield* orchestrator.dispatch({ + type: "thread.create", + commandId: CommandId.make(`create:${threadId}`), + threadId, + projectId: ProjectId.make("project:mcp-race"), + title: "Before", + modelSelection: { instanceId, model: "gpt-5" }, + runtimeMode: "approval-required", + interactionMode: "default", + branch: null, + worktreePath: null, + createdBy: "user", + creationSource: "web", + }); + }); + +it.layer(layerOrchestrator)("writesThreads against a mode raise", (it) => { + it.effect("refuses the write when the user raises the thread after the check", () => + Effect.gen(function* () { + const projections = yield* ProjectionStore.ProjectionStoreV2; + const threadId = ThreadId.make("thread:race-raised"); + yield* createSupervisedThread(threadId); + const outcome = yield* renameRacingTheUser(threadId, true); + assert.deepEqual(outcome, { code: "runtime_mode_escalation_denied" }); + const shell = yield* projections.getThreadShell(threadId); + assert.equal(shell?.runtimeMode, "full-access"); + assert.equal(shell?.title, "Before"); + }).pipe(Effect.provide(ThreadManagement.layer)), + ); + + it.effect("lets the write through when the thread stays within the caller's modes", () => + Effect.gen(function* () { + const projections = yield* ProjectionStore.ProjectionStoreV2; + const threadId = ThreadId.make("thread:race-steady"); + yield* createSupervisedThread(threadId); + const outcome = yield* renameRacingTheUser(threadId, false); + assert.deepEqual(outcome, { renamed: true }); + assert.equal((yield* projections.getThreadShell(threadId))?.title, "Renamed by the agent"); + }).pipe(Effect.provide(ThreadManagement.layer)), + ); +}); diff --git a/apps/server/src/mcp/McpToolAccess.test.ts b/apps/server/src/mcp/McpToolAccess.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..3139faf27d546549f2445a2f501d3e77a1e4677c --- /dev/null +++ b/apps/server/src/mcp/McpToolAccess.test.ts @@ -0,0 +1,306 @@ +import { expect, it } from "@effect/vitest"; +import { + EnvironmentId, + OrchestratorMcpFailure, + ProviderInstanceId, + ProviderInteractionMode, + RuntimeMode, + ThreadId, +} from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Schema from "effect/Schema"; +import { McpSchema, McpServer, Tool, Toolkit } from "effect/ai"; + +import { OrchestratorProjectionError } from "../orchestration-v2/Orchestrator.ts"; +import * as ThreadManagement from "../orchestration-v2/ThreadManagementService.ts"; +import * as McpHttpServer from "./McpHttpServer.ts"; +import * as McpInvocationContext from "./McpInvocationContext.ts"; +import * as McpToolAccess from "./McpToolAccess.ts"; +import { liveThreadShell } from "./McpToolAccess.testkit.ts"; + +const supervisedThreadId = ThreadId.make("thread:supervised"); +const planThreadId = ThreadId.make("thread:plan"); +const fullAccessThreadId = ThreadId.make("thread:full-access"); +const endedThreadId = ThreadId.make("thread:ended"); + +const shells = new Map([ + [supervisedThreadId, liveThreadShell(supervisedThreadId, { runtimeMode: "approval-required" })], + [ + planThreadId, + liveThreadShell(planThreadId, { runtimeMode: "approval-required", interactionMode: "plan" }), + ], + [fullAccessThreadId, liveThreadShell(fullAccessThreadId)], + [endedThreadId, liveThreadShell(endedThreadId, { activeRunId: null })], +]); + +const threadCaller = (threadId: ThreadId): McpInvocationContext.McpInvocationScope => ({ + environmentId: EnvironmentId.make("environment"), + requestNamespace: `provider:${threadId}`, + thread: { + threadId, + providerSessionId: `provider:${threadId}`, + providerInstanceId: ProviderInstanceId.make("codex"), + }, + client: undefined, + capabilities: new Set(["orchestration"]), + issuedAt: 0, +}); + +const clientCaller = ( + runtimeModeCeiling: RuntimeMode, +): McpInvocationContext.McpInvocationScope => ({ + environmentId: EnvironmentId.make("environment"), + requestNamespace: "client:session", + thread: undefined, + client: { sessionId: "session", label: "Claude Code", runtimeModeCeiling }, + capabilities: new Set(["orchestration"]), + issuedAt: 0, +}); + +const mcpClient = McpSchema.McpServerClient.of({ + clientId: 1, + protocolVersion: "2025-06-18", + clientCapabilities: {}, + clientInfo: { name: "mcp-test", version: "1" }, + initializePayload: { + protocolVersion: "2025-06-18", + capabilities: {}, + clientInfo: { name: "mcp-test", version: "1" }, + }, + getClient: Effect.die("unused"), +}); + +// One tool per declaration, each answering with the modes it was handed, so +// the table below reads what the gate let through. +const probe = { + success: Schema.Struct({ ran: Schema.String }), + failure: OrchestratorMcpFailure, + failureMode: "return" as const, + dependencies: [ + McpInvocationContext.McpInvocationContext, + ThreadManagement.ThreadManagementService, + ], +}; +const ProbeToolkit = Toolkit.make( + Tool.make("reads", probe), + Tool.make("reads_as_caller", probe), + Tool.make("acts_as_caller", probe), + Tool.make("writes", probe), + Tool.make("writes_threads", { + ...probe, + parameters: Schema.Struct({ threadId: Schema.optional(ThreadId) }), + }), + Tool.make("starts_threads", { + ...probe, + parameters: Schema.Struct({ + runtimeMode: Schema.optional(RuntimeMode), + interactionMode: Schema.optional(ProviderInteractionMode), + }), + }), + Tool.make("writes_environment", probe), +); +const ran = Effect.succeed({ ran: "ran" }); +const probeHandlers: McpToolAccess.Handlers = { + reads: McpToolAccess.reads(() => ran), + reads_as_caller: McpToolAccess.readsAsCaller(() => ran), + acts_as_caller: McpToolAccess.actsAsCaller(() => ran), + writes: McpToolAccess.writes(() => ran), + writes_threads: McpToolAccess.writesThreads( + (input) => [input.threadId], + () => ran, + ), + starts_threads: McpToolAccess.startsThreads( + (input) => input, + (_, modes) => Effect.succeed({ ran: `${modes.runtimeMode}/${modes.interactionMode}` }), + ), + writes_environment: McpToolAccess.writesEnvironment(() => ran), +}; +const layerProbeHandlers = McpToolAccess.toLayer(ProbeToolkit, probeHandlers); + +const unchecked = () => ran; + +// What the compiler refuses: each line below must fail to typecheck, and its +// `@ts-expect-error` fails the build if one ever compiles. Never called. +export const refusedAtCompileTime = () => { + McpToolAccess.toLayer(ProbeToolkit, { + ...probeHandlers, + // @ts-expect-error a handler that skips its declaration + reads: unchecked, + }); + McpToolAccess.toLayer(ProbeToolkit, { + ...probeHandlers, + // @ts-expect-error a declaration's fields copied onto an unchecked handler + reads: Object.assign( + unchecked, + McpToolAccess.reads(() => ran), + ), + }); + // @ts-expect-error a declaration built outside McpToolAccess + McpToolAccess.Declaration.make(unchecked); + // @ts-expect-error a declaration constructed directly + new McpToolAccess.Declaration(unchecked); + // @ts-expect-error a handlers layer built outside McpToolAccess + McpToolAccess.HandlersLayer.make(Layer.empty); + // The refused layer below types its error and services as unknown, which is fine here. + // @effect-diagnostics-next-line anyUnknownInErrorContext:off + McpHttpServer.toolkitRegistration( + ProbeToolkit, + // @ts-expect-error a handlers layer that skipped `McpToolAccess.toLayer` + ProbeToolkit.toLayer({ + reads: unchecked, + reads_as_caller: unchecked, + acts_as_caller: unchecked, + writes: unchecked, + writes_threads: unchecked, + starts_threads: unchecked, + writes_environment: unchecked, + }), + ); +}; + +const probeServer = (threads: Layer.Layer) => + McpHttpServer.toolkitRegistration(ProbeToolkit, layerProbeHandlers).pipe( + Layer.provideMerge(McpServer.McpServer.layer), + Layer.provideMerge(threads), + ); + +/** A probe's answer, or the gate's refusal, as the result's JSON text. */ +const decodeOutcome = Schema.decodeUnknownEffect( + Schema.fromJsonString( + Schema.Union([Schema.Struct({ ran: Schema.String }), Schema.Struct({ code: Schema.String })]), + ), +); + +const call = ( + tool: string, + scope: McpInvocationContext.McpInvocationScope, + args: Record = {}, +) => + McpServer.McpServer.pipe( + Effect.flatMap((server) => server.callTool({ name: tool, arguments: args })), + Effect.flatMap(({ content }) => + decodeOutcome(content.map((part) => (part.type === "text" ? part.text : "")).join("")), + ), + Effect.map((outcome) => ("code" in outcome ? outcome.code : outcome.ran)), + Effect.provideService(McpInvocationContext.McpInvocationContext, scope), + Effect.provideService(McpSchema.McpServerClient, mcpClient), + ); + +const supervised = threadCaller(supervisedThreadId); +const plan = threadCaller(planThreadId); +const fullAccess = threadCaller(fullAccessThreadId); +const ended = threadCaller(endedThreadId); +const supervisedClient = clientCaller("approval-required"); +const fullAccessClient = clientCaller("full-access"); +// A live full-access thread whose credential may use its browser but not control threads. +const previewOnly = { ...fullAccess, capabilities: new Set(["preview"] as const) }; + +it.effect.each([ + // Reads are open to every caller, even one whose turn ended. + ["reads", ended, {}, "ran"], + ["reads", supervisedClient, {}, "ran"], + + // What belongs to the calling thread needs one; changing it needs its live turn. + ["reads_as_caller", ended, {}, "ran"], + ["reads_as_caller", fullAccessClient, {}, "thread_credential_required"], + ["acts_as_caller", supervised, {}, "ran"], + ["acts_as_caller", ended, {}, "parent_not_active"], + ["acts_as_caller", fullAccessClient, {}, "thread_credential_required"], + + // Any change needs a thread caller's live turn; a client has none to lose. + ["writes", supervised, {}, "ran"], + ["writes", ended, {}, "parent_not_active"], + ["writes", supervisedClient, {}, "ran"], + // Uploads, scheduled tasks, projects and settings need a credential that controls threads. + ["writes", previewOnly, {}, "capability_denied"], + ["writes_environment", previewOnly, {}, "capability_denied"], + + // Changing a thread: only one that runs within the caller's modes. + ["writes_threads", supervised, {}, "ran"], + ["writes_threads", supervised, { threadId: planThreadId }, "ran"], + [ + "writes_threads", + supervised, + { threadId: fullAccessThreadId }, + "runtime_mode_escalation_denied", + ], + ["writes_threads", plan, { threadId: supervisedThreadId }, "interaction_mode_escalation_denied"], + ["writes_threads", fullAccess, { threadId: supervisedThreadId }, "ran"], + ["writes_threads", ended, { threadId: supervisedThreadId }, "parent_not_active"], + ["writes_threads", supervisedClient, { threadId: supervisedThreadId }, "ran"], + [ + "writes_threads", + supervisedClient, + { threadId: fullAccessThreadId }, + "runtime_mode_escalation_denied", + ], + // A thread that does not exist is the tool's to report. + ["writes_threads", supervised, { threadId: "thread:missing" }, "ran"], + + // Starting a thread: the caller's own modes or narrower. + ["starts_threads", supervised, {}, "approval-required/default"], + ["starts_threads", supervised, { runtimeMode: "full-access" }, "runtime_mode_escalation_denied"], + ["starts_threads", plan, {}, "approval-required/plan"], + ["starts_threads", plan, { interactionMode: "default" }, "interaction_mode_escalation_denied"], + ["starts_threads", fullAccess, { runtimeMode: "auto", interactionMode: "plan" }, "auto/plan"], + ["starts_threads", ended, {}, "parent_not_active"], + ["starts_threads", supervisedClient, {}, "approval-required/default"], + ["starts_threads", supervisedClient, { runtimeMode: "auto" }, "runtime_mode_escalation_denied"], + ["starts_threads", fullAccessClient, { runtimeMode: "full-access" }, "full-access/default"], + + // Projects and environment settings need full access. + ["writes_environment", fullAccess, {}, "ran"], + ["writes_environment", supervised, {}, "capability_denied"], + ["writes_environment", plan, {}, "capability_denied"], + ["writes_environment", fullAccessClient, {}, "ran"], + ["writes_environment", supervisedClient, {}, "capability_denied"], +] as const)("%s from %o with %o: %s", ([tool, scope, args, expected]) => + call(tool, scope, args).pipe( + Effect.map((outcome) => expect(outcome).toBe(expected)), + Effect.provide( + probeServer( + Layer.mock(ThreadManagement.ThreadManagementService)({ + getThreadShell: (threadId) => Effect.succeed(shells.get(threadId) ?? null), + }), + ), + ), + ), +); + +it("refuses a declaration or handlers layer built outside McpToolAccess", () => { + // Private constructors only bind the type checker; Reflect.construct reaches them anyway. + expect(() => Reflect.construct(McpToolAccess.Declaration, [unchecked])).toThrow(TypeError); + expect(() => Reflect.construct(McpToolAccess.HandlersLayer, [Layer.empty])).toThrow(TypeError); +}); + +it("refuses a declaration or handlers layer that only wears another's fields", () => { + // The types already refuse these; at runtime the private fields are missing too. + const copiedDeclaration = Object.assign( + unchecked, + McpToolAccess.reads(() => ran), + ); + expect(() => + McpToolAccess.toLayer(ProbeToolkit, { + ...probeHandlers, + // @ts-expect-error a declaration's fields copied onto an unchecked handler + reads: copiedDeclaration, + }), + ).toThrow(TypeError); + const copiedLayer = Object.assign(Layer.empty, layerProbeHandlers); + expect(() => McpToolAccess.HandlersLayer.layer(copiedLayer)).toThrow(TypeError); +}); + +it.effect("refuses a change when the calling thread cannot be read", () => + call("writes", supervised).pipe( + Effect.map((outcome) => expect(outcome).toBe("orchestration_error")), + Effect.provide( + probeServer( + Layer.mock(ThreadManagement.ThreadManagementService)({ + getThreadShell: (threadId) => + Effect.fail(new OrchestratorProjectionError({ threadId, cause: "unreadable" })), + }), + ), + ), + ), +); diff --git a/apps/server/src/mcp/McpToolAccess.testkit.ts b/apps/server/src/mcp/McpToolAccess.testkit.ts new file mode 100644 index 0000000000000000000000000000000000000000..687b34cac2ff834ad996d5e9ec7e9990473da2c7 --- /dev/null +++ b/apps/server/src/mcp/McpToolAccess.testkit.ts @@ -0,0 +1,115 @@ +import { + type OrchestrationV2ThreadProjection, + type OrchestrationV2ThreadShell, + ProjectId, + type ProviderInteractionMode, + ProviderInstanceId, + RunId, + type RuntimeMode, + type ThreadId, +} from "@t3tools/contracts"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; + +import * as ThreadManagement from "../orchestration-v2/ThreadManagementService.ts"; + +const EPOCH = DateTime.makeUnsafe("2026-01-01T00:00:00.000Z"); + +/** + * A thread in the middle of a turn on the `codex` instance: the shape the + * access gate reads for a thread caller, and for the threads a caller targets. + */ +export const liveThreadShell = ( + id: ThreadId, + modes: { + readonly runtimeMode?: RuntimeMode; + readonly interactionMode?: ProviderInteractionMode; + readonly activeRunId?: RunId | null; + } = {}, +): OrchestrationV2ThreadShell => { + const providerInstanceId = ProviderInstanceId.make("codex"); + return { + id, + projectId: ProjectId.make("project:mcp-test"), + title: id, + providerInstanceId, + modelSelection: { instanceId: providerInstanceId, model: "gpt-5" }, + runtimeMode: modes.runtimeMode ?? "full-access", + interactionMode: modes.interactionMode ?? "default", + branch: null, + worktreePath: null, + createdBy: "user", + creationSource: "web", + activeProviderThreadId: null, + lineage: { rootThreadId: id, parentThreadId: null, relationshipToParent: null }, + forkedFrom: null, + latestRunId: null, + activeRunId: modes.activeRunId === undefined ? RunId.make("run:mcp-test") : modes.activeRunId, + status: "running", + pendingRuntimeRequest: null, + latestVisibleMessage: null, + latestUserMessageAt: null, + hasActionableProposedPlan: false, + itemCount: 0, + visibleItemCount: 0, + createdAt: EPOCH, + updatedAt: EPOCH, + archivedAt: null, + settledOverride: null, + settledAt: null, + lastVisitedAt: null, + deletedAt: null, + }; +}; + +/** + * `ThreadManagementService` that answers only the access gate's lookups, + * every thread a live full-access one. For tests of a tool rather than of who + * may call it. + */ +export const liveThreadsLayer = Layer.mock(ThreadManagement.ThreadManagementService)({ + getThreadShell: (threadId) => Effect.succeed(liveThreadShell(threadId)), +}); + +/** + * The thread of `shell` with no runs, messages or other records: what a + * caller looks like once its turn has ended. + */ +export const idleThreadProjection = ( + shell: OrchestrationV2ThreadShell, +): OrchestrationV2ThreadProjection => { + const { + status: _status, + activeRunId: _activeRunId, + latestRunId: _latestRunId, + pendingRuntimeRequest: _pendingRuntimeRequest, + latestVisibleMessage: _latestVisibleMessage, + latestUserMessageAt: _latestUserMessageAt, + hasActionableProposedPlan: _hasActionableProposedPlan, + itemCount: _itemCount, + visibleItemCount: _visibleItemCount, + lastVisitedAt, + ...thread + } = shell; + return { + thread: { ...thread, lastVisitedAt: lastVisitedAt ?? null }, + runs: [], + attempts: [], + nodes: [], + subagents: [], + providerSessions: [], + providerThreads: [], + providerTurns: [], + runtimeRequests: [], + messages: [], + plans: [], + turnItems: [], + checkpointScopes: [], + checkpoints: [], + contextHandoffs: [], + contextTransfers: [], + visibleTurnItems: [], + updatedAt: shell.updatedAt, + }; +}; diff --git a/apps/server/src/mcp/McpToolAccess.ts b/apps/server/src/mcp/McpToolAccess.ts new file mode 100644 index 0000000000000000000000000000000000000000..b6c623a11901018ce8db1a91395a59be73db1b52 --- /dev/null +++ b/apps/server/src/mcp/McpToolAccess.ts @@ -0,0 +1,295 @@ +import { + OrchestratorMcpFailure, + type ProviderInteractionMode, + type RuntimeMode, + type ThreadId, +} from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Ref from "effect/Ref"; +import * as Struct from "effect/Struct"; +import type * as Layer from "effect/Layer"; +import type * as Scope from "effect/Scope"; +import type { Tool, Toolkit } from "effect/ai"; + +import { + DispatchModeLimit, + type DispatchModeRefusal, +} from "../orchestration-v2/DispatchModeLimit.ts"; +import * as McpInvocationContext from "./McpInvocationContext.ts"; +import { resolveInteractionMode, resolveRuntimeMode } from "./OrchestratorMcpService.ts"; +import { + assertFullAccess, + assertLiveCaller, + assertTargetWithinLimits, + type Caller, + loadCaller, + readCaller, + unavailable, +} from "./threadAccess.ts"; + +// Only the classes below assign these, from their static blocks, so nothing +// outside this module can build a declaration or a handlers layer. + +/** + * Both constructors demand this. Their private constructors only stop the type + * checker; this also stops `Reflect.construct` and friends at runtime. + */ +const builtHere: unique symbol = Symbol("t3/mcp/McpToolAccess/builtHere"); + +const refuseOutsideConstruction = (token: symbol) => { + if (token !== builtHere) { + throw new TypeError("Only McpToolAccess builds MCP tool declarations and handler layers."); + } +}; + +/** Builds a declaration; only the declaration functions below call it. */ +let declare: ( + handle: (params: P) => Effect.Effect, +) => Declaration<(params: P) => Effect.Effect>; + +type CheckedHandlerOf = D extends Declaration ? Handler : never; + +/** Turns each declaration back into the handler it checks, keeping its type. */ +interface CheckedHandler extends Struct.Lambda { + (declaration: Declaration): Handler; + readonly "~lambda.out": CheckedHandlerOf; +} +let checkedHandler: CheckedHandler; + +/** Only `toLayer` below builds one. */ +let handlersLayer: , EX, RX>( + layer: Layer.Layer, EX, RX>, +) => HandlersLayer; + +/** + * Who may call a T3 MCP tool. Every handler is built by one of the + * declarations below, which say what the tool does. `toLayer` accepts only + * declarations, and `/mcp` registers only layers `toLayer` built, so a tool + * without a decision here does not compile. Both are nominal classes, so a + * handler or layer cannot pass for one by copying its fields. Effect's own + * registration functions accept any handler, so the + * `t3code/no-raw-mcp-registration` lint rule keeps Effect's `McpServer` inside + * McpHttpServer. + * + * Parameters choose the target; the caller sets the limits: a thread caller + * its own runtime and interaction modes, an outside client the ceiling it was + * approved with. Nothing a caller starts or changes may run with broader + * modes, and a thread caller changes things only while its own run is live. + * A refusal is an `OrchestratorMcpFailure`, so the compiler requires it in the + * tool's failure schema, and `ThreadManagementService` in its dependencies. + * + * The declaration checks the caller before the handler runs. Handlers still + * check what only they can see, such as a queued run belonging to its thread. + */ +export class Declaration { + // A private field makes the class nominal, and only this module can build + // one: its constructor is private and the static block hands the only way + // in to module-scoped functions. Copying a declaration's fields onto + // anything else fails to typecheck and, at runtime, to run. + readonly #handle: Handler; + private constructor(token: typeof builtHere, handle: Handler) { + refuseOutsideConstruction(token); + this.#handle = handle; + } + static { + declare = (handle) => new Declaration(builtHere, handle); + checkedHandler = Struct.lambda((declaration) => declaration.#handle); + } +} + +/** + * The caller of a tool that changes something. Tools that act for a + * capability of their own (preview, device, worktree, pull requests) check it + * themselves. + */ +const writingCaller = loadCaller().pipe(Effect.tap(assertLiveCaller)); + +/** The same, for tools whose only capability is controlling threads. */ +const orchestratingCaller = readCaller().pipe(Effect.tap(assertLiveCaller)); + +const requireThreadCaller = McpInvocationContext.McpInvocationContext.pipe( + Effect.flatMap((scope) => McpInvocationContext.requireThreadScope(scope, "This tool")), +); + +/** Changes nothing, so every caller may call it. */ +export const reads = (handle: (params: P) => Effect.Effect) => + declare((params: P) => handle(params)); + +/** Reads what belongs to the calling T3 thread, such as its preview tabs or devices. */ +export const readsAsCaller = (handle: (params: P) => Effect.Effect) => + declare((params: P) => requireThreadCaller.pipe(Effect.flatMap(() => handle(params)))); + +/** + * Acts as the calling T3 thread (its subagents, preview tabs, devices, + * worktree) while that thread's run is live. Only an agent running inside a + * T3 thread has one. + */ +export const actsAsCaller = (handle: (params: P) => Effect.Effect) => + declare((params: P) => + requireThreadCaller.pipe( + Effect.flatMap(() => writingCaller), + Effect.flatMap(() => handle(params)), + ), + ); + +/** Changes something that belongs to no thread, such as a pending upload or a scheduled task. */ +export const writes = (handle: (params: P) => Effect.Effect) => + declare((params: P) => orchestratingCaller.pipe(Effect.flatMap(() => handle(params)))); + +/** + * Changes the threads `threads` names. An omitted id is the caller's own + * thread; any other thread must run within the caller's modes, checked here + * and again by the orchestrator when it applies each command. A thread that + * does not exist is the handler's to report. + */ +export const writesThreads = ( + threads: (params: P) => ReadonlyArray, + handle: (params: P) => Effect.Effect, +) => + declare((params: P) => + Effect.gen(function* () { + const caller = yield* writingCaller; + for (const threadId of threads(params)) { + if (threadId === undefined || threadId === caller.scope.thread?.threadId) continue; + const target = yield* caller.threads + .getThreadShell(threadId) + .pipe(Effect.mapError(unavailable)); + if (target !== null && target.deletedAt === null) { + yield* assertTargetWithinLimits(caller.limits, target); + } + } + // The target's user can raise its modes after the check above; the + // orchestrator checks again under the thread's lock and records its + // refusal here, since handlers wrap dispatch errors their own way. + const refused = yield* Ref.make(undefined); + return yield* handle(params).pipe( + Effect.provideService(DispatchModeLimit, { ...caller.limits, refused }), + Effect.catch((error) => + Effect.flatMap(Ref.get(refused), (refusal) => + Effect.fail( + refusal === undefined ? error : escalationDenied(refusal), + ), + ), + ), + ); + }), + ); + +/** How an agent hears that a thread it targets was raised above its modes mid-call. */ +const escalationDenied = (refusal: DispatchModeRefusal) => + new OrchestratorMcpFailure({ + code: + refusal.mode === "runtime" + ? "runtime_mode_escalation_denied" + : "interaction_mode_escalation_denied", + message: `Thread ${refusal.threadId} now runs in ${refusal.runtimeMode}/${refusal.interactionMode} mode, above this caller's. Its user changed it while this call ran.`, + }); + +/** The modes a started thread runs with: those requested, else the caller's own. */ +export interface StartedModes { + readonly runtimeMode: RuntimeMode; + readonly interactionMode: ProviderInteractionMode; +} + +/** Starts threads with the modes `modes` requests, which may not be broader than the caller's. */ +export const startsThreads = ( + modes: (params: P) => { + readonly runtimeMode?: RuntimeMode | undefined; + readonly interactionMode?: ProviderInteractionMode | undefined; + }, + handle: (params: P, modes: StartedModes) => Effect.Effect, +) => + declare((params: P) => + Effect.gen(function* () { + const { limits } = yield* writingCaller; + const requested = modes(params); + const started: StartedModes = { + runtimeMode: yield* resolveRuntimeMode(limits.runtimeMode, requested.runtimeMode), + interactionMode: yield* resolveInteractionMode( + limits.interactionMode, + requested.interactionMode, + ), + }; + return yield* handle(params, started); + }), + ); + +const fullAccessRequired = + "Changing projects or environment settings needs a live full-access/default calling thread or a full-access client."; + +/** + * Changes projects or environment settings, which needs a full-access/default + * caller. `check` re-checks the caller wherever the handler waits before + * writing, such as for a lock, since the caller's modes can change meanwhile. + */ +export const writesEnvironment = ( + handle: ( + params: P, + check: Effect.Effect, + ) => Effect.Effect, +) => { + const check = orchestratingCaller.pipe( + Effect.tap((caller) => assertFullAccess(caller, fullAccessRequired)), + ); + return declare((params: P) => check.pipe(Effect.flatMap(() => handle(params, check)))); +}; + +/** What a declaration's own check needs. */ +type CheckServices = Effect.Services; + +/** + * Each handler of `Handlers`, built by one of the declarations above. A + * declaration is never callable, which rules out a handler function wearing a + * declaration's fields. + */ +type Declarations = { + readonly [Name in keyof Handlers]: Declaration & NotCallable; +}; + +/** Anything but a function. */ +type NotCallable = { readonly call?: never } & { readonly apply?: never }; + +/** A toolkit's handlers, each built by one of the declarations above. */ +export type Handlers> = Declarations< + Toolkit.HandlersFrom +>; + +/** + * A toolkit's handler layer built by `toLayer`, the only kind `/mcp` + * registers. Like a declaration it is nominal, so nothing else passes for one. + */ +export class HandlersLayer, EX = never, RX = never> { + readonly #layer: Layer.Layer, EX, RX>; + private constructor( + token: typeof builtHere, + layer: Layer.Layer, EX, RX>, + ) { + refuseOutsideConstruction(token); + this.#layer = layer; + } + static { + handlersLayer = (layer) => new HandlersLayer(builtHere, layer); + } + /** The handlers, for registering this toolkit on the MCP server. */ + static layer, EX, RX>( + handlers: HandlersLayer, + ) { + return handlers.#layer; + } +} + +const checkedHandlers = (declarations: Declarations): Handlers => + Struct.map(declarations, checkedHandler); + +/** `Toolkit.toLayer` for handlers that all declare their access. */ +export const toLayer = , EX = never, RX = never>( + toolkit: Toolkit.Toolkit, + build: Handlers | Effect.Effect, EX, RX>, +): HandlersLayer> => + handlersLayer( + toolkit.toLayer( + Effect.isEffect(build) + ? Effect.map(build, checkedHandlers>) + : checkedHandlers(build), + ), + ); diff --git a/apps/server/src/mcp/OrchestratorMcpService.test.ts b/apps/server/src/mcp/OrchestratorMcpService.test.ts index a73e4599db12f56136bade97af9ccfeafc0d3b6a..dfad03f9974f923247e3c63b628837ae3b829a53 100644 --- a/apps/server/src/mcp/OrchestratorMcpService.test.ts +++ b/apps/server/src/mcp/OrchestratorMcpService.test.ts @@ -1,8 +1,12 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, describe, it } from "@effect/vitest"; import { + CommandId, EnvironmentId, NodeId, + type OrchestrationV2ThreadShell, + type ScheduledTask, + ScheduledTaskId, ProjectId, ProviderDriverKind, ProviderInstanceId, @@ -16,7 +20,14 @@ import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Ref from "effect/Ref"; -import { OrchestratorProjectionError } from "../orchestration-v2/Orchestrator.ts"; +import { + DispatchModeLimit, + type DispatchModeRefusal, +} from "../orchestration-v2/DispatchModeLimit.ts"; +import { + OrchestratorProjectionError, + OrchestratorThreadAboveModeLimitError, +} from "../orchestration-v2/Orchestrator.ts"; import type { ProviderAdapterV2Shape } from "../orchestration-v2/ProviderAdapter.ts"; import * as ProviderAdapterRegistry from "../orchestration-v2/ProviderAdapterRegistry.ts"; import * as ThreadManagementService from "../orchestration-v2/ThreadManagementService.ts"; @@ -26,6 +37,7 @@ import * as ProjectService from "../project/ProjectService.ts"; import * as ScheduledTaskService from "../scheduledTasks/ScheduledTaskService.ts"; import * as SecretRequests from "../secrets/SecretRequests.ts"; import type { McpInvocationScope } from "./McpInvocationContext.ts"; +import { idleThreadProjection, liveThreadShell } from "./McpToolAccess.testkit.ts"; import * as OrchestratorMcpService from "./OrchestratorMcpService.ts"; describe("OrchestratorMcpService", () => { @@ -283,6 +295,8 @@ describe("OrchestratorMcpService", () => { Layer.mock(ThreadManagementService.ThreadManagementService)({ getThreadRecords: (threadId) => Effect.succeed(threadId === parentThreadId ? parentProjection : childProjection), + // The child still runs within the parent's modes. + getThreadShell: (threadId) => Effect.succeed(liveThreadShell(threadId)), dispatch: (command) => Ref.update(dispatched, (commands) => [...commands, command]).pipe( Effect.as({} as never), @@ -357,6 +371,8 @@ describe("OrchestratorMcpService", () => { Layer.mock(ThreadManagementService.ThreadManagementService)({ getThreadRecords: (threadId) => Effect.succeed(threadId === parentThreadId ? parentProjection : childProjection), + // The child still runs within the parent's modes. + getThreadShell: (threadId) => Effect.succeed(liveThreadShell(threadId)), dispatch: (command) => Ref.update(dispatched, (commands) => [...commands, command]).pipe( Effect.andThen(Effect.fail(new Error("simulated stop failure") as never)), @@ -434,6 +450,8 @@ describe("OrchestratorMcpService", () => { Layer.mock(ThreadManagementService.ThreadManagementService)({ getThreadRecords: (threadId) => Effect.succeed(threadId === parentThreadId ? parentProjection : childProjection), + // The child still runs within the parent's modes. + getThreadShell: (threadId) => Effect.succeed(liveThreadShell(threadId)), dispatch: (command) => Ref.update(dispatched, (commands) => [...commands, command]).pipe( Effect.andThen( @@ -479,6 +497,258 @@ describe("OrchestratorMcpService", () => { }).pipe(Effect.provide(OrchestratorMcpService.layer.pipe(Layer.provide(layerDependencies)))); }), ); + + it.effect("refuses to cancel a task whose child now runs above the parent's modes", () => + Effect.gen(function* () { + const parentThreadId = ThreadId.make("thread:mcp-cancel-dispose-failed-parent"); + const childThreadId = ThreadId.make("thread:mcp-cancel-dispose-failed-child"); + const childRunId = RunId.make("run:mcp-cancel-dispose-failed-child"); + const taskId = NodeId.make("node:mcp-cancel-dispose-failed-task"); + const dispatched = yield* Ref.make>([]); + // The parent runs Supervised in plan mode. + const parentProjection = { + thread: { id: parentThreadId, runtimeMode: "approval-required", interactionMode: "plan" }, + runs: [], + contextTransfers: [], + subagents: [ + { + id: taskId, + threadId: parentThreadId, + origin: "app_owned", + childThreadId, + driver: "codex", + model: "gpt-5.6-terra", + result: null, + completionDelivery: { state: "pending" }, + }, + ], + } as unknown as OrchestrationV2ThreadProjection; + const childProjection = { + thread: { id: childThreadId }, + runs: [{ id: childRunId, status: "running" }], + contextTransfers: [], + messages: [], + subagents: [], + providerThreads: [], + } as unknown as OrchestrationV2ThreadProjection; + const layerDependencies = Layer.mergeAll( + NodeServices.layer, + Layer.mock(ThreadManagementService.ThreadManagementService)({ + getThreadRecords: (threadId) => + Effect.succeed(threadId === parentThreadId ? parentProjection : childProjection), + // Its user has since raised the child to full access. + getThreadShell: (threadId) => Effect.succeed(liveThreadShell(threadId)), + dispatch: (command) => + Ref.update(dispatched, (commands) => [...commands, command]).pipe( + Effect.andThen( + command.type === "delegated_task.completion-delivery.dispose" + ? Effect.fail(new Error("simulated disposal failure") as never) + : Effect.succeed({} as never), + ), + ), + stopDelegatedTasks: () => Effect.void, + }), + Layer.mock(ProviderRegistry.ProviderRegistry)({ getProviders: Effect.succeed([]) }), + Layer.mock(ProviderAdapterRegistry.ProviderAdapterRegistryV2)({ + list: () => Effect.succeed([]), + }), + Layer.mock(ProjectService.ProjectService)({}), + Layer.mock(SecretRequests.SecretRequests)({}), + Layer.mock(ScheduledTaskService.ScheduledTaskService)({}), + ); + const scope: McpInvocationScope = { + environmentId: EnvironmentId.make("environment:mcp-cancel-dispose-failed"), + requestNamespace: "provider-session:mcp-cancel-dispose-failed", + thread: { + threadId: parentThreadId, + providerSessionId: "provider-session:mcp-cancel-dispose-failed", + providerInstanceId: ProviderInstanceId.make("codex"), + }, + client: undefined, + capabilities: new Set(["orchestration"]), + issuedAt: 1, + }; + + yield* Effect.gen(function* () { + const service = yield* OrchestratorMcpService.OrchestratorMcpService; + const error = yield* service + .cancelTask(scope, { taskId, clientRequestId: "cancel-above-modes" }) + .pipe(Effect.flip); + assert.equal(error.code, "runtime_mode_escalation_denied"); + assert.deepEqual(yield* Ref.get(dispatched), []); + }).pipe(Effect.provide(OrchestratorMcpService.layer.pipe(Layer.provide(layerDependencies)))); + }), + ); + + /** + * A Supervised parent cancels its Supervised child, under which a task + * now runs at full access. Reports what the cancel did. + */ + const cancelOverRaisedGrandchild = (child: { + readonly deleted: boolean; + /** The grandchild passes the check, and its user raises it before the stop reaches it. */ + readonly raisedDuringStop?: boolean; + }) => + Effect.gen(function* () { + const parentThreadId = ThreadId.make("thread:mcp-cancel-grandchild-parent"); + const childThreadId = ThreadId.make("thread:mcp-cancel-grandchild-child"); + const grandchildThreadId = ThreadId.make("thread:mcp-cancel-grandchild-grandchild"); + const taskId = NodeId.make("node:mcp-cancel-grandchild-task"); + const dispatched = yield* Ref.make>([]); + const stoppedBelow = yield* Ref.make(false); + const appOwnedTask = (id: string, threadId: ThreadId, childId: ThreadId) => ({ + id: NodeId.make(id), + threadId, + origin: "app_owned", + childThreadId: childId, + driver: "codex", + model: "gpt-5.6-terra", + result: null, + completionDelivery: { state: "pending" }, + }); + // A Supervised parent delegated a Supervised child, which delegated a task of its own. + const projections = new Map([ + [ + parentThreadId, + { + thread: { + id: parentThreadId, + runtimeMode: "approval-required", + interactionMode: "default", + }, + runs: [], + contextTransfers: [], + subagents: [appOwnedTask(taskId, parentThreadId, childThreadId)], + }, + ], + [ + childThreadId, + { + thread: { id: childThreadId }, + runs: [{ id: RunId.make("run:mcp-cancel-grandchild-child"), status: "running" }], + contextTransfers: [], + messages: [], + subagents: [ + appOwnedTask("node:mcp-cancel-grandchild-below", childThreadId, grandchildThreadId), + ], + providerThreads: [], + }, + ], + [ + grandchildThreadId, + { + thread: { id: grandchildThreadId }, + runs: [], + contextTransfers: [], + messages: [], + subagents: [], + providerThreads: [], + }, + ], + ]) as unknown as ReadonlyMap; + const layerDependencies = Layer.mergeAll( + NodeServices.layer, + Layer.mock(ThreadManagementService.ThreadManagementService)({ + getThreadRecords: (threadId) => Effect.succeed(projections.get(threadId)!), + // The child still runs Supervised; its user has since raised the task under it + // to full access. + getThreadShell: (threadId) => + Effect.succeed( + threadId === grandchildThreadId && child.raisedDuringStop !== true + ? liveThreadShell(threadId) + : threadId === childThreadId && child.deleted + ? null + : liveThreadShell(threadId, { runtimeMode: "approval-required" }), + ), + dispatch: (command) => + Ref.update(dispatched, (commands) => [...commands, command]).pipe( + Effect.as({} as never), + ), + // Stands in for the orchestrator, which finds the grandchild raised + // under its lock, when the stop runs under the parent's limit. + stopDelegatedTasks: () => + Effect.gen(function* () { + const limit = yield* DispatchModeLimit; + if (child.raisedDuringStop === true && limit?.refused !== undefined) { + const refusal: DispatchModeRefusal = { + threadId: grandchildThreadId, + mode: "runtime", + runtimeMode: "full-access", + interactionMode: "default", + }; + yield* Ref.set(limit.refused, refusal); + return yield* new OrchestratorThreadAboveModeLimitError({ + commandId: CommandId.make("stop-grandchild"), + threadId: grandchildThreadId, + mode: "runtime", + runtimeMode: "full-access", + interactionMode: "default", + }); + } + yield* Ref.set(stoppedBelow, true); + }), + }), + Layer.mock(ProviderRegistry.ProviderRegistry)({ getProviders: Effect.succeed([]) }), + Layer.mock(ProviderAdapterRegistry.ProviderAdapterRegistryV2)({ + list: () => Effect.succeed([]), + }), + Layer.mock(ProjectService.ProjectService)({}), + Layer.mock(SecretRequests.SecretRequests)({}), + Layer.mock(ScheduledTaskService.ScheduledTaskService)({}), + ); + const scope: McpInvocationScope = { + environmentId: EnvironmentId.make("environment:mcp-cancel-grandchild"), + requestNamespace: "provider-session:mcp-cancel-grandchild", + thread: { + threadId: parentThreadId, + providerSessionId: "provider-session:mcp-cancel-grandchild", + providerInstanceId: ProviderInstanceId.make("codex"), + }, + client: undefined, + capabilities: new Set(["orchestration"]), + issuedAt: 1, + }; + + return yield* Effect.gen(function* () { + const service = yield* OrchestratorMcpService.OrchestratorMcpService; + const error = yield* service + .cancelTask(scope, { taskId, clientRequestId: "cancel-grandchild-above-modes" }) + .pipe(Effect.flip); + return { + code: error.code, + dispatched: yield* Ref.get(dispatched), + stoppedBelow: yield* Ref.get(stoppedBelow), + }; + }).pipe(Effect.provide(OrchestratorMcpService.layer.pipe(Layer.provide(layerDependencies)))); + }); + + it.effect("refuses to cancel a task when a task under it now runs above the parent's modes", () => + Effect.gen(function* () { + assert.deepEqual(yield* cancelOverRaisedGrandchild({ deleted: false }), { + code: "runtime_mode_escalation_denied", + dispatched: [], + stoppedBelow: false, + }); + }), + ); + + it.effect("reports a task under the child that its user raises while it is being stopped", () => + Effect.gen(function* () { + const outcome = yield* cancelOverRaisedGrandchild({ deleted: false, raisedDuringStop: true }); + assert.equal(outcome.code, "runtime_mode_escalation_denied"); + assert.isFalse(outcome.stoppedBelow); + }), + ); + + it.effect("checks the tasks under a deleted child before cancelling it", () => + Effect.gen(function* () { + assert.deepEqual(yield* cancelOverRaisedGrandchild({ deleted: true }), { + code: "runtime_mode_escalation_denied", + dispatched: [], + stoppedBelow: false, + }); + }), + ); }); describe("OrchestratorMcpService provider resolution", () => { @@ -1294,4 +1564,227 @@ describe("OrchestratorMcpService provider resolution", () => { } }), ); + + describe("scheduled tasks at modes above the caller's", () => { + const projectId = ProjectId.make("project:scheduled"); + const boundThreadId = ThreadId.make("thread:scheduled-bound"); + const task = (overrides: Partial): ScheduledTask => ({ + id: ScheduledTaskId.make("scheduled-task:webhook"), + title: "On push", + prompt: "Do {{body.instruction}}", + enabled: true, + schedule: { type: "webhook", signature: null }, + projectId, + threadId: null, + workspaceStrategy: { type: "root" }, + modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5" }, + runtimeMode: "approval-required", + interactionMode: "default", + createdBy: "agent", + creationSource: "mcp", + createdAt: "2026-10-05T00:00:00.000Z", + updatedAt: "2026-10-05T00:00:00.000Z", + nextRunAt: null, + lastRunAt: null, + lastRunStatus: "never", + lastRunError: null, + runCount: 0, + webhook: { + path: "/hooks/scheduled-task/secret", + url: "https://t3.example/hooks/secret", + hasSecret: false, + }, + ...overrides, + }); + const supervisedClient: McpInvocationScope = { + environmentId: EnvironmentId.make("environment:scheduled"), + requestNamespace: "client:scheduled", + thread: undefined, + client: { + sessionId: "scheduled", + label: "Claude Code", + runtimeModeCeiling: "approval-required", + }, + capabilities: new Set(["orchestration"]), + issuedAt: 1, + }; + const service = ( + tasks: ReadonlyArray, + boundThread: OrchestrationV2ThreadShell | null, + upserted: Ref.Ref, + ) => + OrchestratorMcpService.layer.pipe( + Layer.provide( + Layer.mergeAll( + NodeServices.layer, + Layer.mock(ThreadManagementService.ThreadManagementService)({ + getThreadShell: (threadId) => + Effect.succeed(threadId === boundThreadId ? boundThread : null), + }), + Layer.mock(ProviderRegistry.ProviderRegistry)({ getProviders: Effect.succeed([]) }), + Layer.mock(ProviderAdapterRegistry.ProviderAdapterRegistryV2)({ + list: () => Effect.succeed([]), + }), + Layer.mock(ProjectService.ProjectService)({}), + Layer.mock(SecretRequests.SecretRequests)({}), + Layer.mock(ScheduledTaskService.ScheduledTaskService)({ + list: () => Effect.succeed({ tasks }), + upsert: () => + Ref.update(upserted, (count) => count + 1).pipe(Effect.as({ task: tasks[0]! })), + }), + ), + ), + ); + + it.effect("hides a webhook URL from a caller below the task's modes", () => + Effect.gen(function* () { + const upserted = yield* Ref.make(0); + const listed = yield* OrchestratorMcpService.OrchestratorMcpService.pipe( + Effect.flatMap((mcp) => mcp.listScheduledTasks(supervisedClient, { projectId })), + Effect.provide( + service( + [ + task({ runtimeMode: "full-access" }), + task({ id: ScheduledTaskId.make("scheduled-task:supervised") }), + ], + null, + upserted, + ), + ), + ); + assert.deepEqual( + listed.tasks.map((summary) => summary.webhookUrl), + [undefined, "https://t3.example/hooks/secret"], + ); + }), + ); + + it.effect("hides a webhook URL from a thread whose turn has ended", () => + Effect.gen(function* () { + const callerId = ThreadId.make("thread:scheduled-ended"); + const shell = liveThreadShell(callerId, { activeRunId: null }); + const listed = yield* OrchestratorMcpService.OrchestratorMcpService.pipe( + Effect.flatMap((mcp) => + mcp.listScheduledTasks( + { + ...supervisedClient, + requestNamespace: "provider:scheduled-ended", + thread: { + threadId: callerId, + providerSessionId: "provider:scheduled-ended", + providerInstanceId: shell.providerInstanceId, + }, + client: undefined, + }, + { projectId }, + ), + ), + Effect.provide( + OrchestratorMcpService.layer.pipe( + Layer.provide( + Layer.mergeAll( + NodeServices.layer, + Layer.mock(ThreadManagementService.ThreadManagementService)({ + getThreadShell: () => Effect.succeed(null), + // Its turn ended: no run is active. + getThreadRecords: () => Effect.succeed(idleThreadProjection(shell)), + }), + Layer.mock(ProviderRegistry.ProviderRegistry)({ + getProviders: Effect.succeed([]), + }), + Layer.mock(ProviderAdapterRegistry.ProviderAdapterRegistryV2)({ + list: () => Effect.succeed([]), + }), + Layer.mock(ProjectService.ProjectService)({}), + Layer.mock(SecretRequests.SecretRequests)({}), + Layer.mock(ScheduledTaskService.ScheduledTaskService)({ + list: () => Effect.succeed({ tasks: [task({})] }), + }), + ), + ), + ), + ), + ); + assert.equal(listed.tasks[0]?.webhookUrl, undefined); + }), + ); + + it.effect("checks a bound task against its thread's current modes", () => + Effect.gen(function* () { + const upserted = yield* Ref.make(0); + // Scheduled while the thread was Supervised; the thread now runs in full access. + const bound = task({ threadId: boundThreadId }); + const layer = service( + [bound], + liveThreadShell(boundThreadId, { runtimeMode: "full-access" }), + upserted, + ); + const mcp = yield* OrchestratorMcpService.OrchestratorMcpService.pipe( + Effect.provide(layer), + ); + const listed = yield* mcp.listScheduledTasks(supervisedClient, { projectId }); + assert.equal(listed.tasks[0]?.webhookUrl, undefined); + const error = yield* mcp + .updateScheduledTask(supervisedClient, { + scheduledTaskId: bound.id, + prompt: "Something else", + }) + .pipe(Effect.flip); + assert.equal(error.code, "runtime_mode_escalation_denied"); + assert.equal(yield* Ref.get(upserted), 0); + }), + ); + + it.effect("reports a saved task even when its bound thread cannot be read", () => + Effect.gen(function* () { + const upserted = yield* Ref.make(0); + const bound = task({ threadId: boundThreadId }); + const lookups = yield* Ref.make(0); + // The edit's own check reads the thread; the read after the save fails. + const mcp = yield* OrchestratorMcpService.OrchestratorMcpService.pipe( + Effect.provide( + OrchestratorMcpService.layer.pipe( + Layer.provide( + Layer.mergeAll( + NodeServices.layer, + Layer.mock(ThreadManagementService.ThreadManagementService)({ + getThreadShell: (threadId) => + Ref.getAndUpdate(lookups, (count) => count + 1).pipe( + Effect.flatMap((count) => + count === 0 + ? Effect.succeed( + liveThreadShell(threadId, { runtimeMode: "approval-required" }), + ) + : Effect.fail(new OrchestratorProjectionError({ threadId })), + ), + ), + }), + Layer.mock(ProviderRegistry.ProviderRegistry)({ + getProviders: Effect.succeed([]), + }), + Layer.mock(ProviderAdapterRegistry.ProviderAdapterRegistryV2)({ + list: () => Effect.succeed([]), + }), + Layer.mock(ProjectService.ProjectService)({}), + Layer.mock(SecretRequests.SecretRequests)({}), + Layer.mock(ScheduledTaskService.ScheduledTaskService)({ + list: () => Effect.succeed({ tasks: [bound] }), + upsert: () => + Ref.update(upserted, (count) => count + 1).pipe(Effect.as({ task: bound })), + }), + ), + ), + ), + ), + ); + const updated = yield* mcp.updateScheduledTask(supervisedClient, { + scheduledTaskId: bound.id, + prompt: "Something else", + }); + assert.equal(yield* Ref.get(upserted), 1); + assert.equal(updated.scheduledTaskId, bound.id); + assert.equal(updated.webhookUrl, undefined); + }), + ); + }); }); diff --git a/apps/server/src/mcp/OrchestratorMcpService.ts b/apps/server/src/mcp/OrchestratorMcpService.ts index fc91a19203a995d966f1bef451c6875bbfecd360..dc826af0686480720c4dfd248b73c0872cff5e09 100644 --- a/apps/server/src/mcp/OrchestratorMcpService.ts +++ b/apps/server/src/mcp/OrchestratorMcpService.ts @@ -66,6 +66,7 @@ import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; +import * as Ref from "effect/Ref"; import * as Schema from "effect/Schema"; import * as Stream from "effect/Stream"; @@ -74,6 +75,10 @@ import { subagentResultForRun, delegatedTaskProgress, } from "../orchestration-v2/SubagentProjection.ts"; +import { + DispatchModeLimit, + type DispatchModeRefusal, +} from "../orchestration-v2/DispatchModeLimit.ts"; import * as ThreadManagementService from "../orchestration-v2/ThreadManagementService.ts"; import * as ProjectService from "../project/ProjectService.ts"; import * as ProviderRegistry from "../provider/ProviderRegistry.ts"; @@ -225,7 +230,12 @@ function scheduledTaskWorkspaceStrategy( : { type: "worktree", baseRef: "main", startFromOrigin: true }; } -function scheduledTaskSummary(task: ScheduledTask): OrchestratorMcpScheduledTask { +/** + * A scheduled task as an agent sees it. `mayRun` says whether the caller may + * run it: a webhook's URL carries the secret that starts the task's runs, so + * only such a caller sees it. + */ +function scheduledTaskSummary(task: ScheduledTask, mayRun: boolean): OrchestratorMcpScheduledTask { return { scheduledTaskId: task.id, title: task.title, @@ -237,7 +247,7 @@ function scheduledTaskSummary(task: ScheduledTask): OrchestratorMcpScheduledTask nextRunAt: task.nextRunAt, lastRunStatus: task.lastRunStatus, // A bare path is not a URL anyone can call, so agents never get one to share. - ...(task.webhook?.url == null ? {} : { webhookUrl: task.webhook.url }), + ...(task.webhook?.url == null || !mayRun ? {} : { webhookUrl: task.webhook.url }), ...(task.webhook === undefined ? {} : { webhookSignature: task.webhook.hasSecret ? "set" : "none" }), @@ -1364,9 +1374,65 @@ const make = Effect.gen(function* () { }).pipe(Effect.timeoutOption(Duration.millis(timeoutMs)), Effect.map(Option.flatten)); /** - * A scheduled task the caller may change: one whose modes are no broader - * than the caller's own, so editing its prompt cannot run work above the - * caller's limits. + * The modes a task's runs execute at: its own, or for a task bound to a + * thread, also that thread's modes as they are now, since its runs are + * messages to that thread. + */ + const scheduledTaskRunModes = (task: ScheduledTask) => + Effect.gen(function* () { + const modes = [{ runtimeMode: task.runtimeMode, interactionMode: task.interactionMode }]; + if (task.threadId === null) return modes; + const bound = yield* threadManagement + .getThreadShell(task.threadId) + .pipe(Effect.mapError(threadManagementFailure)); + return bound === null || bound.deletedAt !== null ? modes : [...modes, bound]; + }); + + const withinLimits = ( + limits: { + readonly runtimeMode: RuntimeMode; + readonly interactionMode: ProviderInteractionMode; + }, + modes: { readonly runtimeMode: RuntimeMode; readonly interactionMode: ProviderInteractionMode }, + ) => + runtimeModeRank(modes.runtimeMode) <= runtimeModeRank(limits.runtimeMode) && + interactionModeRank(modes.interactionMode) <= interactionModeRank(limits.interactionMode); + + /** + * A task as the caller may see it. Its webhook URL starts runs, so only a + * caller that may start one sees it: a thread caller with a live turn, at + * modes covering every mode the task runs at. + */ + const summarizeScheduledTask = ( + scope: McpInvocationScope, + caller: { + readonly parent: Pick | undefined; + readonly limits: { + readonly runtimeMode: RuntimeMode; + readonly interactionMode: ProviderInteractionMode; + }; + }, + task: ScheduledTask, + ) => + Effect.gen(function* () { + const live = + caller.parent === undefined || + Exit.isSuccess(yield* Effect.exit(assertLiveCaller(scope, caller.parent))); + // This runs after a save, so a failed lookup of the bound thread hides + // the webhook URL instead of reporting a saved task as an error. + const modes = yield* scheduledTaskRunModes(task).pipe(Effect.option); + return scheduledTaskSummary( + task, + live && + Option.isSome(modes) && + modes.value.every((mode) => withinLimits(caller.limits, mode)), + ); + }); + + /** + * A scheduled task the caller may change: one whose runs execute at modes no + * broader than the caller's own, so editing its prompt cannot run work above + * the caller's limits. */ const loadScheduledTask = ( scheduledTaskId: ScheduledTask["id"], @@ -1387,8 +1453,10 @@ const make = Effect.gen(function* () { if (task === undefined) { return yield* failure("task_not_found", `Scheduled task ${scheduledTaskId} was not found.`); } - yield* resolveRuntimeMode(limits.runtimeMode, task.runtimeMode); - yield* resolveInteractionMode(limits.interactionMode, task.interactionMode); + for (const modes of yield* scheduledTaskRunModes(task)) { + yield* resolveRuntimeMode(limits.runtimeMode, modes.runtimeMode); + yield* resolveInteractionMode(limits.interactionMode, modes.interactionMode); + } return task; }); @@ -1451,11 +1519,11 @@ const make = Effect.gen(function* () { failure("orchestration_error", `Could not schedule task: ${error.message}`), ), ); - return scheduledTaskSummary(task); + return yield* summarizeScheduledTask(scope, { parent, limits }, task); }), listScheduledTasks: (scope, input) => Effect.gen(function* () { - const { parent } = yield* loadCaller(scope); + const { parent, limits } = yield* loadCaller(scope); const projectId = input.projectId ?? parent?.thread.projectId; const { tasks } = yield* scheduledTasks .list() @@ -1465,9 +1533,10 @@ const make = Effect.gen(function* () { ), ); return { - tasks: tasks - .filter((task) => projectId === undefined || task.projectId === projectId) - .map(scheduledTaskSummary), + tasks: yield* Effect.forEach( + tasks.filter((task) => projectId === undefined || task.projectId === projectId), + (task) => summarizeScheduledTask(scope, { parent, limits }, task), + ), }; }), updateScheduledTask: (scope, input) => @@ -1521,7 +1590,7 @@ const make = Effect.gen(function* () { failure("orchestration_error", `Could not update scheduled task: ${error.message}`), ), ); - return scheduledTaskSummary(task); + return yield* summarizeScheduledTask(scope, { parent, limits }, task); }), deleteScheduledTask: (scope, input) => Effect.gen(function* () { @@ -1851,6 +1920,32 @@ const make = Effect.gen(function* () { const current = yield* readTask(scope, input.taskId); const key = yield* requestKey(input.clientRequestId); const parentProjection = yield* loadProjection(scope.thread.threadId); + // Cancelling stops the child and every task under it, each a write to a + // thread its user may have raised above the parent's modes since it was + // delegated. All of them are checked before anything is stopped. + const assertStoppable = (threadId: ThreadId): Effect.Effect => + Effect.gen(function* () { + const shell = yield* threadManagement + .getThreadShell(threadId) + .pipe(Effect.mapError(threadManagementFailure)); + // A deleted thread takes no stop, but the tasks under it still do. + if (shell !== null && shell.deletedAt === null) { + yield* resolveRuntimeMode(parentProjection.thread.runtimeMode, shell.runtimeMode); + yield* resolveInteractionMode( + parentProjection.thread.interactionMode, + shell.interactionMode, + ); + } + const { subagents } = yield* threadManagement + .getThreadRecords(threadId, ["subagents"]) + .pipe(Effect.mapError(threadManagementFailure)); + for (const task of subagents) { + if (task.origin === "app_owned" && task.childThreadId !== null) { + yield* assertStoppable(task.childThreadId); + } + } + }); + yield* assertStoppable(current.childThreadId); const parentTask = parentProjection.subagents.find( (task) => task.id === input.taskId && task.origin === "app_owned", ); @@ -1910,8 +2005,32 @@ const make = Effect.gen(function* () { ), ); }); + // Checked above; a user raising one of these threads meanwhile is + // caught again under that thread's lock, which leaves it running. + const refused = yield* Ref.make(undefined); + const stopWithinLimit = stopChild.pipe( + Effect.provideService(DispatchModeLimit, { + runtimeMode: parentProjection.thread.runtimeMode, + interactionMode: parentProjection.thread.interactionMode, + refused, + }), + Effect.catch((error) => + Effect.flatMap(Ref.get(refused), (refusal) => + Effect.fail( + refusal === undefined + ? error + : failure( + refusal.mode === "runtime" + ? "runtime_mode_escalation_denied" + : "interaction_mode_escalation_denied", + `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.`, + ), + ), + ), + ), + ); if (isTerminalTaskStatus(current.status)) { - yield* stopChild; + yield* stopWithinLimit; yield* disposeCompletionDelivery; return { taskId: input.taskId, @@ -1930,7 +2049,7 @@ const make = Effect.gen(function* () { `Delegated task ${input.taskId} has no interruptible child run.`, ); } - yield* stopChild; + yield* stopWithinLimit; yield* disposeCompletionDelivery.pipe( Effect.catchCause((cause) => Effect.logWarning("orchestrator-mcp.cancel-task.delivery-dispose-failed", { diff --git a/apps/server/src/mcp/ThreadMetadataMcpService.test.ts b/apps/server/src/mcp/ThreadMetadataMcpService.test.ts index 5735008f6e8ee8bdadf286527a015fc6a1b48eb4..411568f75745cb0b43e5796e5bccdfd4ccc48acb 100644 --- a/apps/server/src/mcp/ThreadMetadataMcpService.test.ts +++ b/apps/server/src/mcp/ThreadMetadataMcpService.test.ts @@ -78,91 +78,3 @@ it.effect("keeps calling-thread storage failures as orchestration errors", () => expect(error.code).toBe("orchestration_error"); }), ); - -it.effect("refuses to change a thread that runs above the caller's modes", () => - Effect.gen(function* () { - const fullAccessThread = ThreadId.make("thread:metadata-full-access"); - const shells = new Map([ - [ - threadId, - { - id: threadId, - projectId: "project", - runtimeMode: "auto", - interactionMode: "default", - activeRunId: "run-live", - archivedAt: null, - providerInstanceId: "codex", - deletedAt: null, - }, - ], - [ - fullAccessThread, - { - id: fullAccessThread, - projectId: "project", - runtimeMode: "full-access", - interactionMode: "default", - deletedAt: null, - }, - ], - ]); - const error = yield* Effect.gen(function* () { - const service = yield* ThreadMetadataMcp.ThreadMetadataMcpService; - return yield* service.update(scope, { - threadId: fullAccessThread, - action: "rename", - title: "Renamed from a narrower thread", - }); - }).pipe( - Effect.provide(layerService((id) => Effect.succeed((shells.get(id) ?? null) as never))), - Effect.flip, - ); - - expect(error.code).toBe("runtime_mode_escalation_denied"); - }), -); - -it.effect("refuses another thread's metadata to a thread caller whose run has ended", () => - Effect.gen(function* () { - const otherThread = ThreadId.make("thread:metadata-other"); - const shells = new Map([ - [ - threadId, - { - id: threadId, - projectId: "project", - runtimeMode: "full-access", - interactionMode: "default", - activeRunId: null, - archivedAt: null, - providerInstanceId: "codex", - deletedAt: null, - }, - ], - [ - otherThread, - { - id: otherThread, - projectId: "project", - runtimeMode: "approval-required", - interactionMode: "default", - deletedAt: null, - }, - ], - ]); - const error = yield* Effect.gen(function* () { - const service = yield* ThreadMetadataMcp.ThreadMetadataMcpService; - return yield* service.update(scope, { - threadId: otherThread, - action: "rename", - title: "Renamed after the run ended", - }); - }).pipe( - Effect.provide(layerService((id) => Effect.succeed((shells.get(id) ?? null) as never))), - Effect.flip, - ); - - expect(error.code).toBe("parent_not_active"); - }), -); diff --git a/apps/server/src/mcp/ThreadMetadataMcpService.ts b/apps/server/src/mcp/ThreadMetadataMcpService.ts index 6c552511e72294d892c2f1cd3d12070a7d4620d6..22402b65394602fc6307695fb784377b5d150168 100644 --- a/apps/server/src/mcp/ThreadMetadataMcpService.ts +++ b/apps/server/src/mcp/ThreadMetadataMcpService.ts @@ -16,7 +16,6 @@ import * as Layer from "effect/Layer"; import * as ThreadManagementService from "../orchestration-v2/ThreadManagementService.ts"; import type { McpInvocationScope } from "./McpInvocationContext.ts"; -import { assertTargetWithinLimits } from "./threadAccess.ts"; export class ThreadMetadataMcpService extends Context.Service< ThreadMetadataMcpService, @@ -167,42 +166,6 @@ const make = Effect.gen(function* () { if (shell === null || shell.deletedAt !== null) { return yield* failure("thread_not_found", `Thread ${threadId} was not found.`); } - // Another thread may only be changed if it runs within the caller's own modes. - if (threadId !== scope.thread?.threadId) { - const limits = - scope.thread === undefined - ? { - runtimeMode: scope.client?.runtimeModeCeiling ?? ("approval-required" as const), - interactionMode: "default" as const, - } - : yield* threadManagement.getThreadShell(scope.thread.threadId).pipe( - Effect.mapError((error) => - failure( - "orchestration_error", - `Unable to locate calling thread: ${errorMessage(error)}`, - ), - ), - Effect.flatMap((caller) => - caller === null - ? Effect.fail(failure("thread_not_found", "The calling thread was not found.")) - : // Like every other cross-thread write, a thread caller needs its live run. - caller.archivedAt !== null || - caller.activeRunId === null || - caller.providerInstanceId !== scope.thread?.providerInstanceId - ? Effect.fail( - failure( - "parent_not_active", - "The calling provider no longer owns an active thread run.", - ), - ) - : Effect.succeed({ - runtimeMode: caller.runtimeMode, - interactionMode: caller.interactionMode, - }), - ), - ); - yield* assertTargetWithinLimits(limits, shell); - } const target = yield* threadManagement .getProjectThreadRecords({ projectId: shell.projectId, threadId }, []) .pipe(Effect.mapError(threadLookupFailure)); diff --git a/apps/server/src/mcp/threadAccess.ts b/apps/server/src/mcp/threadAccess.ts index e0fb29b7e13a47a898402fd54841c0fd1deaf922..115bf8b9f967bd108570c73a1d73c3040a3b01da 100644 --- a/apps/server/src/mcp/threadAccess.ts +++ b/apps/server/src/mcp/threadAccess.ts @@ -60,6 +60,15 @@ export const readCaller = Effect.fn("mcp.readCaller")(function* () { message: "This credential cannot control threads.", }); } + return yield* loadCaller(); +}); + +/** + * The caller and its limits, whichever tools its credential grants. Tools + * check their own capability; `McpToolAccess` uses this for every tool. + */ +export const loadCaller = Effect.fn("mcp.loadCaller")(function* () { + const scope = yield* McpInvocationContext.McpInvocationContext; const threads = yield* ThreadManagement.ThreadManagementService; if (scope.thread === undefined) { return { @@ -104,7 +113,12 @@ export const assertTargetWithinLimits = ( Effect.asVoid, ); -function assertLiveCaller({ caller, scope }: Caller) { +/** + * A thread caller acts only while it owns a live run of a thread that is not + * archived, so a provider token that outlived its session cannot act. An + * OAuth client has no run; its session and ceiling are its authority. + */ +export function assertLiveCaller({ caller, scope }: Caller) { if (caller === undefined) return Effect.void; return caller.archivedAt !== null || caller.activeRunId === null || @@ -119,34 +133,14 @@ function assertLiveCaller({ caller, scope }: Caller) { } /** - * Mutations from a thread need that thread's live run, so an agent whose turn - * ended cannot keep acting. An OAuth client has no run; its session and - * ceiling are its authority. + * Actions that change the environment itself (projects, preferences) need full + * access: a thread caller in full-access/default mode, or a client approved + * with a full-access ceiling. */ -export const readMutationCaller = Effect.fn("mcp.readMutationCaller")(function* () { - const context = yield* readCaller(); - yield* assertLiveCaller(context); - return context; -}); - -/** - * Actions that change the environment itself (projects, preferences, launching - * outside a project) need full access: a thread caller in full-access/default - * mode, or a client approved with a full-access ceiling. - */ -export const readFullAccessCaller = Effect.fn("mcp.readFullAccessCaller")(function* ( - message: string, -) { - const context = yield* readMutationCaller(); - if ( - (context.caller !== undefined && context.caller.archivedAt !== null) || - context.limits.runtimeMode !== "full-access" || - context.limits.interactionMode !== "default" - ) { - return yield* new OrchestratorMcpFailure({ code: "capability_denied", message }); - } - return context; -}); +export const assertFullAccess = (context: Caller, message: string) => + context.limits.runtimeMode === "full-access" && context.limits.interactionMode === "default" + ? Effect.void + : Effect.fail(new OrchestratorMcpFailure({ code: "capability_denied", message })); /** A target project: the one passed, else the calling thread's. */ export const resolveProjectId = (context: Caller, projectId: ProjectId | undefined) => @@ -204,15 +198,6 @@ export const readThread = Effect.fn("mcp.readThread")(function* < return { ...context, projection }; }); -export const readWritableThread = Effect.fn("mcp.readWritableThread")(function* < - K extends ProjectionRecordField = never, ->(threadId?: ThreadId, fields: ReadonlyArray = []) { - const context = yield* readThread(threadId, fields); - yield* assertLiveCaller(context); - yield* assertTargetWithinLimits(context.limits, context.projection.thread); - return context; -}); - export const newCommandId = Effect.fn("mcp.newCommandId")(function* () { const crypto = yield* Crypto.Crypto; return CommandId.make(`mcp:${yield* crypto.randomUUIDv4.pipe(Effect.orDie)}`); diff --git a/apps/server/src/mcp/toolkits/attachment/handlers.ts b/apps/server/src/mcp/toolkits/attachment/handlers.ts index 412cdc5a60ce9a95443e25762c0b9c1d4c3a4afa..9b9a6015d232d54f2cdc96567ade56eccc94a39b 100644 --- a/apps/server/src/mcp/toolkits/attachment/handlers.ts +++ b/apps/server/src/mcp/toolkits/attachment/handlers.ts @@ -3,12 +3,8 @@ import * as Effect from "effect/Effect"; import * as Upload from "../../../assets/AttachmentUpload.ts"; import * as Claims from "../../../orchestration-v2/AttachmentClaims.ts"; import * as ThreadMessageIntake from "../../../orchestration-v2/ThreadMessageIntake.ts"; -import { - newCommandId, - readMutationCaller, - readWritableThread, - unavailable, -} from "../../threadAccess.ts"; +import * as McpToolAccess from "../../McpToolAccess.ts"; +import { newCommandId, readThread, unavailable } from "../../threadAccess.ts"; import { AttachmentToolkit } from "./tools.ts"; export function resolveAttachmentReferences( @@ -31,57 +27,53 @@ export function resolveAttachmentReferences( }); } -export const layer = AttachmentToolkit.toLayer({ - t3_attachment_prepare_upload: (input) => - Effect.gen(function* () { - yield* readMutationCaller(); - return yield* Upload.issueAttachmentUploadUrl(input.upload).pipe( - Effect.mapError(unavailable), - ); - }), - t3_attachment_discard: (input) => - Effect.gen(function* () { - yield* readMutationCaller(); - yield* Upload.deletePendingAttachment(input.attachmentId); - return {}; - }), - t3_thread_send_attachments: (input) => - Effect.gen(function* () { - const { caller, projection } = yield* readWritableThread(input.threadId, ["messages"]); - if (projection.thread.archivedAt !== null) - return yield* new OrchestratorMcpFailure({ - code: "invalid_request", - message: "Unarchive the target thread before sending attachments.", - }); - const attachments = yield* resolveAttachmentReferences( - input.attachments, - projection.messages.flatMap((message) => message.attachments), - ); - const commandId = yield* newCommandId(); - const messageId = MessageId.make(commandId); - const result = yield* ThreadMessageIntake.sendToThread({ - projectId: projection.thread.projectId, - threadId: projection.thread.id, - commandId, - messageId, - ...(caller === undefined ? {} : { senderThreadId: caller.id }), - text: input.message ?? "", - attachments, - mode: "auto", - createdBy: "agent", - creationSource: "mcp", - }).pipe( - Effect.mapError((error) => - error._tag === "AttachmentClaimError" - ? new OrchestratorMcpFailure({ code: "orchestration_error", message: error.message }) - : unavailable(), - ), - ); - return { - threadId: projection.thread.id, - messageId, - runId: result.run.id, - status: result.run.status, - }; - }), +export const layer = McpToolAccess.toLayer(AttachmentToolkit, { + t3_attachment_prepare_upload: McpToolAccess.writes((input) => + Upload.issueAttachmentUploadUrl(input.upload).pipe(Effect.mapError(unavailable)), + ), + t3_attachment_discard: McpToolAccess.writes((input) => + Upload.deletePendingAttachment(input.attachmentId).pipe(Effect.as({})), + ), + t3_thread_send_attachments: McpToolAccess.writesThreads( + (input) => [input.threadId], + (input) => + Effect.gen(function* () { + const { caller, projection } = yield* readThread(input.threadId, ["messages"]); + if (projection.thread.archivedAt !== null) + return yield* new OrchestratorMcpFailure({ + code: "invalid_request", + message: "Unarchive the target thread before sending attachments.", + }); + const attachments = yield* resolveAttachmentReferences( + input.attachments, + projection.messages.flatMap((message) => message.attachments), + ); + const commandId = yield* newCommandId(); + const messageId = MessageId.make(commandId); + const result = yield* ThreadMessageIntake.sendToThread({ + projectId: projection.thread.projectId, + threadId: projection.thread.id, + commandId, + messageId, + ...(caller === undefined ? {} : { senderThreadId: caller.id }), + text: input.message ?? "", + attachments, + mode: "auto", + createdBy: "agent", + creationSource: "mcp", + }).pipe( + Effect.mapError((error) => + error._tag === "AttachmentClaimError" + ? new OrchestratorMcpFailure({ code: "orchestration_error", message: error.message }) + : unavailable(), + ), + ); + return { + threadId: projection.thread.id, + messageId, + runId: result.run.id, + status: result.run.status, + }; + }), + ), }); diff --git a/apps/server/src/mcp/toolkits/core.test.ts b/apps/server/src/mcp/toolkits/core.test.ts index 15b5ee3b80c69770858677d06f79418b24dbbe90..f5674a06c374cfddb5a067837c5ebed9218bcfc3 100644 --- a/apps/server/src/mcp/toolkits/core.test.ts +++ b/apps/server/src/mcp/toolkits/core.test.ts @@ -36,6 +36,7 @@ import * as SecretRequests from "../../secrets/SecretRequests.ts"; import * as ScheduledTaskService from "../../scheduledTasks/ScheduledTaskService.ts"; import * as McpHttpServer from "../McpHttpServer.ts"; import * as McpInvocationContext from "../McpInvocationContext.ts"; +import * as McpToolAccessTestkit from "../McpToolAccess.testkit.ts"; import { dispatchFailure } from "../threadAccess.ts"; import { OrchestratorToolkit } from "./orchestrator/tools.ts"; import { PreviewToolkit } from "./preview/tools.ts"; @@ -131,7 +132,7 @@ const client = McpSchema.McpServerClient.of({ getClient: Effect.die("unused"), }); -it.effect("checks capability before accessing services through the production registration", () => +it.effect("checks capability through the production registration", () => Effect.gen(function* () { const server = yield* McpServer.McpServer; 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 McpHttpServer.layerThreadToolkit.pipe( Layer.provideMerge(McpServer.McpServer.layer), Layer.provide(NodeCrypto.layer), - Layer.provide(Layer.mock(ThreadManagement.ThreadManagementService)({})), + Layer.provide(McpToolAccessTestkit.liveThreadsLayer), ), ), ), @@ -453,12 +454,8 @@ it.effect("a client caller targets any thread within its ceiling and cannot act Layer.provide(NodeCrypto.layer), Layer.provide( Layer.mock(ThreadManagement.ThreadManagementService)({ - getThreadShell: () => - Effect.succeed({ - id: ThreadId.make("other-project-thread"), - projectId: "other-project", - deletedAt: null, - } as never), + getThreadShell: (id) => + Effect.succeed(McpToolAccessTestkit.liveThreadShell(id, { runtimeMode: "auto" })), getProjectThreadRecords: () => Effect.succeed({ thread: { @@ -469,7 +466,7 @@ it.effect("a client caller targets any thread within its ceiling and cannot act deletedAt: null, }, } as never), - dispatch: () => Effect.succeed({ sequence: 7 } as never), + dispatch: () => Effect.succeed({ sequence: 7, storedEvents: [] }), }), ), ), diff --git a/apps/server/src/mcp/toolkits/device/handlers.ts b/apps/server/src/mcp/toolkits/device/handlers.ts index abc06354f19f450a964f23a7fa850851b349e1fe..bf697229718c8dbf65fd8310c3b7b5a470d655d2 100644 --- a/apps/server/src/mcp/toolkits/device/handlers.ts +++ b/apps/server/src/mcp/toolkits/device/handlers.ts @@ -16,6 +16,7 @@ import { nodeRuntimeUnavailableMessage } from "@t3tools/shared/nodeRuntime"; import * as DeviceService from "../../../device/DeviceService.ts"; import * as McpInvocationContext from "../../McpInvocationContext.ts"; +import * as McpToolAccess from "../../McpToolAccess.ts"; import { DeviceScreenshotToolkit, DeviceStandardToolkit, DeviceToolkit } from "./tools.ts"; /** The flags that pin every agent-device command to one device. */ @@ -119,7 +120,7 @@ const pickDevice = ( const toolError = (error: DeviceError | DeviceToolUnavailableError) => error; const handlers = { - device_list: (input) => + device_list: McpToolAccess.readsAsCaller((input) => Effect.gen(function* () { const scope = yield* requireDeviceAccess; const devices = yield* DeviceService.DeviceService; @@ -145,7 +146,8 @@ const handlers = { open, }; }).pipe(Effect.mapError(toolError)), - device_open: (input) => + ), + device_open: McpToolAccess.actsAsCaller((input) => Effect.gen(function* () { const scope = yield* requireDeviceAccess; const devices = yield* DeviceService.DeviceService; @@ -203,7 +205,8 @@ const handlers = { quickStart: agentDeviceQuickStart(device, targetArgs, command), }; }).pipe(Effect.mapError(toolError)), - device_screenshot: (input) => + ), + device_screenshot: McpToolAccess.readsAsCaller((input) => Effect.gen(function* () { const scope = yield* requireDeviceAccess; const devices = yield* DeviceService.DeviceService; @@ -229,7 +232,8 @@ const handlers = { }, }; }).pipe(Effect.mapError(toolError)), - device_close: (input) => + ), + device_close: McpToolAccess.actsAsCaller((input) => Effect.gen(function* () { const scope = yield* requireDeviceAccess; const devices = yield* DeviceService.DeviceService; @@ -241,7 +245,8 @@ const handlers = { }); return {}; }).pipe(Effect.mapError(toolError)), -} satisfies Parameters[0]; + ), +} satisfies McpToolAccess.Handlers; /** Width and height from the IHDR chunk; a PNG that lacks one reports 0×0. */ export function pngDimensions(png: Uint8Array): { width: number; height: number } { @@ -258,8 +263,8 @@ export function pngDimensions(png: Uint8Array): { width: number; height: number const { device_screenshot, ...standardHandlers } = handlers; -export const layerStandard = DeviceStandardToolkit.toLayer(standardHandlers); +export const layerStandard = McpToolAccess.toLayer(DeviceStandardToolkit, standardHandlers); -export const layerScreenshot = DeviceScreenshotToolkit.toLayer({ +export const layerScreenshot = McpToolAccess.toLayer(DeviceScreenshotToolkit, { device_screenshot, }); diff --git a/apps/server/src/mcp/toolkits/device/tools.ts b/apps/server/src/mcp/toolkits/device/tools.ts index 90e75ceb25c70b016e9b31a60ea8b4e4762859a4..da6828e5c7129bfe55bb2ec2b2fb579bf493567e 100644 --- a/apps/server/src/mcp/toolkits/device/tools.ts +++ b/apps/server/src/mcp/toolkits/device/tools.ts @@ -1,4 +1,5 @@ import { + OrchestratorMcpFailure, DeviceToolCloseInput, DeviceToolError, DeviceToolListResult, @@ -15,8 +16,16 @@ import { Tool, Toolkit } from "effect/ai"; import * as McpInvocationContext from "../../McpInvocationContext.ts"; import * as DeviceService from "../../../device/DeviceService.ts"; +import * as ThreadManagementService from "../../../orchestration-v2/ThreadManagementService.ts"; -const dependencies = [McpInvocationContext.McpInvocationContext, DeviceService.DeviceService]; +const dependencies = [ + McpInvocationContext.McpInvocationContext, + ThreadManagementService.ThreadManagementService, + DeviceService.DeviceService, +]; + +/** What a device tool fails with, including the access gate's refusal. */ +const DeviceToolFailure = Schema.Union([DeviceToolError, OrchestratorMcpFailure]); /** * Deliberately a small surface: lifecycle, visibility for the user, and one @@ -36,7 +45,7 @@ const DeviceListTool = Tool.make("device_list", { ), }), success: DeviceToolListResult, - failure: DeviceToolError, + failure: DeviceToolFailure, dependencies, }) .annotate(Tool.Title, "List devices") @@ -50,7 +59,7 @@ const DeviceOpenTool = Tool.make("device_open", { "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.", parameters: DeviceToolOpenInput, success: DeviceToolOpenResult, - failure: DeviceToolError, + failure: DeviceToolFailure, dependencies: [...dependencies, FileSystem.FileSystem, Path.Path, ServerConfig.ServerConfig], }) .annotate(Tool.Title, "Open device") @@ -64,7 +73,7 @@ export const DeviceScreenshotTool = Tool.make("device_screenshot", { "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.", parameters: DeviceToolTargetInput, success: DeviceToolScreenshotResult, - failure: DeviceToolError, + failure: DeviceToolFailure, dependencies, }) .annotate(Tool.Title, "Screenshot device") @@ -80,7 +89,7 @@ const DeviceCloseTool = Tool.make("device_close", { success: Schema.Record(Schema.String, Schema.Never).annotate({ description: "The device was closed.", }), - failure: DeviceToolError, + failure: DeviceToolFailure, dependencies, }) .annotate(Tool.Title, "Close device") diff --git a/apps/server/src/mcp/toolkits/environment/handlers.test.ts b/apps/server/src/mcp/toolkits/environment/handlers.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..e4ae660cc004e1e5ea78be01bff64b8c8d64da27 --- /dev/null +++ b/apps/server/src/mcp/toolkits/environment/handlers.test.ts @@ -0,0 +1,100 @@ +import { expect, it } from "@effect/vitest"; +import { + DEFAULT_SERVER_SETTINGS, + EnvironmentId, + ProviderInstanceId, + ThreadId, + type OrchestrationV2ThreadShell, +} from "@t3tools/contracts"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; +import * as Layer from "effect/Layer"; +import * as Ref from "effect/Ref"; +import * as Stream from "effect/Stream"; + +import * as Environment from "../../../environment/ServerEnvironment.ts"; +import * as ThreadCommandExecutor from "../../../orchestration-v2/ThreadCommandExecutor.ts"; +import * as ThreadManagement from "../../../orchestration-v2/ThreadManagementService.ts"; +import * as Settings from "../../../serverSettings.ts"; +import * as McpInvocationContext from "../../McpInvocationContext.ts"; +import * as McpToolAccess from "../../McpToolAccess.ts"; +import { liveThreadShell } from "../../McpToolAccess.testkit.ts"; +import * as EnvironmentHandlers from "./handlers.ts"; +import { EnvironmentToolkit } from "./tools.ts"; + +const environmentId = EnvironmentId.make("environment:preferences"); +const threadId = ThreadId.make("thread:preferences"); + +it.effect("refuses a preferences update when the caller's turn ends while it waits", () => + Effect.gen(function* () { + const caller = yield* Ref.make(liveThreadShell(threadId)); + const updates = yield* Ref.make(0); + // Completes once the declaration's own check has read the caller. + const checked = yield* Deferred.make(); + const layerDependencies = Layer.mergeAll( + ThreadCommandExecutor.layer, + Layer.succeed(McpInvocationContext.McpInvocationContext, { + environmentId, + requestNamespace: "provider:preferences", + thread: { + threadId, + providerSessionId: "provider:preferences", + providerInstanceId: ProviderInstanceId.make("codex"), + }, + client: undefined, + capabilities: new Set(["orchestration" as const]), + issuedAt: 0, + }), + Layer.mock(ThreadManagement.ThreadManagementService)({ + getThreadShell: () => + Ref.get(caller).pipe(Effect.tap(() => Deferred.succeed(checked, undefined))), + }), + Layer.mock(Environment.ServerEnvironment)({ + getDescriptor: Effect.succeed({ + environmentId, + label: "Test", + platform: { os: "linux", arch: "x64" }, + serverVersion: "0.0.0", + capabilities: { repositoryIdentity: false }, + }), + }), + Layer.mock(Settings.ServerSettingsService)({ + getSettings: Effect.succeed(DEFAULT_SERVER_SETTINGS), + updateSettings: () => + Ref.update(updates, (count) => count + 1).pipe(Effect.as(DEFAULT_SERVER_SETTINGS)), + }), + ); + yield* Effect.gen(function* () { + const toolkit = yield* EnvironmentToolkit; + const executor = yield* ThreadCommandExecutor.ThreadCommandExecutor; + const held = yield* Deferred.make(); + const release = yield* Deferred.make(); + // A turn-completion command holds the thread's lock. + const holder = yield* executor + .withLock( + threadId, + Deferred.succeed(held, undefined).pipe(Effect.andThen(Deferred.await(release))), + ) + .pipe(Effect.forkChild); + yield* Deferred.await(held); + const update = yield* toolkit + .handle("t3_environment_preferences_update", { newWorktreesStartFromOrigin: true }) + .pipe(Stream.unwrap, Stream.runCollect, Effect.forkChild); + // The update passed its first check and waits for the lock; the turn then ends. + yield* Deferred.await(checked); + yield* Ref.update(caller, (shell) => ({ ...shell, activeRunId: null })); + yield* Deferred.succeed(release, undefined); + yield* Fiber.join(holder); + const result = yield* Fiber.join(update); + expect(result.at(-1)?.result).toMatchObject({ code: "parent_not_active" }); + expect(yield* Ref.get(updates)).toBe(0); + }).pipe( + Effect.provide( + McpToolAccess.HandlersLayer.layer(EnvironmentHandlers.layer).pipe( + Layer.provideMerge(layerDependencies), + ), + ), + ); + }), +); diff --git a/apps/server/src/mcp/toolkits/environment/handlers.ts b/apps/server/src/mcp/toolkits/environment/handlers.ts index 1aa3851111f940bdef8e398575702e063333e209..d7b84ca0599c43cea63cce4983a925de27a79d3f 100644 --- a/apps/server/src/mcp/toolkits/environment/handlers.ts +++ b/apps/server/src/mcp/toolkits/environment/handlers.ts @@ -4,7 +4,8 @@ import * as Environment from "../../../environment/ServerEnvironment.ts"; import * as ThreadCommandExecutor from "../../../orchestration-v2/ThreadCommandExecutor.ts"; import * as Settings from "../../../serverSettings.ts"; import * as McpInvocationContext from "../../McpInvocationContext.ts"; -import { readCaller, readFullAccessCaller, unavailable } from "../../threadAccess.ts"; +import * as McpToolAccess from "../../McpToolAccess.ts"; +import { readCaller, unavailable } from "../../threadAccess.ts"; import { EnvironmentToolkit } from "./tools.ts"; export function preferences(settings: ServerSettings) { @@ -28,26 +29,21 @@ export function preferences(settings: ServerSettings) { }, }; } -const access = (writable = false) => - Effect.gen(function* () { - const context = yield* writable - ? readFullAccessCaller( - "Preference updates require a live full-access/default thread or a full-access client.", - ) - : readCaller(); - const environment = yield* Environment.ServerEnvironment; - const descriptor = yield* environment.getDescriptor; - if (descriptor.environmentId !== context.scope.environmentId) - return yield* new OrchestratorMcpFailure({ - code: "capability_denied", - message: "This credential belongs to another environment.", - }); - return { ...context, descriptor, settings: yield* Settings.ServerSettingsService }; - }); -export const layer = EnvironmentToolkit.toLayer({ - t3_environment_read: () => +const access = Effect.gen(function* () { + const context = yield* readCaller(); + const environment = yield* Environment.ServerEnvironment; + const descriptor = yield* environment.getDescriptor; + if (descriptor.environmentId !== context.scope.environmentId) + return yield* new OrchestratorMcpFailure({ + code: "capability_denied", + message: "This credential belongs to another environment.", + }); + return { ...context, descriptor, settings: yield* Settings.ServerSettingsService }; +}); +export const layer = McpToolAccess.toLayer(EnvironmentToolkit, { + t3_environment_read: McpToolAccess.reads(() => Effect.gen(function* () { - const { descriptor, settings } = yield* access(); + const { descriptor, settings } = yield* access; const current = yield* settings.getSettings.pipe(Effect.mapError(unavailable)); return { environmentId: descriptor.environmentId, @@ -57,12 +53,15 @@ export const layer = EnvironmentToolkit.toLayer({ preferences: preferences(current), }; }), - t3_environment_preferences_update: (patch) => + ), + t3_environment_preferences_update: McpToolAccess.writesEnvironment((patch, check) => Effect.gen(function* () { const scope = yield* McpInvocationContext.McpInvocationContext; const executor = yield* ThreadCommandExecutor.ThreadCommandExecutor; const update = Effect.gen(function* () { - const { settings } = yield* access(true); + // The turn may have ended, or the thread's modes changed, while this waited for the lock. + yield* check; + const { settings } = yield* access; return preferences( yield* settings.updateSettings(patch).pipe(Effect.mapError(unavailable)), ); @@ -72,4 +71,5 @@ export const layer = EnvironmentToolkit.toLayer({ ? update : executor.withLock(scope.thread.threadId, update); }), + ), }); diff --git a/apps/server/src/mcp/toolkits/html/handlers.ts b/apps/server/src/mcp/toolkits/html/handlers.ts index 3e3d0226ed9cd35e76f7c7415dde0d1dc094a692..8daa9bd8d2297333053018e93292587a36d10e56 100644 --- a/apps/server/src/mcp/toolkits/html/handlers.ts +++ b/apps/server/src/mcp/toolkits/html/handlers.ts @@ -3,7 +3,7 @@ import * as Effect from "effect/Effect"; import * as HtmlRender from "../../../htmlRender/HtmlRender.ts"; import * as McpInvocationContext from "../../McpInvocationContext.ts"; -import { readMutationCaller } from "../../threadAccess.ts"; +import * as McpToolAccess from "../../McpToolAccess.ts"; import { HtmlPreviewToolkit, HtmlRenderToolkit, type HtmlToolkit } from "./tools.ts"; const INVALID_PAGE_ERRORS = new Set([ @@ -20,14 +20,10 @@ const toFailure = (error: { readonly _tag: string; readonly message: string }) = }); const handlers = { - html_preview: (input) => + // The headless browser runs on the host and can open local files, so only + // agents T3 launched, which already work on this machine, get it. + html_preview: McpToolAccess.readsAsCaller((input) => Effect.gen(function* () { - // The headless browser runs on the host and can open local files, so - // only agents T3 launched, which already work on this machine, get it. - yield* McpInvocationContext.requireThreadScope( - yield* McpInvocationContext.McpInvocationContext, - "html_preview", - ); const htmlRender = yield* HtmlRender.HtmlRender; const { png, ...preview } = yield* htmlRender.preview(input).pipe(Effect.mapError(toFailure)); return { @@ -40,11 +36,11 @@ const handlers = { }, }; }), - html_render: (input) => + ), + // The page is stored in the calling thread, so it needs that thread's live run. + html_render: McpToolAccess.actsAsCaller((input) => Effect.gen(function* () { - // The page is stored in the calling thread, so it needs that thread's - // live run, like any other write. - const { scope } = yield* readMutationCaller(); + const scope = yield* McpInvocationContext.McpInvocationContext; const { thread } = yield* McpInvocationContext.requireThreadScope(scope, "html_render"); const htmlRender = yield* HtmlRender.HtmlRender; const reference = yield* htmlRender @@ -56,12 +52,13 @@ const handlers = { "Shown to the reader above your reply. Don't mention or describe the page; reply with only what it doesn't already say.", }; }), -} satisfies Parameters[0]; + ), +} satisfies McpToolAccess.Handlers; -export const layerPreview = HtmlPreviewToolkit.toLayer({ +export const layerPreview = McpToolAccess.toLayer(HtmlPreviewToolkit, { html_preview: handlers.html_preview, }); -export const layerRender = HtmlRenderToolkit.toLayer({ +export const layerRender = McpToolAccess.toLayer(HtmlRenderToolkit, { html_render: handlers.html_render, }); diff --git a/apps/server/src/mcp/toolkits/html/tools.ts b/apps/server/src/mcp/toolkits/html/tools.ts index 6160b9b1a3abe68f5629b7528461beac761180c4..c92e2e466f02e4fb3a9c773a792afbb531b5084b 100644 --- a/apps/server/src/mcp/toolkits/html/tools.ts +++ b/apps/server/src/mcp/toolkits/html/tools.ts @@ -56,7 +56,11 @@ export const HtmlPreviewTool = Tool.make("html_preview", { }), }), failure: OrchestratorMcpFailure, - dependencies: [McpInvocationContext.McpInvocationContext, HtmlRender.HtmlRender], + dependencies: [ + McpInvocationContext.McpInvocationContext, + ThreadManagementService.ThreadManagementService, + HtmlRender.HtmlRender, + ], }) .annotate(Tool.Title, "Preview HTML") .annotate(Tool.Readonly, true) diff --git a/apps/server/src/mcp/toolkits/orchestrator/handlers.ts b/apps/server/src/mcp/toolkits/orchestrator/handlers.ts index 197b06097def0f964e62a25cdfc6aa6841628d36..e71b3d13888af17c333b1ef59142dacd52bcc97a 100644 --- a/apps/server/src/mcp/toolkits/orchestrator/handlers.ts +++ b/apps/server/src/mcp/toolkits/orchestrator/handlers.ts @@ -2,106 +2,135 @@ import { OrchestratorToolkit } from "./tools.ts"; import * as Effect from "effect/Effect"; import * as McpInvocationContext from "../../McpInvocationContext.ts"; +import * as McpToolAccess from "../../McpToolAccess.ts"; import * as OrchestratorMcpService from "../../OrchestratorMcpService.ts"; import * as ThreadMetadataMcpService from "../../ThreadMetadataMcpService.ts"; const handlers = { - orchestrator_capabilities: () => + orchestrator_capabilities: McpToolAccess.reads(() => Effect.gen(function* () { const scope = yield* McpInvocationContext.McpInvocationContext; const service = yield* OrchestratorMcpService.OrchestratorMcpService; return yield* service.capabilities(scope); }), - delegate_task: (input) => + ), + delegate_task: McpToolAccess.actsAsCaller((input) => Effect.gen(function* () { const scope = yield* McpInvocationContext.McpInvocationContext; const service = yield* OrchestratorMcpService.OrchestratorMcpService; return yield* service.delegateTask(scope, input); }), - task_status: ({ taskId }) => + ), + task_status: McpToolAccess.actsAsCaller(({ taskId }) => Effect.gen(function* () { const scope = yield* McpInvocationContext.McpInvocationContext; const service = yield* OrchestratorMcpService.OrchestratorMcpService; return yield* service.taskStatus(scope, taskId); }), - task_cancel: (input) => + ), + task_cancel: McpToolAccess.actsAsCaller((input) => Effect.gen(function* () { const scope = yield* McpInvocationContext.McpInvocationContext; const service = yield* OrchestratorMcpService.OrchestratorMcpService; return yield* service.cancelTask(scope, input); }), - schedule_task: (input) => - Effect.gen(function* () { - const scope = yield* McpInvocationContext.McpInvocationContext; - const service = yield* OrchestratorMcpService.OrchestratorMcpService; - return yield* service.scheduleTask(scope, input); - }), - list_scheduled_tasks: (input) => + ), + schedule_task: McpToolAccess.startsThreads( + // A scheduled task runs with the caller's own modes. + () => ({}), + (input) => + Effect.gen(function* () { + const scope = yield* McpInvocationContext.McpInvocationContext; + const service = yield* OrchestratorMcpService.OrchestratorMcpService; + return yield* service.scheduleTask(scope, input); + }), + ), + list_scheduled_tasks: McpToolAccess.reads((input) => Effect.gen(function* () { const scope = yield* McpInvocationContext.McpInvocationContext; const service = yield* OrchestratorMcpService.OrchestratorMcpService; return yield* service.listScheduledTasks(scope, input); }), - update_scheduled_task: (input) => + ), + update_scheduled_task: McpToolAccess.writes((input) => Effect.gen(function* () { const scope = yield* McpInvocationContext.McpInvocationContext; const service = yield* OrchestratorMcpService.OrchestratorMcpService; return yield* service.updateScheduledTask(scope, input); }), - delete_scheduled_task: (input) => + ), + delete_scheduled_task: McpToolAccess.writes((input) => Effect.gen(function* () { const scope = yield* McpInvocationContext.McpInvocationContext; const service = yield* OrchestratorMcpService.OrchestratorMcpService; return yield* service.deleteScheduledTask(scope, input); }), - request_secret: (input) => + ), + request_secret: McpToolAccess.actsAsCaller((input) => Effect.gen(function* () { const scope = yield* McpInvocationContext.McpInvocationContext; const service = yield* OrchestratorMcpService.OrchestratorMcpService; return yield* service.requestSecret(scope, input); }), - create_threads: (input) => + ), + create_threads: McpToolAccess.actsAsCaller((input) => Effect.gen(function* () { const scope = yield* McpInvocationContext.McpInvocationContext; const service = yield* OrchestratorMcpService.OrchestratorMcpService; return yield* service.createThreads(scope, input); }), - t3_thread_list: (input) => + ), + t3_thread_list: McpToolAccess.reads((input) => Effect.gen(function* () { const scope = yield* McpInvocationContext.McpInvocationContext; const service = yield* OrchestratorMcpService.OrchestratorMcpService; return yield* service.listThreads(scope, input); }), - t3_thread_read: (input) => + ), + // Reading a child's finished result also acknowledges its delivery to the + // reader's own thread. That is bookkeeping on the caller's own subagent, not + // a change to anything it reads, so this stays a read. + t3_thread_read: McpToolAccess.reads((input) => Effect.gen(function* () { const scope = yield* McpInvocationContext.McpInvocationContext; const service = yield* OrchestratorMcpService.OrchestratorMcpService; return yield* service.readThread(scope, input); }), - t3_thread_update: (input) => - Effect.gen(function* () { - const scope = yield* McpInvocationContext.McpInvocationContext; - const service = yield* ThreadMetadataMcpService.ThreadMetadataMcpService; - return yield* service.update(scope, input); - }), - t3_thread_send: (input) => - Effect.gen(function* () { - const scope = yield* McpInvocationContext.McpInvocationContext; - const service = yield* OrchestratorMcpService.OrchestratorMcpService; - return yield* service.sendToThread(scope, input); - }), - t3_thread_wait: (input) => + ), + t3_thread_update: McpToolAccess.writesThreads( + (input) => [input.threadId], + (input) => + Effect.gen(function* () { + const scope = yield* McpInvocationContext.McpInvocationContext; + const service = yield* ThreadMetadataMcpService.ThreadMetadataMcpService; + return yield* service.update(scope, input); + }), + ), + t3_thread_send: McpToolAccess.writesThreads( + (input) => [input.threadId], + (input) => + Effect.gen(function* () { + const scope = yield* McpInvocationContext.McpInvocationContext; + const service = yield* OrchestratorMcpService.OrchestratorMcpService; + return yield* service.sendToThread(scope, input); + }), + ), + t3_thread_wait: McpToolAccess.reads((input) => Effect.gen(function* () { const scope = yield* McpInvocationContext.McpInvocationContext; const service = yield* OrchestratorMcpService.OrchestratorMcpService; return yield* service.waitForThread(scope, input); }), - t3_thread_interrupt: (input) => - Effect.gen(function* () { - const scope = yield* McpInvocationContext.McpInvocationContext; - const service = yield* OrchestratorMcpService.OrchestratorMcpService; - return yield* service.interruptThread(scope, input); - }), -} satisfies Parameters[0]; + ), + t3_thread_interrupt: McpToolAccess.writesThreads( + (input) => [input.threadId], + (input) => + Effect.gen(function* () { + const scope = yield* McpInvocationContext.McpInvocationContext; + const service = yield* OrchestratorMcpService.OrchestratorMcpService; + return yield* service.interruptThread(scope, input); + }), + ), +} satisfies McpToolAccess.Handlers; -export const layer = OrchestratorToolkit.toLayer(handlers); +export const layer = McpToolAccess.toLayer(OrchestratorToolkit, handlers); diff --git a/apps/server/src/mcp/toolkits/orchestrator/tools.ts b/apps/server/src/mcp/toolkits/orchestrator/tools.ts index 6d4f0366d816c4255de9cfb79f568330fa155317..2779281fe58324c708f7640dc03ca760aea727bf 100644 --- a/apps/server/src/mcp/toolkits/orchestrator/tools.ts +++ b/apps/server/src/mcp/toolkits/orchestrator/tools.ts @@ -32,16 +32,19 @@ import { } from "@t3tools/contracts"; import { Tool, Toolkit } from "effect/ai"; +import * as ThreadManagementService from "../../../orchestration-v2/ThreadManagementService.ts"; import * as McpInvocationContext from "../../McpInvocationContext.ts"; import * as OrchestratorMcpService from "../../OrchestratorMcpService.ts"; import * as ThreadMetadataMcpService from "../../ThreadMetadataMcpService.ts"; const dependencies = [ McpInvocationContext.McpInvocationContext, + ThreadManagementService.ThreadManagementService, OrchestratorMcpService.OrchestratorMcpService, ]; const threadMetadataDependencies = [ McpInvocationContext.McpInvocationContext, + ThreadManagementService.ThreadManagementService, ThreadMetadataMcpService.ThreadMetadataMcpService, ]; diff --git a/apps/server/src/mcp/toolkits/preview/handlers.ts b/apps/server/src/mcp/toolkits/preview/handlers.ts index e56e22668dc59ae8866da9b1088677b984cf19e7..70b20677a502eaf1ccb22e60308fbad18e144838 100644 --- a/apps/server/src/mcp/toolkits/preview/handlers.ts +++ b/apps/server/src/mcp/toolkits/preview/handlers.ts @@ -29,6 +29,7 @@ import { import { resolveAttachmentRelativePath } from "../../../attachmentPaths.ts"; import * as ServerConfig from "../../../config.ts"; import * as McpInvocationContext from "../../McpInvocationContext.ts"; +import * as McpToolAccess from "../../McpToolAccess.ts"; import * as PreviewAutomationBroker from "../../PreviewAutomationBroker.ts"; import { PreviewSnapshotToolkit, PreviewStandardToolkit, PreviewToolkit } from "./tools.ts"; @@ -189,41 +190,64 @@ export const claimPreviewRecording = Effect.fn("PreviewToolkit.claimRecording")( }); const handlers = { - preview_dialog: (input) => invokeTargeted("dialog", input), - preview_status: (input) => invokeTargeted("status", input ?? {}), - preview_open: (input) => + preview_dialog: McpToolAccess.actsAsCaller((input) => + invokeTargeted("dialog", input), + ), + preview_status: McpToolAccess.readsAsCaller((input) => + invokeTargeted("status", input ?? {}), + ), + preview_open: McpToolAccess.actsAsCaller((input) => invokeTargeted("open", normalizePreviewOpenInput(input)), - preview_navigate: (input) => + ), + preview_navigate: McpToolAccess.actsAsCaller((input) => invokeTargeted("navigate", input, input.timeoutMs), - preview_resize: (input) => + ), + preview_resize: McpToolAccess.actsAsCaller((input) => invokeTargeted("resize", input, input.timeoutMs), - preview_set_appearance: (input) => + ), + preview_set_appearance: McpToolAccess.actsAsCaller((input) => invokeTargeted("setColorScheme", input), - preview_snapshot: (input) => { + ), + preview_snapshot: McpToolAccess.readsAsCaller((input) => { // Output selection and saving are MCP-only; the browser still produces a complete snapshot. const { includeImage: _includeImage, save: _save, ...operationInput } = input ?? {}; return invokeTargeted("snapshot", operationInput); - }, - preview_click: (input) => invokeTargeted("click", input, input.timeoutMs), - preview_type: (input) => invokeTargeted("type", input, input.timeoutMs), - preview_hover: (input) => invokeTargeted("hover", input, input.timeoutMs), - preview_select: (input) => + }), + preview_click: McpToolAccess.actsAsCaller((input) => + invokeTargeted("click", input, input.timeoutMs), + ), + preview_type: McpToolAccess.actsAsCaller((input) => + invokeTargeted("type", input, input.timeoutMs), + ), + preview_hover: McpToolAccess.actsAsCaller((input) => + invokeTargeted("hover", input, input.timeoutMs), + ), + preview_select: McpToolAccess.actsAsCaller((input) => invokeTargeted("select", input, input.timeoutMs), - preview_drag: (input) => invokeTargeted("drag", input, input.timeoutMs), - preview_upload: (input) => invokeTargeted("upload", input, input.timeoutMs), - preview_press: (input) => invokeTargeted("press", input), - preview_scroll: (input) => invokeTargeted("scroll", input), - preview_evaluate: ({ tabId, ...input }) => + ), + preview_drag: McpToolAccess.actsAsCaller((input) => + invokeTargeted("drag", input, input.timeoutMs), + ), + preview_upload: McpToolAccess.actsAsCaller((input) => + invokeTargeted("upload", input, input.timeoutMs), + ), + preview_press: McpToolAccess.actsAsCaller((input) => invokeTargeted("press", input)), + preview_scroll: McpToolAccess.actsAsCaller((input) => invokeTargeted("scroll", input)), + preview_evaluate: McpToolAccess.actsAsCaller(({ tabId, ...input }) => invoke("evaluate", input, undefined, tabId).pipe( Effect.map(({ result, toolIcon }) => ({ value: result ?? null, ...(toolIcon ? { toolIcon } : {}), })), ), - preview_wait_for: (input) => invokeTargeted("waitFor", input, input.timeoutMs), - preview_recording_start: (input) => + ), + preview_wait_for: McpToolAccess.readsAsCaller((input) => + invokeTargeted("waitFor", input, input.timeoutMs), + ), + preview_recording_start: McpToolAccess.actsAsCaller((input) => invokeTargeted("recordingStart", input ?? {}), - preview_recording_stop: (input) => + ), + preview_recording_stop: McpToolAccess.actsAsCaller((input) => Effect.gen(function* () { const scope = yield* McpInvocationContext.requireThreadMcpCapability("preview"); const { tabId, ...operationInput } = input; @@ -236,12 +260,13 @@ const handlers = { const artifact = yield* claimPreviewRecording(scope.thread.threadId, response.result); return { ...artifact, ...(response.toolIcon ? { toolIcon: response.toolIcon } : {}) }; }), -} satisfies Parameters[0]; + ), +} satisfies McpToolAccess.Handlers; const { preview_snapshot, ...standardHandlers } = handlers; -export const layerStandard = PreviewStandardToolkit.toLayer(standardHandlers); +export const layerStandard = McpToolAccess.toLayer(PreviewStandardToolkit, standardHandlers); -export const layerSnapshot = PreviewSnapshotToolkit.toLayer({ +export const layerSnapshot = McpToolAccess.toLayer(PreviewSnapshotToolkit, { preview_snapshot, }); diff --git a/apps/server/src/mcp/toolkits/preview/tools.ts b/apps/server/src/mcp/toolkits/preview/tools.ts index 83e78adec15113fb7c5282bee35b11bae7b20e65..9c94a1d446c6def4f5cca9ee2c16c28b7a76d05e 100644 --- a/apps/server/src/mcp/toolkits/preview/tools.ts +++ b/apps/server/src/mcp/toolkits/preview/tools.ts @@ -1,4 +1,5 @@ import { + OrchestratorMcpFailure, ToolActivityIcon, PreviewAutomationClickInput, PreviewAutomationDialogInput, @@ -32,12 +33,17 @@ import { Tool, Toolkit } from "effect/ai"; import * as McpInvocationContext from "../../McpInvocationContext.ts"; import * as PreviewAutomationBroker from "../../PreviewAutomationBroker.ts"; import * as ServerConfig from "../../../config.ts"; +import * as ThreadManagementService from "../../../orchestration-v2/ThreadManagementService.ts"; const dependencies = [ McpInvocationContext.McpInvocationContext, + ThreadManagementService.ThreadManagementService, PreviewAutomationBroker.PreviewAutomationBroker, ]; +/** What a browser action fails with, including the access gate's refusal. */ +const PreviewToolFailure = Schema.Union([PreviewAutomationError, OrchestratorMcpFailure]); + const presentationFields = { toolIcon: Schema.optional(ToolActivityIcon) }; const PreviewActionResult = Schema.Struct(presentationFields).annotate({ @@ -61,7 +67,7 @@ const PreviewStatusTool = Tool.make("preview_status", { "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.", parameters: PreviewAutomationTabTargetInput, success: PreviewAutomationStatus, - failure: PreviewAutomationError, + failure: PreviewToolFailure, dependencies, }) .annotate(Tool.Title, "Get preview status") @@ -75,7 +81,7 @@ const PreviewOpenTool = browserTool( "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.", parameters: PreviewAutomationOpenInput, success: PreviewAutomationStatus, - failure: PreviewAutomationError, + failure: PreviewToolFailure, dependencies, }) .annotate(Tool.Title, "Open browser preview") @@ -88,7 +94,7 @@ const PreviewDialogTool = browserTool( "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.", parameters: PreviewAutomationDialogInput, success: PreviewAutomationStatus, - failure: PreviewAutomationError, + failure: PreviewToolFailure, dependencies, }).annotate(Tool.Title, "Resolve browser dialog"), ); @@ -99,7 +105,7 @@ const PreviewNavigateTool = safeBrowserTool( "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.", parameters: PreviewAutomationNavigateInput, success: PreviewAutomationStatus, - failure: PreviewAutomationError, + failure: PreviewToolFailure, dependencies, }).annotate(Tool.Title, "Navigate browser preview"), ); @@ -110,7 +116,7 @@ const PreviewResizeTool = safeBrowserTool( "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.", parameters: PreviewAutomationResizeInput, success: Schema.Struct({ ...PreviewAutomationResizeResult.fields, ...presentationFields }), - failure: PreviewAutomationError, + failure: PreviewToolFailure, dependencies, }) .annotate(Tool.Title, "Resize browser viewport") @@ -126,7 +132,7 @@ const PreviewSetAppearanceTool = safeBrowserTool( ...PreviewAutomationSetColorSchemeResult.fields, ...presentationFields, }), - failure: PreviewAutomationError, + failure: PreviewToolFailure, dependencies, }) .annotate(Tool.Title, "Set preview appearance") @@ -153,7 +159,7 @@ export const PreviewSnapshotTool = readonlyBrowserTool( ), }), success: PreviewAutomationSnapshot, - failure: PreviewAutomationError, + failure: PreviewToolFailure, dependencies, }).annotate(Tool.Title, "Inspect browser page"), ); @@ -164,7 +170,7 @@ const PreviewClickTool = browserTool( "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.", parameters: PreviewAutomationClickInput, success: PreviewActionResult, - failure: PreviewAutomationError, + failure: PreviewToolFailure, dependencies, }).annotate(Tool.Title, "Click preview page"), ); @@ -175,7 +181,7 @@ const PreviewTypeTool = browserTool( "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.", parameters: PreviewAutomationTypeInput, success: PreviewActionResult, - failure: PreviewAutomationError, + failure: PreviewToolFailure, dependencies, }).annotate(Tool.Title, "Type into preview page"), ); @@ -186,7 +192,7 @@ const PreviewHoverTool = safeBrowserTool( "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.", parameters: PreviewAutomationHoverInput, success: PreviewActionResult, - failure: PreviewAutomationError, + failure: PreviewToolFailure, dependencies, }).annotate(Tool.Title, "Hover preview page"), ); @@ -200,7 +206,7 @@ const PreviewSelectTool = browserTool( ...PreviewAutomationSelectResult.fields, ...presentationFields, }), - failure: PreviewAutomationError, + failure: PreviewToolFailure, dependencies, }).annotate(Tool.Title, "Select preview option"), ); @@ -211,7 +217,7 @@ const PreviewDragTool = browserTool( "Drag one element onto another in the tab selected by tabId, or this agent session's current tab when omitted. Server browser tabs only.", parameters: PreviewAutomationDragInput, success: PreviewActionResult, - failure: PreviewAutomationError, + failure: PreviewToolFailure, dependencies, }).annotate(Tool.Title, "Drag in preview page"), ); @@ -222,7 +228,7 @@ const PreviewUploadTool = browserTool( "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 to set its files without a picker. Server browser tabs only.", parameters: PreviewAutomationUploadInput, success: PreviewActionResult, - failure: PreviewAutomationError, + failure: PreviewToolFailure, dependencies, }).annotate(Tool.Title, "Upload files to preview page"), ); @@ -233,7 +239,7 @@ const PreviewPressTool = browserTool( "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']}.", parameters: PreviewAutomationPressInput, success: PreviewActionResult, - failure: PreviewAutomationError, + failure: PreviewToolFailure, dependencies, }).annotate(Tool.Title, "Press key in preview page"), ); @@ -244,7 +250,7 @@ const PreviewScrollTool = safeBrowserTool( "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.", parameters: PreviewAutomationScrollInput, success: PreviewActionResult, - failure: PreviewAutomationError, + failure: PreviewToolFailure, dependencies, }).annotate(Tool.Title, "Scroll preview page"), ); @@ -267,7 +273,7 @@ const PreviewEvaluateTool = browserTool( "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.", parameters: PreviewAutomationEvaluateInput, success: PreviewEvaluateResult, - failure: PreviewAutomationError, + failure: PreviewToolFailure, dependencies, }).annotate(Tool.Title, "Evaluate JavaScript in preview"), ); @@ -278,7 +284,7 @@ const PreviewWaitForTool = readonlyBrowserTool( "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.", parameters: PreviewAutomationWaitForInput, success: PreviewActionResult, - failure: PreviewAutomationError, + failure: PreviewToolFailure, dependencies, }).annotate(Tool.Title, "Wait for preview page condition"), ); @@ -289,7 +295,7 @@ const PreviewRecordingStartTool = safeBrowserTool( "Start recording the collaborative browser tab selected by tabId, or this agent session's current tab when omitted.", parameters: PreviewAutomationTabTargetInput, success: Schema.Struct({ ...PreviewAutomationRecordingStatus.fields, ...presentationFields }), - failure: PreviewAutomationError, + failure: PreviewToolFailure, dependencies, }).annotate(Tool.Title, "Start browser recording"), ); @@ -300,7 +306,7 @@ const PreviewRecordingStopTool = safeBrowserTool( "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.", parameters: PreviewAutomationTabTargetInput, success: Schema.Struct({ ...PreviewAutomationRecordingArtifact.fields, ...presentationFields }), - failure: PreviewAutomationError, + failure: PreviewToolFailure, dependencies: [...dependencies, FileSystem.FileSystem, ServerConfig.ServerConfig], }).annotate(Tool.Title, "Stop browser recording"), ); diff --git a/apps/server/src/mcp/toolkits/previewControls/handlers.test.ts b/apps/server/src/mcp/toolkits/previewControls/handlers.test.ts index 00cc641e5a62ea08b9cdfec9038b5fb5fe4ccc83..4cd06bc4233b7a6ba4eac152b143a6bbd19ef51f 100644 --- a/apps/server/src/mcp/toolkits/previewControls/handlers.test.ts +++ b/apps/server/src/mcp/toolkits/previewControls/handlers.test.ts @@ -17,6 +17,8 @@ import * as ServerConfig from "../../../config.ts"; import * as Preview from "../../../preview/Manager.ts"; import * as ServerSettings from "../../../serverSettings.ts"; import * as McpInvocationContext from "../../McpInvocationContext.ts"; +import * as McpToolAccess from "../../McpToolAccess.ts"; +import * as McpToolAccessTestkit from "../../McpToolAccess.testkit.ts"; import * as PreviewAutomationBroker from "../../PreviewAutomationBroker.ts"; import * as PreviewControlsHandlers from "./handlers.ts"; import { PreviewControlsToolkit } from "./tools.ts"; @@ -64,12 +66,17 @@ it.effect.each([ PreviewAutomationBroker.layer.pipe(Layer.provide(NodeServices.layer)), Layer.succeed(Preview.PreviewManager, manager), Layer.succeed(McpInvocationContext.McpInvocationContext, scope), + McpToolAccessTestkit.liveThreadsLayer, Layer.mock(ServerSettings.ServerSettingsService)({ getSettings: Effect.succeed(settings), }), ); const toolkit = yield* PreviewControlsToolkit.pipe( - Effect.provide(PreviewControlsHandlers.layer.pipe(Layer.provide(layerDependencies))), + Effect.provide( + McpToolAccess.HandlersLayer.layer(PreviewControlsHandlers.layer).pipe( + Layer.provide(layerDependencies), + ), + ), ); const listed = yield* toolkit .handle("t3_preview_list", {}) diff --git a/apps/server/src/mcp/toolkits/previewControls/handlers.ts b/apps/server/src/mcp/toolkits/previewControls/handlers.ts index 2f77a30c2bcdee21ec38f8753a7e91136cce8875..bdb2bad2ad6e6109df1295a1156fe970fb5875ac 100644 --- a/apps/server/src/mcp/toolkits/previewControls/handlers.ts +++ b/apps/server/src/mcp/toolkits/previewControls/handlers.ts @@ -1,6 +1,7 @@ import * as Effect from "effect/Effect"; import * as Preview from "../../../preview/Manager.ts"; import { requireThreadMcpCapability } from "../../McpInvocationContext.ts"; +import * as McpToolAccess from "../../McpToolAccess.ts"; import { unavailable } from "../../threadAccess.ts"; import { PreviewControlsToolkit } from "./tools.ts"; import * as PreviewAutomationBroker from "../../PreviewAutomationBroker.ts"; @@ -10,8 +11,8 @@ const access = Effect.gen(function* () { const scope = yield* requireThreadMcpCapability("preview"); return { scope, manager: yield* Preview.PreviewManager }; }); -export const layer = PreviewControlsToolkit.toLayer({ - t3_preview_list: (input) => +export const layer = McpToolAccess.toLayer(PreviewControlsToolkit, { + t3_preview_list: McpToolAccess.readsAsCaller((input) => Effect.gen(function* () { const { scope, manager } = yield* access; const result = yield* manager.list({ threadId: scope.thread.threadId }); @@ -23,7 +24,8 @@ export const layer = PreviewControlsToolkit.toLayer({ nextCursor: end < result.sessions.length ? end : null, }; }), - t3_preview_close: (input) => + ), + t3_preview_close: McpToolAccess.actsAsCaller((input) => Effect.gen(function* () { const { scope, manager } = yield* access; const { sessions } = yield* manager.list({ threadId: scope.thread.threadId }); @@ -41,4 +43,5 @@ export const layer = PreviewControlsToolkit.toLayer({ .pipe(Effect.mapError(unavailable)); return {}; }), + ), }); diff --git a/apps/server/src/mcp/toolkits/previewControls/tools.ts b/apps/server/src/mcp/toolkits/previewControls/tools.ts index 33db4a522eb56ea1bb958551e08ec28dfc2489b2..2a14ee7b5e64acccd9ba88a7d6c4a5dd68d293ca 100644 --- a/apps/server/src/mcp/toolkits/previewControls/tools.ts +++ b/apps/server/src/mcp/toolkits/previewControls/tools.ts @@ -7,6 +7,7 @@ import { } from "@t3tools/contracts"; import * as Schema from "effect/Schema"; import { Tool, Toolkit } from "effect/ai"; +import * as ThreadManagementService from "../../../orchestration-v2/ThreadManagementService.ts"; import * as PreviewManager from "../../../preview/Manager.ts"; import * as PreviewAutomationBroker from "../../PreviewAutomationBroker.ts"; import * as McpInvocationContext from "../../McpInvocationContext.ts"; @@ -16,6 +17,7 @@ const shared = { failureMode: "return" as const, dependencies: [ McpInvocationContext.McpInvocationContext, + ThreadManagementService.ThreadManagementService, PreviewManager.PreviewManager, PreviewAutomationBroker.PreviewAutomationBroker, ], diff --git a/apps/server/src/mcp/toolkits/project/handlers.test.ts b/apps/server/src/mcp/toolkits/project/handlers.test.ts index cf7074136edfcf806ad8ab5d316d57543fb8faea..64e96d949b7fa35147fdcc55eb915038ee8828d1 100644 --- a/apps/server/src/mcp/toolkits/project/handlers.test.ts +++ b/apps/server/src/mcp/toolkits/project/handlers.test.ts @@ -10,6 +10,8 @@ import { import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; import * as NodeServices from "@effect/platform-node/NodeServices"; import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Stream from "effect/Stream"; @@ -19,7 +21,10 @@ import * as ThreadManagement from "../../../orchestration-v2/ThreadManagementSer import * as ServerConfig from "../../../config.ts"; import * as Project from "../../../project/ProjectService.ts"; import * as ManagedProjectFolders from "../../../project/ManagedProjectFolders.ts"; +import * as GitVcsDriver from "../../../vcs/GitVcsDriver.ts"; +import * as VcsProcess from "../../../vcs/VcsProcess.ts"; import * as McpInvocationContext from "../../McpInvocationContext.ts"; +import * as McpToolAccess from "../../McpToolAccess.ts"; import * as ProjectHandlers from "./handlers.ts"; import { ProjectToolkit } from "./tools.ts"; @@ -73,13 +78,18 @@ it.effect("attributes a launched thread's first message to the calling thread", }), Layer.mock(Project.ProjectService)({}), Layer.mock(ManagedProjectFolders.ManagedProjectFolders)({ namedProjectsRoot: "/projects" }), + Layer.mock(GitVcsDriver.GitVcsDriver)({}), NodeServices.layer, ServerConfig.layerTest(process.cwd(), { prefix: "t3-source-link-" }).pipe( Layer.provide(NodeServices.layer), ), ); const toolkit = yield* ProjectToolkit.pipe( - Effect.provide(ProjectHandlers.layer.pipe(Layer.provide(layerDependencies))), + Effect.provide( + McpToolAccess.HandlersLayer.layer(ProjectHandlers.layer).pipe( + Layer.provide(layerDependencies), + ), + ), ); const result = yield* toolkit .handle("t3_thread_launch", { title: "Audit", message: "Review the change" }) @@ -142,13 +152,18 @@ it.effect("launches a scratch thread into the Scratch project", () => namedProjectsRoot: "/projects", ensureScratchProject: Effect.succeed({ projectId: scratchProjectId }), }), + Layer.mock(GitVcsDriver.GitVcsDriver)({}), NodeServices.layer, ServerConfig.layerTest(process.cwd(), { prefix: "t3-scratch-launch-" }).pipe( Layer.provide(NodeServices.layer), ), ); const toolkit = yield* ProjectToolkit.pipe( - Effect.provide(ProjectHandlers.layer.pipe(Layer.provide(layerDependencies))), + Effect.provide( + McpToolAccess.HandlersLayer.layer(ProjectHandlers.layer).pipe( + Layer.provide(layerDependencies), + ), + ), ); const handle = (params: Parameters>[1]) => toolkit @@ -240,13 +255,18 @@ it.effect("starts a project from just a title when workspaceRoot is omitted", () }; }), }), + Layer.mock(GitVcsDriver.GitVcsDriver)({}), NodeServices.layer, ServerConfig.layerTest(process.cwd(), { prefix: "t3-named-project-" }).pipe( Layer.provide(NodeServices.layer), ), ); const toolkit = yield* ProjectToolkit.pipe( - Effect.provide(ProjectHandlers.layer.pipe(Layer.provide(layerDependencies))), + Effect.provide( + McpToolAccess.HandlersLayer.layer(ProjectHandlers.layer).pipe( + Layer.provide(layerDependencies), + ), + ), ); const handle = (params: Parameters>[1]) => toolkit @@ -280,6 +300,7 @@ it.effect("starts a project from just a title when workspaceRoot is omitted", () const clientLaunchHarness = (input: { readonly runtimeModeCeiling: "approval-required" | "auto-accept-edits" | "auto" | "full-access"; readonly launched: Array; + readonly workspaceRoot?: string; }) => { const projectId = ProjectId.make("project:client-target"); const modelSelection = { instanceId: ProviderInstanceId.make("claude"), model: "claude-opus" }; @@ -319,15 +340,25 @@ const clientLaunchHarness = (input: { getById: (id) => Effect.succeed( id === projectId - ? Option.some({ id, defaultModelSelection: modelSelection } as unknown as ProjectRecord) + ? Option.some({ + id, + workspaceRoot: input.workspaceRoot ?? "/projects/client-target", + defaultModelSelection: modelSelection, + } as unknown as ProjectRecord) : Option.none(), ), }), Layer.mock(ManagedProjectFolders.ManagedProjectFolders)({ namedProjectsRoot: "/projects" }), NodeServices.layer, - ServerConfig.layerTest(process.cwd(), { prefix: "t3-client-launch-" }).pipe( - Layer.provide(NodeServices.layer), + ).pipe( + Layer.provideMerge(GitVcsDriver.layer), + Layer.provideMerge(VcsProcess.layer), + Layer.provideMerge( + ServerConfig.layerTest(process.cwd(), { prefix: "t3-client-launch-" }).pipe( + Layer.provide(NodeServices.layer), + ), ), + Layer.provideMerge(NodeServices.layer), ); return { projectId, modelSelection, dependencies: layerDependencies }; }; @@ -340,7 +371,9 @@ it.effect("a client launches at its ceiling with the project's default model", ( launched, }); const toolkit = yield* ProjectToolkit.pipe( - Effect.provide(ProjectHandlers.layer.pipe(Layer.provide(dependencies))), + Effect.provide( + McpToolAccess.HandlersLayer.layer(ProjectHandlers.layer).pipe(Layer.provide(dependencies)), + ), ); const handle = (params: Parameters>[1]) => toolkit @@ -360,3 +393,64 @@ it.effect("a client launches at its ceiling with the project's default model", ( expect(launched).toHaveLength(1); }), ); + +it.effect("a launch binds only an existing checkout that is one of the project's worktrees", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-launch-worktree-" }); + const repo = path.join(root, "repo"); + const worktree = path.join(root, "feature"); + const outside = path.join(root, "outside"); + yield* fileSystem.makeDirectory(repo); + yield* fileSystem.makeDirectory(outside); + + const launched: Array = []; + const { projectId, dependencies } = clientLaunchHarness({ + runtimeModeCeiling: "auto", + launched, + workspaceRoot: repo, + }); + const git = yield* GitVcsDriver.GitVcsDriver.pipe(Effect.provide(dependencies)); + for (const args of [ + ["init", "-b", "main"], + ["-c", "user.email=t@t", "-c", "user.name=t", "commit", "--allow-empty", "-m", "init"], + ["worktree", "add", "-b", "feature", worktree], + ]) { + yield* git.execute({ operation: "test.setupRepo", cwd: repo, args }); + } + const toolkit = yield* ProjectToolkit.pipe( + Effect.provide( + McpToolAccess.HandlersLayer.layer(ProjectHandlers.layer).pipe(Layer.provide(dependencies)), + ), + ); + const launchInto = (worktreePath: string) => + toolkit + .handle("t3_thread_launch", { + title: "Fix", + projectId, + workspaceStrategy: { type: "existing_worktree", worktreePath }, + }) + .pipe(Stream.unwrap, Stream.runCollect, Effect.provide(dependencies)); + + expect((yield* launchInto(worktree)).at(-1)?.result).toMatchObject({ projectId }); + expect((yield* launchInto(repo)).at(-1)?.result).toMatchObject({ projectId }); + for (const elsewhere of [outside, path.join(worktree, "..", "outside"), "/"]) { + expect((yield* launchInto(elsewhere)).at(-1)?.result).toMatchObject({ + code: "invalid_request", + }); + } + expect(launched.map((launch) => launch.workspaceStrategy)).toEqual([ + { type: "existing_worktree", worktreePath: worktree }, + { type: "existing_worktree", worktreePath: repo }, + ]); + + // A removed worktree stays listed as prunable until `git worktree prune`; + // whatever directory is later made at its path is not one of the project's. + yield* fileSystem.remove(worktree, { recursive: true }); + yield* fileSystem.makeDirectory(worktree); + expect((yield* launchInto(worktree)).at(-1)?.result).toMatchObject({ + code: "invalid_request", + }); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); diff --git a/apps/server/src/mcp/toolkits/project/handlers.ts b/apps/server/src/mcp/toolkits/project/handlers.ts index a854be3f362a942309e943e325e3595edb58514d..9a7d7024111ef2a864de00ad8a91f633a13b9d16 100644 --- a/apps/server/src/mcp/toolkits/project/handlers.ts +++ b/apps/server/src/mcp/toolkits/project/handlers.ts @@ -1,20 +1,15 @@ import { MessageId, ThreadId, OrchestratorMcpFailure, ProjectId } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; import * as Option from "effect/Option"; import * as ThreadMessageIntake from "../../../orchestration-v2/ThreadMessageIntake.ts"; import * as Claims from "../../../orchestration-v2/AttachmentClaims.ts"; import * as Project from "../../../project/ProjectService.ts"; import * as ManagedProjectFolders from "../../../project/ManagedProjectFolders.ts"; import * as Repositories from "../../../sourceControl/SourceControlRepositoryService.ts"; -import { resolveRuntimeMode } from "../../OrchestratorMcpService.ts"; -import { - newCommandId, - readCaller, - readFullAccessCaller, - readMutationCaller, - resolveProjectId, - unavailable, -} from "../../threadAccess.ts"; +import * as GitVcsDriver from "../../../vcs/GitVcsDriver.ts"; +import * as McpToolAccess from "../../McpToolAccess.ts"; +import { newCommandId, readCaller, resolveProjectId, unavailable } from "../../threadAccess.ts"; import { ProjectToolkit } from "./tools.ts"; function projectFailure(error: Project.ProjectServiceError) { @@ -28,121 +23,137 @@ function projectFailure(error: Project.ProjectServiceError) { return new OrchestratorMcpFailure({ code: "invalid_request", message }); } +/** + * An existing checkout a launch may bind: one of the project's own git + * worktrees. Without this check a launch could point an agent at any directory + * on the machine. + */ +const assertProjectWorktree = Effect.fn("mcp.assertProjectWorktree")(function* ( + workspaceRoot: string, + worktreePath: string, +) { + const git = yield* GitVcsDriver.GitVcsDriver; + const fileSystem = yield* FileSystem.FileSystem; + const real = (path: string) => fileSystem.realPath(path).pipe(Effect.orElseSucceed(() => path)); + const worktrees = yield* git.listWorktreePaths(workspaceRoot).pipe( + Effect.flatMap((paths) => Effect.forEach(paths, real)), + Effect.orElseSucceed((): ReadonlyArray => []), + ); + if (!worktrees.includes(yield* real(worktreePath))) + return yield* new OrchestratorMcpFailure({ + code: "invalid_request", + message: + "worktreePath must be one of the project's git worktrees. t3_worktree_list shows them.", + }); +}); + const access = Effect.gen(function* () { yield* readCaller(); return yield* Project.ProjectService; }); -const mutation = Effect.gen(function* () { - yield* readFullAccessCaller( - "Project changes require a live full-access/default calling thread or a full-access client.", - ); - return yield* Project.ProjectService; -}); -export const layer = ProjectToolkit.toLayer({ - t3_thread_launch: (input) => - Effect.gen(function* () { - const context = yield* readMutationCaller(); - const { caller, limits } = context; - // A thread caller launches only as itself (full-access/default), as before. A client - // launches anything up to its ceiling. - if ( - caller !== undefined && - (caller.runtimeMode !== "full-access" || caller.interactionMode !== "default") - ) - return yield* new OrchestratorMcpFailure({ - code: "capability_denied", - message: "Project launches require a full-access/default calling thread.", - }); - const runtimeMode = yield* resolveRuntimeMode( - limits.runtimeMode, - input.runtimeMode ?? caller?.runtimeMode, - ); - const commandId = yield* newCommandId(); - const threadId = ThreadId.make(commandId); - const messageId = MessageId.make(commandId); - const attachments = input.attachments ?? []; - if (attachments.some((attachment) => !Claims.attachmentIsPendingUpload(attachment))) - return yield* new OrchestratorMcpFailure({ - code: "invalid_request", - message: "A new thread accepts only pending attachment uploads.", - }); - if ( - input.scratch === true && - (input.projectId !== undefined || input.workspaceStrategy !== undefined) - ) - return yield* new OrchestratorMcpFailure({ - code: "invalid_request", - message: - "scratch:true picks its own project and folder; omit projectId and workspaceStrategy.", - }); - const projectId = - input.scratch === true - ? (yield* ManagedProjectFolders.ManagedProjectFolders.pipe( - Effect.flatMap((folders) => folders.ensureScratchProject), - Effect.mapError( - (error) => - new OrchestratorMcpFailure({ - code: "orchestration_error", - message: error.message, - }), - ), - )).projectId - : yield* resolveProjectId(context, input.projectId); - const modelSelection = - input.modelSelection ?? - caller?.modelSelection ?? - (yield* Project.ProjectService.pipe( +export const layer = McpToolAccess.toLayer(ProjectToolkit, { + t3_thread_launch: McpToolAccess.startsThreads( + (input) => input, + (input, { runtimeMode, interactionMode }) => + Effect.gen(function* () { + const context = yield* readCaller(); + const { caller } = context; + const commandId = yield* newCommandId(); + const threadId = ThreadId.make(commandId); + const messageId = MessageId.make(commandId); + const attachments = input.attachments ?? []; + if (attachments.some((attachment) => !Claims.attachmentIsPendingUpload(attachment))) + return yield* new OrchestratorMcpFailure({ + code: "invalid_request", + message: "A new thread accepts only pending attachment uploads.", + }); + if ( + input.scratch === true && + (input.projectId !== undefined || input.workspaceStrategy !== undefined) + ) + return yield* new OrchestratorMcpFailure({ + code: "invalid_request", + message: + "scratch:true picks its own project and folder; omit projectId and workspaceStrategy.", + }); + const projectId = + input.scratch === true + ? (yield* ManagedProjectFolders.ManagedProjectFolders.pipe( + Effect.flatMap((folders) => folders.ensureScratchProject), + Effect.mapError( + (error) => + new OrchestratorMcpFailure({ + code: "orchestration_error", + message: error.message, + }), + ), + )).projectId + : yield* resolveProjectId(context, input.projectId); + const readProject = Project.ProjectService.pipe( Effect.flatMap((projects) => projects.getById(projectId)), Effect.mapError(unavailable), - Effect.map((project) => - Option.getOrUndefined(Option.flatMapNullishOr(project, (p) => p.defaultModelSelection)), + Effect.map(Option.getOrUndefined), + ); + if (input.workspaceStrategy?.type === "existing_worktree") { + const project = yield* readProject; + if (project === undefined) + return yield* new OrchestratorMcpFailure({ + code: "invalid_request", + message: "The project was not found.", + }); + yield* assertProjectWorktree(project.workspaceRoot, input.workspaceStrategy.worktreePath); + } + const modelSelection = + input.modelSelection ?? + caller?.modelSelection ?? + (yield* readProject)?.defaultModelSelection ?? + undefined; + if (modelSelection === undefined) + return yield* new OrchestratorMcpFailure({ + code: "invalid_request", + message: + "Pass modelSelection: the project has no default model. orchestrator_capabilities lists providers and models.", + }); + const result = yield* ThreadMessageIntake.launchThread({ + commandId, + threadId, + projectId, + title: input.title, + modelSelection, + runtimeMode, + interactionMode, + workspaceStrategy: input.workspaceStrategy ?? { type: "root" }, + ...(input.message === undefined && attachments.length === 0 + ? {} + : { + initialMessage: { + messageId, + ...(caller === undefined ? {} : { senderThreadId: caller.id }), + text: input.message ?? "", + attachments, + }, + }), + createdBy: "agent", + creationSource: "mcp", + }).pipe( + Effect.mapError((error) => + error._tag === "AttachmentClaimError" + ? new OrchestratorMcpFailure({ code: "orchestration_error", message: error.message }) + : unavailable(), ), - )); - if (modelSelection === undefined) - return yield* new OrchestratorMcpFailure({ - code: "invalid_request", - message: - "Pass modelSelection: the project has no default model. orchestrator_capabilities lists providers and models.", - }); - const result = yield* ThreadMessageIntake.launchThread({ - commandId, - threadId, - projectId, - title: input.title, - modelSelection, - runtimeMode, - interactionMode: input.interactionMode ?? caller?.interactionMode ?? "default", - workspaceStrategy: input.workspaceStrategy ?? { type: "root" }, - ...(input.message === undefined && attachments.length === 0 - ? {} - : { - initialMessage: { - messageId, - ...(caller === undefined ? {} : { senderThreadId: caller.id }), - text: input.message ?? "", - attachments, - }, - }), - createdBy: "agent", - creationSource: "mcp", - }).pipe( - Effect.mapError((error) => - error._tag === "AttachmentClaimError" - ? new OrchestratorMcpFailure({ code: "orchestration_error", message: error.message }) - : unavailable(), - ), - ); - const thread = result.projection.thread; - const run = result.projection.runs.find((run) => run.userMessageId === messageId); - return { - threadId: thread.id, - projectId: thread.projectId, - modelSelection: thread.modelSelection, - runId: run?.id ?? null, - status: run?.status ?? null, - }; - }), - t3_project_list: (input) => + ); + const thread = result.projection.thread; + const run = result.projection.runs.find((run) => run.userMessageId === messageId); + return { + threadId: thread.id, + projectId: thread.projectId, + modelSelection: thread.modelSelection, + runId: run?.id ?? null, + status: run?.status ?? null, + }; + }), + ), + t3_project_list: McpToolAccess.reads((input) => Effect.gen(function* () { const projects = yield* access; const snapshot = yield* projects.snapshot.pipe(Effect.mapError(unavailable)); @@ -151,7 +162,8 @@ export const layer = ProjectToolkit.toLayer({ end = start + (input.limit ?? 20); return { projects: rows.slice(start, end), nextCursor: end < rows.length ? end : null }; }), - t3_project_read: (input) => + ), + t3_project_read: McpToolAccess.reads((input) => Effect.gen(function* () { const projects = yield* access; const result = yield* projects.getById(input.projectId).pipe(Effect.mapError(unavailable)); @@ -162,9 +174,10 @@ export const layer = ProjectToolkit.toLayer({ }); return result.value; }), - t3_project_create: ({ workspaceRoot, ...input }) => + ), + t3_project_create: McpToolAccess.writesEnvironment(({ workspaceRoot, ...input }) => Effect.gen(function* () { - const projects = yield* mutation; + const projects = yield* Project.ProjectService; if (workspaceRoot === undefined) { // Project creation records no model default (only an update does), so // reject what this mode would otherwise drop silently. @@ -205,23 +218,25 @@ export const layer = ProjectToolkit.toLayer({ .create({ ...input, workspaceRoot, commandId, projectId: ProjectId.make(commandId) }) .pipe(Effect.mapError(projectFailure)); }), - t3_project_update: (input) => + ), + t3_project_update: McpToolAccess.writesEnvironment((input) => Effect.gen(function* () { - const projects = yield* mutation; + const projects = yield* Project.ProjectService; return yield* projects .update({ ...input, commandId: yield* newCommandId() }) .pipe(Effect.mapError(projectFailure)); }), - t3_project_delete: (input) => + ), + t3_project_delete: McpToolAccess.writesEnvironment((input) => Effect.gen(function* () { - const projects = yield* mutation; + const projects = yield* Project.ProjectService; return yield* projects .delete({ ...input, commandId: yield* newCommandId() }) .pipe(Effect.mapError(projectFailure)); }), - t3_project_clone: (input) => + ), + t3_project_clone: McpToolAccess.writesEnvironment((input) => Effect.gen(function* () { - yield* mutation; const repositories = yield* Repositories.SourceControlRepositoryService; return yield* repositories.cloneRepository(input).pipe( Effect.mapError( @@ -233,4 +248,5 @@ export const layer = ProjectToolkit.toLayer({ ), ); }), + ), }); diff --git a/apps/server/src/mcp/toolkits/project/tools.ts b/apps/server/src/mcp/toolkits/project/tools.ts index 43d0a0866e4ed6a8152d6f15066cdea53e441811..ab04bd5a195ba4256cab7a93ee22278419671172 100644 --- a/apps/server/src/mcp/toolkits/project/tools.ts +++ b/apps/server/src/mcp/toolkits/project/tools.ts @@ -27,6 +27,7 @@ import * as ProjectService from "../../../project/ProjectService.ts"; import * as ManagedProjectFolders from "../../../project/ManagedProjectFolders.ts"; import * as ThreadManagementService from "../../../orchestration-v2/ThreadManagementService.ts"; import * as SourceControlRepositoryService from "../../../sourceControl/SourceControlRepositoryService.ts"; +import * as GitVcsDriver from "../../../vcs/GitVcsDriver.ts"; import * as McpInvocationContext from "../../McpInvocationContext.ts"; const shared = { @@ -102,7 +103,7 @@ const ProjectCloneTool = Tool.make("t3_project_clone", { const ThreadLaunchTool = Tool.make("t3_thread_launch", { ...shared, description: - '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.', + '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.', parameters: Schema.Struct({ projectId: Schema.optional(ProjectId), scratch: Schema.optional( @@ -118,7 +119,7 @@ const ThreadLaunchTool = Tool.make("t3_thread_launch", { workspaceStrategy: Schema.optional( OrchestrationV2ThreadLaunchWorkspaceStrategy.annotate({ description: - "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.", + "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.", }), ), message: Schema.optional( @@ -140,6 +141,7 @@ const ThreadLaunchTool = Tool.make("t3_thread_launch", { ...shared.dependencies, ThreadLaunchService.ThreadLaunchService, ManagedProjectFolders.ManagedProjectFolders, + GitVcsDriver.GitVcsDriver, FileSystem.FileSystem, ServerConfig.ServerConfig, ], diff --git a/apps/server/src/mcp/toolkits/pullRequests/handlers.test.ts b/apps/server/src/mcp/toolkits/pullRequests/handlers.test.ts index 7ffd5f6fdc09dd1e0dcfb97e980b36b4331e60da..6101727cd89c71fc20cd02bcd2d74d5b2ad730c7 100644 --- a/apps/server/src/mcp/toolkits/pullRequests/handlers.test.ts +++ b/apps/server/src/mcp/toolkits/pullRequests/handlers.test.ts @@ -23,6 +23,8 @@ import { } from "../../../orchestration-v2/testkit/pullRequestFixtures.ts"; import * as ProjectService from "../../../project/ProjectService.ts"; import * as McpInvocationContext from "../../McpInvocationContext.ts"; +import * as McpToolAccess from "../../McpToolAccess.ts"; +import * as McpToolAccessTestkit from "../../McpToolAccess.testkit.ts"; import { listThreadPullRequests } from "./handlers.ts"; import * as PullRequestsHandlers from "./handlers.ts"; import { PullRequestLinkFailedError, PullRequestsToolkit } from "./tools.ts"; @@ -163,9 +165,14 @@ const makeHarness = Effect.fn("makePullRequestsToolkitHarness")(function* ( dispatch, }), Layer.succeed(Crypto.Crypto, testCrypto), + McpToolAccessTestkit.liveThreadsLayer, ); const toolkit = yield* PullRequestsToolkit.pipe( - Effect.provide(PullRequestsHandlers.layer.pipe(Layer.provide(layerDependencies))), + Effect.provide( + McpToolAccess.HandlersLayer.layer(PullRequestsHandlers.layer).pipe( + Layer.provide(layerDependencies), + ), + ), ); const call = ( name: Name, diff --git a/apps/server/src/mcp/toolkits/pullRequests/handlers.ts b/apps/server/src/mcp/toolkits/pullRequests/handlers.ts index dd44c9d34065a98b1eabeef8485529bc8e1c2a85..cced337670546fe95b9b69d39059273787735f77 100644 --- a/apps/server/src/mcp/toolkits/pullRequests/handlers.ts +++ b/apps/server/src/mcp/toolkits/pullRequests/handlers.ts @@ -26,7 +26,7 @@ import * as Option from "effect/Option"; import * as Orchestrator from "../../../orchestration-v2/Orchestrator.ts"; import * as ProjectService from "../../../project/ProjectService.ts"; import * as McpInvocationContext from "../../McpInvocationContext.ts"; -import { assertTargetWithinLimits } from "../../threadAccess.ts"; +import * as McpToolAccess from "../../McpToolAccess.ts"; import { type ListThreadPullRequestsResult, PullRequestLinkFailedError, @@ -40,7 +40,6 @@ import { PullRequestWatchFailedError, PullRequestWatchFromSubagentError, PullRequestThreadNotFoundError, - PullRequestThreadAboveLimitsError, PullRequestThreadRequiredError, PullRequestsToolkit, type ThreadPullRequestEntry, @@ -189,47 +188,6 @@ const make = Effect.gen(function* () { return thread.value; }); - /** - * A thread whose pull requests the caller may change: its own, or one that - * runs within the caller's modes. - */ - const requireWritableThread = Effect.fn("PullRequestsToolkit.requireWritableThread")(function* ( - Failure: - | typeof PullRequestLinkFailedError - | typeof PullRequestUnlinkFailedError - | typeof PullRequestWatchFailedError, - requested: ThreadId | undefined, - ) { - const thread = yield* requireThread(Failure, requested); - const scope = yield* McpInvocationContext.McpInvocationContext; - if (thread.id === scope.thread?.threadId) return thread; - const limits = - scope.thread === undefined - ? { - runtimeMode: scope.client?.runtimeModeCeiling ?? ("approval-required" as const), - interactionMode: "default" as const, - } - : yield* engine.getThreadShell(scope.thread.threadId).pipe( - Effect.mapError((cause) => new Failure({ cause })), - Effect.map((caller) => - // A thread caller changes other threads only while its own run is live. - caller === null || - caller.archivedAt !== null || - caller.activeRunId === null || - caller.providerInstanceId !== scope.thread?.providerInstanceId - ? undefined - : { runtimeMode: caller.runtimeMode, interactionMode: caller.interactionMode }, - ), - ); - if (limits === undefined) { - return yield* new PullRequestThreadAboveLimitsError({ threadId: thread.id }); - } - yield* assertTargetWithinLimits(limits, thread).pipe( - Effect.mapError(() => new PullRequestThreadAboveLimitsError({ threadId: thread.id })), - ); - return thread; - }); - const projectOf = ( thread: OrchestrationV2ThreadShell, Failure: @@ -267,7 +225,7 @@ const make = Effect.gen(function* () { input: PullRequestTargetInput, watching: boolean, ) { - const thread = yield* requireWritableThread(PullRequestWatchFailedError, input.threadId); + const thread = yield* requireThread(PullRequestWatchFailedError, input.threadId); const project = yield* projectOf(thread, PullRequestWatchFailedError); const target = yield* resolveTarget(input, project); const watchedLink = (shell: OrchestrationV2ThreadShell) => @@ -306,10 +264,15 @@ const make = Effect.gen(function* () { }; }); - return PullRequestsToolkit.of({ - link_pull_request: (input) => + /** A tool that changes `threadId`, or the caller's own thread when it is omitted. */ + const writesThread =

( + handle: (params: P) => Effect.Effect, + ) => McpToolAccess.writesThreads((params: P) => [params.threadId], handle); + + return { + link_pull_request: writesThread((input) => Effect.gen(function* () { - const thread = yield* requireWritableThread(PullRequestLinkFailedError, input.threadId); + const thread = yield* requireThread(PullRequestLinkFailedError, input.threadId); const project = yield* projectOf(thread, PullRequestLinkFailedError); const target = yield* resolveTarget(input, project); const existing = threadPullRequestsOf(thread).find((link) => @@ -337,9 +300,10 @@ const make = Effect.gen(function* () { ); return { ...target, alreadyLinked }; }), - unlink_pull_request: (input) => + ), + unlink_pull_request: writesThread((input) => Effect.gen(function* () { - const thread = yield* requireWritableThread(PullRequestUnlinkFailedError, input.threadId); + const thread = yield* requireThread(PullRequestUnlinkFailedError, input.threadId); const project = yield* projectOf(thread, PullRequestUnlinkFailedError); const target = yield* resolveTarget(input, project); if (!threadPullRequestsOf(thread).some((link) => threadPullRequestKeysEqual(link, target))) @@ -370,13 +334,15 @@ const make = Effect.gen(function* () { wasLinked, }; }), - list_thread_pull_requests: (input) => + ), + list_thread_pull_requests: McpToolAccess.reads((input) => requireThread(PullRequestListFailedError, input.threadId).pipe( Effect.map(listThreadPullRequests), ), - watch_pull_request: (input) => setWatching(input, true), - unwatch_pull_request: (input) => setWatching(input, false), - }); + ), + watch_pull_request: writesThread((input) => setWatching(input, true)), + unwatch_pull_request: writesThread((input) => setWatching(input, false)), + } satisfies McpToolAccess.Handlers; }); -export const layer = PullRequestsToolkit.toLayer(make); +export const layer = McpToolAccess.toLayer(PullRequestsToolkit, make); diff --git a/apps/server/src/mcp/toolkits/pullRequests/tools.ts b/apps/server/src/mcp/toolkits/pullRequests/tools.ts index 2a150617a4059e0a693f8ea264bc22d5b8c3387f..e5c3d53503ffb8e224847873a1613d1d9b567fe4 100644 --- a/apps/server/src/mcp/toolkits/pullRequests/tools.ts +++ b/apps/server/src/mcp/toolkits/pullRequests/tools.ts @@ -1,5 +1,6 @@ import { McpCapabilityUnavailableError, + OrchestratorMcpFailure, PositiveInt, PullRequestState, ThreadPullRequestLinkSource, @@ -12,10 +13,12 @@ import * as Toolkit from "effect/ai/Toolkit"; import * as McpInvocationContext from "../../McpInvocationContext.ts"; import * as Orchestrator from "../../../orchestration-v2/Orchestrator.ts"; +import * as ThreadManagementService from "../../../orchestration-v2/ThreadManagementService.ts"; import * as ProjectService from "../../../project/ProjectService.ts"; const dependencies = [ McpInvocationContext.McpInvocationContext, + ThreadManagementService.ThreadManagementService, Orchestrator.OrchestratorV2, ProjectService.ProjectService, ]; @@ -96,15 +99,6 @@ export class PullRequestThreadRequiredError extends Schema.TaggedError()( - "PullRequestThreadAboveLimitsError", - { threadId: Schema.String }, -) { - override get message(): string { - 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.`; - } -} - export class PullRequestThreadNotFoundError extends Schema.TaggedError()( "PullRequestThreadNotFoundError", { threadId: Schema.String }, @@ -169,12 +163,12 @@ export class PullRequestListFailedError extends Schema.TaggedError OrchestrationV2Command, ) { - const { threads, projection } = yield* readWritableThread(threadId); + const { threads, projection } = yield* readThread(threadId); const result = yield* threads .dispatch(command({ commandId: yield* newCommandId(), threadId: projection.thread.id })) .pipe(Effect.mapError(dispatchFailure)); return { sequence: result.sequence }; }); -const readQuestion = Effect.fn("mcp.readQuestion")(function* ( - input: { - threadId?: ThreadId | undefined; - requestId: RuntimeRequestId; - }, - writable = false, -) { - const context = yield* writable - ? readWritableThread(input.threadId, ["runtimeRequests", "turnItems"]) - : readThread(input.threadId, ["runtimeRequests", "turnItems"]); +const readQuestion = Effect.fn("mcp.readQuestion")(function* (input: { + threadId?: ThreadId | undefined; + requestId: RuntimeRequestId; +}) { + const context = yield* readThread(input.threadId, ["runtimeRequests", "turnItems"]); const request = context.projection.runtimeRequests.find( (request) => request.id === input.requestId && @@ -76,12 +70,14 @@ const readQuestion = Effect.fn("mcp.readQuestion")(function* ( }); return { ...context, request, item }; }); -export const layer = ThreadToolkit.toLayer({ - run_scheduled_task_now: (input) => +/** A tool that changes `threadId`, or the caller's own thread when it is omitted. */ +const writesThread =

( + handle: (params: P) => Effect.Effect, +) => McpToolAccess.writesThreads((params: P) => [params.threadId], handle); + +export const layer = McpToolAccess.toLayer(ThreadToolkit, { + run_scheduled_task_now: McpToolAccess.writesEnvironment((input) => Effect.gen(function* () { - yield* readFullAccessCaller( - "Running a scheduled task requires a live full-access/default thread or a full-access client.", - ); const scheduler = yield* ScheduledTasks.ScheduledTaskService; const { tasks } = yield* scheduler.list().pipe(Effect.mapError(unavailable)); if (!tasks.some((task) => task.id === input.taskId)) @@ -100,7 +96,8 @@ export const layer = ThreadToolkit.toLayer({ nextRunAt: task.nextRunAt, }; }), - t3_thread_search: (input) => + ), + t3_thread_search: McpToolAccess.reads((input) => Effect.gen(function* () { const { caller } = yield* readCaller(); const { projectId: requested, ...query } = input; @@ -116,9 +113,10 @@ export const layer = ThreadToolkit.toLayer({ : result.matches.filter((match) => match.projectId === projectId), }; }), - t3_thread_fork: (input) => + ), + t3_thread_fork: writesThread((input) => Effect.gen(function* () { - const { threads, projection } = yield* readWritableThread(input.threadId); + const { threads, projection } = yield* readThread(input.threadId); const commandId = yield* newCommandId(); const targetThreadId = ThreadId.make(`${commandId}:fork`); const result = yield* threads @@ -135,24 +133,28 @@ export const layer = ThreadToolkit.toLayer({ .pipe(Effect.mapError(dispatchFailure)); return { sequence: result.sequence, targetThreadId }; }), - t3_thread_merge_back: (input) => - Effect.gen(function* () { - const context = yield* readWritableThread(input.targetThreadId); - const source = yield* readWritableThread(input.sourceThreadId); - const result = yield* context.threads - .dispatch({ - type: "thread.merge_back", - commandId: yield* newCommandId(), - sourceThreadId: source.projection.thread.id, - targetThreadId: input.targetThreadId, - sourcePoint: input.sourcePoint, - createdBy: "agent", - creationSource: "mcp", - }) - .pipe(Effect.mapError(dispatchFailure)); - return { sequence: result.sequence, targetThreadId: input.targetThreadId }; - }), - t3_thread_transfers: (input) => + ), + t3_thread_merge_back: McpToolAccess.writesThreads( + (input) => [input.targetThreadId, input.sourceThreadId], + (input) => + Effect.gen(function* () { + const context = yield* readThread(input.targetThreadId); + const source = yield* readThread(input.sourceThreadId); + const result = yield* context.threads + .dispatch({ + type: "thread.merge_back", + commandId: yield* newCommandId(), + sourceThreadId: source.projection.thread.id, + targetThreadId: input.targetThreadId, + sourcePoint: input.sourcePoint, + createdBy: "agent", + creationSource: "mcp", + }) + .pipe(Effect.mapError(dispatchFailure)); + return { sequence: result.sequence, targetThreadId: input.targetThreadId }; + }), + ), + t3_thread_transfers: McpToolAccess.reads((input) => Effect.gen(function* () { const { projection } = yield* readThread(input.threadId, ["contextTransfers"]); return { @@ -166,7 +168,8 @@ export const layer = ThreadToolkit.toLayer({ ), }; }), - t3_thread_configuration: (input) => + ), + t3_thread_configuration: McpToolAccess.reads((input) => Effect.gen(function* () { const { projection: { thread }, @@ -178,12 +181,13 @@ export const layer = ThreadToolkit.toLayer({ interactionMode: thread.interactionMode, }; }), - t3_thread_configure: (input) => + ), + t3_thread_configure: writesThread((input) => Effect.gen(function* () { const { threads, projection: { thread }, - } = yield* readWritableThread(input.threadId); + } = yield* readThread(input.threadId); const type = modelSelectionCommandType(thread.providerInstanceId, input.modelSelection); const result = yield* threads .dispatch({ @@ -195,7 +199,8 @@ export const layer = ThreadToolkit.toLayer({ .pipe(Effect.mapError(dispatchFailure)); return { sequence: result.sequence }; }), - t3_pending_request_list: (input) => + ), + t3_pending_request_list: McpToolAccess.reads((input) => Effect.gen(function* () { const { projection } = yield* readThread(input.threadId, ["runtimeRequests"]); return { @@ -204,14 +209,16 @@ export const layer = ThreadToolkit.toLayer({ .map((request) => request.id), }; }), - t3_pending_request_read: (input) => + ), + t3_pending_request_read: McpToolAccess.reads((input) => Effect.gen(function* () { const { item } = yield* readQuestion(input); return { requestId: input.requestId, questions: item.questions }; }), - t3_pending_request_respond: (input) => + ), + t3_pending_request_respond: writesThread((input) => Effect.gen(function* () { - const { threads, projection } = yield* readQuestion(input, true); + const { threads, projection } = yield* readQuestion(input); const result = yield* threads .dispatch({ type: "runtime-request.respond", @@ -223,7 +230,8 @@ export const layer = ThreadToolkit.toLayer({ .pipe(Effect.mapError(dispatchFailure)); return { sequence: result.sequence }; }), - t3_queue_list: (input) => + ), + t3_queue_list: McpToolAccess.reads((input) => Effect.gen(function* () { const { projection } = yield* readThread(input.threadId, ["runs", "messages"]); const runs = queuedRunsInDeliveryOrder(projection); @@ -237,7 +245,8 @@ export const layer = ThreadToolkit.toLayer({ nextCursor: end < runs.length ? end : null, }; }), - t3_queue_read: (input) => + ), + t3_queue_read: McpToolAccess.reads((input) => Effect.gen(function* () { const { projection } = yield* readThread(input.threadId, ["runs", "messages"]); const entry = queueEntry(projection, input.queuedRunId, 16000); @@ -249,36 +258,41 @@ export const layer = ThreadToolkit.toLayer({ })) ); }), - t3_queue_edit: (input) => + ), + t3_queue_edit: writesThread((input) => dispatch(input.threadId, (common) => ({ ...common, type: "queued-run.edit", runId: input.queuedRunId, text: input.text, })), - t3_queue_cancel: (input) => + ), + t3_queue_cancel: writesThread((input) => dispatch(input.threadId, (common) => ({ ...common, type: "queued-run.cancel", runId: input.queuedRunId, })), - t3_queue_reorder: (input) => + ), + t3_queue_reorder: writesThread((input) => dispatch(input.threadId, (common) => ({ ...common, type: "queued-run.reorder", runId: input.queuedRunId, beforeRunId: input.beforeRunId, })), - t3_queue_promote_to_steer: (input) => + ), + t3_queue_promote_to_steer: writesThread((input) => dispatch(input.threadId, (common) => ({ ...common, type: "queued-message.promote-to-steer", queuedRunId: input.queuedRunId, targetRunId: input.targetRunId, })), - t3_thread_organize: (input) => + ), + t3_thread_organize: writesThread((input) => Effect.gen(function* () { - const { threads, projection } = yield* readWritableThread(input.threadId); + const { threads, projection } = yield* readThread(input.threadId); const common = { commandId: yield* newCommandId(), threadId: projection.thread.id }; let command: OrchestrationV2Command; switch (input.action) { @@ -304,4 +318,5 @@ export const layer = ThreadToolkit.toLayer({ const result = yield* threads.dispatch(command).pipe(Effect.mapError(dispatchFailure)); return { sequence: result.sequence }; }), + ), }); diff --git a/apps/server/src/mcp/toolkits/worktree/handlers.ts b/apps/server/src/mcp/toolkits/worktree/handlers.ts index b9539a9f3307b44b92a578e730d04cde74479260..c6fabe18c6fb88eab83f39cd207b7b54623bfb40 100644 --- a/apps/server/src/mcp/toolkits/worktree/handlers.ts +++ b/apps/server/src/mcp/toolkits/worktree/handlers.ts @@ -6,11 +6,12 @@ import { readThread, unavailable } from "../../threadAccess.ts"; import * as Effect from "effect/Effect"; import * as McpInvocationContext from "../../McpInvocationContext.ts"; +import * as McpToolAccess from "../../McpToolAccess.ts"; import * as WorktreeMcpService from "../../WorktreeMcpService.ts"; import { WorktreeToolkit } from "./tools.ts"; const handlers = { - t3_worktree_list: (input) => + t3_worktree_list: McpToolAccess.reads((input) => Effect.gen(function* () { const context = yield* McpInvocationContext.McpInvocationContext; if (!context.capabilities.has("worktree")) @@ -34,18 +35,21 @@ const handlers = { .listRefs({ ...refs, cwd: thread.worktreePath ?? project.value.workspaceRoot }) .pipe(Effect.mapError(unavailable)); }), - t3_worktree_handoff: (input) => + ), + t3_worktree_handoff: McpToolAccess.actsAsCaller((input) => Effect.gen(function* () { const scope = yield* McpInvocationContext.McpInvocationContext; const service = yield* WorktreeMcpService.WorktreeMcpService; return yield* service.handoff(scope, input); }), - t3_worktree_status: () => + ), + t3_worktree_status: McpToolAccess.readsAsCaller(() => Effect.gen(function* () { const scope = yield* McpInvocationContext.McpInvocationContext; const service = yield* WorktreeMcpService.WorktreeMcpService; return yield* service.status(scope); }), -} satisfies Parameters[0]; + ), +} satisfies McpToolAccess.Handlers; -export const layer = WorktreeToolkit.toLayer(handlers); +export const layer = McpToolAccess.toLayer(WorktreeToolkit, handlers); diff --git a/apps/server/src/mcp/toolkits/worktree/tools.ts b/apps/server/src/mcp/toolkits/worktree/tools.ts index 56e227aa14b626e06d7c5131a233bec55cd265e9..fda1fc9fa3c8635ec951a2a7d61480c862c24901 100644 --- a/apps/server/src/mcp/toolkits/worktree/tools.ts +++ b/apps/server/src/mcp/toolkits/worktree/tools.ts @@ -19,15 +19,19 @@ import * as WorktreeMcpService from "../../WorktreeMcpService.ts"; const dependencies = [ McpInvocationContext.McpInvocationContext, + ThreadManagementService.ThreadManagementService, WorktreeMcpService.WorktreeMcpService, ]; +/** What handoff and status fail with, including the access gate's refusal. */ +const WorktreeToolFailure = Schema.Union([WorktreeMcpFailure, OrchestratorMcpFailure]); + const WorktreeHandoffTool = Tool.make("t3_worktree_handoff", { description: "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.", parameters: WorktreeMcpHandoffInput, success: WorktreeMcpHandoffResult, - failure: WorktreeMcpFailure, + failure: WorktreeToolFailure, failureMode: "return", dependencies, }) @@ -45,7 +49,7 @@ const WorktreeStatusTool = Tool.make("t3_worktree_status", { // Schema.Struct({}) serializes to `anyOf: [object, array]`, which is not a // valid MCP tool input schema and makes clients reject the whole server. success: WorktreeMcpStatusResult, - failure: WorktreeMcpFailure, + failure: WorktreeToolFailure, failureMode: "return", dependencies, }) diff --git a/apps/server/src/orchestration-v2/DispatchModeLimit.test.ts b/apps/server/src/orchestration-v2/DispatchModeLimit.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..dca74400e82113351cfdccc54d57330396afb3be --- /dev/null +++ b/apps/server/src/orchestration-v2/DispatchModeLimit.test.ts @@ -0,0 +1,167 @@ +import { assert, it } from "@effect/vitest"; +import { + CommandId, + ProjectId, + ProviderDriverKind, + ProviderInstanceId, + ThreadId, +} from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as SqlitePersistence from "../persistence/Sqlite.ts"; +import { CodexProviderCapabilitiesV2 } from "./Adapters/CodexAdapterV2.ts"; +import { DispatchModeLimit } from "./DispatchModeLimit.ts"; +import * as Orchestrator from "./Orchestrator.ts"; +import * as ProjectionStore from "./ProjectionStore.ts"; +import type { ProviderAdapterV2Shape } from "./ProviderAdapter.ts"; +import * as ProviderAdapterRegistry from "./ProviderAdapterRegistry.ts"; +import * as ProviderReplayHarness from "./testkit/ProviderReplayHarness.ts"; + +const instanceId = ProviderInstanceId.make("codex"); +const adapter = { + instanceId, + driver: ProviderDriverKind.make("codex"), + getCapabilities: () => Effect.succeed(CodexProviderCapabilitiesV2), + planSelectionTransition: () => Effect.succeed({ type: "apply_on_next_turn" as const }), + openSession: () => Effect.die("No provider process needed for metadata commands"), +} as ProviderAdapterV2Shape; +const layerDatabase = SqlitePersistence.layerMemory; +const layerTest = Layer.mergeAll( + layerDatabase, + ProjectionStore.layer.pipe(Layer.provide(layerDatabase)), + ProviderReplayHarness.layerWithRegistry( + { name: "dispatch-mode-limit" }, + ProviderAdapterRegistry.layerFromAdapters([adapter]), + { databaseLayer: layerDatabase, runEffectWorker: false }, + ), +); + +const supervised = { runtimeMode: "approval-required", interactionMode: "default" } as const; + +const createThread = (threadId: ThreadId) => + Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + yield* orchestrator.dispatch({ + type: "thread.create", + commandId: CommandId.make(`create:${threadId}`), + threadId, + projectId: ProjectId.make("project:dispatch-mode-limit"), + title: "Before", + modelSelection: { instanceId, model: "gpt-5" }, + ...supervised, + branch: null, + worktreePath: null, + createdBy: "user", + creationSource: "web", + }); + }); + +it.layer(layerTest)("DispatchModeLimit", (it) => { + it.effect("refuses a limited command on a thread its user raised, and records nothing", () => + Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const projections = yield* ProjectionStore.ProjectionStoreV2; + const threadId = ThreadId.make("thread:limit-raised"); + yield* createThread(threadId); + // The user raises the thread; the user's own commands have no limit. + yield* orchestrator.dispatch({ + type: "thread.runtime-mode.set", + commandId: CommandId.make("raise"), + threadId, + runtimeMode: "full-access", + }); + const rename = { + type: "thread.metadata.update", + commandId: CommandId.make("rename"), + threadId, + title: "After", + } as const; + const refused = yield* orchestrator + .dispatch(rename) + .pipe(Effect.provideService(DispatchModeLimit, supervised), Effect.flip); + assert.equal(refused._tag, "OrchestratorThreadAboveModeLimitError"); + assert.equal((yield* projections.getThreadShell(threadId))?.title, "Before"); + + // Refused before planning, so no receipt holds the command id: once the + // user lowers the thread again, the same command goes through. + yield* orchestrator.dispatch({ + type: "thread.runtime-mode.set", + commandId: CommandId.make("lower"), + threadId, + runtimeMode: "approval-required", + }); + yield* orchestrator + .dispatch(rename) + .pipe(Effect.provideService(DispatchModeLimit, supervised)); + assert.equal((yield* projections.getThreadShell(threadId))?.title, "After"); + }), + ); + + it.effect("names the interaction mode when that is what was raised", () => + Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const threadId = ThreadId.make("thread:limit-plan"); + yield* createThread(threadId); + const refused = yield* orchestrator + .dispatch({ + type: "thread.metadata.update", + commandId: CommandId.make("rename-plan"), + threadId, + title: "After", + }) + .pipe( + Effect.provideService(DispatchModeLimit, { + runtimeMode: "full-access", + interactionMode: "plan", + }), + Effect.flip, + ); + assert.ok(refused._tag === "OrchestratorThreadAboveModeLimitError"); + assert.equal(refused.mode, "interaction"); + }), + ); + + it.effect("refuses to fork a source thread its user raised", () => + Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const projections = yield* ProjectionStore.ProjectionStoreV2; + const sourceThreadId = ThreadId.make("thread:limit-fork-source"); + const targetThreadId = ThreadId.make("thread:limit-fork-target"); + yield* createThread(sourceThreadId); + yield* orchestrator.dispatch({ + type: "thread.runtime-mode.set", + commandId: CommandId.make("raise-source"), + threadId: sourceThreadId, + runtimeMode: "full-access", + }); + const fork = { + type: "thread.fork", + commandId: CommandId.make("fork"), + sourceThreadId, + targetThreadId, + sourcePoint: { type: "latest_stable" }, + createdBy: "agent", + creationSource: "mcp", + } as const; + const refused = yield* orchestrator + .dispatch(fork) + .pipe(Effect.provideService(DispatchModeLimit, supervised), Effect.flip); + assert.ok(refused._tag === "OrchestratorThreadAboveModeLimitError"); + assert.equal(refused.threadId, sourceThreadId); + assert.isNull(yield* projections.getThreadShell(targetThreadId)); + + // The refusal records no receipt: once the source is lowered, the same + // command is planned again (and fails only for want of a finished run). + yield* orchestrator.dispatch({ + type: "thread.runtime-mode.set", + commandId: CommandId.make("lower-source"), + threadId: sourceThreadId, + runtimeMode: "approval-required", + }); + const retried = yield* orchestrator + .dispatch(fork) + .pipe(Effect.provideService(DispatchModeLimit, supervised), Effect.flip); + assert.equal(retried._tag, "OrchestratorDispatchError"); + }), + ); +}); diff --git a/apps/server/src/orchestration-v2/DispatchModeLimit.ts b/apps/server/src/orchestration-v2/DispatchModeLimit.ts new file mode 100644 index 0000000000000000000000000000000000000000..a0a6fda2ee918edf35f63d402247add4b147ffd4 --- /dev/null +++ b/apps/server/src/orchestration-v2/DispatchModeLimit.ts @@ -0,0 +1,52 @@ +import type { ProviderInteractionMode, RuntimeMode, ThreadId } from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import type * as Ref from "effect/Ref"; + +export interface DispatchModes { + readonly runtimeMode: RuntimeMode; + readonly interactionMode: ProviderInteractionMode; +} + +/** A thread the orchestrator refused to touch because it ran above the limit. */ +export interface DispatchModeRefusal extends DispatchModes { + readonly threadId: ThreadId; + readonly mode: "runtime" | "interaction"; +} + +/** + * The broadest modes a command may touch, for commands an MCP caller sends. + * A caller checks its target's modes before dispatching, but the target's + * user can raise them meanwhile; the orchestrator re-checks this limit inside + * the thread's command lock, where nothing else can change them. Unset for + * the user's own commands, which have no limit. + * + * The orchestrator also records its refusal in `refused`, so the sender can + * report it however the code in between wrapped the dispatch error. + */ +export interface DispatchModeLimitValue extends DispatchModes { + readonly refused?: Ref.Ref; +} + +export const DispatchModeLimit = Context.Reference( + "t3/orchestration-v2/DispatchModeLimit", + { defaultValue: () => undefined }, +); + +const runtimeModeRank: Record = { + "approval-required": 0, + "auto-accept-edits": 1, + auto: 2, + "full-access": 3, +}; +const interactionModeRank: Record = { plan: 0, default: 1 }; + +/** Which of `modes` is broader than `limit`, if either. */ +export const exceededDispatchModeLimit = ( + limit: DispatchModes, + modes: DispatchModes, +): "runtime" | "interaction" | undefined => + runtimeModeRank[modes.runtimeMode] > runtimeModeRank[limit.runtimeMode] + ? "runtime" + : interactionModeRank[modes.interactionMode] > interactionModeRank[limit.interactionMode] + ? "interaction" + : undefined; diff --git a/apps/server/src/orchestration-v2/Orchestrator.ts b/apps/server/src/orchestration-v2/Orchestrator.ts index de100d291b8fc03249c15bacb57aae8cee8fd59a..5d4b1cfda15f3fa4d696ebab128c75811ef5cc9b 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.ts @@ -46,8 +46,10 @@ import { latestProviderTurnForAttempt, orchestrationV2RunWorkStartedAt, ProviderInstanceId, + ProviderInteractionMode, type ProviderSessionId, RunId, + RuntimeMode, ThreadLinkedPullRequest, ThreadId, type TurnItemId, @@ -86,6 +88,7 @@ import * as EffectOutbox from "./EffectOutbox.ts"; import type { OrchestrationEffectRequestV2, PendingOrchestrationEffectV2 } from "./EffectOutbox.ts"; import { IdAllocatorV2 } from "./IdAllocator.ts"; import * as ThreadCommandExecutor from "./ThreadCommandExecutor.ts"; +import { DispatchModeLimit, exceededDispatchModeLimit } from "./DispatchModeLimit.ts"; import { applyToProjection, emptyProjection, @@ -187,6 +190,22 @@ export class OrchestratorSubagentThreadReadOnlyError extends Schema.TaggedError< } } +/** The command's thread runs above the modes its sender may touch (see `DispatchModeLimit`). */ +export class OrchestratorThreadAboveModeLimitError extends Schema.TaggedError()( + "OrchestratorThreadAboveModeLimitError", + { + commandId: CommandId, + threadId: ThreadId, + mode: Schema.Literals(["runtime", "interaction"]), + runtimeMode: RuntimeMode, + interactionMode: ProviderInteractionMode, + }, +) { + override get message(): string { + return `Thread ${this.threadId} now runs in ${this.runtimeMode}/${this.interactionMode} mode, above what this caller may change.`; + } +} + export class OrchestratorCommandPreviouslyRejectedError extends Schema.TaggedError()( "OrchestratorCommandPreviouslyRejectedError", { @@ -236,6 +255,7 @@ export const OrchestratorV2Error = Schema.Union([ OrchestratorCommandPreviouslyRejectedError, OrchestratorCommandIdConflictError, OrchestratorSubagentThreadReadOnlyError, + OrchestratorThreadAboveModeLimitError, ]); export type OrchestratorV2Error = typeof OrchestratorV2Error.Type; @@ -3418,6 +3438,33 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio }, ); + /** Fails when a limited sender's command would touch a thread running above its limit. */ + const refuseAboveDispatchModeLimit = Effect.fn("orchestrationV2.dispatch.refuseAboveModeLimit")( + function* ( + command: OrchestrationV2ServerCommand, + threadId: ThreadId, + modes: { + readonly runtimeMode: RuntimeMode; + readonly interactionMode: ProviderInteractionMode; + }, + ) { + const limit = yield* DispatchModeLimit; + const exceeded = limit === undefined ? undefined : exceededDispatchModeLimit(limit, modes); + if (limit === undefined || exceeded === undefined) return; + const refusal = { + threadId, + mode: exceeded, + runtimeMode: modes.runtimeMode, + interactionMode: modes.interactionMode, + }; + if (limit.refused !== undefined) yield* Ref.set(limit.refused, refusal); + return yield* new OrchestratorThreadAboveModeLimitError({ + commandId: command.commandId, + ...refusal, + }); + }, + ); + const dispatchThreadFork = Effect.fn("orchestrationV2.dispatch.threadFork")(function* ( command: Extract, events: Ref.Ref>, @@ -3448,6 +3495,9 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio }), ), ); + // The source is not under this command's lock, so check the modes this + // command copies, not an earlier read the source's user could outrun. + yield* refuseAboveDispatchModeLimit(command, command.sourceThreadId, sourceProjection.thread); const sourceRun = runForSourcePoint(sourceProjection, command.sourcePoint); @@ -3536,6 +3586,9 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio }), ), ); + // The source is not under this command's lock, so check the modes this + // command copies, not an earlier read the source's user could outrun. + yield* refuseAboveDispatchModeLimit(command, command.sourceThreadId, sourceProjection.thread); const targetProjection = yield* projectionStore .getThreadRecords(command.targetThreadId, ["contextTransfers"]) .pipe( @@ -10404,6 +10457,19 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio } satisfies OrchestratorV2DispatchResult; } + // A limited sender checked these modes before dispatching; the thread's + // user may have raised them since, and only here can they not change. + const limit = yield* DispatchModeLimit; + if (limit !== undefined) { + // A fork or merge-back source is not under this lock: its dispatch + // checks the copy it reads instead. + const threadId = commandThreadId(command); + const shell = yield* projectionStore + .getThreadShell(threadId) + .pipe(Effect.mapError((cause) => new OrchestratorProjectionError({ threadId, cause }))); + if (shell !== null) yield* refuseAboveDispatchModeLimit(command, threadId, shell); + } + const plan = yield* dispatchOnce(command).pipe( Effect.flatMap((planned) => // A settle that finds the provider already ended everything, or a @@ -10423,6 +10489,9 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio ), Effect.catch((cause) => Effect.gen(function* () { + // Refused like the check above: nothing recorded, so the same command + // can go through once the thread's user lowers it again. + if (cause._tag === "OrchestratorThreadAboveModeLimitError") return yield* cause; const rejectedAt = yield* DateTime.now; const receipt = yield* eventSink .commitRejectedCommand({ diff --git a/apps/server/src/orchestration-v2/ThreadMessageIntake.ts b/apps/server/src/orchestration-v2/ThreadMessageIntake.ts index b3c37e2fea83b58e7a1846c3c6280293def930af..c8f65fd0340afba36aceeed11c896a06334d8181 100644 --- a/apps/server/src/orchestration-v2/ThreadMessageIntake.ts +++ b/apps/server/src/orchestration-v2/ThreadMessageIntake.ts @@ -23,6 +23,7 @@ function dispatchWasNotAccepted( case "OrchestratorCommandPreviouslyRejectedError": case "OrchestratorCommandIdConflictError": case "OrchestratorSubagentThreadReadOnlyError": + case "OrchestratorThreadAboveModeLimitError": return true; default: return false; diff --git a/apps/server/src/provider/T3OrchestrationInstructions.ts b/apps/server/src/provider/T3OrchestrationInstructions.ts index edf6093fa12cf0b2c370ca18f482398fe7606865..fe700c0e2084525573333061df2b9202098f3faa 100644 --- a/apps/server/src/provider/T3OrchestrationInstructions.ts +++ b/apps/server/src/provider/T3OrchestrationInstructions.ts @@ -20,7 +20,7 @@ For independent implementation or a PR stack in its own worktree, use \`t3_threa - Existing worktree: \`{"title":"Continue cleanup","workspaceStrategy":{"type":"existing_worktree","worktreePath":"/absolute/path/to/worktree","branch":"feature/ui-cleanup"},"message":"Continue the cleanup."}\` - Project's main checkout: \`workspaceStrategy:{"type":"root"}\`. Omitting workspaceStrategy also selects root; it does not inherit the caller's worktree. -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. +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. \`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. diff --git a/apps/server/src/vcs/GitVcsDriver.ts b/apps/server/src/vcs/GitVcsDriver.ts index 92d8e98313f3fd8612763ecbe1f76341b4fb5958..6513d037c8dcbdda2bf335f4ccb2718a2fabd3e5 100644 --- a/apps/server/src/vcs/GitVcsDriver.ts +++ b/apps/server/src/vcs/GitVcsDriver.ts @@ -398,6 +398,11 @@ export class GitVcsDriver extends Context.Service< readonly pruneWorktrees: (input: { readonly cwd: string; }) => Effect.Effect; + /** + * Absolute paths of every live worktree of the repository at `cwd`, the + * main checkout included. Worktrees whose directory is gone are left out. + */ + readonly listWorktreePaths: (cwd: string) => Effect.Effect; readonly deleteLocalBranch: ( input: GitDeleteLocalBranchInput, ) => Effect.Effect; diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts index 92f7478fa34bd2983f7f777ea0ae73e6a0a1d8ea..d07baa1727c15ca68dae08c9d90d7a5bf816ca7d 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -3954,6 +3954,26 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* }), ); + const listWorktreePaths: GitVcsDriver.GitVcsDriver["Service"]["listWorktreePaths"] = (cwd) => + runGitStdout("GitVcsDriver.listWorktreePaths", cwd, [ + "worktree", + "list", + "--porcelain", + "-z", + ]).pipe( + // One record per worktree, each ended by an empty field. A `prunable` + // record's directory is gone, and another checkout may now sit there. + Effect.map((stdout) => + stdout.split("\0\0").flatMap((record) => { + const fields = record.split("\0"); + const worktree = fields.find((field) => field.startsWith("worktree ")); + return worktree === undefined || fields.some((field) => field.startsWith("prunable")) + ? [] + : [path.resolve(cwd, worktree.slice("worktree ".length))]; + }), + ), + ); + const withListRefsInvalidation = ( cwd: string, effect: Effect.Effect, @@ -4024,5 +4044,6 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* switchRef: (input) => withListRefsInvalidation(input.cwd, switchRef(input)), initRepo: initRepoWithListRefsInvalidation, listLocalBranchNames, + listWorktreePaths, }); }); diff --git a/docs/orchestration-v2/orchestrator-mcp-server.md b/docs/orchestration-v2/orchestrator-mcp-server.md index 37f469dab3710d3ad0fc5ad8f7b54a6deb6a868d..fb79ac46fa07b41f3ffeb63ab97fc2f13ade13ce 100644 --- a/docs/orchestration-v2/orchestrator-mcp-server.md +++ b/docs/orchestration-v2/orchestrator-mcp-server.md @@ -325,8 +325,8 @@ this binding. Pass the task in `message`. Project, model, and modes inherit when omitted; workspace does not. `scratch: true` launches without a project, in a folder of its own under the environment's Scratch project. For stacked PRs, use the parent branch as `baseRef` with -`startFromOrigin: false`. Launch requires a full-access/default caller and has -no retry key, so inspect existing threads after a failed or lost response before +`startFromOrigin: false`. The new thread may not run with broader runtime or +interaction modes than the caller. Launch has no retry key, so inspect existing threads after a failed or lost response before launching again. `create_threads` remains the batch option for a shared checkout. ### `t3_thread_list` diff --git a/oxlint-plugin-t3code/index.ts b/oxlint-plugin-t3code/index.ts index 475b991e0ddeb47711cb7b373d9432fec2eb537f..f8c47dfccdf19445ff9b3c48d0c42633677eb961 100644 --- a/oxlint-plugin-t3code/index.ts +++ b/oxlint-plugin-t3code/index.ts @@ -7,6 +7,7 @@ import noInlineSchemaCompile from "./rules/no-inline-schema-compile.ts"; import noManualEffectRuntimeInTests from "./rules/no-manual-effect-runtime-in-tests.ts"; import noMobileUniwindThemeEscapeHatches from "./rules/no-mobile-uniwind-theme-escape-hatches.ts"; import noNativeTitleTooltip from "./rules/no-native-title-tooltip.ts"; +import noRawMcpRegistration from "./rules/no-raw-mcp-registration.ts"; import noTestInLoop from "./rules/no-test-in-loop.ts"; import noUnscopedHas from "./rules/no-unscoped-has.ts"; import preferCatchTags from "./rules/prefer-catch-tags.ts"; @@ -24,6 +25,7 @@ export default definePlugin({ "no-manual-effect-runtime-in-tests": noManualEffectRuntimeInTests, "no-mobile-uniwind-theme-escape-hatches": noMobileUniwindThemeEscapeHatches, "no-native-title-tooltip": noNativeTitleTooltip, + "no-raw-mcp-registration": noRawMcpRegistration, "no-test-in-loop": noTestInLoop, "no-unscoped-has": noUnscopedHas, "prefer-catch-tags": preferCatchTags, diff --git a/oxlint-plugin-t3code/rules/no-raw-mcp-registration.test.ts b/oxlint-plugin-t3code/rules/no-raw-mcp-registration.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..cc72f70a300a9be1f7e75779d0581223782e182e --- /dev/null +++ b/oxlint-plugin-t3code/rules/no-raw-mcp-registration.test.ts @@ -0,0 +1,185 @@ +import { assert, describe } from "@effect/vitest"; + +import { createOxlintRuleHarness } from "../test/utils.ts"; + +const rule = createOxlintRuleHarness("t3code/no-raw-mcp-registration"); +const testFile = createOxlintRuleHarness("t3code/no-raw-mcp-registration", { + filename: "server.test.ts", +}); + +describe("t3code/no-raw-mcp-registration", () => { + rule.valid( + "allows the rest of effect/ai and type-only McpServer imports", + ` + import { McpSchema, Tool, Toolkit } from "effect/ai"; + import type { McpServer } from "effect/ai"; + import { type McpServer as Server } from "effect/ai"; + export type Service = McpServer.McpServer | Server.McpServer; + export const used = [McpSchema, Tool, Toolkit]; + `, + ); + + rule.invalid( + "reports importing McpServer from effect/ai", + ` + import { McpServer } from "effect/ai"; + export const server = McpServer.McpServer; + `, + (output) => { + assert.match(output, /Only McpHttpServer may use Effect's McpServer/); + }, + ); + + rule.invalid( + "reports importing McpServer under another name", + ` + import { McpServer as Server } from "effect/ai"; + export const registration = Server.toolkit(SomeToolkit); + `, + ); + + rule.invalid( + "reports a namespace import of effect/ai", + ` + import * as Ai from "effect/ai"; + export const registration = Ai.McpServer.toolkit(SomeToolkit); + `, + ); + + rule.invalid( + "reports importing the McpServer module directly", + ` + import { toolkit } from "effect/ai/McpServer"; + export const registration = toolkit(SomeToolkit); + `, + ); + + rule.invalid( + "reports re-exporting McpServer", + ` + export { McpServer as Server } from "effect/ai"; + `, + ); + + rule.invalid( + "reports re-exporting all of effect/ai", + ` + export * from "effect/ai"; + `, + ); + + rule.invalid( + "reports a dynamic import of the McpServer module", + ` + export const load = () => import("effect/ai/McpServer"); + `, + ); + + rule.invalid( + "reports a dynamic import written as a template", + ` + export const load = () => import(\`effect/ai/McpServer\`); + `, + ); + + rule.invalid( + "reports McpServer.toolkit", + ` + export const registration = McpServer.toolkit(SomeToolkit); + `, + (output) => { + assert.match(output, /McpServer\.toolkit registers on \/mcp/); + }, + ); + + rule.invalid( + "reports McpServer.resource and McpServer.prompt", + ` + export const resource = McpServer.resource({ uri: "t3://x", name: "x", content: "x" }); + export const prompt = McpServer.prompt({ name: "x", content: () => "x" }); + `, + ); + + rule.invalid( + "reports addTool on the McpServer service", + ` + import * as Effect from "effect/Effect"; + export const register = Effect.gen(function* () { + const server = yield* Service; + yield* server.addTool({ tool, annotations, handle: () => Effect.die("unchecked") }); + }); + `, + (output) => { + assert.match(output, /\.addTool registers on \/mcp/); + }, + ); + + rule.invalid( + "reports a registration method read without calling it", + ` + export const add = server.addTool; + `, + ); + + rule.invalid( + "reports a registration function taken by destructuring", + ` + const { toolkit } = McpServer; + export const registration = toolkit(SomeToolkit); + `, + ); + + rule.invalid( + "reports a registration method taken by destructuring", + ` + import * as Effect from "effect/Effect"; + export const register = Effect.gen(function* () { + const { addTool } = yield* Service; + yield* addTool({ tool, annotations, handle: () => Effect.die("unchecked") }); + }); + `, + ); + + testFile.valid( + "lets tests import McpServer to build a server", + ` + import { McpServer } from "effect/ai"; + export const layer = McpServer.McpServer.layer; + `, + ); + + testFile.invalid( + "reports a registration in a test", + ` + import { McpServer } from "effect/ai"; + export const registration = McpServer.toolkit(SomeToolkit); + `, + ); + + testFile.invalid( + "reports a test importing McpServer under another name", + ` + import { McpServer as Server } from "effect/ai"; + export const registration = Server.toolkit(SomeToolkit); + `, + (output) => { + assert.match(output, /Tests import McpServer only as/); + }, + ); + + testFile.invalid( + "reports a test importing effect/ai as a namespace", + ` + import * as Ai from "effect/ai"; + export const registration = Ai.McpServer.toolkit(SomeToolkit); + `, + ); + + testFile.invalid( + "reports a test importing the McpServer module directly", + ` + import { toolkit } from "effect/ai/McpServer"; + export const registration = toolkit(SomeToolkit); + `, + ); +}); diff --git a/oxlint-plugin-t3code/rules/no-raw-mcp-registration.ts b/oxlint-plugin-t3code/rules/no-raw-mcp-registration.ts new file mode 100644 index 0000000000000000000000000000000000000000..6f3b3df49f591bdd68639a15e5dbcb9c9b8a5d37 --- /dev/null +++ b/oxlint-plugin-t3code/rules/no-raw-mcp-registration.ts @@ -0,0 +1,148 @@ +import { defineRule } from "@oxlint/plugins"; +import * as Option from "effect/Option"; + +import { getPropertyName, isIdentifier, unwrapExpression } from "../utils.ts"; + +// Effect's MCP server, and the modules that re-export it. +const MCP_SERVER_MODULE = "effect/ai/McpServer"; +const AI_MODULES = new Set(["effect/ai", "effect/ai/index"]); +// Registering anything straight on the MCP server skips McpToolAccess, where +// every T3 MCP tool declares who may call it. These are the module functions... +const MODULE_REGISTRATIONS = new Set([ + "toolkit", + "registerToolkit", + "resource", + "registerResource", + "prompt", + "registerPrompt", +]); +// ...and the methods on the McpServer service itself. +const SERVICE_REGISTRATIONS = new Set([ + "addTool", + "addResource", + "addResourceTemplate", + "addPrompt", +]); +const TEST_FILE_PATTERN = /\.(?:test|spec)\.[cm]?[jt]sx?$/u; + +const HOW = + "Build the handlers with McpToolAccess.toLayer and register them through McpHttpServer's toolkitRegistration."; +const registrationMessage = (name: string) => + `${name} registers on /mcp without the access checks McpToolAccess declares. ${HOW}`; +const importMessage = `Only McpHttpServer may use Effect's McpServer: a registration anywhere else skips the access checks McpToolAccess declares. ${HOW}`; +const testImportMessage = + 'Tests import McpServer only as `import { McpServer } from "effect/ai"`, so a registration on it is reported.'; + +/** A module specifier: a string, or a template with nothing substituted. */ +const literalString = (node: unknown): Option.Option => + Option.flatMap(unwrapExpression(node), (expression) => { + if (expression.type === "Literal" && typeof expression.value === "string") { + return Option.some(expression.value); + } + if (expression.type === "TemplateLiteral" && expression.expressions.length === 0) { + return Option.fromNullishOr(expression.quasis[0]?.value.cooked); + } + return Option.none(); + }); + +const namesMcpServer = (node: unknown) => + Option.getOrUndefined(getPropertyName(node)) === "McpServer"; + +const exposesMcpServer = (source: Option.Option) => + Option.isSome(source) && (source.value === MCP_SERVER_MODULE || AI_MODULES.has(source.value)); + +/** + * Keeps every registration on `/mcp` inside McpHttpServer, whose helpers + * accept only handlers McpToolAccess built; the lint config exempts that file. + * Elsewhere it reports runtime imports of Effect's McpServer, under any name + * or path, and reading a registration method or function in any form. Tests + * may import McpServer to build a server, but only under its own name, so a + * registration on it is still reported. It guards against a registration that + * skips the checks by accident; deliberately working around it needs a + * reviewed change here. + */ +export default defineRule({ + meta: { + type: "problem", + docs: { + description: + "Disallow registering on the MCP server without McpToolAccess; tools must declare who may call them.", + }, + }, + create(context) { + const isTest = TEST_FILE_PATTERN.test(context.filename); + return { + ImportDeclaration(node) { + if (node.importKind === "type") return; + const source = literalString(node.source); + if (Option.isNone(source)) return; + const exposes = node.specifiers.some((specifier) => { + if (specifier.type !== "ImportSpecifier") return exposesMcpServer(source); + if (specifier.importKind === "type") return false; + if (source.value === MCP_SERVER_MODULE) return true; + return ( + AI_MODULES.has(source.value) && + namesMcpServer(specifier.imported) && + // A test's `McpServer.toolkit(...)` is reported below by name. + !(isTest && namesMcpServer(specifier.local)) + ); + }); + if (exposes) context.report({ node, message: isTest ? testImportMessage : importMessage }); + }, + ExportNamedDeclaration(node) { + if (node.source === null || node.exportKind === "type") return; + const source = literalString(node.source); + if (Option.isNone(source)) return; + const exposes = node.specifiers.some( + (specifier) => + specifier.exportKind !== "type" && + (source.value === MCP_SERVER_MODULE || + (AI_MODULES.has(source.value) && namesMcpServer(specifier.local))), + ); + if (exposes) context.report({ node, message: importMessage }); + }, + ExportAllDeclaration(node) { + if (node.exportKind === "type") return; + if (exposesMcpServer(literalString(node.source))) { + context.report({ node, message: importMessage }); + } + }, + ImportExpression(node) { + if (exposesMcpServer(literalString(node.source))) { + context.report({ node, message: importMessage }); + } + }, + MemberExpression(node) { + const name = getPropertyName(node.property); + if (Option.isNone(name)) return; + if (SERVICE_REGISTRATIONS.has(name.value)) { + context.report({ node, message: registrationMessage(`.${name.value}`) }); + } else if ( + MODULE_REGISTRATIONS.has(name.value) && + isIdentifier(unwrapExpression(node.object), "McpServer") + ) { + context.report({ node, message: registrationMessage(`McpServer.${name.value}`) }); + } + }, + // `const { addTool } = server`, `const { toolkit } = McpServer` + ObjectPattern(node) { + const fromMcpServer = + node.parent.type === "VariableDeclarator" && + isIdentifier(unwrapExpression(node.parent.init), "McpServer"); + for (const property of node.properties) { + if (property.type !== "Property") continue; + const name = getPropertyName(property.key); + if (Option.isNone(name)) continue; + if (SERVICE_REGISTRATIONS.has(name.value)) { + context.report({ node: property, message: registrationMessage(`.${name.value}`) }); + } else if (fromMcpServer && MODULE_REGISTRATIONS.has(name.value)) { + context.report({ + node: property, + message: registrationMessage(`McpServer.${name.value}`), + }); + } + } + }, + }; + }, +}); diff --git a/vite.config.ts b/vite.config.ts index 5fdca6eeec5d5bc3e84ed3070b899499e2d20de6..ca3ed94432ccafb1899b208abbf1708f2301f45c 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -167,6 +167,7 @@ export default defineConfig({ "t3code/no-inline-schema-compile": "warn", "t3code/no-manual-effect-runtime-in-tests": "error", "t3code/no-native-title-tooltip": "error", + "t3code/no-raw-mcp-registration": "error", "t3code/no-test-in-loop": "error", "t3code/no-unscoped-has": "error", "t3code/namespace-node-imports": "error", @@ -179,6 +180,11 @@ export default defineConfig({ files: ["packages/shared/src/hostProcess.ts"], rules: { "t3code/no-global-process-runtime": "off" }, }, + { + // The registration helpers that only accept handlers built by McpToolAccess. + files: ["apps/server/src/mcp/McpHttpServer.ts"], + rules: { "t3code/no-raw-mcp-registration": "off" }, + }, { files: ["apps/web/src/**"], excludeFiles: ["apps/web/src/components/ui/**"],