refactor: gpt 스트림 오류 최소화

This commit is contained in:
donghyeon-ka
2026-09-17 15:34:01 +09:00
parent d7ceca39a0
commit 0a8c8e55a8
11 changed files with 957 additions and 29 deletions
+30
View File
@@ -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.
+36 -13
View File
@@ -67,7 +67,7 @@ Typical tasks include:
- "Install Node.js packages and build the project." - "Install Node.js packages and build the project."
- "Upload a file, verify its hash, and move it into place." - "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 ## How it works
@@ -83,7 +83,8 @@ The MCP transport is stateless, but long-running command sessions are kept in me
## Key features ## Key features
- Shell commands, complete scripts, builds, tests, package installation, Git, and service management - 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 - 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 - 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 - 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 ### 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 - `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 - `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 - `read_process`: Poll output using a cursor and inspect process termination state
- `terminate_process`: Send `SIGINT`, `SIGTERM`, or `SIGKILL` to a managed process group - `terminate_process`: Send `SIGINT`, `SIGTERM`, or `SIGKILL` to a managed process group
- `list_processes`: List running or recently completed process sessions - `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 ### Filesystem
- `list_directory`, `stat_path`, `read_file`, `write_file` - `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`. 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 ### 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` | | 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` | | 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 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, 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` | `false` | `true` | `false` | `true` | | 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. 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. - 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. - 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`. - 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 ## 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. - `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. - `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`. - `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. - 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. - 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: The default tests use a real Streamable HTTP MCP client and cover:
- Bearer authentication, stateless request processing, and request tracing headers - 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 - Interactive stdin, output pagination, timeouts, termination, and completed-process retention
- UTF-8 character boundaries, strict base64 validation, file modes, and copy/move conflicts - UTF-8 character boundaries, strict base64 validation, file modes, and copy/move conflicts
- Unified diff validation, application, reverse application, and 3-way application - Unified diff validation, application, reverse application, and 3-way application
### Full E2E verification against a running external MCP server ### 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 ```bash
MCP_E2E_URL='https://mcp.example.com/mcp' \ 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/http-server.ts` | Stateless Streamable HTTP, OAuth routing, and health endpoint |
| `src/mcp-server.ts` | MCP server metadata and tool registration | | `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-service.ts` | File reading, writing, transfer, and path operations |
| `src/file-tools.ts` | Filesystem tools and input schemas | | `src/file-tools.ts` | Filesystem tools and input schemas |
| `src/oauth.ts` | DCR, PKCE, token issuance/refresh/revocation, and approval UI | | `src/oauth.ts` | DCR, PKCE, token issuance/refresh/revocation, and approval UI |
| `deploy/` | systemd, environment-file, and Nginx examples | | `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 | | `test/` | Configuration, file, process, MCP, and OAuth unit/integration tests |
## License ## License
@@ -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.
@@ -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.
+364
View File
@@ -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<ProcessOutputStream, string>;
}
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<string, ManagedTaskEntry>();
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<ManagedTaskResult> {
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<ProcessOutputStream, string> = { 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<ManagedTaskResult> {
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<Array<Omit<ManagedTaskResult, "tail" | "importantLines" | "reused">>> {
const retained = new Map(this.#processManager.list().map((process) => [process.sessionId, process]));
const results: Array<Omit<ManagedTaskResult, "tail" | "importantLines" | "reused">> = [];
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<ManagedTaskResult> {
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<ManagedTaskResult> {
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<ProcessOutputStream, string>,
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 };
}
}
+184
View File
@@ -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)),
);
}
+30 -12
View File
@@ -1,30 +1,42 @@
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; 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 type { AppConfig } from "./config.js";
import { registerExecTools } from "./exec-tools.js"; import { registerExecTools } from "./exec-tools.js";
import { FileService } from "./file-service.js"; import { FileService } from "./file-service.js";
import { registerFileTools } from "./file-tools.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"; import { ProcessManager } from "./process-manager.js";
export interface McpServices { export interface McpServices {
processManager: ProcessManager; processManager: ProcessManager;
managedTaskManager: ManagedTaskManager;
fileService: FileService; fileService: FileService;
} }
export function createServices(config: AppConfig): McpServices { 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 { return {
processManager: new ProcessManager({ processManager,
maxRetainedOutputBytes: config.maxRetainedProcessOutputBytes, managedTaskManager: new ManagedTaskManager(processManager, {
processRetentionMs: config.processRetentionMs, logDirectory: path.join(os.tmpdir(), "cokacremote-managed-tasks"),
maxProcesses: config.maxProcesses, defaultTailBytes: Math.min(32 * 1024, config.maxOutputBytes),
defaultMaxOutputBytes: config.maxOutputBytes, maxTailBytes: Math.min(256 * 1024, config.maxOutputBytes),
}),
fileService: new FileService({
defaultCwd: config.defaultCwd,
maxChunkBytes: config.maxFileChunkBytes,
maxEditFileBytes: config.maxEditFileBytes,
maxOutputBytes: config.maxOutputBytes,
}), }),
fileService,
}; };
} }
@@ -36,7 +48,7 @@ export function createMcpServer(config: AppConfig, services: McpServices): McpSe
}, },
{ {
instructions: 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: {} }, capabilities: { logging: {} },
}, },
); );
@@ -47,6 +59,12 @@ export function createMcpServer(config: AppConfig, services: McpServices): McpSe
services.processManager, services.processManager,
services.fileService, services.fileService,
); );
registerManagedTaskTools(
server,
config,
services.managedTaskManager,
services.fileService,
);
registerFileTools(server, config, services.fileService); registerFileTools(server, config, services.fileService);
return server; return server;
} }
+10
View File
@@ -37,6 +37,7 @@ interface ManagedProcess {
timeoutHandle: NodeJS.Timeout | undefined; timeoutHandle: NodeJS.Timeout | undefined;
retentionHandle: NodeJS.Timeout | undefined; retentionHandle: NodeJS.Timeout | undefined;
cleanup: (() => Promise<void>) | undefined; cleanup: (() => Promise<void>) | undefined;
onOutput: ((stream: ProcessOutputStream, data: Buffer) => void) | undefined;
} }
function isContinuationByte(value: number): boolean { function isContinuationByte(value: number): boolean {
@@ -141,6 +142,7 @@ export interface StartProcessRequest {
timeoutMs?: number | undefined; timeoutMs?: number | undefined;
stdin?: string | undefined; stdin?: string | undefined;
cleanup?: (() => Promise<void>) | undefined; cleanup?: (() => Promise<void>) | undefined;
onOutput?: ((stream: ProcessOutputStream, data: Buffer) => void) | undefined;
} }
export interface ReadProcessRequest { export interface ReadProcessRequest {
@@ -220,6 +222,7 @@ export class ProcessManager {
timeoutHandle: undefined, timeoutHandle: undefined,
retentionHandle: undefined, retentionHandle: undefined,
cleanup: request.cleanup, cleanup: request.cleanup,
onOutput: request.onOutput,
}; };
this.#processes.set(sessionId, managed); this.#processes.set(sessionId, managed);
@@ -496,6 +499,13 @@ export class ProcessManager {
stream: ProcessOutputStream, stream: ProcessOutputStream,
data: Buffer, data: Buffer,
): void { ): void {
if (managed.onOutput) {
try {
managed.onOutput(stream, data);
} catch (error) {
managed.error ??= `Output observer failed: ${errorMessage(error)}`;
}
}
managed.totalOutputBytes += data.length; managed.totalOutputBytes += data.length;
const pending = managed.pendingOutput[stream]; const pending = managed.pendingOutput[stream];
const combined = pending.length > 0 ? Buffer.concat([pending, data]) : data; const combined = pending.length > 0 ? Buffer.concat([pending, data]) : data;
+58
View File
@@ -14,20 +14,24 @@ import { createServices } from "../src/mcp-server.js";
const ALL_TOOLS = [ const ALL_TOOLS = [
"apply_patch", "apply_patch",
"cancel_managed_task",
"chmod_path", "chmod_path",
"copy_path", "copy_path",
"download_file", "download_file",
"exec_command", "exec_command",
"hash_file", "hash_file",
"list_directory", "list_directory",
"list_managed_tasks",
"list_processes", "list_processes",
"make_directory", "make_directory",
"move_path", "move_path",
"read_file", "read_file",
"read_managed_task",
"read_process", "read_process",
"remove_path", "remove_path",
"replace_in_file", "replace_in_file",
"run_script", "run_script",
"start_managed_task",
"stat_path", "stat_path",
"terminate_process", "terminate_process",
"upload_file", "upload_file",
@@ -40,20 +44,24 @@ type ToolResult = Awaited<ReturnType<Client["callTool"]>>;
const EXPECTED_ANNOTATIONS = { const EXPECTED_ANNOTATIONS = {
apply_patch: [false, true, false, false], apply_patch: [false, true, false, false],
cancel_managed_task: [false, true, false, false],
chmod_path: [false, true, true, false], chmod_path: [false, true, true, false],
copy_path: [false, true, true, false], copy_path: [false, true, true, false],
download_file: [true, false, true, false], download_file: [true, false, true, false],
exec_command: [false, true, false, true], exec_command: [false, true, false, true],
hash_file: [true, false, true, false], hash_file: [true, false, true, false],
list_directory: [true, false, true, false], list_directory: [true, false, true, false],
list_managed_tasks: [true, false, true, false],
list_processes: [true, false, true, false], list_processes: [true, false, true, false],
make_directory: [false, false, true, false], make_directory: [false, false, true, false],
move_path: [false, true, true, false], move_path: [false, true, true, false],
read_file: [true, false, true, false], read_file: [true, false, true, false],
read_managed_task: [true, false, true, false],
read_process: [true, false, true, false], read_process: [true, false, true, false],
remove_path: [false, true, true, false], remove_path: [false, true, true, false],
replace_in_file: [false, true, false, false], replace_in_file: [false, true, false, false],
run_script: [false, true, false, true], run_script: [false, true, false, true],
start_managed_task: [false, true, false, true],
stat_path: [true, false, true, false], stat_path: [true, false, true, false],
terminate_process: [false, true, false, false], terminate_process: [false, true, false, false],
upload_file: [false, true, true, false], upload_file: [false, true, true, false],
@@ -336,6 +344,56 @@ describe.sequential("all registered MCP tools", () => {
waitMs: 2000, waitMs: 2000,
}); });
expect(terminated).toMatchObject({ running: false, completed: true, signal: "SIGTERM" }); 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); }, 30_000);
it("handles text, metadata, listings, permissions, and unified patches", async () => { it("handles text, metadata, listings, permissions, and unified patches", async () => {
+158
View File
@@ -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);
});
});
+4 -4
View File
@@ -37,7 +37,7 @@ describe("tool authentication metadata", () => {
MCP_OAUTH_RESOURCE: "https://mcp.example.com/mcp", MCP_OAUTH_RESOURCE: "https://mcp.example.com/mcp",
}); });
expect(tools).toHaveLength(20); expect(tools).toHaveLength(24);
for (const tool of tools) { for (const tool of tools) {
expect(tool._meta, tool.name).toEqual({ expect(tool._meta, tool.name).toEqual({
securitySchemes: [{ type: "oauth2", scopes: ["mcp:tools"] }], 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 () => { it("does not infer noauth from the internal authentication bypass", async () => {
const tools = await listTools({ MCP_ALLOW_NO_AUTH: "true" }); const tools = await listTools({ MCP_ALLOW_NO_AUTH: "true" });
expect(tools).toHaveLength(20); expect(tools).toHaveLength(24);
for (const tool of tools) { for (const tool of tools) {
expect(tool._meta, tool.name).toBeUndefined(); 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 () => { it("does not mislabel static bearer authentication as noauth or OAuth", async () => {
const tools = await listTools({ MCP_AUTH_TOKEN: "static-secret" }); const tools = await listTools({ MCP_AUTH_TOKEN: "static-secret" });
expect(tools).toHaveLength(20); expect(tools).toHaveLength(24);
for (const tool of tools) { for (const tool of tools) {
expect(tool._meta, tool.name).toBeUndefined(); expect(tool._meta, tool.name).toBeUndefined();
} }
@@ -81,7 +81,7 @@ describe("client-facing metadata accuracy", () => {
it("describes every tool and every input field", async () => { it("describes every tool and every input field", async () => {
const tools = await listTools({ MCP_AUTH_TOKEN: "static-secret" }); const tools = await listTools({ MCP_AUTH_TOKEN: "static-secret" });
expect(tools).toHaveLength(20); expect(tools).toHaveLength(24);
for (const tool of tools) { for (const tool of tools) {
expect(tool.title?.trim().length, tool.name).toBeGreaterThan(0); expect(tool.title?.trim().length, tool.name).toBeGreaterThan(0);
expect(tool.description?.trim().length, tool.name).toBeGreaterThan(0); expect(tool.description?.trim().length, tool.name).toBeGreaterThan(0);