backburner-mcp 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,11 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ *.egg-info/
4
+ build/
5
+ dist/
6
+ .pytest_cache/
7
+ .venv/
8
+ venv/
9
+ .idea/
10
+ .vscode/
11
+ CLAUDE.md
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Rohit Yajee
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,116 @@
1
+ Metadata-Version: 2.4
2
+ Name: backburner-mcp
3
+ Version: 0.1.0
4
+ Summary: An MCP server that lets AI agents run long jobs in the background and collect results later
5
+ Author: Rohit Yajee
6
+ License: MIT
7
+ License-File: LICENSE
8
+ Keywords: agents,background-jobs,mcp,model-context-protocol,tasks
9
+ Requires-Python: >=3.10
10
+ Requires-Dist: mcp>=1.26.0
11
+ Description-Content-Type: text/markdown
12
+
13
+ # backburner
14
+
15
+ **Put your AI agent's slow work on the back burner. Keep cooking.**
16
+
17
+ `backburner` is an MCP server that gives any AI assistant (Claude, and any
18
+ other MCP client) the ability to run long shell commands as **background
19
+ tasks** — start a test suite, a build, a scrape, a batch job — then keep
20
+ working and check back for the results, instead of sitting frozen until
21
+ it finishes.
22
+
23
+ ```
24
+ agent: start_task("pytest -q") -> { task_id: "a1b2c3d4", status: "working" }
25
+ ... agent does other useful work ...
26
+ agent: task_status("a1b2c3d4") -> { status: "completed", duration_seconds: 312 }
27
+ agent: task_result("a1b2c3d4") -> { output: "418 passed in 311.2s" }
28
+ ```
29
+
30
+ ## Why
31
+
32
+ AI agents are bad at waiting. A tool call that takes 10 minutes blocks the
33
+ whole conversation — or times out and loses the work entirely. The MCP
34
+ specification is formalizing a Tasks pattern for exactly this problem
35
+ (extension finalized in the 2026-07-28 spec release); `backburner` brings
36
+ that workflow to every client **today** via plain tools, with first-class
37
+ Tasks-extension support on the roadmap.
38
+
39
+ ## Tools
40
+
41
+ | Tool | What it does |
42
+ |------|--------------|
43
+ | `start_task(command, cwd?, timeout_seconds?)` | Run a shell command in the background, returns a task id immediately |
44
+ | `task_status(task_id)` | `working` / `completed` / `failed` / `cancelled` / `timed_out` / `interrupted` |
45
+ | `task_result(task_id, tail_lines?)` | Captured output — works mid-run too, so you can peek at progress |
46
+ | `cancel_task(task_id)` | Kill the task and its whole process tree |
47
+ | `list_tasks(limit?)` | Recent tasks, newest first |
48
+
49
+ ## Features
50
+
51
+ - **Survives restarts** — tasks are tracked in SQLite under `~/.backburner/`;
52
+ output is captured to per-task log files. If the server dies mid-task,
53
+ orphaned tasks are honestly marked `interrupted`, never silently lost.
54
+ - **Real cancellation** — kills the full process tree (worker processes
55
+ included), on Windows and Unix.
56
+ - **Peek at live progress** — `task_result` on a running task returns the
57
+ output so far.
58
+ - **Timeouts** — pass `timeout_seconds` and a runaway task is killed and
59
+ honestly marked `timed_out` instead of hanging forever.
60
+ - **Command policy** — restrict what the AI may run with environment
61
+ variables (regexes, comma-separated; deny always wins):
62
+
63
+ ```bash
64
+ BACKBURNER_ALLOW="^pytest,^npm (test|run build)" # only these may run
65
+ BACKBURNER_DENY="rm -rf,shutdown,format" # these never run
66
+ ```
67
+ - **Zero infrastructure** — stdlib only (SQLite, subprocess, threads).
68
+ No Redis, no Celery, no Docker.
69
+ - **Tested** — a pytest suite covers the full job lifecycle: completion,
70
+ failure, cancellation, timeouts, crash recovery, and the command policy.
71
+
72
+ ## Install
73
+
74
+ ```bash
75
+ pip install -e .
76
+ ```
77
+
78
+ ### Claude Code
79
+
80
+ ```bash
81
+ claude mcp add backburner -- python -m backburner.server
82
+ ```
83
+
84
+ ### Claude Desktop / other clients
85
+
86
+ ```json
87
+ {
88
+ "mcpServers": {
89
+ "backburner": {
90
+ "command": "python",
91
+ "args": ["-m", "backburner.server"]
92
+ }
93
+ }
94
+ }
95
+ ```
96
+
97
+ ## Security note
98
+
99
+ `backburner` executes the shell commands the AI sends it, with your user's
100
+ permissions. That is its job — but treat it like giving your agent a
101
+ terminal. Run it only with clients whose tool-use you review/approve,
102
+ prefer permission modes that require confirmation for `start_task`, and
103
+ use `BACKBURNER_ALLOW` / `BACKBURNER_DENY` to scope what may run.
104
+
105
+ ## Roadmap
106
+
107
+ - [x] Task timeouts and max-runtime limits
108
+ - [x] Allowlist/denylist for commands
109
+ - [ ] MCP Tasks extension support (spec 2026-07-28) — native `tasks/get`,
110
+ `tasks/cancel` alongside the plain tools
111
+ - [ ] Structured progress reporting (parse % / step markers from output)
112
+ - [ ] PyPI release
113
+
114
+ ## License
115
+
116
+ MIT
@@ -0,0 +1,104 @@
1
+ # backburner
2
+
3
+ **Put your AI agent's slow work on the back burner. Keep cooking.**
4
+
5
+ `backburner` is an MCP server that gives any AI assistant (Claude, and any
6
+ other MCP client) the ability to run long shell commands as **background
7
+ tasks** — start a test suite, a build, a scrape, a batch job — then keep
8
+ working and check back for the results, instead of sitting frozen until
9
+ it finishes.
10
+
11
+ ```
12
+ agent: start_task("pytest -q") -> { task_id: "a1b2c3d4", status: "working" }
13
+ ... agent does other useful work ...
14
+ agent: task_status("a1b2c3d4") -> { status: "completed", duration_seconds: 312 }
15
+ agent: task_result("a1b2c3d4") -> { output: "418 passed in 311.2s" }
16
+ ```
17
+
18
+ ## Why
19
+
20
+ AI agents are bad at waiting. A tool call that takes 10 minutes blocks the
21
+ whole conversation — or times out and loses the work entirely. The MCP
22
+ specification is formalizing a Tasks pattern for exactly this problem
23
+ (extension finalized in the 2026-07-28 spec release); `backburner` brings
24
+ that workflow to every client **today** via plain tools, with first-class
25
+ Tasks-extension support on the roadmap.
26
+
27
+ ## Tools
28
+
29
+ | Tool | What it does |
30
+ |------|--------------|
31
+ | `start_task(command, cwd?, timeout_seconds?)` | Run a shell command in the background, returns a task id immediately |
32
+ | `task_status(task_id)` | `working` / `completed` / `failed` / `cancelled` / `timed_out` / `interrupted` |
33
+ | `task_result(task_id, tail_lines?)` | Captured output — works mid-run too, so you can peek at progress |
34
+ | `cancel_task(task_id)` | Kill the task and its whole process tree |
35
+ | `list_tasks(limit?)` | Recent tasks, newest first |
36
+
37
+ ## Features
38
+
39
+ - **Survives restarts** — tasks are tracked in SQLite under `~/.backburner/`;
40
+ output is captured to per-task log files. If the server dies mid-task,
41
+ orphaned tasks are honestly marked `interrupted`, never silently lost.
42
+ - **Real cancellation** — kills the full process tree (worker processes
43
+ included), on Windows and Unix.
44
+ - **Peek at live progress** — `task_result` on a running task returns the
45
+ output so far.
46
+ - **Timeouts** — pass `timeout_seconds` and a runaway task is killed and
47
+ honestly marked `timed_out` instead of hanging forever.
48
+ - **Command policy** — restrict what the AI may run with environment
49
+ variables (regexes, comma-separated; deny always wins):
50
+
51
+ ```bash
52
+ BACKBURNER_ALLOW="^pytest,^npm (test|run build)" # only these may run
53
+ BACKBURNER_DENY="rm -rf,shutdown,format" # these never run
54
+ ```
55
+ - **Zero infrastructure** — stdlib only (SQLite, subprocess, threads).
56
+ No Redis, no Celery, no Docker.
57
+ - **Tested** — a pytest suite covers the full job lifecycle: completion,
58
+ failure, cancellation, timeouts, crash recovery, and the command policy.
59
+
60
+ ## Install
61
+
62
+ ```bash
63
+ pip install -e .
64
+ ```
65
+
66
+ ### Claude Code
67
+
68
+ ```bash
69
+ claude mcp add backburner -- python -m backburner.server
70
+ ```
71
+
72
+ ### Claude Desktop / other clients
73
+
74
+ ```json
75
+ {
76
+ "mcpServers": {
77
+ "backburner": {
78
+ "command": "python",
79
+ "args": ["-m", "backburner.server"]
80
+ }
81
+ }
82
+ }
83
+ ```
84
+
85
+ ## Security note
86
+
87
+ `backburner` executes the shell commands the AI sends it, with your user's
88
+ permissions. That is its job — but treat it like giving your agent a
89
+ terminal. Run it only with clients whose tool-use you review/approve,
90
+ prefer permission modes that require confirmation for `start_task`, and
91
+ use `BACKBURNER_ALLOW` / `BACKBURNER_DENY` to scope what may run.
92
+
93
+ ## Roadmap
94
+
95
+ - [x] Task timeouts and max-runtime limits
96
+ - [x] Allowlist/denylist for commands
97
+ - [ ] MCP Tasks extension support (spec 2026-07-28) — native `tasks/get`,
98
+ `tasks/cancel` alongside the plain tools
99
+ - [ ] Structured progress reporting (parse % / step markers from output)
100
+ - [ ] PyPI release
101
+
102
+ ## License
103
+
104
+ MIT
File without changes
@@ -0,0 +1,261 @@
1
+ """
2
+ The job engine: start shell commands as background jobs, track them in
3
+ SQLite, capture their output to log files, and cancel them on demand.
4
+
5
+ This is deliberately independent of MCP — it's a plain Python library,
6
+ so it can be tested alone and later exposed through any protocol.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import os
12
+ import re
13
+ import signal
14
+ import sqlite3
15
+ import subprocess
16
+ import sys
17
+ import threading
18
+ import time
19
+ import uuid
20
+ from dataclasses import dataclass
21
+ from pathlib import Path
22
+
23
+ # All job state lives under the user's home dir so it survives restarts
24
+ # and works no matter where the server is launched from.
25
+ DATA_DIR = Path(os.environ.get("BACKBURNER_HOME", Path.home() / ".backburner"))
26
+ LOG_DIR = DATA_DIR / "logs"
27
+ DB_PATH = DATA_DIR / "jobs.db"
28
+
29
+ WORKING = "working"
30
+ COMPLETED = "completed"
31
+ FAILED = "failed"
32
+ CANCELLED = "cancelled"
33
+ TIMED_OUT = "timed_out"
34
+ INTERRUPTED = "interrupted" # was running when the server died
35
+
36
+
37
+ def check_command_allowed(command: str) -> None:
38
+ """Enforce the operator's command policy, set via environment variables.
39
+
40
+ BACKBURNER_DENY — comma-separated regexes; a command matching ANY is refused.
41
+ BACKBURNER_ALLOW — comma-separated regexes; if set, a command must match at
42
+ least one or it is refused. Deny wins over allow.
43
+
44
+ Both are matched case-insensitively anywhere in the command
45
+ (e.g. BACKBURNER_ALLOW="^pytest,^npm (test|run build)").
46
+ Raises PermissionError with a clear message when a command is refused.
47
+ """
48
+ deny = [p.strip() for p in os.environ.get("BACKBURNER_DENY", "").split(",") if p.strip()]
49
+ for pattern in deny:
50
+ if re.search(pattern, command, re.IGNORECASE):
51
+ raise PermissionError(
52
+ f"command refused: matches deny pattern {pattern!r} (BACKBURNER_DENY)"
53
+ )
54
+ allow = [p.strip() for p in os.environ.get("BACKBURNER_ALLOW", "").split(",") if p.strip()]
55
+ if allow and not any(re.search(p, command, re.IGNORECASE) for p in allow):
56
+ raise PermissionError(
57
+ "command refused: does not match any allow pattern (BACKBURNER_ALLOW)"
58
+ )
59
+
60
+
61
+ @dataclass
62
+ class Job:
63
+ id: str
64
+ command: str
65
+ cwd: str
66
+ status: str
67
+ created_at: float
68
+ finished_at: float | None
69
+ exit_code: int | None
70
+ pid: int | None
71
+ log_path: str
72
+
73
+ def to_dict(self) -> dict:
74
+ d = {
75
+ "task_id": self.id,
76
+ "command": self.command,
77
+ "cwd": self.cwd,
78
+ "status": self.status,
79
+ "created_at": time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(self.created_at)),
80
+ }
81
+ if self.status == WORKING:
82
+ d["running_for_seconds"] = round(time.time() - self.created_at)
83
+ if self.finished_at is not None:
84
+ d["duration_seconds"] = round(self.finished_at - self.created_at)
85
+ if self.exit_code is not None:
86
+ d["exit_code"] = self.exit_code
87
+ return d
88
+
89
+
90
+ class JobManager:
91
+ def __init__(self) -> None:
92
+ DATA_DIR.mkdir(parents=True, exist_ok=True)
93
+ LOG_DIR.mkdir(parents=True, exist_ok=True)
94
+ self._lock = threading.Lock()
95
+ self._procs: dict[str, subprocess.Popen] = {}
96
+ self._init_db()
97
+ self._mark_orphans()
98
+
99
+ # ---------------------------------------------------------------- db
100
+ def _db(self) -> sqlite3.Connection:
101
+ conn = sqlite3.connect(DB_PATH)
102
+ conn.row_factory = sqlite3.Row
103
+ return conn
104
+
105
+ def _init_db(self) -> None:
106
+ with self._db() as conn:
107
+ conn.execute(
108
+ """CREATE TABLE IF NOT EXISTS jobs (
109
+ id TEXT PRIMARY KEY,
110
+ command TEXT NOT NULL,
111
+ cwd TEXT NOT NULL,
112
+ status TEXT NOT NULL,
113
+ created_at REAL NOT NULL,
114
+ finished_at REAL,
115
+ exit_code INTEGER,
116
+ pid INTEGER,
117
+ log_path TEXT NOT NULL
118
+ )"""
119
+ )
120
+
121
+ def _mark_orphans(self) -> None:
122
+ # Jobs still marked 'working' from a previous run can't be ours —
123
+ # their monitor threads died with the old process.
124
+ with self._db() as conn:
125
+ conn.execute(
126
+ "UPDATE jobs SET status = ?, finished_at = ? WHERE status = ?",
127
+ (INTERRUPTED, time.time(), WORKING),
128
+ )
129
+
130
+ def _row_to_job(self, row: sqlite3.Row) -> Job:
131
+ return Job(
132
+ id=row["id"], command=row["command"], cwd=row["cwd"],
133
+ status=row["status"], created_at=row["created_at"],
134
+ finished_at=row["finished_at"], exit_code=row["exit_code"],
135
+ pid=row["pid"], log_path=row["log_path"],
136
+ )
137
+
138
+ def _get(self, job_id: str) -> Job:
139
+ with self._db() as conn:
140
+ row = conn.execute("SELECT * FROM jobs WHERE id = ?", (job_id,)).fetchone()
141
+ if row is None:
142
+ raise KeyError(f"no task with id '{job_id}'")
143
+ return self._row_to_job(row)
144
+
145
+ # ------------------------------------------------------------- public
146
+ def start(
147
+ self, command: str, cwd: str | None = None,
148
+ timeout_seconds: float | None = None,
149
+ ) -> Job:
150
+ check_command_allowed(command)
151
+ job_id = uuid.uuid4().hex[:8]
152
+ cwd = str(Path(cwd).resolve()) if cwd else os.getcwd()
153
+ if not Path(cwd).is_dir():
154
+ raise ValueError(f"working directory does not exist: {cwd}")
155
+ if timeout_seconds is not None and timeout_seconds <= 0:
156
+ raise ValueError("timeout_seconds must be positive")
157
+ log_path = str(LOG_DIR / f"{job_id}.log")
158
+
159
+ log_file = open(log_path, "w", encoding="utf-8", errors="replace")
160
+ # Children buffer stdout when it's a file, which would make live
161
+ # peeking useless; force line-buffering where the runtime honors it.
162
+ env = {**os.environ, "PYTHONUNBUFFERED": "1"}
163
+ # New process group so cancel() can kill the command AND any
164
+ # children it spawned (e.g. pytest workers), not just the shell.
165
+ if sys.platform == "win32":
166
+ proc = subprocess.Popen(
167
+ command, shell=True, cwd=cwd, env=env,
168
+ stdout=log_file, stderr=subprocess.STDOUT,
169
+ stdin=subprocess.DEVNULL,
170
+ creationflags=subprocess.CREATE_NEW_PROCESS_GROUP,
171
+ )
172
+ else:
173
+ proc = subprocess.Popen(
174
+ command, shell=True, cwd=cwd, env=env,
175
+ stdout=log_file, stderr=subprocess.STDOUT,
176
+ stdin=subprocess.DEVNULL,
177
+ start_new_session=True,
178
+ )
179
+
180
+ now = time.time()
181
+ with self._db() as conn:
182
+ conn.execute(
183
+ "INSERT INTO jobs VALUES (?, ?, ?, ?, ?, NULL, NULL, ?, ?)",
184
+ (job_id, command, cwd, WORKING, now, proc.pid, log_path),
185
+ )
186
+ with self._lock:
187
+ self._procs[job_id] = proc
188
+
189
+ threading.Thread(
190
+ target=self._watch, args=(job_id, proc, log_file), daemon=True
191
+ ).start()
192
+ if timeout_seconds is not None:
193
+ timer = threading.Timer(
194
+ timeout_seconds, self._finish_early, args=(job_id, TIMED_OUT)
195
+ )
196
+ timer.daemon = True
197
+ timer.start()
198
+ return self._get(job_id)
199
+
200
+ def _watch(self, job_id: str, proc: subprocess.Popen, log_file) -> None:
201
+ exit_code = proc.wait()
202
+ log_file.close()
203
+ with self._lock:
204
+ self._procs.pop(job_id, None)
205
+ with self._db() as conn:
206
+ # Don't overwrite a 'cancelled' status written by cancel().
207
+ conn.execute(
208
+ "UPDATE jobs SET status = CASE WHEN status = ? THEN ? ELSE status END,"
209
+ " finished_at = ?, exit_code = ? WHERE id = ?",
210
+ (WORKING, COMPLETED if exit_code == 0 else FAILED,
211
+ time.time(), exit_code, job_id),
212
+ )
213
+
214
+ def status(self, job_id: str) -> Job:
215
+ return self._get(job_id)
216
+
217
+ def result(self, job_id: str, tail_lines: int = 100) -> dict:
218
+ job = self._get(job_id)
219
+ out = job.to_dict()
220
+ try:
221
+ text = Path(job.log_path).read_text(encoding="utf-8", errors="replace")
222
+ lines = text.splitlines()
223
+ out["output_total_lines"] = len(lines)
224
+ out["output"] = "\n".join(lines[-tail_lines:]) if lines else ""
225
+ except FileNotFoundError:
226
+ out["output"] = ""
227
+ out["output_total_lines"] = 0
228
+ return out
229
+
230
+ def cancel(self, job_id: str) -> Job:
231
+ return self._finish_early(job_id, CANCELLED)
232
+
233
+ def _finish_early(self, job_id: str, final_status: str) -> Job:
234
+ """Kill a working job's process tree and record why (cancel/timeout)."""
235
+ job = self._get(job_id)
236
+ if job.status != WORKING:
237
+ return job # already finished; nothing to do
238
+ with self._db() as conn:
239
+ conn.execute(
240
+ "UPDATE jobs SET status = ?, finished_at = ? WHERE id = ? AND status = ?",
241
+ (final_status, time.time(), job_id, WORKING),
242
+ )
243
+ with self._lock:
244
+ proc = self._procs.get(job_id)
245
+ if proc is not None and proc.poll() is None:
246
+ if sys.platform == "win32":
247
+ # /T kills the whole process tree.
248
+ subprocess.run(
249
+ ["taskkill", "/PID", str(proc.pid), "/T", "/F"],
250
+ capture_output=True,
251
+ )
252
+ else:
253
+ os.killpg(os.getpgid(proc.pid), signal.SIGTERM)
254
+ return self._get(job_id)
255
+
256
+ def list(self, limit: int = 20) -> list[Job]:
257
+ with self._db() as conn:
258
+ rows = conn.execute(
259
+ "SELECT * FROM jobs ORDER BY created_at DESC LIMIT ?", (limit,)
260
+ ).fetchall()
261
+ return [self._row_to_job(r) for r in rows]
@@ -0,0 +1,77 @@
1
+ """
2
+ backburner — an MCP server that lets AI agents run long jobs in the
3
+ background and collect the results later, instead of blocking.
4
+
5
+ Run: python -m backburner.server
6
+ """
7
+
8
+ from mcp.server.fastmcp import FastMCP
9
+
10
+ from backburner.jobs import JobManager
11
+
12
+ mcp = FastMCP(
13
+ "backburner",
14
+ instructions=(
15
+ "Run long shell commands as background tasks. Start a task, keep "
16
+ "working on other things, then poll its status and fetch the output "
17
+ "when it's done. Ideal for test suites, builds, scrapes, batch jobs — "
18
+ "anything too slow to wait for."
19
+ ),
20
+ )
21
+ manager = JobManager()
22
+
23
+
24
+ @mcp.tool()
25
+ def start_task(
26
+ command: str, cwd: str | None = None, timeout_seconds: float | None = None
27
+ ) -> dict:
28
+ """Start a shell command as a background task and return immediately.
29
+
30
+ Args:
31
+ command: The shell command to run (e.g. "pytest -q" or "npm run build").
32
+ cwd: Working directory for the command. Defaults to the server's cwd.
33
+ timeout_seconds: If set, the task is killed and marked 'timed_out'
34
+ when it runs longer than this. Recommended for unattended jobs.
35
+
36
+ Returns the new task's id and initial status. The command keeps running
37
+ after this call returns — use task_status / task_result to follow it.
38
+ """
39
+ return manager.start(command, cwd, timeout_seconds).to_dict()
40
+
41
+
42
+ @mcp.tool()
43
+ def task_status(task_id: str) -> dict:
44
+ """Check on a task: working, completed, failed, cancelled, timed_out, or interrupted."""
45
+ return manager.status(task_id).to_dict()
46
+
47
+
48
+ @mcp.tool()
49
+ def task_result(task_id: str, tail_lines: int = 100) -> dict:
50
+ """Get a task's captured output (stdout+stderr merged).
51
+
52
+ Args:
53
+ task_id: The task to inspect. Works for finished AND still-running
54
+ tasks, so you can peek at live progress.
55
+ tail_lines: How many trailing lines of output to return.
56
+ """
57
+ return manager.result(task_id, tail_lines)
58
+
59
+
60
+ @mcp.tool()
61
+ def cancel_task(task_id: str) -> dict:
62
+ """Cancel a running task, killing its whole process tree."""
63
+ return manager.cancel(task_id).to_dict()
64
+
65
+
66
+ @mcp.tool()
67
+ def list_tasks(limit: int = 20) -> list[dict]:
68
+ """List recent tasks, newest first."""
69
+ return [j.to_dict() for j in manager.list(limit)]
70
+
71
+
72
+ def main() -> None:
73
+ mcp.run()
74
+
75
+
76
+ if __name__ == "__main__":
77
+ main()
@@ -0,0 +1,20 @@
1
+ [project]
2
+ name = "backburner-mcp"
3
+ version = "0.1.0"
4
+ description = "An MCP server that lets AI agents run long jobs in the background and collect results later"
5
+ readme = "README.md"
6
+ requires-python = ">=3.10"
7
+ license = { text = "MIT" }
8
+ authors = [{ name = "Rohit Yajee" }]
9
+ keywords = ["mcp", "model-context-protocol", "background-jobs", "tasks", "agents"]
10
+ dependencies = ["mcp>=1.26.0"]
11
+
12
+ [project.scripts]
13
+ backburner = "backburner.server:main"
14
+
15
+ [build-system]
16
+ requires = ["hatchling"]
17
+ build-backend = "hatchling.build"
18
+
19
+ [tool.hatch.build.targets.wheel]
20
+ packages = ["backburner"]
@@ -0,0 +1,7 @@
1
+ """Point backburner at a throwaway data dir BEFORE the package is imported,
2
+ so tests never touch the real ~/.backburner database."""
3
+
4
+ import os
5
+ import tempfile
6
+
7
+ os.environ["BACKBURNER_HOME"] = tempfile.mkdtemp(prefix="backburner-test-")
@@ -0,0 +1,161 @@
1
+ import sys
2
+ import time
3
+
4
+ import pytest
5
+
6
+ from backburner import jobs
7
+ from backburner.jobs import (
8
+ CANCELLED, COMPLETED, FAILED, INTERRUPTED, TIMED_OUT, WORKING,
9
+ JobManager, check_command_allowed,
10
+ )
11
+
12
+ PY = sys.executable
13
+
14
+
15
+ def wait_until_done(m: JobManager, job_id: str, timeout: float = 20.0):
16
+ deadline = time.time() + timeout
17
+ while time.time() < deadline:
18
+ job = m.status(job_id)
19
+ if job.status != WORKING:
20
+ return job
21
+ time.sleep(0.1)
22
+ pytest.fail(f"job {job_id} still working after {timeout}s")
23
+
24
+
25
+ @pytest.fixture()
26
+ def manager():
27
+ return JobManager()
28
+
29
+
30
+ # --------------------------------------------------------------- lifecycle
31
+ def test_successful_job_completes_with_output(manager):
32
+ job = manager.start(f'{PY} -c "print(\'hello quest\')"')
33
+ assert job.status == WORKING
34
+ done = wait_until_done(manager, job.id)
35
+ assert done.status == COMPLETED
36
+ assert done.exit_code == 0
37
+ result = manager.result(job.id)
38
+ assert "hello quest" in result["output"]
39
+
40
+
41
+ def test_failing_job_reports_failed_and_exit_code(manager):
42
+ job = manager.start(f'{PY} -c "raise SystemExit(3)"')
43
+ done = wait_until_done(manager, job.id)
44
+ assert done.status == FAILED
45
+ assert done.exit_code == 3
46
+
47
+
48
+ def test_cancel_kills_running_job(manager):
49
+ job = manager.start(f'{PY} -c "import time; time.sleep(60)"')
50
+ time.sleep(1.0) # let the process actually start
51
+ manager.cancel(job.id)
52
+ done = wait_until_done(manager, job.id, timeout=10)
53
+ assert done.status == CANCELLED
54
+
55
+
56
+ def test_cancel_finished_job_is_noop(manager):
57
+ job = manager.start(f'{PY} -c "print(1)"')
58
+ done = wait_until_done(manager, job.id)
59
+ again = manager.cancel(job.id)
60
+ assert again.status == done.status == COMPLETED
61
+
62
+
63
+ def test_timeout_marks_job_timed_out(manager):
64
+ job = manager.start(
65
+ f'{PY} -c "import time; time.sleep(60)"', timeout_seconds=2
66
+ )
67
+ done = wait_until_done(manager, job.id, timeout=15)
68
+ assert done.status == TIMED_OUT
69
+
70
+
71
+ def test_fast_job_beats_its_timeout(manager):
72
+ job = manager.start(f'{PY} -c "print(\'quick\')"', timeout_seconds=30)
73
+ done = wait_until_done(manager, job.id)
74
+ assert done.status == COMPLETED
75
+ # the pending timer must NOT later flip a finished job's status
76
+ time.sleep(0.5)
77
+ assert manager.status(job.id).status == COMPLETED
78
+
79
+
80
+ # ------------------------------------------------------------- validation
81
+ def test_unknown_task_id_raises(manager):
82
+ with pytest.raises(KeyError):
83
+ manager.status("nope1234")
84
+
85
+
86
+ def test_bad_cwd_raises(manager):
87
+ with pytest.raises(ValueError):
88
+ manager.start("echo hi", cwd="Z:/definitely/not/a/dir")
89
+
90
+
91
+ def test_nonpositive_timeout_raises(manager):
92
+ with pytest.raises(ValueError):
93
+ manager.start("echo hi", timeout_seconds=0)
94
+
95
+
96
+ # ------------------------------------------------------------ result tail
97
+ def test_result_tail_lines(manager):
98
+ job = manager.start(f'{PY} -c "[print(i) for i in range(50)]"')
99
+ wait_until_done(manager, job.id)
100
+ result = manager.result(job.id, tail_lines=5)
101
+ assert result["output_total_lines"] == 50
102
+ assert result["output"].splitlines() == ["45", "46", "47", "48", "49"]
103
+
104
+
105
+ # ----------------------------------------------------------------- listing
106
+ def test_list_returns_newest_first(manager):
107
+ a = manager.start(f'{PY} -c "print(\'a\')"')
108
+ time.sleep(0.05)
109
+ b = manager.start(f'{PY} -c "print(\'b\')"')
110
+ wait_until_done(manager, a.id)
111
+ wait_until_done(manager, b.id)
112
+ listed = [j.id for j in manager.list(limit=50)]
113
+ assert listed.index(b.id) < listed.index(a.id)
114
+
115
+
116
+ # ------------------------------------------------------------ crash honesty
117
+ def test_orphaned_jobs_marked_interrupted(manager):
118
+ job = manager.start(f'{PY} -c "import time; time.sleep(60)"')
119
+ # Simulate a server restart: a fresh manager finds the 'working' row
120
+ # but owns no process handle for it.
121
+ fresh = JobManager()
122
+ assert fresh.status(job.id).status == INTERRUPTED
123
+ manager.cancel(job.id) # clean up the real process
124
+
125
+
126
+ # ---------------------------------------------------------- command policy
127
+ def test_deny_pattern_blocks_command(monkeypatch):
128
+ monkeypatch.setenv("BACKBURNER_DENY", r"rm\s+-rf,format")
129
+ with pytest.raises(PermissionError, match="deny pattern"):
130
+ check_command_allowed("rm -rf /")
131
+
132
+
133
+ def test_allowlist_blocks_unlisted_command(monkeypatch):
134
+ monkeypatch.setenv("BACKBURNER_ALLOW", r"^pytest,^npm (test|run build)")
135
+ with pytest.raises(PermissionError, match="allow pattern"):
136
+ check_command_allowed("curl http://evil.example")
137
+
138
+
139
+ def test_allowlist_permits_listed_command(monkeypatch):
140
+ monkeypatch.setenv("BACKBURNER_ALLOW", r"^pytest,^npm (test|run build)")
141
+ check_command_allowed("pytest -q tests/")
142
+ check_command_allowed("npm run build")
143
+
144
+
145
+ def test_deny_wins_over_allow(monkeypatch):
146
+ monkeypatch.setenv("BACKBURNER_ALLOW", r".*")
147
+ monkeypatch.setenv("BACKBURNER_DENY", r"shutdown")
148
+ with pytest.raises(PermissionError):
149
+ check_command_allowed("shutdown /s /t 0")
150
+
151
+
152
+ def test_no_policy_allows_anything(monkeypatch):
153
+ monkeypatch.delenv("BACKBURNER_ALLOW", raising=False)
154
+ monkeypatch.delenv("BACKBURNER_DENY", raising=False)
155
+ check_command_allowed("echo unrestricted")
156
+
157
+
158
+ def test_start_enforces_policy(manager, monkeypatch):
159
+ monkeypatch.setenv("BACKBURNER_DENY", "forbidden")
160
+ with pytest.raises(PermissionError):
161
+ manager.start("echo forbidden-thing")
@@ -0,0 +1,28 @@
1
+ """The MCP layer is thin by design; verify the contract it exposes."""
2
+
3
+ import asyncio
4
+
5
+ from backburner.server import mcp
6
+
7
+ EXPECTED_TOOLS = {
8
+ "start_task", "task_status", "task_result", "cancel_task", "list_tasks",
9
+ }
10
+
11
+
12
+ def test_all_tools_registered():
13
+ tools = asyncio.run(mcp.list_tools())
14
+ assert {t.name for t in tools} == EXPECTED_TOOLS
15
+
16
+
17
+ def test_every_tool_has_a_description():
18
+ tools = asyncio.run(mcp.list_tools())
19
+ for tool in tools:
20
+ assert tool.description and len(tool.description) > 20, tool.name
21
+
22
+
23
+ def test_start_task_accepts_timeout_parameter():
24
+ tools = {t.name: t for t in asyncio.run(mcp.list_tools())}
25
+ props = tools["start_task"].inputSchema["properties"]
26
+ assert "timeout_seconds" in props
27
+ assert "command" in props
28
+ assert "cwd" in props