commit 68c418400e6709ad7b02d0f5016e0f6273bc649b Author: Sidney Marvin Fricke Date: Wed Jun 24 11:48:08 2026 +0200 init diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..6e28395 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,16 @@ +services: + mcp-bash: + build: + context: ./server + dockerfile: Dockerfile + container_name: mcp-bash + restart: unless-stopped + ports: + - "8000:8000" + volumes: + # shared workspace: host files are visible inside the PTY sessions + - ./workspace:/workspace + environment: + - PYTHONUNBUFFERED=1 + # tty: true keeps stdin open for ptyprocess inside the container + tty: true diff --git a/mcp-config.json b/mcp-config.json new file mode 100644 index 0000000..5987b26 --- /dev/null +++ b/mcp-config.json @@ -0,0 +1,8 @@ +{ + "mcpServers": { + "bash": { + "type": "http", + "url": "http://localhost:8000/mcp" + } + } +} diff --git a/server/Dockerfile b/server/Dockerfile new file mode 100644 index 0000000..90e3145 --- /dev/null +++ b/server/Dockerfile @@ -0,0 +1,17 @@ +FROM python:3.12-slim + +# tools agents typically need in the PTY +RUN apt-get update && apt-get install -y --no-install-recommends \ + bash curl wget git vim nano htop procps jq less unzip \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /app + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY main.py . + +EXPOSE 8000 + +CMD ["python", "-u", "main.py"] diff --git a/server/main.py b/server/main.py new file mode 100644 index 0000000..598fa26 --- /dev/null +++ b/server/main.py @@ -0,0 +1,394 @@ +""" +MCP HTTP Server — persistent PTY sessions + background jobs +""" + +import asyncio +import os +import re +import signal +import subprocess +import threading +import time +import uuid +from typing import Any + +import pyte +import ptyprocess +import uvicorn +from fastapi import FastAPI, HTTPException +from fastapi.responses import JSONResponse +from pydantic import BaseModel + +app = FastAPI(title="MCP Bash Server", version="1.0.0") + +# ─── Stores ────────────────────────────────────────────────────────────────── + +class PtySession: + def __init__(self, cols: int = 220, rows: int = 50): + self.id = str(uuid.uuid4()) + self.cols = cols + self.rows = rows + self.screen = pyte.Screen(cols, rows) + self.stream = pyte.ByteStream(self.screen) + self._lock = threading.Lock() + self.created_at = time.time() + + self.proc = ptyprocess.PtyProcess.spawn( + ["/bin/bash", "--login"], + dimensions=(rows, cols), + env={**os.environ, "TERM": "xterm-256color", "COLUMNS": str(cols), "LINES": str(rows)}, + ) + self._reader = threading.Thread(target=self._read_loop, daemon=True) + self._reader.start() + + def _read_loop(self): + while self.proc.isalive(): + try: + data = self.proc.read(4096) + except EOFError: + break + with self._lock: + self.stream.feed(data) + + def send(self, text: str): + self.proc.write(text.encode()) + + def snapshot(self) -> dict[str, Any]: + with self._lock: + lines = [] + for y in range(self.rows): + row = "" + for x in range(self.cols): + char = self.screen.buffer[y][x] + row += char.data + lines.append(row.rstrip()) + # trim trailing blank lines + while lines and lines[-1] == "": + lines.pop() + cursor = {"x": self.screen.cursor.x, "y": self.screen.cursor.y} + return {"lines": lines, "cursor": cursor, "alive": self.proc.isalive()} + + def kill(self): + try: + self.proc.terminate(force=True) + except Exception: + pass + + +class BackgroundJob: + def __init__(self, cmd: str): + self.id = str(uuid.uuid4()) + self.cmd = cmd + self.output_lines: list[str] = [] + self.exit_code: int | None = None + self.started_at = time.time() + self.finished_at: float | None = None + self._lock = threading.Lock() + + self._proc = subprocess.Popen( + cmd, + shell=True, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + executable="/bin/bash", + ) + self._reader = threading.Thread(target=self._read_loop, daemon=True) + self._reader.start() + + def _read_loop(self): + for line in self._proc.stdout: + with self._lock: + self.output_lines.append(line.rstrip("\n")) + self._proc.wait() + with self._lock: + self.exit_code = self._proc.returncode + self.finished_at = time.time() + + @property + def status(self) -> str: + if self.exit_code is None: + return "running" + return "done" if self.exit_code == 0 else "failed" + + def output(self, offset: int = 0) -> tuple[list[str], int]: + with self._lock: + return self.output_lines[offset:], len(self.output_lines) + + def kill(self): + try: + self._proc.send_signal(signal.SIGTERM) + except Exception: + pass + + +_pty_sessions: dict[str, PtySession] = {} +_jobs: dict[str, BackgroundJob] = {} + +# ─── MCP Tool Discovery ─────────────────────────────────────────────────────── + +TOOLS = [ + { + "name": "exec", + "description": "Run a shell command and return stdout+stderr synchronously (max 30 s).", + "inputSchema": { + "type": "object", + "properties": { + "cmd": {"type": "string", "description": "Shell command to execute"}, + "timeout": {"type": "integer", "description": "Timeout in seconds (default 30)", "default": 30}, + }, + "required": ["cmd"], + }, + }, + { + "name": "pty_create", + "description": "Open a persistent PTY (interactive terminal) session. Returns session_id.", + "inputSchema": { + "type": "object", + "properties": { + "cols": {"type": "integer", "default": 220}, + "rows": {"type": "integer", "default": 50}, + }, + }, + }, + { + "name": "pty_send", + "description": "Send text/keystrokes to a PTY session. Use \\n for Enter, \\x03 for Ctrl-C, \\x04 for Ctrl-D.", + "inputSchema": { + "type": "object", + "properties": { + "session_id": {"type": "string"}, + "text": {"type": "string", "description": "Raw text to send (escape sequences allowed)"}, + }, + "required": ["session_id", "text"], + }, + }, + { + "name": "pty_read", + "description": "Read the current screen state of a PTY session (rendered terminal grid).", + "inputSchema": { + "type": "object", + "properties": { + "session_id": {"type": "string"}, + "wait_ms": {"type": "integer", "default": 200, "description": "Wait N ms before reading (let output settle)"}, + }, + "required": ["session_id"], + }, + }, + { + "name": "pty_kill", + "description": "Terminate and remove a PTY session.", + "inputSchema": { + "type": "object", + "properties": {"session_id": {"type": "string"}}, + "required": ["session_id"], + }, + }, + { + "name": "pty_list", + "description": "List all open PTY sessions.", + "inputSchema": {"type": "object", "properties": {}}, + }, + { + "name": "job_run", + "description": "Start a long-running background job. Returns job_id immediately.", + "inputSchema": { + "type": "object", + "properties": { + "cmd": {"type": "string", "description": "Shell command to run in background"}, + }, + "required": ["cmd"], + }, + }, + { + "name": "job_output", + "description": "Retrieve output lines of a background job (optionally from an offset).", + "inputSchema": { + "type": "object", + "properties": { + "job_id": {"type": "string"}, + "offset": {"type": "integer", "default": 0, "description": "Start reading from this line index"}, + }, + "required": ["job_id"], + }, + }, + { + "name": "job_status", + "description": "Get status (running/done/failed), exit code, and line count of a background job.", + "inputSchema": { + "type": "object", + "properties": {"job_id": {"type": "string"}}, + "required": ["job_id"], + }, + }, + { + "name": "job_kill", + "description": "Send SIGTERM to a running background job.", + "inputSchema": { + "type": "object", + "properties": {"job_id": {"type": "string"}}, + "required": ["job_id"], + }, + }, + { + "name": "job_list", + "description": "List all known background jobs with their current status.", + "inputSchema": {"type": "object", "properties": {}}, + }, +] + +# ─── MCP Endpoints ──────────────────────────────────────────────────────────── + +@app.get("/") +def root(): + return {"service": "mcp-bash", "version": "1.0.0"} + + +@app.get("/mcp/tools") +def list_tools(): + return {"tools": TOOLS} + + +class ToolCallRequest(BaseModel): + name: str + arguments: dict[str, Any] = {} + + +@app.post("/mcp/call") +async def call_tool(req: ToolCallRequest): + name = req.name + args = req.arguments + + # ── exec ───────────────────────────────────────────────────────────────── + if name == "exec": + cmd = args.get("cmd", "") + timeout = int(args.get("timeout", 30)) + try: + result = subprocess.run( + cmd, shell=True, capture_output=True, text=True, + timeout=timeout, executable="/bin/bash", + ) + return _ok({"stdout": result.stdout, "stderr": result.stderr, "exit_code": result.returncode}) + except subprocess.TimeoutExpired: + return _err(f"Command timed out after {timeout}s") + + # ── pty_create ──────────────────────────────────────────────────────────── + elif name == "pty_create": + cols = int(args.get("cols", 220)) + rows = int(args.get("rows", 50)) + sess = PtySession(cols=cols, rows=rows) + _pty_sessions[sess.id] = sess + await asyncio.sleep(0.3) # let bash prompt appear + return _ok({"session_id": sess.id, "cols": cols, "rows": rows}) + + # ── pty_send ────────────────────────────────────────────────────────────── + elif name == "pty_send": + sess = _get_pty(args.get("session_id", "")) + text = _decode_escapes(args.get("text", "")) + sess.send(text) + return _ok({"sent": len(text)}) + + # ── pty_read ────────────────────────────────────────────────────────────── + elif name == "pty_read": + sess = _get_pty(args.get("session_id", "")) + wait_ms = int(args.get("wait_ms", 200)) + if wait_ms > 0: + await asyncio.sleep(wait_ms / 1000) + snap = sess.snapshot() + return _ok(snap) + + # ── pty_kill ────────────────────────────────────────────────────────────── + elif name == "pty_kill": + sess = _get_pty(args.get("session_id", "")) + sess.kill() + del _pty_sessions[sess.id] + return _ok({"killed": sess.id}) + + # ── pty_list ────────────────────────────────────────────────────────────── + elif name == "pty_list": + return _ok({ + "sessions": [ + {"session_id": sid, "alive": s.proc.isalive(), "created_at": s.created_at} + for sid, s in _pty_sessions.items() + ] + }) + + # ── job_run ─────────────────────────────────────────────────────────────── + elif name == "job_run": + job = BackgroundJob(args.get("cmd", "")) + _jobs[job.id] = job + return _ok({"job_id": job.id, "status": job.status}) + + # ── job_output ──────────────────────────────────────────────────────────── + elif name == "job_output": + job = _get_job(args.get("job_id", "")) + offset = int(args.get("offset", 0)) + lines, total = job.output(offset) + return _ok({"job_id": job.id, "offset": offset, "lines": lines, "total_lines": total}) + + # ── job_status ──────────────────────────────────────────────────────────── + elif name == "job_status": + job = _get_job(args.get("job_id", "")) + _, total = job.output(0) + return _ok({ + "job_id": job.id, "status": job.status, + "exit_code": job.exit_code, + "total_lines": total, + "started_at": job.started_at, "finished_at": job.finished_at, + }) + + # ── job_kill ────────────────────────────────────────────────────────────── + elif name == "job_kill": + job = _get_job(args.get("job_id", "")) + job.kill() + return _ok({"killed": job.id}) + + # ── job_list ────────────────────────────────────────────────────────────── + elif name == "job_list": + return _ok({ + "jobs": [ + {"job_id": jid, "cmd": j.cmd, "status": j.status, + "exit_code": j.exit_code, "total_lines": j.output(0)[1]} + for jid, j in _jobs.items() + ] + }) + + raise HTTPException(status_code=404, detail=f"Unknown tool: {name}") + + +# ─── Helpers ────────────────────────────────────────────────────────────────── + +_ESCAPE_RE = re.compile(r"\\(n|r|t|\\|x[0-9a-fA-F]{2}|u[0-9a-fA-F]{4})") + +def _decode_escapes(text: str) -> str: + """Decode only ASCII escape sequences — leaves UTF-8 text intact.""" + def _replace(m: re.Match) -> str: + s = m.group(0) + return s.encode("utf-8").decode("unicode_escape") + return _ESCAPE_RE.sub(_replace, text) + + +def _ok(data: dict) -> JSONResponse: + return JSONResponse({"ok": True, **data}) + + +def _err(msg: str, status: int = 400) -> JSONResponse: + return JSONResponse({"ok": False, "error": msg}, status_code=status) + + +def _get_pty(session_id: str) -> PtySession: + sess = _pty_sessions.get(session_id) + if not sess: + raise HTTPException(status_code=404, detail=f"PTY session not found: {session_id}") + return sess + + +def _get_job(job_id: str) -> BackgroundJob: + job = _jobs.get(job_id) + if not job: + raise HTTPException(status_code=404, detail=f"Job not found: {job_id}") + return job + + +if __name__ == "__main__": + uvicorn.run(app, host="0.0.0.0", port=8000) diff --git a/server/requirements.txt b/server/requirements.txt new file mode 100644 index 0000000..58facdf --- /dev/null +++ b/server/requirements.txt @@ -0,0 +1,4 @@ +fastapi==0.115.5 +uvicorn[standard]==0.32.1 +pyte==0.8.0 +ptyprocess==0.7.0