multi-tasks-mcp
v0.0.1
Published
MCP server that drives multi-tasks parallel batch jobs for AI agents
Readme
multi-tasks-mcp
MCP server that lets AI agents drive multi-tasks parallel batch jobs: multi-process execution, crash resume, failed-task retry.
Security: task logic is arbitrary JavaScript executed on this host with the current user's permissions. Connecting this server to an MCP client is equivalent to allowing that AI to run any code on your machine. Only connect trusted MCP clients.
Install & configure
npm i multi-tasks-mcpMCP host config example (stdio):
{
"mcpServers": {
"multi-tasks": {
"command": "npx",
"args": ["-y", "multi-tasks-mcp"],
"env": { "MULTI_TASKS_MCP_JOB_ROOT": "/abs/path/to/jobs" }
}
}
}MULTI_TASKS_MCP_JOB_ROOT is optional; it defaults to <cwd>/.jobs. All job directories are created under this root.
Task module contract
A task module is a CommonJS module:
module.exports = {
initialTasks, // optional: array or () => array | Promise<array>
processTask, // required: (task, helper) => result | Promise<result>
options, // optional: { numberOfWorkers, taskTimeout, maxTaskRetries, shouldTerminate, setSysListener }
};create_job's tasks argument takes precedence over initialTasks.
Two rules every task module must follow:
- Task functions must be self-contained. Any module used inside
processTask/initialTasksmust berequired inside the function body (e.g. writeconst fs = require('fs');in the body, not at module top level). Reason: onresume_job(resume/retry_fails/restart) multi-tasks revives these functions from their serialized source stored in the job's config archive (task_config.json); top-levelrequires are lost, and referencing outer variables then throwsReferenceError. - Crashed tasks are not re-queued by default. When task code crashes a worker, the failed task is only re-executed if
maxTaskRetrieswas set explicitly (or you later callresume_jobwith moderetry_fails); otherwise the task ends as failed. SetmaxTaskRetriesfor any batch you care about.
How to operate
The server is asynchronous: create_job returns immediately and the job runs in a detached runner process. The typical operation loop is create → poll → collect, with stop/resume for long or stuck jobs:
- Create: call
create_jobwith one ofprocessTask/processTaskCode/processTaskFile, plustasks. Keep the returnedjobId— it is the handle for everything below. - Poll: call
job_statuswith thejobIdevery few seconds.countsshows live progress (pending/running/succeeded/failed). Stop polling whenstatebecomesdoneorexited. - Collect: when
stateisdone, calljob_resultswithkind: 'succ'for results andkind: 'errors'for failures, paging withoffset/limitfor large batches. - Troubleshoot: when
stateisexited(runner crashed, was killed, orstop_jobwas called), readlogTailfromjob_statusfor the cause, then callresume_jobwith moderesumeto continue from where it stopped,retry_failsto also re-run failed tasks, orrestartto start over. - Stop early: call
stop_jobto kill a running job. Nothing is lost — progress stays on disk andresume_jobbrings it back.
End-to-end example (any MCP client; here the official SDK):
//callJson: thin wrapper around client.callTool that parses the JSON text result
const callJson = async (name, args) => {
const res = await client.callTool({ name, arguments: args });
if (res.isError) {
throw new Error(res.content[0].text);
};
return JSON.parse(res.content[0].text);
};
const { jobId } = await callJson('create_job', {
processTask: `(task) => {
const fs = require('fs');//required inside the function body, see the contract above
return { squared: task.n * task.n };
}`,
tasks: [{ n: 1 }, { n: 2 }, { n: 3 }],
numberOfWorkers: 2,
maxTaskRetries: 2,
});
//poll until the job leaves the running state
let status;
do {
await new Promise((r) => setTimeout(r, 2000));
status = await callJson('job_status', { jobId });
} while (status.state === 'running' || status.state === 'unknown');
if (status.state === 'exited') {
console.error('runner exited, log tail:', status.logTail);
await callJson('resume_job', { jobId, mode: 'resume' });//then keep polling
} else {
const results = await callJson('job_results', { jobId, kind: 'succ' });
console.log(results.items.map((it) => it.result));
};Where things live on disk (per job, under MULTI_TASKS_MCP_JOB_ROOT/<jobId>/):
runner.log— runner process stdout/stderr; first place to look when a job misbehaves.job.done.json— the final report; its presence is what makesstatebecomedone.data/<jobId>/— multi-tasks' own files:progress/(per-task state),results/succ+results/errors(per-task results),.sys/run.log(multi-tasks' internal log).
Tools
All tools return JSON text. Errors come back with isError: true and a message.
create_job
Create and start a parallel batch job in the background. Provide task logic via exactly one of processTask, processTaskCode, or processTaskFile (mutually exclusive). Tasks come from tasks (JSON array) or the module's initialTasks; with processTask the tasks array is required.
Arguments:
processTask(string, optional): source text of theprocessTask(task, helper)function itself — whatprocessTask.toString()returns, e.g.(task) => { return {...}; }orasync (task) => {...}. The server wraps it into a module for you. Requirestasks.processTaskCode(string, optional): source of a full CommonJS module exportingprocessTask(task, helper); optionallyinitialTasksandoptions. Use this when you needinitialTasks,options, or helper functions aroundprocessTask.processTaskFile(string, optional): absolute path of such a module file.tasks(array, optional): task objects array; takes precedence over moduleinitialTasks.numberOfWorkers(integer, optional, 1–32).taskTimeout(integer, optional): per-task timeout in ms.maxTaskRetries(integer, optional, 0–10).
Call example (simplest form):
await client.callTool({
name: 'create_job',
arguments: {
processTask: `(task) => { return { squared: task.n * task.n }; }`,
tasks: [{ n: 1 }, { n: 2 }, { n: 3 }],
numberOfWorkers: 2,
},
});Call example (full module form):
await client.callTool({
name: 'create_job',
arguments: {
processTaskCode: `module.exports = {
initialTasks: [{ n: 1 }, { n: 2 }, { n: 3 }],
processTask: (task) => { return { squared: task.n * task.n }; },
options: { numberOfWorkers: 2 },
};`,
},
});Return example:
{
"jobId": "job-20260901-080000-a1b2c3",
"jobDir": "C:\\work\\.jobs\\job-20260901-080000-a1b2c3",
"taskFolder": "C:\\work\\.jobs\\job-20260901-080000-a1b2c3\\data\\job-20260901-080000-a1b2c3",
"pid": 12345
}list_jobs
List all jobs known to this server, newest first. No arguments.
Call example:
await client.callTool({ name: 'list_jobs', arguments: {} });Return example:
[
{ "jobId": "job-20260901-080000-a1b2c3", "state": "done", "createdAt": "2026-09-01T08:00:00.000Z" }
]job_status
Get job state (unknown / running / exited / done), per-queue task counts, final report (when done) and runner log tail.
Arguments:
jobId(string, required): jobId returned bycreate_job.
Call example:
await client.callTool({ name: 'job_status', arguments: { jobId: 'job-20260901-080000-a1b2c3' } });Return example (done job):
{
"jobId": "job-20260901-080000-a1b2c3",
"state": "done",
"counts": { "pending": 0, "running": 0, "succeeded": 3, "failed": 0 },
"report": {
"taskId": "job-20260901-080000-a1b2c3",
"taskFolder": ".jobs/job-20260901-080000-a1b2c3/data/job-20260901-080000-a1b2c3",
"startTimestamp": 1788100000000,
"endTimestamp": 1788100004200,
"cost": 4200,
"total": 3,
"succeeded": 3,
"failed": 0,
"failedTasks": [],
"succButSaveFailed": 0,
"workers": { "count": 2, "crashed": 0 }
},
"logTail": "..."
}report is null until the job finishes.
job_results
Read task results paginated. kind="succ" for succeeded results, kind="errors" for failed ones. limit is capped at 200.
Arguments:
jobId(string, required).kind("succ"|"errors", optional, default"succ").offset(integer >= 0, optional, default 0).limit(integer >= 1, optional, default 50, capped at 200).
Call example:
await client.callTool({
name: 'job_results',
arguments: { jobId: 'job-20260901-080000-a1b2c3', kind: 'succ' },
});Return example:
{
"kind": "succ",
"total": 3,
"offset": 0,
"limit": 50,
"items": [
{
"subid": "job-20260901-080000-a1b2c3--k3x9az",
"task": { "n": 1, "masterTaskId": "job-20260901-080000-a1b2c3", "subid": "job-20260901-080000-a1b2c3--k3x9az" },
"result": { "squared": 1 }
},
{
"subid": "job-20260901-080000-a1b2c3--m7q2bw",
"task": { "n": 2, "masterTaskId": "job-20260901-080000-a1b2c3", "subid": "job-20260901-080000-a1b2c3--m7q2bw" },
"result": { "squared": 4 }
},
{
"subid": "job-20260901-080000-a1b2c3--p8r4cx",
"task": { "n": 3, "masterTaskId": "job-20260901-080000-a1b2c3", "subid": "job-20260901-080000-a1b2c3--p8r4cx" },
"result": { "squared": 9 }
}
]
}Note: multi-tasks annotates each task with masterTaskId and subid before running it, so items[].task contains your original fields plus those two.
stop_job
Kill the runner process tree of a running job. Progress is kept on disk; use resume_job to continue later.
Arguments:
jobId(string, required).
Call example:
await client.callTool({ name: 'stop_job', arguments: { jobId: 'job-20260901-080000-a1b2c3' } });Return example:
{ "jobId": "job-20260901-080000-a1b2c3", "stopped": true }stopped is false when the runner was not alive (already finished or exited).
resume_job
Restart a stopped/exited job runner. Mode "resume" continues pending tasks; "retry_fails" also re-runs failed tasks; "restart" wipes progress and starts over. Fails if the job is currently running.
Arguments:
jobId(string, required).mode("resume"|"retry_fails"|"restart", required).
Call example:
await client.callTool({
name: 'resume_job',
arguments: { jobId: 'job-20260901-080000-a1b2c3', mode: 'resume' },
});Return example:
{ "jobId": "job-20260901-080000-a1b2c3", "pid": 23456 }Job states
unknown -> running -> done | exitedunknown: the job directory exists but no task data has been written to the file system yet.running: the runner process is alive.done: the job finished;job_status.reportcarries the final counts.exited: the runner process died without finishing (crash, kill, orstop_job). Progress is kept on disk; callresume_jobto continue.
Disk cleanup
Job directories under MULTI_TASKS_MCP_JOB_ROOT are never deleted by this server — that is what makes stop/resume possible. Disk reclamation is the user's responsibility: delete job directories you no longer need.
License
MIT
