From 0a8c8e55a8697649293f1576fe98902837b4ec83 Mon Sep 17 00:00:00 2001 From: donghyeon-ka Date: Thu, 17 Sep 2026 15:34:01 +0900 Subject: [PATCH] =?UTF-8?q?refactor:=20gpt=20=EC=8A=A4=ED=8A=B8=EB=A6=BC?= =?UTF-8?q?=20=EC=98=A4=EB=A5=98=20=EC=B5=9C=EC=86=8C=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AGENTS.md | 30 ++ README.md | 49 ++- .../plans/2026-09-17-managed-task-tools.md | 57 +++ .../2026-09-17-managed-task-tools-design.md | 26 ++ src/managed-task-manager.ts | 364 ++++++++++++++++++ src/managed-task-tools.ts | 184 +++++++++ src/mcp-server.ts | 42 +- src/process-manager.ts | 10 + test/all-tools.integration.test.ts | 58 +++ test/managed-task-manager.test.ts | 158 ++++++++ test/tool-metadata.test.ts | 8 +- 11 files changed, 957 insertions(+), 29 deletions(-) create mode 100644 AGENTS.md create mode 100644 docs/superpowers/plans/2026-09-17-managed-task-tools.md create mode 100644 docs/superpowers/specs/2026-09-17-managed-task-tools-design.md create mode 100644 src/managed-task-manager.ts create mode 100644 src/managed-task-tools.ts create mode 100644 test/managed-task-manager.test.ts diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..6ede228 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,30 @@ +# Agent Execution Policy + +## Coka MCP long-running work + +Use the managed-task tools for builds, tests, package installs, Docker builds, integration tests, migrations, benchmarks, and any command that may take more than a few seconds or emit substantial output. + +- Prefer `start_managed_task` over `exec_command` for long-running work. +- Give every managed task a stable, descriptive `taskKey` such as `root-ci`, `backend-tests`, or `docker-build-api`. +- Reuse the same `taskKey` when recovering after a ChatGPT/message-stream interruption. Do not start the same command again merely because the response stream was interrupted. +- Before retrying work after an interruption, call `list_managed_tasks` or `read_managed_task` and inspect the existing task state. +- Use `read_managed_task` with long waits rather than repeatedly polling at short intervals. +- Managed-task responses are intentionally compact. Full stdout/stderr is stored in the task log file; inspect only the reported tail/highlights unless deeper diagnostics are required. +- Do not dump full build/test logs into MCP responses. Prefer exit status, important lines, and a bounded tail. +- Group related repository inspection and edits into coherent calls instead of issuing many tiny shell commands. +- Run targeted tests before the full suite. Run the full CI/build once near the end unless a failure requires another run. +- Never re-run an already successful build/test solely to reconstruct conversational context. + +## Low-level process tools + +Use `exec_command`, `read_process`, `write_stdin`, and `terminate_process` when interactive stdin, exact paged stdout/stderr, or low-level process control is specifically required. They remain the escape hatch, not the default for long-running non-interactive work. + +## Recovery order + +When a response is interrupted: + +1. Inspect `list_managed_tasks`. +2. Reuse the matching task by `taskKey`. +3. If it is still running, wait with `read_managed_task`. +4. If it finished, consume its existing result and log summary. +5. Re-run only when the prior task failed for a reason that requires a retry, or when an explicit fresh run is requested. diff --git a/README.md b/README.md index 15202b9..0062b0e 100644 --- a/README.md +++ b/README.md @@ -67,7 +67,7 @@ Typical tasks include: - "Install Node.js packages and build the project." - "Upload a file, verify its hash, and move it into place." -Internally, these actions are provided through 20 MCP tools for shell execution, long-running processes, and filesystem operations. +Internally, these actions are provided through 24 MCP tools for shell execution, recoverable managed tasks, low-level process control, and filesystem operations. ## How it works @@ -83,7 +83,8 @@ The MCP transport is stateless, but long-running command sessions are kept in me ## Key features - Shell commands, complete scripts, builds, tests, package installation, Git, and service management -- Output polling, stdin delivery, and termination control for long-running processes +- Recoverable long-running tasks with stable task keys, duplicate-run protection, compact responses, and full on-disk logs +- Low-level output polling, stdin delivery, and termination control for interactive processes - Read, write, edit, transfer, and delete host files, including absolute paths - Built-in static Bearer authentication and OAuth 2.1/DCR/PKCE for compatible MCP clients - Stateless JSON transport per request, per-process output retention, and response size limits @@ -93,13 +94,32 @@ The MCP transport is stateless, but long-running command sessions are kept in me ### Execution and processes -- `exec_command`: Run shell commands, builds, tests, package installation, Git, service management, and log inspection +For long-running, non-interactive builds, tests, package installation, Docker builds, migrations, and similar work, prefer the managed-task tools: + +- `start_managed_task`: Start a shell task under a stable `taskKey`, persist its complete output to a log file, and reuse an existing retained task instead of accidentally running it twice +- `read_managed_task`: Recover a task by `taskKey`, optionally wait for completion, and return compact status, important diagnostic lines, and a bounded log tail +- `list_managed_tasks`: List running and recently retained tasks by stable key after a client/message-stream interruption +- `cancel_managed_task`: Terminate a managed task by stable key + +The low-level process tools remain available when interactive stdin or exact cursor-based output is required: + +- `exec_command`: Run a shell command directly and return immediately when it completes or expose its low-level process session - `run_script`: Run complete scripts with Bash, sh, Node.js, Python, or an arbitrary interpreter - `write_stdin`: Write input to a long-running process and retrieve subsequent output - `read_process`: Poll output using a cursor and inspect process termination state - `terminate_process`: Send `SIGINT`, `SIGTERM`, or `SIGKILL` to a managed process group - `list_processes`: List running or recently completed process sessions +A typical resilient workflow is: + +1. Call `start_managed_task` with a stable key such as `root-ci`. +2. If the client response is interrupted, call `list_managed_tasks` instead of starting the command again. +3. Continue with `read_managed_task` using the same key and a longer `waitMs`. +4. Inspect the returned status/highlights/tail. Read the full log file only when deeper diagnostics are actually needed. +5. Use `restartCompleted=true` only when a fresh run is intentionally required. + +Managed task identity is retained in service memory for the same period as process sessions. A service restart therefore loses the `taskKey` mapping even though a previously written temporary log file may still exist. + ### Filesystem - `list_directory`, `stat_path`, `read_file`, `write_file` @@ -109,7 +129,7 @@ The MCP transport is stateless, but long-running command sessions are kept in me Relative paths are resolved from `MCP_DEFAULT_CWD`, while absolute paths and `~/...` paths are also allowed. Uploads and downloads use base64 chunk transfer with `nextOffset`. -The server provides 20 tools in total. `remove_path` permanently deletes targets without using a trash folder, and `apply_patch` uses the host's `git apply --unsafe-paths`. +The server provides 24 tools in total. `remove_path` permanently deletes targets without using a trash folder, and `apply_patch` uses the host's `git apply --unsafe-paths`. ### Tool safety and authentication metadata @@ -117,11 +137,11 @@ Every tool explicitly publishes all four MCP safety hints. The values describe t | Behavior | Tools | `readOnlyHint` | `destructiveHint` | `idempotentHint` | `openWorldHint` | |---|---|---:|---:|---:|---:| -| Read-only, closed world | `list_directory`, `stat_path`, `read_file`, `download_file`, `hash_file`, `read_process`, `list_processes` | `true` | `false` | `true` | `false` | +| Read-only, closed world | `list_directory`, `stat_path`, `read_file`, `download_file`, `hash_file`, `read_process`, `list_processes`, `read_managed_task`, `list_managed_tasks` | `true` | `false` | `true` | `false` | | Additive and idempotent | `make_directory` | `false` | `false` | `true` | `false` | | Destructive and idempotent | `upload_file`, `copy_path`, `move_path`, `remove_path`, `chmod_path` | `false` | `true` | `true` | `false` | -| Destructive and non-idempotent, closed world | `write_file`, `replace_in_file`, `apply_patch`, `terminate_process` | `false` | `true` | `false` | `false` | -| Destructive and non-idempotent, open world | `exec_command`, `run_script`, `write_stdin` | `false` | `true` | `false` | `true` | +| Destructive and non-idempotent, closed world | `write_file`, `replace_in_file`, `apply_patch`, `terminate_process`, `cancel_managed_task` | `false` | `true` | `false` | `false` | +| Destructive and non-idempotent, open world | `exec_command`, `run_script`, `write_stdin`, `start_managed_task` | `false` | `true` | `false` | `true` | These annotations are advisory client metadata, not access control. They do not replace authentication, which is enforced by the built-in HTTP layer or an upstream gateway when configured. When built-in OAuth is enabled, every tool advertises the `oauth2` security scheme with the `mcp:tools` scope through `_meta.securitySchemes`. Static-Bearer-only and built-in-auth-disabled (`MCP_ALLOW_NO_AUTH`) deployments intentionally omit this OpenAI extension: a pre-shared Bearer token is neither `noauth` nor `oauth2`, while disabling built-in authentication may represent a deliberately anonymous endpoint, upstream authentication, or private-network access. The process cannot infer that external policy honestly, so authentication, if any, remains connection- or deployment-level. @@ -142,7 +162,7 @@ These annotations are advisory client metadata, not access control. They do not - If an older client sends a stale `Mcp-Session-Id` header, the server ignores it for request processing. - An authenticated `GET /mcp` or `DELETE /mcp` request returning `405 Method Not Allowed` is expected. Missing or invalid authentication may produce `401 Unauthorized` before the request reaches that method check. The server does not maintain a server-push SSE session. - MCP transport sessions and command process `sessionId` values are unrelated. A process `sessionId` returned by `exec_command` can be reused by later HTTP requests to `write_stdin`, `read_process`, and `terminate_process`. -- Running and retained process state is stored in service memory and is lost when the service restarts. +- Running and retained process state, including managed-task key mappings, is stored in service memory and is lost when the service restarts. Managed-task full logs are written beneath the host temporary directory and are not a durable task registry. ## Requirements @@ -327,7 +347,8 @@ sudo journalctl -u remote-dev-mcp -o cat | grep '"event":"mcp_request"' - `Error fetching OAuth configuration`: Check `MCP_OAUTH_ENABLED`, the public URL, and the Nginx proxy for `/.well-known/` routes. - `401 Unauthorized` on MCP requests: Check the Bearer token or OAuth access token. - `403 Host header is not allowed`: Add the request domain to `MCP_ALLOWED_HOSTS`. -- A command returns a `sessionId` instead of completing immediately: Poll it with `read_process` or send input with `write_stdin`. +- A managed task is still running: Reuse its `taskKey` with `read_managed_task`; after a client interruption, call `list_managed_tasks` before starting anything again. +- A low-level command returns a `sessionId` instead of completing immediately: Poll it with `read_process` or send input with `write_stdin`. - MCP requests are independent stateless POST requests. An authenticated `GET /mcp` or `DELETE /mcp` returning `405 Method Not Allowed` is expected and means the server does not provide a separate SSE stream. Authentication failures may return `401 Unauthorized` first. - Service restart behavior: Managed process state and unexchanged authorization codes are lost. OAuth client registrations and issued tokens remain in the state file. @@ -342,14 +363,14 @@ npm run build The default tests use a real Streamable HTTP MCP client and cover: - Bearer authentication, stateless request processing, and request tracing headers -- Success paths, failure paths, and input boundary cases for all 20 tools +- Success paths, failure paths, and input boundary cases for all 24 tools - Interactive stdin, output pagination, timeouts, termination, and completed-process retention - UTF-8 character boundaries, strict base64 validation, file modes, and copy/move conflicts - Unified diff validation, application, reverse application, and 3-way application ### Full E2E verification against a running external MCP server -From a separate source checkout with development dependencies installed, you can verify all 20 tools against a real HTTPS endpoint: +From a separate source checkout with development dependencies installed, you can verify all 24 tools against a real HTTPS endpoint: ```bash MCP_E2E_URL='https://mcp.example.com/mcp' \ @@ -396,12 +417,14 @@ This verification executes real commands on the target server and creates, modif |---|---| | `src/http-server.ts` | Stateless Streamable HTTP, OAuth routing, and health endpoint | | `src/mcp-server.ts` | MCP server metadata and tool registration | -| `src/exec-tools.ts` | Command, script, and long-running process tools | +| `src/exec-tools.ts` | Low-level command, script, and process-control tools | +| `src/managed-task-manager.ts` | Stable task-key registry, full task logs, deduplication, recovery, and compact summaries | +| `src/managed-task-tools.ts` | High-level managed-task MCP schemas and tool registration | | `src/file-service.ts` | File reading, writing, transfer, and path operations | | `src/file-tools.ts` | Filesystem tools and input schemas | | `src/oauth.ts` | DCR, PKCE, token issuance/refresh/revocation, and approval UI | | `deploy/` | systemd, environment-file, and Nginx examples | -| `test/all-tools.integration.test.ts` | E2E tests for all 20 tools and external endpoints | +| `test/all-tools.integration.test.ts` | E2E tests for all 24 tools and external endpoints | | `test/` | Configuration, file, process, MCP, and OAuth unit/integration tests | ## License diff --git a/docs/superpowers/plans/2026-09-17-managed-task-tools.md b/docs/superpowers/plans/2026-09-17-managed-task-tools.md new file mode 100644 index 0000000..e13cbb6 --- /dev/null +++ b/docs/superpowers/plans/2026-09-17-managed-task-tools.md @@ -0,0 +1,57 @@ +# Managed Task Tools Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add recoverable, compact-output managed task tools for long-running Coka MCP commands. + +**Architecture:** Add a `ManagedTaskManager` facade on top of the existing `ProcessManager`. Extend process startup with an internal output observer so managed tasks can persist complete output without changing low-level tool responses, then register four additive MCP tools. + +**Tech Stack:** Node.js 22+, TypeScript, MCP SDK, Zod, Vitest. + +**Spec:** `docs/superpowers/specs/2026-09-17-managed-task-tools-design.md` + +## Global Constraints + +- Preserve all existing low-level process tools and semantics. +- Do not execute the same retained managed task twice when the same task key, command, and cwd are supplied. +- Store full managed-task output on disk while keeping MCP responses bounded. +- Recovery must work by stable task key after a response-stream interruption. + +--- + +### Task 1: Managed task core + +**Files:** +- Create: `src/managed-task-manager.ts` +- Modify: `src/process-manager.ts` +- Test: `test/managed-task-manager.test.ts` + +- [x] Write failing tests for reuse, compact summaries/full logs, restart, cancellation, and recovery listing. +- [x] Run the focused test and verify RED. +- [x] Add the smallest ProcessManager output-observer seam and ManagedTaskManager implementation. +- [x] Run the focused test and verify GREEN. + +### Task 2: MCP tool surface + +**Files:** +- Create: `src/managed-task-tools.ts` +- Modify: `src/mcp-server.ts` +- Modify: `test/tool-metadata.test.ts` +- Modify: `test/all-tools.integration.test.ts` + +- [x] Update inventory/metadata tests first and verify RED. +- [x] Register `start_managed_task`, `read_managed_task`, `list_managed_tasks`, and `cancel_managed_task`. +- [x] Exercise start/reuse/read/list/cancel through MCP integration tests. +- [x] Verify focused tests GREEN. + +### Task 3: Documentation and regression verification + +**Files:** +- Modify: `README.md` +- Create: `AGENTS.md` + +- [x] Document the high-level managed-task workflow and recovery behavior. +- [x] Run `npm test`. +- [x] Run `npm run typecheck`. +- [x] Run `npm run build`. +- [x] Run `git diff --check` and inspect the final diff. diff --git a/docs/superpowers/specs/2026-09-17-managed-task-tools-design.md b/docs/superpowers/specs/2026-09-17-managed-task-tools-design.md new file mode 100644 index 0000000..4b64f7e --- /dev/null +++ b/docs/superpowers/specs/2026-09-17-managed-task-tools-design.md @@ -0,0 +1,26 @@ +# Managed Task Tools Design + +## Goal + +Make long-running Coka MCP work resilient to ChatGPT response-stream interruption while reducing tool-call count and response payload size. + +## Design + +Keep the existing `ProcessManager` and all low-level process tools intact. Add a `ManagedTaskManager` facade that owns stable task keys, writes the complete process output to a log file, returns bounded summaries, and maps task keys back to process session IDs. + +A managed task is identified by a caller-supplied `taskKey`. Calling start again with the same key and same command/cwd reuses the retained task instead of executing it twice. A completed task may be explicitly restarted. Reusing a key for a different command/cwd is rejected unless the prior task has completed and an explicit restart is requested. + +## MCP tools + +- `start_managed_task`: start or safely reuse a non-interactive long-running shell command. Wait only for a small initial interval, then return compact state. +- `read_managed_task`: optionally long-poll a task and return compact state, important lines, and a bounded log tail. +- `list_managed_tasks`: list recoverable running/recent tasks by stable task key. +- `cancel_managed_task`: terminate a running managed task using the existing process-tree termination semantics. + +## Log behavior + +Full stdout and stderr are appended to a per-task log beneath the configured managed-task log directory. The MCP result never returns the full log by default. It returns a bounded tail plus selected important lines captured across the full output stream (errors, failures, warnings, successful/failed build markers, and common test summaries), so early failures are not lost when later output pushes them outside the tail. + +## Compatibility + +Existing `exec_command`, `run_script`, `read_process`, `write_stdin`, `terminate_process`, and `list_processes` behavior remains unchanged. Managed-task functionality is additive. diff --git a/src/managed-task-manager.ts b/src/managed-task-manager.ts new file mode 100644 index 0000000..c29625b --- /dev/null +++ b/src/managed-task-manager.ts @@ -0,0 +1,364 @@ +import { createHash, randomUUID } from "node:crypto"; +import { + appendFileSync, + mkdirSync, + openSync, + closeSync, + readSync, + statSync, + writeFileSync, +} from "node:fs"; +import path from "node:path"; + +import { + ProcessManager, + type ProcessOutputStream, + type StartProcessRequest, +} from "./process-manager.js"; + +const MIN_TAIL_BYTES = 1024; +const STATUS_OUTPUT_BYTES = 16 * 1024; +const IMPORTANT_LINE_LIMIT = 40; +const MAX_PENDING_LINE_CHARS = 16 * 1024; +const IMPORTANT_LINE = + /\b(error|errors|failed|failure|failures|exception|warning|warn|build successful|build failed|tests?\b.*\b(?:passed|failed)|test files\b.*\b(?:passed|failed))\b/i; + +export interface ManagedTaskManagerOptions { + logDirectory: string; + defaultTailBytes: number; + maxTailBytes: number; +} + +export interface StartManagedTaskRequest extends StartProcessRequest { + taskKey: string; + initialWaitMs?: number | undefined; + tailBytes?: number | undefined; + restartCompleted?: boolean | undefined; +} + +export interface ReadManagedTaskRequest { + waitMs?: number | undefined; + tailBytes?: number | undefined; +} + +export type ManagedTaskResult = { + taskKey: string; + sessionId: string; + command: string; + cwd: string; + running: boolean; + completed: boolean; + status: "running" | "succeeded" | "failed" | "timed_out" | "cancelled"; + pid: number | undefined; + startedAt: string; + endedAt: string | undefined; + wallTimeMs: number; + exitCode: number | null | undefined; + signal: NodeJS.Signals | null | undefined; + timedOut: boolean; + error: string | undefined; + succeeded: boolean | undefined; + logPath: string; + logBytes: number; + tail: string; + importantLines: string[]; + reused: boolean; +}; + +interface ManagedTaskEntry { + taskKey: string; + sessionId: string; + executable: string; + args: string[]; + command: string; + cwd: string; + logPath: string; + importantLines: string[]; + pendingLines: Record; +} + +function sameStringArray(left: string[], right: string[]): boolean { + return left.length === right.length && left.every((value, index) => value === right[index]); +} + +function safeTaskName(taskKey: string): string { + const readable = taskKey + .toLowerCase() + .replace(/[^a-z0-9._-]+/g, "-") + .replace(/^-+|-+$/g, "") + .slice(0, 48) || "task"; + const digest = createHash("sha256").update(taskKey).digest("hex").slice(0, 10); + return `${readable}-${digest}`; +} + +function skipUtf8ContinuationBytes(data: Buffer): Buffer { + let offset = 0; + while (offset < data.length && data[offset]! >= 0x80 && data[offset]! <= 0xbf) { + offset += 1; + } + return data.subarray(offset); +} + +export class ManagedTaskManager { + readonly #processManager: ProcessManager; + readonly #options: ManagedTaskManagerOptions; + readonly #tasks = new Map(); + + constructor(processManager: ProcessManager, options: ManagedTaskManagerOptions) { + this.#processManager = processManager; + this.#options = { + ...options, + defaultTailBytes: Math.max(MIN_TAIL_BYTES, options.defaultTailBytes), + maxTailBytes: Math.max(MIN_TAIL_BYTES, options.maxTailBytes), + }; + mkdirSync(this.#options.logDirectory, { recursive: true, mode: 0o700 }); + } + + async start(request: StartManagedTaskRequest): Promise { + const taskKey = request.taskKey.trim(); + if (!taskKey) { + throw new Error("taskKey must not be empty"); + } + + const existing = this.#tasks.get(taskKey); + if (existing) { + if (!this.#isRetained(existing.sessionId)) { + this.#tasks.delete(taskKey); + } else { + const existingState = await this.#processManager.read(existing.sessionId, { + maxOutputBytes: STATUS_OUTPUT_BYTES, + }); + if (request.restartCompleted === true) { + if (existingState.running) { + throw new Error(`Managed task '${taskKey}' is still running and cannot be restarted`); + } + this.#tasks.delete(taskKey); + } else { + if (!this.#matches(existing, request)) { + throw new Error( + `Managed task key '${taskKey}' already refers to a different command or working directory`, + ); + } + return this.#result(existing, request.tailBytes, true); + } + } + } + + const logPath = path.join( + this.#options.logDirectory, + `${safeTaskName(taskKey)}-${Date.now()}-${randomUUID().slice(0, 8)}.log`, + ); + writeFileSync(logPath, Buffer.alloc(0), { mode: 0o600 }); + + const importantLines: string[] = []; + const pendingLines: Record = { stdout: "", stderr: "" }; + const sessionId = this.#processManager.start({ + executable: request.executable, + args: request.args, + commandForDisplay: request.commandForDisplay, + cwd: request.cwd, + env: request.env, + timeoutMs: request.timeoutMs, + stdin: request.stdin, + cleanup: request.cleanup, + onOutput: (stream, data) => { + appendFileSync(logPath, data); + this.#captureImportantLines(importantLines, pendingLines, stream, data); + }, + }); + const entry: ManagedTaskEntry = { + taskKey, + sessionId, + executable: request.executable, + args: [...request.args], + command: request.commandForDisplay, + cwd: request.cwd, + logPath, + importantLines, + pendingLines, + }; + this.#tasks.set(taskKey, entry); + + await this.#processManager.waitForExit(sessionId, Math.max(0, request.initialWaitMs ?? 1000)); + return this.#result(entry, request.tailBytes, false); + } + + async read( + taskKey: string, + request: ReadManagedTaskRequest = {}, + ): Promise { + const entry = this.#require(taskKey); + await this.#processManager.waitForExit(entry.sessionId, Math.max(0, request.waitMs ?? 0)); + return this.#result(entry, request.tailBytes, true); + } + + async list(): Promise>> { + const retained = new Map(this.#processManager.list().map((process) => [process.sessionId, process])); + const results: Array> = []; + + for (const [taskKey, entry] of this.#tasks) { + if (!retained.has(entry.sessionId)) { + this.#tasks.delete(taskKey); + continue; + } + const result = await this.#result(entry, MIN_TAIL_BYTES, true); + const { tail: _tail, importantLines: _importantLines, reused: _reused, ...compact } = result; + results.push(compact); + } + + return results.sort((left, right) => right.startedAt.localeCompare(left.startedAt)); + } + + async cancel( + taskKey: string, + signal: NodeJS.Signals = "SIGTERM", + graceMs = 3000, + tailBytes?: number, + ): Promise { + const entry = this.#require(taskKey); + await this.#processManager.terminate(entry.sessionId, signal, graceMs); + return this.#result(entry, tailBytes, true); + } + + #require(taskKey: string): ManagedTaskEntry { + const normalized = taskKey.trim(); + const entry = this.#tasks.get(normalized); + if (!entry) { + throw new Error(`Unknown managed task: ${normalized}`); + } + if (!this.#isRetained(entry.sessionId)) { + this.#tasks.delete(normalized); + throw new Error(`Managed task '${normalized}' is no longer retained`); + } + return entry; + } + + #isRetained(sessionId: string): boolean { + return this.#processManager.list().some((process) => process.sessionId === sessionId); + } + + #matches(entry: ManagedTaskEntry, request: StartManagedTaskRequest): boolean { + return ( + entry.executable === request.executable && + sameStringArray(entry.args, request.args) && + entry.command === request.commandForDisplay && + entry.cwd === request.cwd + ); + } + + async #result( + entry: ManagedTaskEntry, + requestedTailBytes: number | undefined, + reused: boolean, + ): Promise { + const state = await this.#processManager.read(entry.sessionId, { + maxOutputBytes: STATUS_OUTPUT_BYTES, + }); + const tailBytes = Math.max( + MIN_TAIL_BYTES, + Math.min(requestedTailBytes ?? this.#options.defaultTailBytes, this.#options.maxTailBytes), + ); + const { tail, logBytes } = this.#readTail(entry.logPath, tailBytes); + const importantLines = this.#importantLines(entry); + const completed = !state.running; + const succeeded = completed + ? state.exitCode === 0 && !state.timedOut && state.error === undefined + : undefined; + const status: ManagedTaskResult["status"] = state.running + ? "running" + : state.timedOut + ? "timed_out" + : state.signal && state.exitCode !== 0 + ? "cancelled" + : succeeded + ? "succeeded" + : "failed"; + + return { + taskKey: entry.taskKey, + sessionId: entry.sessionId, + command: state.command, + cwd: state.cwd, + running: state.running, + completed, + status, + pid: state.pid, + startedAt: state.startedAt, + endedAt: state.endedAt, + wallTimeMs: state.wallTimeMs, + exitCode: state.exitCode, + signal: state.signal, + timedOut: state.timedOut, + error: state.error, + succeeded, + logPath: entry.logPath, + logBytes, + tail, + importantLines, + reused, + }; + } + + #captureImportantLines( + importantLines: string[], + pendingLines: Record, + stream: ProcessOutputStream, + data: Buffer, + ): void { + const combined = pendingLines[stream] + data.toString("utf8"); + const lines = combined.split(/\r?\n/); + const remainder = lines.pop() ?? ""; + pendingLines[stream] = remainder.slice(-MAX_PENDING_LINE_CHARS); + + for (const line of lines) { + this.#rememberImportantLine(importantLines, line); + } + } + + #importantLines(entry: ManagedTaskEntry): string[] { + const importantLines = [...entry.importantLines]; + for (const pending of Object.values(entry.pendingLines)) { + this.#rememberImportantLine(importantLines, pending); + } + return importantLines.slice(-IMPORTANT_LINE_LIMIT); + } + + #rememberImportantLine(importantLines: string[], line: string): void { + const normalized = line.trim(); + if (!normalized || !IMPORTANT_LINE.test(normalized)) { + return; + } + importantLines.push(normalized); + if (importantLines.length > IMPORTANT_LINE_LIMIT) { + importantLines.splice(0, importantLines.length - IMPORTANT_LINE_LIMIT); + } + } + + #readTail(logPath: string, tailBytes: number): { tail: string; logBytes: number } { + let size: number; + try { + size = statSync(logPath).size; + } catch { + return { tail: "", logBytes: 0 }; + } + if (size === 0) { + return { tail: "", logBytes: 0 }; + } + + const bytesToRead = Math.min(size, tailBytes + 4); + const start = Math.max(0, size - bytesToRead); + const buffer = Buffer.alloc(bytesToRead); + const fd = openSync(logPath, "r"); + try { + readSync(fd, buffer, 0, bytesToRead, start); + } finally { + closeSync(fd); + } + + let bounded = start > 0 ? skipUtf8ContinuationBytes(buffer) : buffer; + if (bounded.length > tailBytes) { + bounded = skipUtf8ContinuationBytes(bounded.subarray(bounded.length - tailBytes)); + } + return { tail: bounded.toString("utf8"), logBytes: size }; + } +} diff --git a/src/managed-task-tools.ts b/src/managed-task-tools.ts new file mode 100644 index 0000000..d80ce0d --- /dev/null +++ b/src/managed-task-tools.ts @@ -0,0 +1,184 @@ +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import * as z from "zod/v4"; + +import type { AppConfig } from "./config.js"; +import { FileService } from "./file-service.js"; +import { ManagedTaskManager } from "./managed-task-manager.js"; +import { runTool } from "./tool-result.js"; +import { TOOL_ANNOTATIONS, toolAuthMetadata } from "./tool-metadata.js"; + +export function registerManagedTaskTools( + server: McpServer, + config: AppConfig, + taskManager: ManagedTaskManager, + fileService: FileService, +): void { + const authMetadata = toolAuthMetadata(config); + const maxTailBytes = Math.max(1024, Math.min(config.maxOutputBytes, 256 * 1024)); + const taskKeySchema = z + .string() + .min(1) + .max(128) + .regex(/^[A-Za-z0-9][A-Za-z0-9._:-]*$/) + .describe( + "Stable caller-chosen task key used to recover or deduplicate work, for example root-ci or backend-tests.", + ); + const environmentSchema = z + .record(z.string(), z.string()) + .optional() + .describe("Environment variables added to or overriding the server process environment."); + const tailBytesSchema = z + .number() + .int() + .min(1024) + .max(maxTailBytes) + .default(Math.min(32 * 1024, maxTailBytes)) + .describe("Maximum bytes from the end of the full task log included in the response."); + + server.registerTool( + "start_managed_task", + { + title: "Start managed task", + description: + "Start a non-interactive long-running shell task with a stable task key, persistent full log, bounded response, and duplicate-run protection. Repeating the same retained taskKey with the same command/cwd returns the existing task instead of starting it again.", + inputSchema: { + taskKey: taskKeySchema, + cmd: z.string().min(1).describe("Shell command or script to execute."), + workdir: z + .string() + .optional() + .describe(`Working directory. Relative paths resolve from ${config.defaultCwd}.`), + shell: z + .string() + .optional() + .describe(`Shell executable. Defaults to ${config.defaultShell}.`), + login: z + .boolean() + .default(true) + .describe("Use login-shell semantics (-lc) instead of -c."), + env: environmentSchema, + timeoutMs: z + .number() + .int() + .min(0) + .default(0) + .describe( + "Milliseconds before the task is terminated. Zero disables the task timeout.", + ), + initialWaitMs: z + .number() + .int() + .min(0) + .max(5000) + .default(1000) + .describe( + "Brief initial wait for quick tasks to finish. Long tasks return after this interval and continue under the same task key.", + ), + tailBytes: tailBytesSchema, + restartCompleted: z + .boolean() + .default(false) + .describe( + "When true, explicitly start a fresh run if the retained task has already completed. Running tasks are never restarted.", + ), + }, + annotations: TOOL_ANNOTATIONS.destructiveNonIdempotentOpen, + _meta: authMetadata, + }, + async ({ + taskKey, + cmd, + workdir, + shell, + login, + env, + timeoutMs, + initialWaitMs, + tailBytes, + restartCompleted, + }) => + runTool(async () => { + const cwd = fileService.resolve(".", workdir); + const executable = shell || config.defaultShell; + return taskManager.start({ + taskKey, + executable, + args: [login ? "-lc" : "-c", cmd], + commandForDisplay: cmd, + cwd, + env, + timeoutMs, + initialWaitMs, + tailBytes, + restartCompleted, + }); + }), + ); + + server.registerTool( + "read_managed_task", + { + title: "Read managed task", + description: + "Recover a managed task by stable task key, optionally wait for completion, and return compact status, important diagnostic lines, and a bounded tail of its full on-disk log.", + inputSchema: { + taskKey: taskKeySchema, + waitMs: z + .number() + .int() + .min(0) + .max(300_000) + .default(30_000) + .describe("How long to wait for task completion before returning current state."), + tailBytes: tailBytesSchema, + }, + annotations: TOOL_ANNOTATIONS.readOnlyClosed, + _meta: authMetadata, + }, + async ({ taskKey, waitMs, tailBytes }) => + runTool(() => taskManager.read(taskKey, { waitMs, tailBytes })), + ); + + server.registerTool( + "list_managed_tasks", + { + title: "List managed tasks", + description: + "List running and recently retained managed tasks by stable task key so interrupted conversations can recover work without re-executing it.", + inputSchema: {}, + annotations: TOOL_ANNOTATIONS.readOnlyClosed, + _meta: authMetadata, + }, + async () => runTool(async () => ({ tasks: await taskManager.list() })), + ); + + server.registerTool( + "cancel_managed_task", + { + title: "Cancel managed task", + description: + "Terminate a managed task by stable task key. SIGINT and SIGTERM may escalate to SIGKILL after the grace period through the existing process manager.", + inputSchema: { + taskKey: taskKeySchema, + signal: z + .enum(["SIGINT", "SIGTERM", "SIGKILL"]) + .default("SIGTERM") + .describe("Signal sent to the managed task process tree."), + graceMs: z + .number() + .int() + .min(0) + .max(60_000) + .default(3000) + .describe( + "For SIGINT or SIGTERM, milliseconds before SIGKILL escalation; zero disables escalation.", + ), + tailBytes: tailBytesSchema, + }, + annotations: TOOL_ANNOTATIONS.destructiveNonIdempotentClosed, + _meta: authMetadata, + }, + async ({ taskKey, signal, graceMs, tailBytes }) => + runTool(() => taskManager.cancel(taskKey, signal, graceMs, tailBytes)), + ); +} diff --git a/src/mcp-server.ts b/src/mcp-server.ts index 6a3b68a..549a1a0 100644 --- a/src/mcp-server.ts +++ b/src/mcp-server.ts @@ -1,30 +1,42 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import os from "node:os"; +import path from "node:path"; import type { AppConfig } from "./config.js"; import { registerExecTools } from "./exec-tools.js"; import { FileService } from "./file-service.js"; import { registerFileTools } from "./file-tools.js"; +import { ManagedTaskManager } from "./managed-task-manager.js"; +import { registerManagedTaskTools } from "./managed-task-tools.js"; import { ProcessManager } from "./process-manager.js"; export interface McpServices { processManager: ProcessManager; + managedTaskManager: ManagedTaskManager; fileService: FileService; } export function createServices(config: AppConfig): McpServices { + const processManager = new ProcessManager({ + maxRetainedOutputBytes: config.maxRetainedProcessOutputBytes, + processRetentionMs: config.processRetentionMs, + maxProcesses: config.maxProcesses, + defaultMaxOutputBytes: config.maxOutputBytes, + }); + const fileService = new FileService({ + defaultCwd: config.defaultCwd, + maxChunkBytes: config.maxFileChunkBytes, + maxEditFileBytes: config.maxEditFileBytes, + maxOutputBytes: config.maxOutputBytes, + }); return { - processManager: new ProcessManager({ - maxRetainedOutputBytes: config.maxRetainedProcessOutputBytes, - processRetentionMs: config.processRetentionMs, - maxProcesses: config.maxProcesses, - defaultMaxOutputBytes: config.maxOutputBytes, - }), - fileService: new FileService({ - defaultCwd: config.defaultCwd, - maxChunkBytes: config.maxFileChunkBytes, - maxEditFileBytes: config.maxEditFileBytes, - maxOutputBytes: config.maxOutputBytes, + processManager, + managedTaskManager: new ManagedTaskManager(processManager, { + logDirectory: path.join(os.tmpdir(), "cokacremote-managed-tasks"), + defaultTailBytes: Math.min(32 * 1024, config.maxOutputBytes), + maxTailBytes: Math.min(256 * 1024, config.maxOutputBytes), }), + fileService, }; } @@ -36,7 +48,7 @@ export function createMcpServer(config: AppConfig, services: McpServices): McpSe }, { instructions: - "This server is an unrestricted remote development environment. Tools operate directly on the host with the MCP service process's full OS permissions. Use exec_command for shell, build, test, package, Git, service, and log workflows; run_script for complete Bash, Node.js, or Python scripts; and the file tools for direct file operations. Poll long-running commands with read_process or write_stdin.", + "This server is an unrestricted remote development environment. Tools operate directly on the host with the MCP service process's full OS permissions. Prefer start_managed_task/read_managed_task for long-running non-interactive builds, tests, package installs, and similar work because stable task keys prevent accidental duplicate execution after response interruptions and responses stay compact. Use exec_command/read_process/write_stdin for low-level or interactive process control, run_script for complete scripts, and file tools for direct file operations.", capabilities: { logging: {} }, }, ); @@ -47,6 +59,12 @@ export function createMcpServer(config: AppConfig, services: McpServices): McpSe services.processManager, services.fileService, ); + registerManagedTaskTools( + server, + config, + services.managedTaskManager, + services.fileService, + ); registerFileTools(server, config, services.fileService); return server; } diff --git a/src/process-manager.ts b/src/process-manager.ts index a134082..85f66b3 100644 --- a/src/process-manager.ts +++ b/src/process-manager.ts @@ -37,6 +37,7 @@ interface ManagedProcess { timeoutHandle: NodeJS.Timeout | undefined; retentionHandle: NodeJS.Timeout | undefined; cleanup: (() => Promise) | undefined; + onOutput: ((stream: ProcessOutputStream, data: Buffer) => void) | undefined; } function isContinuationByte(value: number): boolean { @@ -141,6 +142,7 @@ export interface StartProcessRequest { timeoutMs?: number | undefined; stdin?: string | undefined; cleanup?: (() => Promise) | undefined; + onOutput?: ((stream: ProcessOutputStream, data: Buffer) => void) | undefined; } export interface ReadProcessRequest { @@ -220,6 +222,7 @@ export class ProcessManager { timeoutHandle: undefined, retentionHandle: undefined, cleanup: request.cleanup, + onOutput: request.onOutput, }; this.#processes.set(sessionId, managed); @@ -496,6 +499,13 @@ export class ProcessManager { stream: ProcessOutputStream, data: Buffer, ): void { + if (managed.onOutput) { + try { + managed.onOutput(stream, data); + } catch (error) { + managed.error ??= `Output observer failed: ${errorMessage(error)}`; + } + } managed.totalOutputBytes += data.length; const pending = managed.pendingOutput[stream]; const combined = pending.length > 0 ? Buffer.concat([pending, data]) : data; diff --git a/test/all-tools.integration.test.ts b/test/all-tools.integration.test.ts index 4813488..9bf1713 100644 --- a/test/all-tools.integration.test.ts +++ b/test/all-tools.integration.test.ts @@ -14,20 +14,24 @@ import { createServices } from "../src/mcp-server.js"; const ALL_TOOLS = [ "apply_patch", + "cancel_managed_task", "chmod_path", "copy_path", "download_file", "exec_command", "hash_file", "list_directory", + "list_managed_tasks", "list_processes", "make_directory", "move_path", "read_file", + "read_managed_task", "read_process", "remove_path", "replace_in_file", "run_script", + "start_managed_task", "stat_path", "terminate_process", "upload_file", @@ -40,20 +44,24 @@ type ToolResult = Awaited>; const EXPECTED_ANNOTATIONS = { apply_patch: [false, true, false, false], + cancel_managed_task: [false, true, false, false], chmod_path: [false, true, true, false], copy_path: [false, true, true, false], download_file: [true, false, true, false], exec_command: [false, true, false, true], hash_file: [true, false, true, false], list_directory: [true, false, true, false], + list_managed_tasks: [true, false, true, false], list_processes: [true, false, true, false], make_directory: [false, false, true, false], move_path: [false, true, true, false], read_file: [true, false, true, false], + read_managed_task: [true, false, true, false], read_process: [true, false, true, false], remove_path: [false, true, true, false], replace_in_file: [false, true, false, false], run_script: [false, true, false, true], + start_managed_task: [false, true, false, true], stat_path: [true, false, true, false], terminate_process: [false, true, false, false], upload_file: [false, true, true, false], @@ -336,6 +344,56 @@ describe.sequential("all registered MCP tools", () => { waitMs: 2000, }); expect(terminated).toMatchObject({ running: false, completed: true, signal: "SIGTERM" }); + + const managed = await callOk("start_managed_task", { + taskKey: "e2e-managed-task", + cmd: "printf 'managed-start\n'; sleep 0.1; printf 'BUILD SUCCESSFUL\n'", + workdir: testRoot, + initialWaitMs: 0, + }); + expect(managed).toMatchObject({ + taskKey: "e2e-managed-task", + running: true, + reused: false, + }); + const reusedManaged = await callOk("start_managed_task", { + taskKey: "e2e-managed-task", + cmd: "printf 'managed-start\n'; sleep 0.1; printf 'BUILD SUCCESSFUL\n'", + workdir: testRoot, + initialWaitMs: 0, + }); + expect(reusedManaged).toMatchObject({ + sessionId: managed.sessionId, + reused: true, + }); + const listedManaged = await callOk("list_managed_tasks"); + expect(listedManaged.tasks).toEqual( + expect.arrayContaining([ + expect.objectContaining({ taskKey: "e2e-managed-task" }), + ]), + ); + const finishedManaged = await callOk("read_managed_task", { + taskKey: "e2e-managed-task", + waitMs: 3000, + }); + expect(finishedManaged).toMatchObject({ + completed: true, + status: "succeeded", + exitCode: 0, + }); + expect(String(finishedManaged.tail)).toContain("BUILD SUCCESSFUL"); + + await callOk("start_managed_task", { + taskKey: "e2e-managed-cancel", + cmd: "sleep 10", + workdir: testRoot, + initialWaitMs: 0, + }); + await callOk("cancel_managed_task", { + taskKey: "e2e-managed-cancel", + signal: "SIGTERM", + graceMs: 1000, + }); }, 30_000); it("handles text, metadata, listings, permissions, and unified patches", async () => { diff --git a/test/managed-task-manager.test.ts b/test/managed-task-manager.test.ts new file mode 100644 index 0000000..4c3849d --- /dev/null +++ b/test/managed-task-manager.test.ts @@ -0,0 +1,158 @@ +import { mkdtemp, readFile, rm } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; + +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import { ManagedTaskManager } from "../src/managed-task-manager.js"; +import { ProcessManager } from "../src/process-manager.js"; + +function createProcessManager(): ProcessManager { + return new ProcessManager({ + maxRetainedOutputBytes: 32 * 1024, + processRetentionMs: 60_000, + maxProcesses: 16, + defaultMaxOutputBytes: 64 * 1024, + }); +} + +describe("ManagedTaskManager", () => { + let temporaryDirectory: string; + let processManager: ProcessManager; + let tasks: ManagedTaskManager; + + beforeEach(async () => { + temporaryDirectory = await mkdtemp(path.join(os.tmpdir(), "cokacremote-managed-task-test-")); + processManager = createProcessManager(); + tasks = new ManagedTaskManager(processManager, { + logDirectory: path.join(temporaryDirectory, "logs"), + defaultTailBytes: 4096, + maxTailBytes: 16 * 1024, + }); + }); + + afterEach(async () => { + await processManager.shutdown(); + await rm(temporaryDirectory, { recursive: true, force: true }); + }); + + it("reuses a retained task key instead of executing the same work twice", async () => { + const command = "printf run >> marker.txt; sleep 0.2; printf 'done\\n'"; + const request = { + taskKey: "duplicate-protected", + executable: "/bin/bash", + args: ["-c", command], + commandForDisplay: command, + cwd: temporaryDirectory, + initialWaitMs: 0, + }; + + const first = await tasks.start(request); + const second = await tasks.start(request); + + expect(first.reused).toBe(false); + expect(second.reused).toBe(true); + expect(second.sessionId).toBe(first.sessionId); + + await processManager.waitForExit(first.sessionId, 2000); + const completed = await tasks.read("duplicate-protected"); + expect(completed).toMatchObject({ running: false, completed: true, exitCode: 0 }); + expect(await readFile(path.join(temporaryDirectory, "marker.txt"), "utf8")).toBe("run"); + }); + + it("stores complete output while returning only a bounded diagnostic tail", async () => { + const command = + "node -e \"process.stdout.write('x'.repeat(100000)); process.stderr.write('\\nERROR final-marker\\n')\""; + const started = await tasks.start({ + taskKey: "large-output", + executable: "/bin/bash", + args: ["-c", command], + commandForDisplay: command, + cwd: temporaryDirectory, + initialWaitMs: 2000, + }); + + expect(started.completed).toBe(true); + const result = await tasks.read("large-output", { tailBytes: 4096 }); + const completeLog = await readFile(result.logPath, "utf8"); + + expect(completeLog.length).toBeGreaterThan(100_000); + expect(result.tail.length).toBeLessThanOrEqual(4096); + expect(result.tail).toContain("ERROR final-marker"); + expect(result.importantLines).toEqual( + expect.arrayContaining([expect.stringContaining("ERROR final-marker")]), + ); + }); + + it("keeps important lines from the full log even when they fall outside the returned tail", async () => { + const command = + "node -e \"console.log('ERROR early-marker'); process.stdout.write('x'.repeat(100000))\""; + await tasks.start({ + taskKey: "early-error", + executable: "/bin/bash", + args: ["-c", command], + commandForDisplay: command, + cwd: temporaryDirectory, + initialWaitMs: 2000, + }); + + const result = await tasks.read("early-error", { tailBytes: 4096 }); + + expect(result.tail).not.toContain("ERROR early-marker"); + expect(result.importantLines).toEqual( + expect.arrayContaining([expect.stringContaining("ERROR early-marker")]), + ); + }); + + it("reuses completed work by default and reruns only when explicitly requested", async () => { + const command = "printf x >> restart-marker.txt"; + const request = { + taskKey: "restartable", + executable: "/bin/bash", + args: ["-c", command], + commandForDisplay: command, + cwd: temporaryDirectory, + initialWaitMs: 2000, + }; + + const first = await tasks.start(request); + const reused = await tasks.start(request); + const restarted = await tasks.start({ ...request, restartCompleted: true }); + + expect(first.completed).toBe(true); + expect(reused).toMatchObject({ reused: true, sessionId: first.sessionId }); + expect(restarted.reused).toBe(false); + expect(restarted.sessionId).not.toBe(first.sessionId); + expect(await readFile(path.join(temporaryDirectory, "restart-marker.txt"), "utf8")).toBe( + "xx", + ); + }); + + it("lists tasks by stable key and can cancel a running task", async () => { + const command = "printf started; sleep 10"; + const started = await tasks.start({ + taskKey: "recover-me", + executable: "/bin/bash", + args: ["-c", command], + commandForDisplay: command, + cwd: temporaryDirectory, + initialWaitMs: 0, + }); + + expect(await tasks.list()).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + taskKey: "recover-me", + sessionId: started.sessionId, + running: true, + }), + ]), + ); + + await tasks.cancel("recover-me", "SIGTERM", 20); + await processManager.waitForExit(started.sessionId, 2000); + const cancelled = await tasks.read("recover-me"); + expect(cancelled.running).toBe(false); + expect(cancelled.completed).toBe(true); + }); +}); diff --git a/test/tool-metadata.test.ts b/test/tool-metadata.test.ts index 3fa6570..1af2948 100644 --- a/test/tool-metadata.test.ts +++ b/test/tool-metadata.test.ts @@ -37,7 +37,7 @@ describe("tool authentication metadata", () => { MCP_OAUTH_RESOURCE: "https://mcp.example.com/mcp", }); - expect(tools).toHaveLength(20); + expect(tools).toHaveLength(24); for (const tool of tools) { expect(tool._meta, tool.name).toEqual({ securitySchemes: [{ type: "oauth2", scopes: ["mcp:tools"] }], @@ -48,7 +48,7 @@ describe("tool authentication metadata", () => { it("does not infer noauth from the internal authentication bypass", async () => { const tools = await listTools({ MCP_ALLOW_NO_AUTH: "true" }); - expect(tools).toHaveLength(20); + expect(tools).toHaveLength(24); for (const tool of tools) { expect(tool._meta, tool.name).toBeUndefined(); } @@ -57,7 +57,7 @@ describe("tool authentication metadata", () => { it("does not mislabel static bearer authentication as noauth or OAuth", async () => { const tools = await listTools({ MCP_AUTH_TOKEN: "static-secret" }); - expect(tools).toHaveLength(20); + expect(tools).toHaveLength(24); for (const tool of tools) { expect(tool._meta, tool.name).toBeUndefined(); } @@ -81,7 +81,7 @@ describe("client-facing metadata accuracy", () => { it("describes every tool and every input field", async () => { const tools = await listTools({ MCP_AUTH_TOKEN: "static-secret" }); - expect(tools).toHaveLength(20); + expect(tools).toHaveLength(24); for (const tool of tools) { expect(tool.title?.trim().length, tool.name).toBeGreaterThan(0); expect(tool.description?.trim().length, tool.name).toBeGreaterThan(0);