src/server/jobs-mcp.ts
Rama main · réplica de lectura del repositorio original.
// In-process MCP server exposing the durable background-job registry (background-jobs.ts) to the
// claude-code engine.
//
// Why this exists: on the claude-code engine the model's only two ways to run long work were both
// broken. `Bash{run_in_background}` jobs live inside the CLI process, and the engine bounds the
// bg wait (BG_TASK_MAX_WAIT_MS) — a job longer than that dies with the CLI, silently. Folklore
// `nohup … &` survives, but nothing wakes the session when it exits, so the outcome lands nowhere
// (a headless worker "finished" and nobody checked its deliverable — the viral-quickcut incident).
// The server-owned registry solves exactly this (detached children, wake-on-exit via wake.ts,
// restart adoption), and the agentx engine already mounts it as Shell/ShellOutput/… tools. This
// file is the same registry behind SDK MCP tools, the way share-file.ts mounts `share_file`.
import { createSdkMcpServer, tool } from '@anthropic-ai/claude-agent-sdk';
import { z } from 'zod/v4';
import { sessionJobRegistry, type JobOwner } from './background-jobs.ts';
import { JOBS_SERVER } from './security/enforce.ts';
/** Tool ids as the SDK exposes them, for allowedTools. */
export const JOBS_TOOL_IDS = ['job_start', 'job_status', 'job_output', 'job_kill', 'job_list']
.map((t) => `mcp__${JOBS_SERVER}__${t}`);
const text = (t: string, isError = false) => ({ content: [{ type: 'text' as const, text: t }], ...(isError ? { isError: true } : {}) });
/** In-process MCP server over the session's view of the job registry. Mount per turn (the view is
* stateless); the jobs belong to the server and outlive the turn, the CLI, and a server restart. */
export function jobsMcpServer(owner: JobOwner) {
const reg = sessionJobRegistry(owner);
return createSdkMcpServer({
name: JOBS_SERVER,
version: '1.0.0',
tools: [
tool(
'job_start',
'Run a shell command as a DURABLE background job, owned by the server: it survives the end of this turn (and even a server restart), and when it exits you are woken with its outcome — never use `nohup …/… &` for long work, and never launch work longer than ~10 minutes with Bash run_in_background (it is killed when the turn\'s wait budget ends). After starting one you may end your turn; you will be woken. A headless worker (`claude -p …`) MUST be launched this way.',
{ command: z.string().min(1).describe('Shell command line. Runs detached, in the session cwd; stdout+stderr go to the job log.') },
async ({ command }) => {
try {
const id = await reg.start(command);
return text(`Started background job ${id}. It keeps running after this turn ends; you will be woken when it exits. Check it with job_status/job_output. Its exit is NOT completion — on wake, verify the job\'s deliverable itself.`);
} catch (e) { return text(`job_start failed: ${(e as Error).message}`, true); }
},
),
tool(
'job_status',
'Status of one background job (running/exited + exit code). Returns no output; a finished job still wakes you later unless you read job_output.',
{ id: z.string().min(1) },
async ({ id }) => {
const s = reg.status(id);
return s ? text(JSON.stringify(s)) : text(`no such job in this session: ${id}`, true);
},
),
tool(
'job_output',
'Tail of a background job\'s log. Reading a FINISHED job\'s output marks it observed (no follow-up wake) — so only read it when you will act on the result now.',
{ id: z.string().min(1) },
async ({ id }) => {
const out = reg.output(id);
return out == null ? text(`no such job in this session: ${id}`, true) : text(out || '(no output yet)');
},
),
tool(
'job_kill',
'Kill a background job (its whole process group).',
{ id: z.string().min(1) },
async ({ id }) => (reg.kill(id) ? text(`killed ${id}`) : text(`no such running job: ${id}`, true)),
),
tool(
'job_list',
'List this session\'s background jobs.',
{},
async () => text(JSON.stringify(reg.list())),
),
],
});
}