diff --git a/apps/server/src/mcp/McpHttpServer.ts b/apps/server/src/mcp/McpHttpServer.ts index f1a1459d0944..b9bb8c27c5b0 100644 --- a/apps/server/src/mcp/McpHttpServer.ts +++ b/apps/server/src/mcp/McpHttpServer.ts @@ -14,6 +14,7 @@ import * as McpInvocationContext from "./McpInvocationContext.ts"; import * as OrchestratorMcpService from "./OrchestratorMcpService.ts"; import * as McpSessionRegistry from "./McpSessionRegistry.ts"; import * as PreviewAutomationBroker from "./PreviewAutomationBroker.ts"; +import * as ProjectScriptMcpService from "./ProjectScriptMcpService.ts"; import { OrchestratorToolkitHandlersLive } from "./toolkits/orchestrator/handlers.ts"; import { OrchestratorToolkit } from "./toolkits/orchestrator/tools.ts"; import { @@ -25,6 +26,8 @@ import { PreviewSnapshotToolkit, PreviewStandardToolkit, } from "./toolkits/preview/tools.ts"; +import { ProjectScriptToolkitHandlersLive } from "./toolkits/project_script/handlers.ts"; +import { ProjectScriptToolkit } from "./toolkits/project_script/tools.ts"; import { TerminalToolkitHandlersLive } from "./toolkits/terminal/handlers.ts"; import { TerminalToolkit } from "./toolkits/terminal/tools.ts"; import { WorktreeToolkitHandlersLive } from "./toolkits/worktree/handlers.ts"; @@ -239,6 +242,12 @@ export const TerminalToolkitRegistrationLive = McpServer.toolkit(TerminalToolkit Layer.provide(TerminalMcpService.layer), ); +export const ProjectScriptToolkitRegistrationLive = McpServer.toolkit(ProjectScriptToolkit).pipe( + Layer.provide(ProjectScriptToolkitHandlersLive), + Layer.provide(ProjectScriptMcpService.layer), + Layer.provide(TerminalMcpService.layer), +); + const McpTransportLive = McpServer.layerHttp({ name: "T3 Code", version: packageJson.version, @@ -249,6 +258,7 @@ const McpTransportLive = McpServer.layerHttp({ export const layer = Layer.mergeAll( PreviewToolkitRegistrationLive, OrchestratorToolkitRegistrationLive, + ProjectScriptToolkitRegistrationLive, TerminalToolkitRegistrationLive, WorktreeToolkitRegistrationLive, ).pipe(Layer.provideMerge(McpTransportLive)); diff --git a/apps/server/src/mcp/OrchestratorMcpService.ts b/apps/server/src/mcp/OrchestratorMcpService.ts index cf286d50d643..41e692d0c500 100644 --- a/apps/server/src/mcp/OrchestratorMcpService.ts +++ b/apps/server/src/mcp/OrchestratorMcpService.ts @@ -1167,6 +1167,7 @@ const make = Effect.gen(function* () { incrementalThreadRead: true, scheduledTasks: true, managedTerminals: true, + savedProjectScripts: true, maxBatchThreads: 20, }, }; diff --git a/apps/server/src/mcp/ProjectScriptMcpService.test.ts b/apps/server/src/mcp/ProjectScriptMcpService.test.ts new file mode 100644 index 000000000000..c9c5b23fbe07 --- /dev/null +++ b/apps/server/src/mcp/ProjectScriptMcpService.test.ts @@ -0,0 +1,739 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, describe, it } from "@effect/vitest"; +import { + EnvironmentId, + ProjectId, + ProviderInstanceId, + ThreadId, + type OrchestrationV2ThreadProjection, + type Project, + type ProjectScript, +} from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Deferred from "effect/Deferred"; +import * as FileSystem from "effect/FileSystem"; +import * as Fiber from "effect/Fiber"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; +import { expect } from "vite-plus/test"; + +import * as ProcessRunner from "../processRunner.ts"; +import { makeKeyedSerialExecutor } from "../orchestration-v2/KeyedSerialExecutor.ts"; +import { ThreadCommandExecutor } from "../orchestration-v2/ThreadCommandExecutor.ts"; +import { + ThreadManagementService, + ThreadManagementThreadNotFoundError, +} from "../orchestration-v2/ThreadManagementService.ts"; +import * as ProjectService from "../project/ProjectService.ts"; +import * as TerminalManager from "../terminal/Manager.ts"; +import * as PtyAdapter from "../terminal/PtyAdapter.ts"; +import type { McpInvocationScope } from "./McpInvocationContext.ts"; +import * as ProjectScriptMcpService from "./ProjectScriptMcpService.ts"; +import * as TerminalMcpService from "./TerminalMcpService.ts"; + +class FakePtyProcess implements PtyAdapter.PtyProcess { + readonly writes: string[] = []; + readonly pid: number; + readonly failWrite: boolean; + killCalls = 0; + private readonly dataListeners = new Set<(data: string) => void>(); + private readonly exitListeners = new Set<(event: PtyAdapter.PtyExitEvent) => void>(); + + constructor(pid: number, failWrite: boolean) { + this.pid = pid; + this.failWrite = failWrite; + } + + write(data: string): void { + if (this.failWrite) throw new Error("script PTY write failed"); + this.writes.push(data); + } + + resize(): void {} + + kill(): void { + this.killCalls += 1; + this.emitExit({ exitCode: 0, signal: null }); + } + + onData(listener: (data: string) => void): () => void { + this.dataListeners.add(listener); + return () => this.dataListeners.delete(listener); + } + + onExit(listener: (event: PtyAdapter.PtyExitEvent) => void): () => void { + this.exitListeners.add(listener); + return () => this.exitListeners.delete(listener); + } + + emitExit(event: PtyAdapter.PtyExitEvent): void { + for (const listener of this.exitListeners) listener(event); + } +} + +class FakePtyAdapter { + readonly spawnInputs: PtyAdapter.PtySpawnInput[] = []; + readonly processes: FakePtyProcess[] = []; + failNextWrite = false; + + spawn(input: PtyAdapter.PtySpawnInput): Effect.Effect { + this.spawnInputs.push(input); + const process = new FakePtyProcess(14_000 + this.processes.length, this.failNextWrite); + this.failNextWrite = false; + this.processes.push(process); + return Effect.succeed(process); + } +} + +function projection(input: { + readonly threadId: ThreadId; + readonly projectId: ProjectId; + readonly runtimeMode?: "approval-required" | "full-access"; + readonly interactionMode?: "plan" | "default"; + readonly worktreePath?: string | null; + readonly executionCwd?: string; +}): OrchestrationV2ThreadProjection { + const providerSessionId = `provider-session:${input.threadId}`; + const providerThreadId = `provider-thread:${input.threadId}`; + return { + thread: { + id: input.threadId, + projectId: input.projectId, + runtimeMode: input.runtimeMode ?? "full-access", + interactionMode: input.interactionMode ?? "default", + worktreePath: input.worktreePath ?? null, + activeProviderThreadId: input.executionCwd === undefined ? null : providerThreadId, + deletedAt: null, + }, + providerThreads: + input.executionCwd === undefined ? [] : [{ id: providerThreadId, providerSessionId }], + providerSessions: + input.executionCwd === undefined + ? [] + : [{ id: providerSessionId, cwd: input.executionCwd, status: "ready" }], + } as unknown as OrchestrationV2ThreadProjection; +} + +function project(input: { + readonly projectId: ProjectId; + readonly workspaceRoot: string; + readonly scripts: ReadonlyArray; +}): Project { + return { + id: input.projectId, + title: "Script project", + workspaceRoot: input.workspaceRoot, + repositoryIdentity: null, + faviconPath: null, + defaultModelSelection: null, + scripts: [...input.scripts], + createdAt: "2026-08-29T00:00:00.000Z", + updatedAt: "2026-08-29T00:00:00.000Z", + deletedAt: null, + }; +} + +describe("ProjectScriptMcpService", () => { + it.effect("runs only saved scripts in dedicated managed terminals", () => + Effect.scoped( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-project-script-mcp-" }); + const workspaceRoot = path.join(baseDir, "workspace"); + const worktreePath = path.join(baseDir, "worktree"); + const nestedExecutionCwd = path.join(worktreePath, "packages", "app"); + yield* fs.makeDirectory(workspaceRoot, { recursive: true }); + yield* fs.makeDirectory(nestedExecutionCwd, { recursive: true }); + + const projectId = ProjectId.make("project:script-mcp"); + const missingProjectId = ProjectId.make("project:script-mcp-missing"); + const callerThreadId = ThreadId.make("thread:script-mcp-caller"); + const targetThreadId = ThreadId.make("thread:script-mcp-target"); + const restrictedThreadId = ThreadId.make("thread:script-mcp-restricted"); + const missingProjectThreadId = ThreadId.make("thread:script-mcp-missing-project"); + const projections = new Map([ + [callerThreadId, projection({ threadId: callerThreadId, projectId })], + [ + targetThreadId, + projection({ + threadId: targetThreadId, + projectId, + worktreePath, + executionCwd: nestedExecutionCwd, + }), + ], + [ + restrictedThreadId, + projection({ + threadId: restrictedThreadId, + projectId, + runtimeMode: "approval-required", + interactionMode: "plan", + }), + ], + [ + missingProjectThreadId, + projection({ threadId: missingProjectThreadId, projectId: missingProjectId }), + ], + ]); + let savedScripts: ReadonlyArray = [ + { + id: "dev", + name: "Development server", + command: "pnpm run dev", + icon: "play", + runOnWorktreeCreate: false, + previewUrl: "http://localhost:3000", + autoOpenPreview: true, + }, + { + id: "test", + name: "Tests", + command: "pnpm test", + icon: "test", + runOnWorktreeCreate: false, + }, + ]; + const ptyAdapter = new FakePtyAdapter(); + const manager = yield* TerminalManager.makeWithOptions({ + logsDir: path.join(baseDir, "terminal-history"), + ptyAdapter, + processKillGraceMs: 1, + }); + const replacementAdmissionEntered = yield* Deferred.make(); + const replacementAdmissionRelease = yield* Deferred.make(); + const exitAdmissionEntered = yield* Deferred.make(); + const exitAdmissionRelease = yield* Deferred.make(); + let replacementAdmissionAttempts = 0; + const gatedManager = TerminalManager.TerminalManager.of({ + ...manager, + admitRunningSessionHandle: (input, onAdmitted) => { + if ( + input.terminalId === "term-script-registration-replacement" && + replacementAdmissionAttempts++ === 0 + ) { + return Deferred.succeed(replacementAdmissionEntered, undefined).pipe( + Effect.andThen(Deferred.await(replacementAdmissionRelease)), + Effect.andThen(manager.admitRunningSessionHandle(input, onAdmitted)), + ); + } + if (input.terminalId === "term-script-registration-exit") { + return Deferred.succeed(exitAdmissionEntered, undefined).pipe( + Effect.andThen(Deferred.await(exitAdmissionRelease)), + Effect.andThen(manager.admitRunningSessionHandle(input, onAdmitted)), + ); + } + return manager.admitRunningSessionHandle(input, onAdmitted); + }, + }); + const threadDispatch = yield* makeKeyedSerialExecutor(); + const threads = ThreadManagementService.of({ + getThreadProjection: (threadId: ThreadId) => + projections.has(threadId) + ? Effect.succeed(projections.get(threadId)!) + : Effect.fail(new Error("missing projection") as never), + getProjectThread: ({ + projectId: expectedProjectId, + threadId, + }: { + readonly projectId: ProjectId; + readonly threadId: ThreadId; + }) => { + const target = projections.get(threadId); + return target !== undefined && target.thread.projectId === expectedProjectId + ? Effect.succeed(target) + : Effect.fail( + new ThreadManagementThreadNotFoundError({ + projectId: expectedProjectId, + threadId, + }), + ); + }, + } as unknown as ThreadManagementService["Service"]); + const projects = ProjectService.ProjectService.of({ + getById: (requestedProjectId: ProjectId) => + Effect.succeed( + requestedProjectId === projectId + ? Option.some(project({ projectId, workspaceRoot, scripts: savedScripts })) + : Option.none(), + ), + } as unknown as ProjectService.ProjectService["Service"]); + const terminalService = yield* TerminalMcpService.make.pipe( + Effect.provideService(ThreadManagementService, threads), + Effect.provideService(ProjectService.ProjectService, projects), + Effect.provideService(TerminalManager.TerminalManager, gatedManager), + Effect.provideService(ThreadCommandExecutor, threadDispatch), + ); + let afterCloseOwned: (() => Effect.Effect) | null = null; + const terminalServiceWithCloseGate = TerminalMcpService.TerminalMcpService.of({ + ...terminalService, + closeOwned: (scope, input) => + terminalService.closeOwned(scope, input).pipe( + Effect.tap((closed) => { + const action = afterCloseOwned; + afterCloseOwned = null; + return closed && action !== null ? action() : Effect.void; + }), + ), + }); + const service = yield* ProjectScriptMcpService.make.pipe( + Effect.provideService( + TerminalMcpService.TerminalMcpService, + terminalServiceWithCloseGate, + ), + Effect.provideService(TerminalManager.TerminalManager, gatedManager), + ); + const scope: McpInvocationScope = { + environmentId: EnvironmentId.make("environment:script-mcp"), + threadId: callerThreadId, + providerSessionId: "provider-session:script-mcp", + providerInstanceId: ProviderInstanceId.make("codex"), + capabilities: new Set(["orchestration"]), + issuedAt: 1, + }; + const page = yield* service.list(scope, { + threadId: targetThreadId, + limit: 1, + commandPreviewChars: 5, + }); + expect(page).toMatchObject({ + threadId: targetThreadId, + projectId, + total: 2, + nextCursor: 1, + scripts: [ + { + scriptId: "dev", + commandPreview: "pnpm ", + commandCharacters: 12, + commandTruncated: true, + previewUrl: "http://localhost:3000", + autoOpenPreview: true, + }, + ], + }); + assert.equal(ptyAdapter.spawnInputs.length, 0); + + const unknownScript = yield* service + .run(scope, { + threadId: targetThreadId, + scriptId: "missing", + terminalId: "term-script-missing", + }) + .pipe(Effect.flip); + assert.equal(unknownScript.code, "script_not_found"); + const unknownThread = yield* service + .list(scope, { threadId: ThreadId.make("thread:script-mcp-unknown") }) + .pipe(Effect.flip); + assert.equal(unknownThread.code, "thread_not_found"); + const missingProject = yield* service + .list({ ...scope, threadId: missingProjectThreadId }, {}) + .pipe(Effect.flip); + assert.equal(missingProject.code, "project_not_found"); + const denied = yield* service + .run(scope, { + threadId: restrictedThreadId, + scriptId: "dev", + terminalId: "term-script-denied", + }) + .pipe(Effect.flip); + assert.equal(denied.code, "execution_policy_denied"); + assert.equal(ptyAdapter.spawnInputs.length, 0); + + const first = yield* service.run(scope, { + threadId: targetThreadId, + scriptId: "dev", + terminalId: "term-script-first", + }); + assert.equal(first.outcome, "input_accepted"); + assert.equal(first.terminalId, "term-script-first"); + assert.equal(first.previewAutoOpened, false); + assert.equal(first.terminal.worktreePath, worktreePath); + assert.equal(ptyAdapter.spawnInputs[0]?.cwd, nestedExecutionCwd); + assert.equal(ptyAdapter.spawnInputs[0]?.env?.T3CODE_PROJECT_ROOT, workspaceRoot); + assert.equal(ptyAdapter.spawnInputs[0]?.env?.T3CODE_WORKTREE_PATH, worktreePath); + expect(ptyAdapter.processes[0]?.writes).toEqual(["pnpm run dev\r"]); + + const duplicate = yield* service + .run(scope, { + threadId: targetThreadId, + scriptId: "dev", + terminalId: first.terminalId, + }) + .pipe(Effect.flip); + assert.equal(duplicate.code, "terminal_already_exists"); + assert.equal(ptyAdapter.spawnInputs.length, 1); + + const second = yield* service.run(scope, { + threadId: targetThreadId, + scriptId: "dev", + terminalId: "term-script-second", + }); + assert.notEqual(second.terminalId, first.terminalId); + assert.equal(ptyAdapter.spawnInputs.length, 2); + expect(ptyAdapter.processes[1]?.writes).toEqual(["pnpm run dev\r"]); + + const unrelatedStop = yield* service + .stop(scope, { + threadId: targetThreadId, + scriptId: "test", + terminalId: second.terminalId, + }) + .pipe(Effect.flip); + assert.equal(unrelatedStop.code, "script_run_not_found"); + const stopped = yield* service.stop(scope, { + threadId: targetThreadId, + scriptId: "dev", + terminalId: first.terminalId, + }); + assert.isTrue(stopped.stopped); + assert.equal( + yield* manager.inspectSession({ threadId: targetThreadId, terminalId: first.terminalId }), + null, + ); + + const stopRaceA = yield* service.run(scope, { + threadId: targetThreadId, + scriptId: "dev", + terminalId: "term-script-stop-reopen-race", + }); + afterCloseOwned = () => + service + .run(scope, { + threadId: targetThreadId, + scriptId: "dev", + terminalId: stopRaceA.terminalId, + }) + .pipe(Effect.orDie, Effect.asVoid); + assert.isTrue( + (yield* service.stop(scope, { + threadId: targetThreadId, + scriptId: "dev", + terminalId: stopRaceA.terminalId, + })).stopped, + ); + assert.isTrue( + (yield* service.stop(scope, { + threadId: targetThreadId, + scriptId: "dev", + terminalId: stopRaceA.terminalId, + })).stopped, + ); + + savedScripts = savedScripts.filter((script) => script.id !== "dev"); + const stoppedAfterDefinitionRemoval = yield* service.stop(scope, { + threadId: targetThreadId, + scriptId: "dev", + terminalId: second.terminalId, + }); + assert.isTrue(stoppedAfterDefinitionRemoval.stopped); + savedScripts = [ + { + id: "dev", + name: "Development server", + command: "pnpm run dev", + icon: "play", + runOnWorktreeCreate: false, + previewUrl: "http://localhost:3000", + autoOpenPreview: true, + }, + ...savedScripts, + ]; + + const externallyClosed = yield* service.run(scope, { + threadId: targetThreadId, + scriptId: "dev", + terminalId: "term-script-external-close", + }); + yield* manager.close({ + threadId: targetThreadId, + terminalId: externallyClosed.terminalId, + }); + yield* manager.openFresh({ + threadId: targetThreadId, + terminalId: externallyClosed.terminalId, + cwd: nestedExecutionCwd, + worktreePath, + cols: 120, + rows: 30, + }); + const staleClose = yield* service + .stop(scope, { + threadId: targetThreadId, + scriptId: "dev", + terminalId: externallyClosed.terminalId, + }) + .pipe(Effect.flip); + assert.equal(staleClose.code, "script_run_not_found"); + assert.equal( + (yield* manager.inspectSession({ + threadId: targetThreadId, + terminalId: externallyClosed.terminalId, + }))?.status, + "running", + ); + yield* manager.close({ + threadId: targetThreadId, + terminalId: externallyClosed.terminalId, + }); + + const externallyRestarted = yield* service.run(scope, { + threadId: targetThreadId, + scriptId: "dev", + terminalId: "term-script-external-restart", + }); + yield* manager.restart({ + threadId: targetThreadId, + terminalId: externallyRestarted.terminalId, + cwd: nestedExecutionCwd, + worktreePath, + cols: 120, + rows: 30, + }); + const staleRestart = yield* service + .stop(scope, { + threadId: targetThreadId, + scriptId: "dev", + terminalId: externallyRestarted.terminalId, + }) + .pipe(Effect.flip); + assert.equal(staleRestart.code, "script_run_not_found"); + assert.equal( + (yield* manager.inspectSession({ + threadId: targetThreadId, + terminalId: externallyRestarted.terminalId, + }))?.status, + "running", + ); + yield* manager.close({ + threadId: targetThreadId, + terminalId: externallyRestarted.terminalId, + }); + + const staleRegistration = yield* service + .run(scope, { + threadId: targetThreadId, + scriptId: "dev", + terminalId: "term-script-registration-replacement", + }) + .pipe(Effect.forkChild); + yield* Deferred.await(replacementAdmissionEntered); + yield* manager.close({ + threadId: targetThreadId, + terminalId: "term-script-registration-replacement", + }); + const replacementRegistration = yield* service.run(scope, { + threadId: targetThreadId, + scriptId: "dev", + terminalId: "term-script-registration-replacement", + }); + assert.equal(replacementRegistration.outcome, "input_accepted"); + yield* Deferred.succeed(replacementAdmissionRelease, undefined); + const changedBeforeRegistration = yield* Fiber.join(staleRegistration).pipe(Effect.flip); + assert.equal(changedBeforeRegistration.code, "terminal_not_found"); + assert.isTrue( + (yield* service.stop(scope, { + threadId: targetThreadId, + scriptId: "dev", + terminalId: "term-script-registration-replacement", + })).stopped, + ); + + const registrationExitObserved = yield* Deferred.make(); + const unsubscribeRegistrationExit = yield* manager.subscribe((event) => + event.type === "exited" && event.terminalId === "term-script-registration-exit" + ? Deferred.succeed(registrationExitObserved, undefined) + : Effect.void, + ); + const exitedBeforeRegistration = yield* service + .run(scope, { + threadId: targetThreadId, + scriptId: "dev", + terminalId: "term-script-registration-exit", + }) + .pipe(Effect.forkChild); + yield* Deferred.await(exitAdmissionEntered); + const exitingProcess = ptyAdapter.processes.at(-1); + expect(exitingProcess).toBeDefined(); + exitingProcess?.kill(); + yield* Deferred.await(registrationExitObserved); + yield* Deferred.succeed(exitAdmissionRelease, undefined); + const exitedRegistration = yield* Fiber.join(exitedBeforeRegistration).pipe(Effect.flip); + assert.equal(exitedRegistration.code, "terminal_not_found"); + const exitedRegistrationStop = yield* service + .stop(scope, { + threadId: targetThreadId, + scriptId: "dev", + terminalId: "term-script-registration-exit", + }) + .pipe(Effect.flip); + assert.equal(exitedRegistrationStop.code, "script_run_not_found"); + assert.equal( + (yield* manager.inspectSession({ + threadId: targetThreadId, + terminalId: "term-script-registration-exit", + }))?.status, + "exited", + ); + unsubscribeRegistrationExit(); + yield* manager.close({ + threadId: targetThreadId, + terminalId: "term-script-registration-exit", + }); + + const scriptStarted = yield* Deferred.make(); + const releaseStarted = yield* Deferred.make(); + const unsubscribeStarted = yield* manager.subscribe((event) => + event.type === "started" && event.terminalId === "term-script-interrupted" + ? Deferred.succeed(scriptStarted, undefined).pipe( + Effect.andThen(Deferred.await(releaseStarted)), + ) + : Effect.void, + ); + const interruptedRun = yield* service + .run(scope, { + threadId: targetThreadId, + scriptId: "dev", + terminalId: "term-script-interrupted", + }) + .pipe(Effect.forkChild); + yield* Deferred.await(scriptStarted); + const interruption = yield* Fiber.interrupt(interruptedRun).pipe(Effect.forkChild); + yield* Deferred.succeed(releaseStarted, undefined); + yield* Fiber.join(interruption); + unsubscribeStarted(); + const stoppedInterruptedRun = yield* service.stop(scope, { + threadId: targetThreadId, + scriptId: "dev", + terminalId: "term-script-interrupted", + }); + assert.isTrue(stoppedInterruptedRun.stopped); + + ptyAdapter.failNextWrite = true; + const partial = yield* service.run(scope, { + threadId: targetThreadId, + scriptId: "test", + terminalId: "term-script-partial", + }); + assert.equal(partial.outcome, "terminal_opened_input_failed"); + assert.equal(partial.inputAcceptance, null); + assert.equal( + partial.error, + "The terminal was opened, but its saved script input was not accepted.", + ); + assert.notInclude(partial.error ?? "", "script PTY write failed"); + assert.isNotNull( + yield* manager.inspectSession({ + threadId: targetThreadId, + terminalId: partial.terminalId, + }), + ); + yield* service.stop(scope, { + threadId: targetThreadId, + scriptId: "test", + terminalId: partial.terminalId, + }); + + const queuedDrains: Array> = []; + const queuedPtyAdapter = new FakePtyAdapter(); + const queuedManager = yield* TerminalManager.makeWithOptions({ + logsDir: path.join(baseDir, "queued-terminal-history"), + ptyAdapter: queuedPtyAdapter, + processKillGraceMs: 1, + processEventDrainRunner: (effect) => { + queuedDrains.push(effect); + }, + }); + const queuedTerminalService = yield* TerminalMcpService.make.pipe( + Effect.provideService(ThreadManagementService, threads), + Effect.provideService(ProjectService.ProjectService, projects), + Effect.provideService(TerminalManager.TerminalManager, queuedManager), + Effect.provideService(ThreadCommandExecutor, threadDispatch), + ); + const queuedScriptService = yield* ProjectScriptMcpService.make.pipe( + Effect.provideService(TerminalMcpService.TerminalMcpService, queuedTerminalService), + Effect.provideService(TerminalManager.TerminalManager, queuedManager), + ); + const queuedExitRun = yield* queuedScriptService.run(scope, { + threadId: targetThreadId, + scriptId: "dev", + terminalId: "term-script-clear-queued-exit", + }); + assert.equal(queuedExitRun.outcome, "input_accepted"); + const queuedExitProcess = queuedPtyAdapter.processes[0]; + expect(queuedExitProcess).toBeDefined(); + if (!queuedExitProcess) return; + queuedExitProcess.emitExit({ exitCode: 23, signal: null }); + expect(queuedDrains).toHaveLength(1); + + yield* queuedManager.clear({ + threadId: targetThreadId, + terminalId: queuedExitRun.terminalId, + }); + yield* queuedDrains[0]!; + expect( + yield* queuedManager.inspectSession({ + threadId: targetThreadId, + terminalId: queuedExitRun.terminalId, + }), + ).toMatchObject({ status: "exited", exitCode: 23 }); + const clearedOwnership = yield* queuedScriptService + .stop(scope, { + threadId: targetThreadId, + scriptId: "dev", + terminalId: queuedExitRun.terminalId, + }) + .pipe(Effect.flip); + assert.equal(clearedOwnership.code, "script_run_not_found"); + + yield* queuedManager.close({ + threadId: targetThreadId, + terminalId: queuedExitRun.terminalId, + }); + const replacementAfterExit = yield* queuedScriptService.run(scope, { + threadId: targetThreadId, + scriptId: "dev", + terminalId: queuedExitRun.terminalId, + }); + assert.equal(replacementAfterExit.outcome, "input_accepted"); + assert.isTrue( + (yield* queuedScriptService.stop(scope, { + threadId: targetThreadId, + scriptId: "dev", + terminalId: replacementAfterExit.terminalId, + })).stopped, + ); + + savedScripts = [ + ...savedScripts, + { + id: "too-large", + name: "Too large", + command: "x".repeat(65_536), + icon: "build", + runOnWorktreeCreate: false, + }, + ]; + const spawnsBeforeTooLarge = ptyAdapter.spawnInputs.length; + const tooLarge = yield* service + .run(scope, { + threadId: targetThreadId, + scriptId: "too-large", + terminalId: "term-script-too-large", + }) + .pipe(Effect.flip); + assert.equal(tooLarge.code, "script_command_too_large"); + assert.equal(ptyAdapter.spawnInputs.length, spawnsBeforeTooLarge); + }), + ).pipe( + Effect.provide( + Layer.merge( + NodeServices.layer, + ProcessRunner.layer.pipe(Layer.provide(NodeServices.layer)), + ), + ), + ), + ); +}); diff --git a/apps/server/src/mcp/ProjectScriptMcpService.ts b/apps/server/src/mcp/ProjectScriptMcpService.ts new file mode 100644 index 000000000000..982647494757 --- /dev/null +++ b/apps/server/src/mcp/ProjectScriptMcpService.ts @@ -0,0 +1,259 @@ +import { + PROJECT_SCRIPT_MCP_DEFAULT_LIST_LIMIT, + PROJECT_SCRIPT_MCP_DEFAULT_PREVIEW_CHARS, + type ProjectScript, + ProjectScriptMcpFailure, + type ProjectScriptMcpListInput, + type ProjectScriptMcpListResult, + type ProjectScriptMcpRunInput, + type ProjectScriptMcpRunResult, + type ProjectScriptMcpStopInput, + type ProjectScriptMcpStopResult, + type TerminalMcpFailure, + ThreadId, +} from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Result from "effect/Result"; + +import type { McpInvocationScope } from "./McpInvocationContext.ts"; +import { TerminalMcpService } from "./TerminalMcpService.ts"; +import * as TerminalManager from "../terminal/Manager.ts"; + +const MAX_SCRIPT_INPUT_CHARS = 65_535; + +interface ScriptRunOwnership { + readonly projectId: string; + readonly scriptId: string; + readonly handle: TerminalManager.TerminalSessionHandle; +} + +type ScriptRuns = Map>; + +function terminalFailure(error: TerminalMcpFailure): ProjectScriptMcpFailure { + return new ProjectScriptMcpFailure({ + code: error.code, + message: + error.code === "operation_failed" ? "The saved script operation failed." : error.message, + }); +} + +function terminalInputFailureMessage(error: TerminalMcpFailure): string { + return error.code === "operation_failed" + ? "The terminal was opened, but its saved script input was not accepted." + : error.message; +} + +export class ProjectScriptMcpService extends Context.Service< + ProjectScriptMcpService, + { + readonly list: ( + scope: McpInvocationScope, + input: ProjectScriptMcpListInput, + ) => Effect.Effect; + readonly run: ( + scope: McpInvocationScope, + input: ProjectScriptMcpRunInput, + ) => Effect.Effect; + readonly stop: ( + scope: McpInvocationScope, + input: ProjectScriptMcpStopInput, + ) => Effect.Effect; + } +>()("t3/mcp/ProjectScriptMcpService") {} + +function scriptForId(scripts: ReadonlyArray, scriptId: string) { + return scripts.find((candidate) => candidate.id === scriptId); +} + +function preview(script: ProjectScript, maxChars: number) { + const characters = Array.from(script.command); + return { + scriptId: script.id, + name: script.name, + icon: script.icon, + runOnWorktreeCreate: script.runOnWorktreeCreate, + previewUrl: script.previewUrl ?? null, + autoOpenPreview: script.autoOpenPreview ?? false, + commandPreview: characters.slice(0, maxChars).join(""), + commandCharacters: characters.length, + commandTruncated: characters.length > maxChars, + } as const; +} + +export const make = Effect.gen(function* () { + const terminals = yield* TerminalMcpService; + const terminalManager = yield* TerminalManager.TerminalManager; + const runs: ScriptRuns = new Map(); + + const recordRun = (threadId: ThreadId, terminalId: string, ownership: ScriptRunOwnership) => { + const forThread = runs.get(threadId) ?? new Map(); + forThread.set(terminalId, ownership); + runs.set(threadId, forThread); + }; + + const removeRun = ( + threadId: ThreadId, + terminalId: string, + handle: TerminalManager.TerminalSessionHandle, + ) => { + const forThread = runs.get(threadId); + if (forThread?.get(terminalId)?.handle.incarnation !== handle.incarnation) return; + forThread.delete(terminalId); + if (forThread.size === 0) runs.delete(threadId); + }; + + const load = Effect.fn("ProjectScriptMcpService.load")(function* ( + scope: McpInvocationScope, + threadId: ThreadId | undefined, + ) { + return yield* terminals.resolveTarget(scope, threadId).pipe(Effect.mapError(terminalFailure)); + }); + + const unsubscribe = yield* terminalManager.subscribeSessionInvalidation((handle) => + Effect.sync(() => removeRun(ThreadId.make(handle.threadId), handle.terminalId, handle)), + ); + yield* Effect.addFinalizer(() => Effect.sync(unsubscribe)); + + return ProjectScriptMcpService.of({ + list: (scope, input) => + Effect.gen(function* () { + const resolved = yield* load(scope, input.threadId); + const cursor = input.cursor ?? 0; + const limit = input.limit ?? PROJECT_SCRIPT_MCP_DEFAULT_LIST_LIMIT; + const maxChars = input.commandPreviewChars ?? PROJECT_SCRIPT_MCP_DEFAULT_PREVIEW_CHARS; + const page = resolved.project.scripts.slice(cursor, cursor + limit); + return { + threadId: resolved.target.thread.id, + projectId: resolved.project.id, + scripts: page.map((script) => preview(script, maxChars)), + nextCursor: + cursor + page.length < resolved.project.scripts.length ? cursor + page.length : null, + total: resolved.project.scripts.length, + }; + }), + run: (scope, input) => + Effect.gen(function* () { + const resolved = yield* load(scope, input.threadId); + const script = scriptForId(resolved.project.scripts, input.scriptId); + if (script === undefined) { + return yield* new ProjectScriptMcpFailure({ + code: "script_not_found", + message: `Saved script '${input.scriptId}' was not found in project '${resolved.project.id}'.`, + }); + } + if (script.command.length > MAX_SCRIPT_INPUT_CHARS) { + return yield* new ProjectScriptMcpFailure({ + code: "script_command_too_large", + message: `Saved script '${script.id}' exceeds the managed terminal input limit.`, + }); + } + + const opened = yield* Effect.uninterruptible( + Effect.gen(function* () { + const opened = yield* terminals + .openFreshOwned(scope, { + threadId: resolved.target.thread.id, + terminalId: input.terminalId, + }) + .pipe(Effect.mapError(terminalFailure)); + const ownership = { + projectId: resolved.project.id, + scriptId: script.id, + handle: opened.handle, + } satisfies ScriptRunOwnership; + const admitted = yield* terminalManager.admitRunningSessionHandle( + { + threadId: resolved.target.thread.id, + terminalId: input.terminalId, + handle: opened.handle, + }, + () => recordRun(resolved.target.thread.id, input.terminalId, ownership), + ); + if (!admitted) { + return yield* new ProjectScriptMcpFailure({ + code: "terminal_not_found", + message: `Terminal '${input.terminalId}' changed before script ownership could be recorded.`, + }); + } + return opened; + }), + ); + + const write = yield* Effect.result( + terminals.writeOwned(scope, { + threadId: resolved.target.thread.id, + terminalId: input.terminalId, + data: `${script.command}\r`, + handle: opened.handle, + }), + ); + return Result.match(write, { + onFailure: (error) => ({ + threadId: resolved.target.thread.id, + projectId: resolved.project.id, + scriptId: script.id, + terminalId: input.terminalId, + outcome: "terminal_opened_input_failed" as const, + terminal: opened.terminal, + inputAcceptance: null, + error: terminalInputFailureMessage(error), + previewUrl: script.previewUrl ?? null, + previewAutoOpened: false as const, + }), + onSuccess: (inputAcceptance) => ({ + threadId: resolved.target.thread.id, + projectId: resolved.project.id, + scriptId: script.id, + terminalId: input.terminalId, + outcome: "input_accepted" as const, + terminal: opened.terminal, + inputAcceptance, + error: null, + previewUrl: script.previewUrl ?? null, + previewAutoOpened: false as const, + }), + }); + }), + stop: (scope, input) => + Effect.gen(function* () { + const resolved = yield* load(scope, input.threadId); + const ownership = runs.get(resolved.target.thread.id)?.get(input.terminalId); + if ( + ownership === undefined || + ownership.projectId !== resolved.project.id || + ownership.scriptId !== input.scriptId + ) { + return yield* new ProjectScriptMcpFailure({ + code: "script_run_not_found", + message: `Terminal '${input.terminalId}' is not a managed run of saved script '${input.scriptId}' for thread '${resolved.target.thread.id}'.`, + }); + } + const closed = yield* terminals + .closeOwned(scope, { + threadId: resolved.target.thread.id, + terminalId: input.terminalId, + handle: ownership.handle, + }) + .pipe(Effect.mapError(terminalFailure)); + if (!closed) { + removeRun(resolved.target.thread.id, input.terminalId, ownership.handle); + return yield* new ProjectScriptMcpFailure({ + code: "script_run_not_found", + message: `Terminal '${input.terminalId}' no longer names the managed run of saved script '${input.scriptId}'.`, + }); + } + removeRun(resolved.target.thread.id, input.terminalId, ownership.handle); + return { + threadId: resolved.target.thread.id, + projectId: resolved.project.id, + scriptId: input.scriptId, + terminalId: input.terminalId, + stopped: true, + }; + }), + }); +}); + +export const layer = Layer.effect(ProjectScriptMcpService, make); diff --git a/apps/server/src/mcp/toolkits/project_script/handlers.ts b/apps/server/src/mcp/toolkits/project_script/handlers.ts new file mode 100644 index 000000000000..691c279ca262 --- /dev/null +++ b/apps/server/src/mcp/toolkits/project_script/handlers.ts @@ -0,0 +1,28 @@ +import * as Effect from "effect/Effect"; + +import { McpInvocationContext } from "../../McpInvocationContext.ts"; +import { ProjectScriptMcpService } from "../../ProjectScriptMcpService.ts"; +import { ProjectScriptToolkit } from "./tools.ts"; + +const handlers = { + t3_project_script_list: (input) => + Effect.gen(function* () { + const scope = yield* McpInvocationContext; + const service = yield* ProjectScriptMcpService; + return yield* service.list(scope, input); + }), + t3_project_script_run: (input) => + Effect.gen(function* () { + const scope = yield* McpInvocationContext; + const service = yield* ProjectScriptMcpService; + return yield* service.run(scope, input); + }), + t3_project_script_stop: (input) => + Effect.gen(function* () { + const scope = yield* McpInvocationContext; + const service = yield* ProjectScriptMcpService; + return yield* service.stop(scope, input); + }), +} satisfies Parameters[0]; + +export const ProjectScriptToolkitHandlersLive = ProjectScriptToolkit.toLayer(handlers); diff --git a/apps/server/src/mcp/toolkits/project_script/tools.ts b/apps/server/src/mcp/toolkits/project_script/tools.ts new file mode 100644 index 000000000000..47c2b71dc298 --- /dev/null +++ b/apps/server/src/mcp/toolkits/project_script/tools.ts @@ -0,0 +1,67 @@ +import { + ProjectScriptMcpFailure, + ProjectScriptMcpListInput, + ProjectScriptMcpListResult, + ProjectScriptMcpRunInput, + ProjectScriptMcpRunResult, + ProjectScriptMcpStopInput, + ProjectScriptMcpStopResult, +} from "@t3tools/contracts"; +import * as Tool from "effect/unstable/ai/Tool"; +import * as Toolkit from "effect/unstable/ai/Toolkit"; + +import { McpInvocationContext } from "../../McpInvocationContext.ts"; +import { ProjectScriptMcpService } from "../../ProjectScriptMcpService.ts"; + +const dependencies = [McpInvocationContext, ProjectScriptMcpService]; + +export const ProjectScriptListTool = Tool.make("t3_project_script_list", { + description: + "List a bounded page of saved scripts for the target thread's current project. Omit threadId for the calling thread. Commands are bounded previews with explicit truncation; this read does not open a terminal or run a script.", + parameters: ProjectScriptMcpListInput, + success: ProjectScriptMcpListResult, + failure: ProjectScriptMcpFailure, + failureMode: "return", + dependencies, +}) + .annotate(Tool.Title, "List saved project scripts") + .annotate(Tool.Readonly, true) + .annotate(Tool.Destructive, false) + .annotate(Tool.Idempotent, true) + .annotate(Tool.OpenWorld, false); + +export const ProjectScriptRunTool = Tool.make("t3_project_script_run", { + description: + "Run one saved project script by scriptId in a new dedicated managed terminal using the explicit terminalId. Reusing a loaded terminalId is rejected atomically. Arbitrary command text, cwd, environment, and preview launch are not accepted. Success means the PTY accepted the saved command, not that the command succeeded. The operation is non-idempotent and requires both calling and target threads to use full-access runtime mode and default interaction mode.", + parameters: ProjectScriptMcpRunInput, + success: ProjectScriptMcpRunResult, + failure: ProjectScriptMcpFailure, + failureMode: "return", + dependencies, +}) + .annotate(Tool.Title, "Run a saved project script") + .annotate(Tool.Readonly, false) + .annotate(Tool.Destructive, true) + .annotate(Tool.Idempotent, false) + .annotate(Tool.OpenWorld, true); + +export const ProjectScriptStopTool = Tool.make("t3_project_script_stop", { + description: + "Stop one dedicated script run using its saved scriptId and the terminalId returned by t3_project_script_run. This never searches for or kills a process by name and cannot stop an unrelated terminal. Both calling and target threads must use full-access runtime mode and default interaction mode.", + parameters: ProjectScriptMcpStopInput, + success: ProjectScriptMcpStopResult, + failure: ProjectScriptMcpFailure, + failureMode: "return", + dependencies, +}) + .annotate(Tool.Title, "Stop a saved project script") + .annotate(Tool.Readonly, false) + .annotate(Tool.Destructive, true) + .annotate(Tool.Idempotent, false) + .annotate(Tool.OpenWorld, false); + +export const ProjectScriptToolkit = Toolkit.make( + ProjectScriptListTool, + ProjectScriptRunTool, + ProjectScriptStopTool, +); diff --git a/apps/server/src/mcp/toolkits/worktree/registration.test.ts b/apps/server/src/mcp/toolkits/worktree/registration.test.ts index 7b7e15e36df8..1fccf31f6799 100644 --- a/apps/server/src/mcp/toolkits/worktree/registration.test.ts +++ b/apps/server/src/mcp/toolkits/worktree/registration.test.ts @@ -31,7 +31,10 @@ const StubServicesLive = Layer.mergeAll( Layer.mock(GitWorkflowService.GitWorkflowService)({}), Layer.mock(ProjectSetupScriptRunner.ProjectSetupScriptRunner)({}), Layer.mock(VcsStatusBroadcaster)({}), - Layer.mock(TerminalManager.TerminalManager)({}), + Layer.mock(TerminalManager.TerminalManager)({ + subscribe: () => Effect.succeed(() => {}), + subscribeSessionInvalidation: () => Effect.succeed(() => {}), + }), threadCommandExecutorLayer, ); @@ -122,6 +125,9 @@ it.effect("production mcp layer lists worktree tools over http", () => expect(toolNames).toContain("t3_terminal_read"); expect(toolNames).toContain("t3_terminal_open"); expect(toolNames).toContain("t3_terminal_write"); + expect(toolNames).toContain("t3_project_script_list"); + expect(toolNames).toContain("t3_project_script_run"); + expect(toolNames).toContain("t3_project_script_stop"); // The handoff tool mutates thread state, reaches the network (origin // fetch), and runs project setup scripts, so its MCP hints must not diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts index 258c2e180c06..e4cc27a2edbc 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts @@ -45,6 +45,7 @@ import { formatClaudeResumeCompactionQuestion } from "@t3tools/shared/claudeComp import { attachmentRelativePath } from "../../attachmentStore.ts"; import * as McpProviderSession from "../../mcp/McpProviderSession.ts"; +import { ProjectScriptToolkit } from "../../mcp/toolkits/project_script/tools.ts"; import { OrchestratorToolkit } from "../../mcp/toolkits/orchestrator/tools.ts"; import { TerminalToolkit } from "../../mcp/toolkits/terminal/tools.ts"; import type { EventNdjsonLogger } from "../../provider/Layers/EventNdjsonLogger.ts"; @@ -583,6 +584,7 @@ describe("ClaudeAdapterV2 MCP query overrides", () => { it("matches the read-only allowlist to the orchestrator toolkit annotations", () => { const readOnlyToolNames = [ ...Object.values(OrchestratorToolkit.tools), + ...Object.values(ProjectScriptToolkit.tools), ...Object.values(TerminalToolkit.tools), ] .filter((tool) => Context.get(tool.annotations, Tool.Readonly)) diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts index 302f742f278e..5dfe8db93639 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts @@ -808,6 +808,7 @@ export const CLAUDE_READ_ONLY_T3_MCP_ALLOWED_TOOLS: ReadonlyArray = [ "mcp__t3-code__list_scheduled_tasks", "mcp__t3-code__t3_thread_list", "mcp__t3-code__t3_thread_wait", + "mcp__t3-code__t3_project_script_list", "mcp__t3-code__t3_terminal_list", "mcp__t3-code__t3_terminal_read", ]; diff --git a/apps/server/src/terminal/Manager.ts b/apps/server/src/terminal/Manager.ts index 94aa648f7980..38e59d69ead0 100644 --- a/apps/server/src/terminal/Manager.ts +++ b/apps/server/src/terminal/Manager.ts @@ -196,6 +196,21 @@ export class TerminalManager extends Context.Service< input: TerminalWriteInput & { readonly handle: TerminalSessionHandle }, ) => Effect.Effect; + /** + * Register ownership only while an in-process incarnation is still running. + * + * The callback runs synchronously under the existing manager admission boundary and must not + * call back into TerminalManager. + */ + readonly admitRunningSessionHandle: ( + input: { + readonly threadId: string; + readonly terminalId: string; + readonly handle: TerminalSessionHandle; + }, + onAdmitted: () => void, + ) => Effect.Effect; + /** * Resize the PTY backing a terminal session. */ @@ -260,6 +275,11 @@ export class TerminalManager extends Context.Service< listener: (event: TerminalEvent) => Effect.Effect, ) => Effect.Effect<() => void>; + /** Subscribe to invalidated in-process incarnations without widening terminal wire events. */ + readonly subscribeSessionInvalidation: ( + listener: (handle: TerminalSessionHandle) => Effect.Effect, + ) => Effect.Effect<() => void>; + /** * Subscribe to lightweight terminal metadata with an initial full snapshot. * @@ -373,6 +393,7 @@ type DrainProcessEventAction = sequence: number; exitCode: number | null; exitSignal: number | null; + handle: TerminalSessionHandle; }; interface TerminalManagerState { @@ -1464,6 +1485,9 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func }); const threadLocksRef = yield* SynchronizedRef.make(new Map()); const terminalEventListeners = new Set<(event: TerminalEvent) => Effect.Effect>(); + const sessionInvalidationListeners = new Set< + (handle: TerminalSessionHandle) => Effect.Effect + >(); const workerScope = yield* Scope.make("sequential"); yield* Effect.addFinalizer(() => Scope.close(workerScope, Exit.void)); @@ -1474,6 +1498,13 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func } }); + const publishSessionInvalidation = (handle: TerminalSessionHandle) => + Effect.gen(function* () { + for (const listener of sessionInvalidationListeners) { + yield* listener(handle).pipe(Effect.ignoreCause({ log: true })); + } + }); + const historyPath = (threadId: string, terminalId: string) => { const threadPart = toSafeThreadId(threadId); if (terminalId === DEFAULT_TERMINAL_ID) { @@ -1985,6 +2016,7 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func } const process = session.process; + const handle = sessionHandle(session); cleanupProcessHandles(session); session.process = null; session.pid = null; @@ -2011,6 +2043,7 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func sequence: eventStamp.sequence, exitCode: session.exitCode, exitSignal: session.exitSignal, + handle, } as const; }); @@ -2038,6 +2071,7 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func threadId: action.threadId, terminalId: action.terminalId, }); + yield* publishSessionInvalidation(action.handle); yield* publishEvent({ type: "exited", threadId: action.threadId, @@ -2141,6 +2175,7 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func eventType: "started" | "restarted", ) { yield* stopProcess(session); + yield* publishSessionInvalidation(sessionHandle(session)); yield* Effect.annotateCurrentSpan({ "terminal.thread_id": session.threadId, "terminal.id": session.terminalId, @@ -2299,6 +2334,7 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func if (Option.isSome(session)) { yield* stopProcess(session.value); + yield* publishSessionInvalidation(sessionHandle(session.value)); yield* unregisterTerminal({ threadId, terminalId }); yield* persistHistory(threadId, terminalId, session.value.history); } @@ -2745,6 +2781,16 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func }; }); + const subscribeSessionInvalidation: TerminalManager["Service"]["subscribeSessionInvalidation"] = ( + listener, + ) => + Effect.sync(() => { + sessionInvalidationListeners.add(listener); + return () => { + sessionInvalidationListeners.delete(listener); + }; + }); + const attachStream: TerminalManager["Service"]["attachStream"] = (input, listener) => { let unsubscribe: (() => void) | null = null; @@ -2962,6 +3008,27 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func }), ); + const admitRunningSessionHandle: TerminalManager["Service"]["admitRunningSessionHandle"] = ( + input, + onAdmitted, + ) => + withThreadLock( + input.threadId, + modifyManagerState((state) => { + const session = state.sessions.get(toSessionKey(input.threadId, input.terminalId)); + if ( + session === undefined || + !matchesHandle(session, input.handle) || + session.status !== "running" || + session.process === null + ) { + return [false, state] as const; + } + onAdmitted(); + return [true, state] as const; + }), + ); + const resizeLocked = Effect.fn("terminal.resize")(function* (input: TerminalResizeInput) { const session = yield* getSession(input.threadId, input.terminalId); // ResizeObserver traffic can already be in flight when the UI closes the session. @@ -3165,6 +3232,7 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func write, writeStrict, writeWithHandle, + admitRunningSessionHandle, resize, resizeAndInspect, clear, @@ -3174,6 +3242,7 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func close, closeWithHandle, subscribe, + subscribeSessionInvalidation, subscribeMetadata, }); }); diff --git a/docs/orchestration-v2/orchestrator-mcp-server.md b/docs/orchestration-v2/orchestrator-mcp-server.md index 8d3aaa2b089c..1ce1c1dd3f83 100644 --- a/docs/orchestration-v2/orchestrator-mcp-server.md +++ b/docs/orchestration-v2/orchestrator-mcp-server.md @@ -168,7 +168,8 @@ accepted the bytes. ## Tool Surface -The server exposes orchestration and managed-terminal tool families through the same endpoint. +The server exposes orchestration, managed-terminal, and saved-project-script tool families through +the same endpoint. ### `orchestrator_capabilities` diff --git a/docs/user/agent-managed-terminals.md b/docs/user/agent-managed-terminals.md index 59a68295c23a..f2578df42e8b 100644 --- a/docs/user/agent-managed-terminals.md +++ b/docs/user/agent-managed-terminals.md @@ -9,3 +9,11 @@ Agents connected through T3 Code's built-in MCP server can inspect and control t Terminal mutations are host execution. They are available only when both the calling conversation and target conversation use full-access runtime mode and default interaction mode. Plan, approval-required, auto-accept-edits, and auto runtime contexts can still list and read retained terminal state, but cannot open, write, resize, clear, restart, or close terminals through MCP. `t3_terminal_write` reports only that the running PTY accepted the bytes. It does not report whether a shell command later succeeded. Writes and restarts are non-idempotent. Use `t3_terminal_read` to observe output and status. `t3_terminal_close` always requires one terminal ID and never closes every conversation terminal by omission. + +## Saved project scripts + +`t3_project_script_list` returns a bounded page of scripts saved on the current project. Commands are bounded previews with explicit length and truncation fields. Agents cannot create, edit, or delete script definitions through these tools. + +`t3_project_script_run` accepts only a saved script ID and an explicit terminal ID. It opens that new dedicated managed terminal atomically; a loaded ID is rejected instead of reused. Choosing the ID lets an agent recover a lost response without accidentally starting another shell. The run derives the same conversation execution directory and project/worktree environment as terminal controls, then writes the saved command followed by Enter. It never accepts arbitrary command text, host paths, environment variables, or secrets. Preview metadata is returned for context, but MCP never opens a browser or preview automatically. + +Every run returns its terminal ID. Use terminal reads to follow output and `t3_project_script_stop` with the saved script ID and returned terminal ID to stop that exact managed run. Stop remains available if the saved script definition is later renamed or removed. T3 binds this authority to the terminal incarnation opened for the run; closing, restarting, or recreating that terminal invalidates the old run handle, so stop cannot affect a replacement. If opening succeeds but PTY input fails, the result reports the surviving terminal so it can still be inspected or stopped. Script run and stop are non-idempotent and use the same full-access/default caller-and-target policy as other terminal mutations. diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index 36e7138807e5..19d290ffcac1 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -11,6 +11,7 @@ export * from "./remoteAccess.ts"; export * from "./ipc.ts"; export * from "./terminal.ts"; export * from "./terminalMcp.ts"; +export * from "./projectScriptMcp.ts"; export * from "./provider.ts"; export * from "./providerInstance.ts"; export * from "./providerSetup.ts"; diff --git a/packages/contracts/src/orchestratorMcp.ts b/packages/contracts/src/orchestratorMcp.ts index ec05aa42379e..0dabffd728b5 100644 --- a/packages/contracts/src/orchestratorMcp.ts +++ b/packages/contracts/src/orchestratorMcp.ts @@ -486,6 +486,7 @@ export const OrchestratorMcpCapabilitiesResult = Schema.Struct({ incrementalThreadRead: Schema.Boolean, scheduledTasks: Schema.Boolean, managedTerminals: Schema.optional(Schema.Boolean), + savedProjectScripts: Schema.optional(Schema.Boolean), maxBatchThreads: Schema.Number, }), }); diff --git a/packages/contracts/src/projectScriptMcp.test.ts b/packages/contracts/src/projectScriptMcp.test.ts new file mode 100644 index 000000000000..651059607bec --- /dev/null +++ b/packages/contracts/src/projectScriptMcp.test.ts @@ -0,0 +1,57 @@ +import { assert, describe, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as Schema from "effect/Schema"; + +import { + PROJECT_SCRIPT_MCP_MAX_LIST_LIMIT, + PROJECT_SCRIPT_MCP_MAX_PREVIEW_CHARS, + ProjectScriptMcpListInput, + ProjectScriptMcpRunInput, + ProjectScriptMcpStopInput, +} from "./projectScriptMcp.ts"; + +const decodeList = Schema.decodeUnknownEffect(ProjectScriptMcpListInput); +const decodeRun = Schema.decodeUnknownEffect(ProjectScriptMcpRunInput); +const decodeStop = Schema.decodeUnknownEffect(ProjectScriptMcpStopInput); + +describe("project script MCP contracts", () => { + it.effect("bounds pages and command previews", () => + Effect.gen(function* () { + assert.deepEqual(yield* decodeList({}), {}); + assert.isTrue( + Exit.isFailure( + yield* Effect.exit(decodeList({ limit: PROJECT_SCRIPT_MCP_MAX_LIST_LIMIT + 1 })), + ), + ); + assert.isTrue( + Exit.isFailure( + yield* Effect.exit( + decodeList({ commandPreviewChars: PROJECT_SCRIPT_MCP_MAX_PREVIEW_CHARS + 1 }), + ), + ), + ); + }), + ); + + it.effect("requires saved script and returned terminal handles", () => + Effect.gen(function* () { + assert.isTrue(Exit.isFailure(yield* Effect.exit(decodeRun({})))); + assert.isTrue(Exit.isFailure(yield* Effect.exit(decodeStop({ scriptId: "dev" })))); + assert.isTrue(Exit.isFailure(yield* Effect.exit(decodeRun({ scriptId: "dev" })))); + assert.deepEqual(yield* decodeRun({ scriptId: "dev", terminalId: "script-run:1" }), { + scriptId: "dev", + terminalId: "script-run:1", + }); + assert.deepEqual(yield* decodeStop({ scriptId: "dev", terminalId: "script-run:1" }), { + scriptId: "dev", + terminalId: "script-run:1", + }); + const persistedScriptId = "saved-script:".padEnd(1_024, "x"); + assert.deepEqual( + yield* decodeRun({ scriptId: persistedScriptId, terminalId: "script-run:long-id" }), + { scriptId: persistedScriptId, terminalId: "script-run:long-id" }, + ); + }), + ); +}); diff --git a/packages/contracts/src/projectScriptMcp.ts b/packages/contracts/src/projectScriptMcp.ts new file mode 100644 index 000000000000..22bd7b7f3ca6 --- /dev/null +++ b/packages/contracts/src/projectScriptMcp.ts @@ -0,0 +1,110 @@ +import * as Schema from "effect/Schema"; + +import { NonNegativeInt, ProjectId, ThreadId, TrimmedNonEmptyString } from "./baseSchemas.ts"; +import { ProjectScriptIcon } from "./project.ts"; +import { TerminalId } from "./terminal.ts"; +import { TerminalMcpAcceptedInputResult, TerminalMcpSession } from "./terminalMcp.ts"; + +export const PROJECT_SCRIPT_MCP_DEFAULT_LIST_LIMIT = 20; +export const PROJECT_SCRIPT_MCP_MAX_LIST_LIMIT = 50; +export const PROJECT_SCRIPT_MCP_DEFAULT_PREVIEW_CHARS = 240; +export const PROJECT_SCRIPT_MCP_MAX_PREVIEW_CHARS = 1_000; + +const ListLimit = Schema.Int.check(Schema.isGreaterThanOrEqualTo(1)).check( + Schema.isLessThanOrEqualTo(PROJECT_SCRIPT_MCP_MAX_LIST_LIMIT), +); +const PreviewLimit = Schema.Int.check(Schema.isGreaterThanOrEqualTo(1)).check( + Schema.isLessThanOrEqualTo(PROJECT_SCRIPT_MCP_MAX_PREVIEW_CHARS), +); +const ScriptId = TrimmedNonEmptyString; +const TargetThreadFields = { threadId: Schema.optional(ThreadId) }; + +export const ProjectScriptMcpListInput = Schema.Struct({ + ...TargetThreadFields, + cursor: Schema.optional(NonNegativeInt), + limit: Schema.optional(ListLimit), + commandPreviewChars: Schema.optional(PreviewLimit), +}); +export type ProjectScriptMcpListInput = typeof ProjectScriptMcpListInput.Type; + +export const ProjectScriptMcpRunInput = Schema.Struct({ + ...TargetThreadFields, + scriptId: ScriptId, + terminalId: TerminalId, +}); +export type ProjectScriptMcpRunInput = typeof ProjectScriptMcpRunInput.Type; + +export const ProjectScriptMcpStopInput = Schema.Struct({ + ...TargetThreadFields, + scriptId: ScriptId, + terminalId: TerminalId, +}); +export type ProjectScriptMcpStopInput = typeof ProjectScriptMcpStopInput.Type; + +export const ProjectScriptMcpPreview = Schema.Struct({ + scriptId: ScriptId, + name: TrimmedNonEmptyString, + icon: ProjectScriptIcon, + runOnWorktreeCreate: Schema.Boolean, + previewUrl: Schema.NullOr(Schema.String), + autoOpenPreview: Schema.Boolean, + commandPreview: Schema.String, + commandCharacters: NonNegativeInt, + commandTruncated: Schema.Boolean, +}); +export type ProjectScriptMcpPreview = typeof ProjectScriptMcpPreview.Type; + +export const ProjectScriptMcpListResult = Schema.Struct({ + threadId: ThreadId, + projectId: ProjectId, + scripts: Schema.Array(ProjectScriptMcpPreview), + nextCursor: Schema.NullOr(NonNegativeInt), + total: NonNegativeInt, +}); +export type ProjectScriptMcpListResult = typeof ProjectScriptMcpListResult.Type; + +export const ProjectScriptMcpRunResult = Schema.Struct({ + threadId: ThreadId, + projectId: ProjectId, + scriptId: ScriptId, + terminalId: TerminalId, + outcome: Schema.Literals(["input_accepted", "terminal_opened_input_failed"]), + terminal: TerminalMcpSession, + inputAcceptance: Schema.NullOr(TerminalMcpAcceptedInputResult), + error: Schema.NullOr(Schema.String), + previewUrl: Schema.NullOr(Schema.String), + previewAutoOpened: Schema.Literal(false), +}); +export type ProjectScriptMcpRunResult = typeof ProjectScriptMcpRunResult.Type; + +export const ProjectScriptMcpStopResult = Schema.Struct({ + threadId: ThreadId, + projectId: ProjectId, + scriptId: ScriptId, + terminalId: TerminalId, + stopped: Schema.Literal(true), +}); +export type ProjectScriptMcpStopResult = typeof ProjectScriptMcpStopResult.Type; + +export const ProjectScriptMcpFailureCode = Schema.Literals([ + "capability_denied", + "thread_not_found", + "project_not_found", + "execution_policy_denied", + "script_not_found", + "script_command_too_large", + "script_run_not_found", + "terminal_not_found", + "terminal_not_running", + "terminal_already_exists", + "operation_failed", +]); +export type ProjectScriptMcpFailureCode = typeof ProjectScriptMcpFailureCode.Type; + +export class ProjectScriptMcpFailure extends Schema.TaggedErrorClass()( + "ProjectScriptMcpFailure", + { + code: ProjectScriptMcpFailureCode, + message: Schema.String, + }, +) {} diff --git a/packages/shared/src/t3McpToolPresentation.ts b/packages/shared/src/t3McpToolPresentation.ts index 42d0c9a17c40..c1d64884a37f 100644 --- a/packages/shared/src/t3McpToolPresentation.ts +++ b/packages/shared/src/t3McpToolPresentation.ts @@ -59,6 +59,9 @@ const T3_MCP_TOOLS: Record< t3_terminal_clear: { displayName: "Clear managed terminal output" }, t3_terminal_restart: { displayName: "Restart a managed terminal" }, t3_terminal_close: { displayName: "Close a managed terminal" }, + t3_project_script_list: { displayName: "List saved project scripts" }, + t3_project_script_run: { displayName: "Run a saved project script" }, + t3_project_script_stop: { displayName: "Stop a saved project script" }, t3_worktree_handoff: { displayName: "Hand off thread to a git worktree" }, t3_worktree_status: { displayName: "Get thread worktree status" }, preview_status: { displayName: "Get preview browser status" },