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.
- backburner_mcp-0.1.0/.gitignore +11 -0
- backburner_mcp-0.1.0/LICENSE +21 -0
- backburner_mcp-0.1.0/PKG-INFO +116 -0
- backburner_mcp-0.1.0/README.md +104 -0
- backburner_mcp-0.1.0/backburner/__init__.py +0 -0
- backburner_mcp-0.1.0/backburner/jobs.py +261 -0
- backburner_mcp-0.1.0/backburner/server.py +77 -0
- backburner_mcp-0.1.0/pyproject.toml +20 -0
- backburner_mcp-0.1.0/tests/conftest.py +7 -0
- backburner_mcp-0.1.0/tests/test_jobs.py +161 -0
- backburner_mcp-0.1.0/tests/test_server.py +28 -0
|
@@ -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,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
|