mcp-switchboard-server-harness 0.3.0.dev3__py3-none-any.whl

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,3 @@
1
+ """A stdio MCP server exposing filesystem, git and shell tools for coding agents."""
2
+
3
+ __version__ = "0.3.0"
@@ -0,0 +1,3 @@
1
+ from .server import main
2
+
3
+ main()
@@ -0,0 +1,828 @@
1
+ """The harness MCP server.
2
+
3
+ A plain stdio MCP server, so it is tunnelled like any other: reference it from
4
+ an ``mcp.json`` as ``uvx mcp-switchboard-server-harness`` (the client also adds
5
+ it by default). The tool functions are ordinary module-level functions so they
6
+ can be unit-tested directly; everything that runs a subprocess is ``async`` so
7
+ one slow command never stalls the server.
8
+
9
+ **Confinement.** File tools resolve every path (following symlinks) and refuse
10
+ anything outside the *root*, which defaults to the working directory and is set
11
+ with ``--root`` / ``MCP_SWITCHBOARD_HARNESS_ROOT``. That is a guard against
12
+ mistakes and path tricks, not a sandbox: ``run_command``, ``run_python`` and the
13
+ ``process_*`` tools execute arbitrary code as the launching user, and can do
14
+ anything that user can. Only expose the hub's ``/mcp`` endpoints to consumers
15
+ you trust.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import asyncio
21
+ import atexit
22
+ import base64
23
+ import binascii
24
+ import fnmatch
25
+ import functools
26
+ import inspect
27
+ import json
28
+ import os
29
+ import shutil
30
+ import signal
31
+ import subprocess
32
+ import sys
33
+ import tempfile
34
+ import uuid
35
+ from dataclasses import dataclass, field
36
+ from datetime import datetime, timezone
37
+ from pathlib import Path
38
+ from typing import Any, Dict, List, Optional, Tuple
39
+
40
+ from mcp.server.mcpserver import MCPServer
41
+ from mcp.server.mcpserver.exceptions import ToolError
42
+ from mcp_types import ToolAnnotations
43
+ from pydantic import BaseModel, Field
44
+
45
+ from . import __version__
46
+
47
+ ENV_PREFIX = "MCP_SWITCHBOARD_HARNESS_"
48
+ DEFAULT_TIMEOUT = 120.0 # seconds, per command
49
+ MAX_TIMEOUT = 3600.0
50
+ DEFAULT_MAX_OUTPUT = 100_000 # characters returned per stream / file read
51
+ MAX_PROCESSES = 16
52
+ PROCESS_BUFFER_LIMIT = 1_000_000 # characters kept per background stream
53
+ _SKIP_DIRS = {".git", "node_modules", "__pycache__", ".venv"}
54
+
55
+
56
+ # ---------- Configuration and path confinement -----------------------------
57
+
58
+ _root: Path = Path.cwd().resolve()
59
+ _max_output: int = DEFAULT_MAX_OUTPUT
60
+
61
+
62
+ def configure(root: Optional[str] = None, max_output: Optional[int] = None) -> None:
63
+ """Set the confinement root and/or the output cap; None leaves a setting unchanged
64
+ (the root starts as the current directory)."""
65
+ global _root, _max_output
66
+ if root is not None:
67
+ new_root = Path(root).resolve()
68
+ if not new_root.is_dir():
69
+ raise ValueError(f"root is not a directory: {new_root}")
70
+ _root = new_root
71
+ if max_output is not None:
72
+ if max_output < 1:
73
+ raise ValueError("max output must be positive")
74
+ _max_output = max_output
75
+
76
+
77
+ def _resolve(path: str) -> Path:
78
+ """Resolve ``path`` (relative to the root) and ensure it stays inside the root.
79
+
80
+ ``realpath`` semantics: symlinks are followed even for the parts of the path
81
+ that exist, so a link pointing outside the root is refused.
82
+ """
83
+ p = Path(path)
84
+ if not p.is_absolute():
85
+ p = _root / p
86
+ resolved = Path(os.path.realpath(p))
87
+ if resolved != _root and _root not in resolved.parents:
88
+ raise PermissionError(f"{path!r} is outside the allowed root {_root}")
89
+ return resolved
90
+
91
+
92
+ def _rel(p: Path) -> str:
93
+ """A resolved path, shown relative to the root ('.' for the root itself)."""
94
+ try:
95
+ return p.relative_to(_root).as_posix() or "."
96
+ except ValueError:
97
+ return str(p)
98
+
99
+
100
+ def _cap(text: str, limit: Optional[int] = None) -> Tuple[str, bool]:
101
+ limit = _max_output if limit is None else limit
102
+ return (text[:limit], True) if len(text) > limit else (text, False)
103
+
104
+
105
+ # ---------- Subprocess helpers ---------------------------------------------
106
+
107
+
108
+ def _kill_group(proc: "asyncio.subprocess.Process") -> None:
109
+ """Kill the process and everything it spawned (it leads its own session)."""
110
+ try:
111
+ os.killpg(proc.pid, signal.SIGKILL)
112
+ except (ProcessLookupError, PermissionError):
113
+ pass
114
+
115
+
116
+ def _clamp_timeout(timeout: Optional[float]) -> float:
117
+ if timeout is None:
118
+ return DEFAULT_TIMEOUT
119
+ if timeout <= 0:
120
+ raise ValueError("timeout must be positive")
121
+ return min(timeout, MAX_TIMEOUT)
122
+
123
+
124
+ async def _run(
125
+ argv: List[str],
126
+ cwd: Optional[Path] = None,
127
+ input: Optional[str] = None,
128
+ env: Optional[Dict[str, str]] = None,
129
+ timeout: Optional[float] = None,
130
+ ) -> Dict[str, Any]:
131
+ """Run a command to completion; on timeout the whole process group is killed."""
132
+ seconds = _clamp_timeout(timeout)
133
+ try:
134
+ proc = await asyncio.create_subprocess_exec(
135
+ *argv,
136
+ cwd=str(cwd or _root),
137
+ env=env,
138
+ stdin=asyncio.subprocess.PIPE if input is not None else asyncio.subprocess.DEVNULL,
139
+ stdout=asyncio.subprocess.PIPE,
140
+ stderr=asyncio.subprocess.PIPE,
141
+ start_new_session=True,
142
+ )
143
+ except FileNotFoundError as e:
144
+ raise RuntimeError(f"command not found: {argv[0]}") from e
145
+ try:
146
+ out, err = await asyncio.wait_for(
147
+ proc.communicate(input.encode() if input is not None else None), seconds
148
+ )
149
+ except asyncio.TimeoutError as e:
150
+ _kill_group(proc)
151
+ await proc.wait()
152
+ raise RuntimeError(f"command timed out after {seconds:g}s: {argv[0]}") from e
153
+ except asyncio.CancelledError:
154
+ _kill_group(proc)
155
+ raise
156
+ return {
157
+ "returncode": proc.returncode,
158
+ "stdout": out.decode(errors="replace"),
159
+ "stderr": err.decode(errors="replace"),
160
+ }
161
+
162
+
163
+ def _atomic_write(path: Path, data: bytes) -> None:
164
+ """Write via a temp file in the same directory and ``os.replace``, so readers
165
+ (and a crash) never see a half-written file. Keeps an existing file's mode."""
166
+ path.parent.mkdir(parents=True, exist_ok=True)
167
+ fd, tmp = tempfile.mkstemp(dir=path.parent, prefix=f".{path.name}.")
168
+ try:
169
+ with os.fdopen(fd, "wb") as f:
170
+ f.write(data)
171
+ if path.exists():
172
+ shutil.copymode(path, tmp)
173
+ os.replace(tmp, path)
174
+ except BaseException:
175
+ try:
176
+ os.unlink(tmp)
177
+ except FileNotFoundError:
178
+ pass
179
+ raise
180
+
181
+
182
+ # ---------- Result models (advertised as output schemas) --------------------
183
+
184
+
185
+ class CommandResult(BaseModel):
186
+ stdout: str
187
+ stderr: str
188
+ exit_code: int
189
+ return_value: Optional[Dict[str, Any]] = Field(
190
+ default=None, description="Reserved for structured results; currently always null."
191
+ )
192
+ truncated: bool = Field(default=False, description="stdout or stderr was cut at the output limit.")
193
+ stdout_total: int = Field(default=0, description="Full length of stdout in characters, before truncation.")
194
+ stderr_total: int = Field(default=0, description="Full length of stderr in characters, before truncation.")
195
+
196
+
197
+ class FileReadResult(BaseModel):
198
+ content: Optional[str] = Field(default=None, description="Base64 of the bytes read; set when binary=true.")
199
+ raw_text: Optional[str] = Field(default=None, description="UTF-8 text read; set when binary=false.")
200
+ size: int = Field(description="Total size of the file in bytes.")
201
+ offset: int = Field(description="Where the read started (bytes for binary, characters for text).")
202
+ truncated: bool = Field(description="More data remains after this chunk; read again with a larger offset.")
203
+
204
+
205
+ class FileWriteResult(BaseModel):
206
+ success: bool
207
+ message: str
208
+
209
+
210
+ class FileDeleteResult(BaseModel):
211
+ deleted: bool
212
+ message: str
213
+
214
+
215
+ class DirEntry(BaseModel):
216
+ name: str
217
+ path: str = Field(description="Relative to the root.")
218
+ size: int
219
+ is_dir: bool
220
+ mtime: Optional[str] = Field(default=None, description="Modification time, ISO 8601 UTC.")
221
+
222
+
223
+ class DirListResult(BaseModel):
224
+ entries: List[DirEntry]
225
+
226
+
227
+ class FileMoveResult(BaseModel):
228
+ moved: bool
229
+ message: str
230
+
231
+
232
+ class GrepMatch(BaseModel):
233
+ file: str
234
+ line_no: int
235
+ text: str
236
+ is_match: bool = Field(description="False for a surrounding context line.")
237
+
238
+
239
+ class GrepResult(BaseModel):
240
+ matches: List[GrepMatch]
241
+ truncated: bool = Field(description="max_results was reached; narrow the search.")
242
+
243
+
244
+ class ProcessStarted(BaseModel):
245
+ id: str
246
+ pid: int
247
+
248
+
249
+ class ProcessOutput(BaseModel):
250
+ id: str
251
+ stdout: str = Field(description="New output since the previous read.")
252
+ stderr: str
253
+ running: bool
254
+ exit_code: Optional[int] = None
255
+ dropped: bool = Field(default=False, description="Older output was discarded because the buffer filled.")
256
+
257
+
258
+ class ProcessKilled(BaseModel):
259
+ id: str
260
+ killed: bool
261
+ message: str
262
+
263
+
264
+ # ---------- Shell, files ---------------------------------------------------
265
+
266
+
267
+ async def run_command(
268
+ command: str,
269
+ cwd: Optional[str] = None,
270
+ env: Optional[Dict[str, str]] = None,
271
+ timeout: Optional[float] = None,
272
+ ) -> CommandResult:
273
+ """Execute a shell command (via `bash -c`). cwd defaults to the root; env entries override the inherited environment; timeout is in seconds (default 120, max 3600) and kills the command and its children."""
274
+ merged = {**os.environ, **env} if env else None
275
+ result = await _run(
276
+ ["bash", "-c", command], cwd=_resolve(cwd) if cwd else None, env=merged, timeout=timeout
277
+ )
278
+ out, out_cut = _cap(result["stdout"])
279
+ err, err_cut = _cap(result["stderr"])
280
+ return CommandResult(
281
+ stdout=out,
282
+ stderr=err,
283
+ exit_code=result["returncode"],
284
+ truncated=out_cut or err_cut,
285
+ stdout_total=len(result["stdout"]),
286
+ stderr_total=len(result["stderr"]),
287
+ )
288
+
289
+
290
+ def file_read(
291
+ path: str, binary: bool = False, offset: int = 0, limit: Optional[int] = None
292
+ ) -> FileReadResult:
293
+ """Read a file. With binary=true the bytes come back base64-encoded in `content`; otherwise UTF-8 text in `raw_text`. Output is capped (default 100000): use offset and limit (bytes for binary, characters for text) to page through large files."""
294
+ p = _resolve(path)
295
+ if offset < 0:
296
+ raise ValueError("offset must not be negative")
297
+ want = _max_output if limit is None else min(limit, _max_output)
298
+ if want < 1:
299
+ raise ValueError("limit must be positive")
300
+ size = p.stat().st_size
301
+ if binary:
302
+ with open(p, "rb") as f:
303
+ f.seek(offset)
304
+ chunk = f.read(want)
305
+ return FileReadResult(
306
+ content=base64.b64encode(chunk).decode("ascii"),
307
+ size=size,
308
+ offset=offset,
309
+ truncated=offset + len(chunk) < size,
310
+ )
311
+ text = p.read_text()
312
+ chunk = text[offset : offset + want]
313
+ return FileReadResult(
314
+ raw_text=chunk, size=size, offset=offset, truncated=offset + len(chunk) < len(text)
315
+ )
316
+
317
+
318
+ def file_write(path: str, content: str, append: bool = False, binary: bool = False) -> FileWriteResult:
319
+ """Write a file, creating parent directories. With binary=true, `content` is base64 and is decoded to bytes; otherwise it is written as UTF-8 text. Overwrites are atomic; append=true appends instead."""
320
+ p = _resolve(path)
321
+ try:
322
+ data = base64.b64decode(content, validate=True) if binary else content.encode()
323
+ except (binascii.Error, ValueError) as e:
324
+ return FileWriteResult(success=False, message=f"content is not valid base64: {e}")
325
+ try:
326
+ if append:
327
+ p.parent.mkdir(parents=True, exist_ok=True)
328
+ with open(p, "ab") as f:
329
+ f.write(data)
330
+ else:
331
+ _atomic_write(p, data)
332
+ except OSError as e:
333
+ return FileWriteResult(success=False, message=str(e))
334
+ return FileWriteResult(success=True, message=f"{'appended' if append else 'wrote'} {len(data)} bytes to {path}")
335
+
336
+
337
+ def file_delete(path: str, recursive: bool = True) -> FileDeleteResult:
338
+ """Delete a file, symlink or directory. Directories need recursive=true unless empty. A missing path is reported, not raised. The root itself cannot be deleted."""
339
+ lexical = Path(os.path.normpath(Path(path) if Path(path).is_absolute() else _root / path))
340
+ if lexical == _root:
341
+ return FileDeleteResult(deleted=False, message="refusing to delete the root")
342
+ p = _resolve(str(lexical.parent)) / lexical.name # resolve the parent only: deleting a symlink removes the link
343
+ try:
344
+ if p.is_symlink() or p.is_file():
345
+ p.unlink()
346
+ elif p.is_dir():
347
+ if recursive:
348
+ shutil.rmtree(p)
349
+ else:
350
+ p.rmdir()
351
+ else:
352
+ return FileDeleteResult(deleted=False, message=f"{path} does not exist")
353
+ except OSError as e:
354
+ return FileDeleteResult(deleted=False, message=str(e))
355
+ return FileDeleteResult(deleted=True, message=f"deleted {path}")
356
+
357
+
358
+ def _entry(p: Path) -> DirEntry:
359
+ st = p.lstat()
360
+ return DirEntry(
361
+ name=p.name,
362
+ path=_rel(p),
363
+ size=st.st_size,
364
+ is_dir=p.is_dir() and not p.is_symlink(),
365
+ mtime=datetime.fromtimestamp(st.st_mtime, tz=timezone.utc).isoformat(),
366
+ )
367
+
368
+
369
+ def dir_list(path: str, recursive: bool = False) -> DirListResult:
370
+ """List a directory's entries with size, type and mtime; recursive=true descends into sub-folders (symlinks are not followed)."""
371
+ base = _resolve(path)
372
+ if not base.is_dir():
373
+ raise NotADirectoryError(path)
374
+ if recursive:
375
+ paths: List[Path] = []
376
+ for dirpath, dirnames, filenames in os.walk(base):
377
+ dirnames.sort()
378
+ paths.extend(Path(dirpath) / n for n in [*dirnames, *sorted(filenames)])
379
+ else:
380
+ paths = sorted(base.iterdir())
381
+ return DirListResult(entries=[_entry(p) for p in paths])
382
+
383
+
384
+ def file_move(src: str, dst: str) -> FileMoveResult:
385
+ """Move or rename a file or directory. Refuses to overwrite an existing destination; creates missing parent directories."""
386
+ s, d = _resolve(src), _resolve(dst)
387
+ if not os.path.lexists(s):
388
+ return FileMoveResult(moved=False, message=f"{src} does not exist")
389
+ if os.path.lexists(d):
390
+ return FileMoveResult(moved=False, message=f"{dst} already exists")
391
+ try:
392
+ d.parent.mkdir(parents=True, exist_ok=True)
393
+ shutil.move(str(s), str(d))
394
+ except OSError as e:
395
+ return FileMoveResult(moved=False, message=str(e))
396
+ return FileMoveResult(moved=True, message=f"moved {src} to {dst}")
397
+
398
+
399
+ # ---------- Reading and searching -------------------------------------------
400
+
401
+
402
+ def tree_of_files(root: str = ".") -> Dict[str, Any]:
403
+ """Nested view of a directory tree: each directory maps to its subdirectories, with its files under "__files__"; skips .git, node_modules, __pycache__ and .venv."""
404
+ base = _resolve(root)
405
+ tree: Dict[str, Any] = {}
406
+ for dirpath, dirnames, filenames in os.walk(base):
407
+ dirnames[:] = sorted(d for d in dirnames if d not in _SKIP_DIRS)
408
+ node = tree
409
+ for part in Path(dirpath).relative_to(base).parts:
410
+ node = node.setdefault(part, {})
411
+ node["__files__"] = sorted(filenames)
412
+ return tree
413
+
414
+
415
+ async def _tracked_files(base: Path) -> Optional[List[Path]]:
416
+ """Files git knows about (tracked plus untracked-not-ignored) under ``base``,
417
+ or None if ``base`` is not inside a git work tree."""
418
+ try:
419
+ result = await _run(
420
+ ["git", "ls-files", "--cached", "--others", "--exclude-standard", "-z"], cwd=base
421
+ )
422
+ except RuntimeError:
423
+ return None
424
+ if result["returncode"] != 0:
425
+ return None
426
+ return [base / name for name in result["stdout"].split("\0") if name]
427
+
428
+
429
+ async def find_files(pattern: str, root: str = ".", limit: int = 1000) -> List[str]:
430
+ """Find files under root whose path or name matches a glob such as "**/*.py". Inside a git repository .gitignore is honoured; otherwise .git, node_modules, __pycache__ and .venv are skipped. Returns at most `limit` root-relative paths, sorted."""
431
+ base = _resolve(root)
432
+ candidates = await _tracked_files(base)
433
+ if candidates is None:
434
+ candidates = []
435
+ for dirpath, dirnames, filenames in os.walk(base):
436
+ dirnames[:] = [d for d in dirnames if d not in _SKIP_DIRS]
437
+ candidates.extend(Path(dirpath) / n for n in filenames)
438
+ found = []
439
+ for p in candidates:
440
+ rel = p.relative_to(base).as_posix()
441
+ if fnmatch.fnmatch(rel, pattern) or fnmatch.fnmatch(p.name, pattern):
442
+ if p.is_file():
443
+ found.append(_rel(p))
444
+ return sorted(found)[: max(limit, 0)]
445
+
446
+
447
+ async def ripgrep(
448
+ query: str,
449
+ path: str = ".",
450
+ glob: Optional[str] = None,
451
+ ignore_case: bool = False,
452
+ context: int = 0,
453
+ max_results: int = 200,
454
+ ) -> GrepResult:
455
+ """Search file contents with ripgrep (must be installed). glob filters files (e.g. "*.py"), context adds N surrounding lines per match, and at most max_results lines are returned (`truncated` says if more existed)."""
456
+ target = _resolve(path)
457
+ argv = ["rg", "--json"]
458
+ if ignore_case:
459
+ argv.append("--ignore-case")
460
+ if glob:
461
+ argv += ["--glob", glob]
462
+ if context > 0:
463
+ argv += ["--context", str(min(context, 20))]
464
+ argv += ["--", query, str(target)]
465
+ result = await _run(argv)
466
+ if result["returncode"] not in (0, 1): # 1 means no matches
467
+ raise RuntimeError(result["stderr"].strip() or "ripgrep failed")
468
+ matches: List[GrepMatch] = []
469
+ truncated = False
470
+ for line in result["stdout"].splitlines():
471
+ try:
472
+ obj = json.loads(line)
473
+ except ValueError:
474
+ continue
475
+ if obj.get("type") not in ("match", "context"):
476
+ continue
477
+ if len(matches) >= max_results:
478
+ truncated = True
479
+ break
480
+ data = obj["data"]
481
+ matches.append(
482
+ GrepMatch(
483
+ file=_rel(Path(data["path"].get("text", ""))),
484
+ line_no=data["line_number"],
485
+ text=data["lines"].get("text", "").rstrip("\n"),
486
+ is_match=obj["type"] == "match",
487
+ )
488
+ )
489
+ return GrepResult(matches=matches, truncated=truncated)
490
+
491
+
492
+ def read_lines(path: str, start: int = 1, end: Optional[int] = None) -> List[str]:
493
+ """Lines start..end (1-based, inclusive) of a file; end defaults to the last line."""
494
+ lines = _resolve(path).read_text().splitlines()
495
+ return lines[max(start, 1) - 1 : end]
496
+
497
+
498
+ def edit_file(
499
+ path: str,
500
+ new_content: Optional[str] = None,
501
+ old_str: Optional[str] = None,
502
+ new_str: Optional[str] = None,
503
+ replace_all: bool = False,
504
+ ) -> str:
505
+ """Edit an existing file: either overwrite it with new_content, or replace old_str with new_str. old_str must occur exactly once unless replace_all is true (an ambiguous match is an error, so the wrong spot is never edited silently). Writes are atomic."""
506
+ p = _resolve(path)
507
+ if not p.is_file():
508
+ raise FileNotFoundError(str(path))
509
+ if new_content is not None:
510
+ _atomic_write(p, new_content.encode())
511
+ return "wrote full content"
512
+ if old_str is None or new_str is None:
513
+ raise ValueError("provide new_content, or both old_str and new_str")
514
+ if not old_str:
515
+ raise ValueError("old_str must not be empty")
516
+ text = p.read_text()
517
+ count = text.count(old_str)
518
+ if count == 0:
519
+ raise ValueError(f"{old_str!r} not found in {path}")
520
+ if count > 1 and not replace_all:
521
+ raise ValueError(
522
+ f"{old_str!r} occurs {count} times in {path}; add context to make it unique or set replace_all"
523
+ )
524
+ _atomic_write(p, text.replace(old_str, new_str).encode())
525
+ return f"replaced {count} occurrence{'s' if count != 1 else ''}"
526
+
527
+
528
+ async def run_python(code: str, timeout: Optional[float] = None) -> Dict[str, Any]:
529
+ """Run a Python snippet in a fresh interpreter (cwd = the root) and return {returncode, stdout, stderr}. Output is capped like run_command."""
530
+ result = await _run([sys.executable, "-"], input=code, timeout=timeout)
531
+ result["stdout"], _ = _cap(result["stdout"])
532
+ result["stderr"], _ = _cap(result["stderr"])
533
+ return result
534
+
535
+
536
+ # ---------- Git -------------------------------------------------------------
537
+
538
+
539
+ def _ref(value: str, what: str) -> str:
540
+ if not value or value.startswith("-"):
541
+ raise ValueError(f"invalid {what}: {value!r}")
542
+ return value
543
+
544
+
545
+ def _git_paths(paths: Optional[List[str]]) -> List[str]:
546
+ return ["--", *(_rel(_resolve(p)) for p in paths)] if paths else []
547
+
548
+
549
+ async def _git(*args: str) -> str:
550
+ result = await _run(["git", *args])
551
+ if result["returncode"] != 0:
552
+ raise RuntimeError(result["stderr"].strip() or result["stdout"].strip() or "git failed")
553
+ text, cut = _cap((result["stdout"] or result["stderr"]).strip())
554
+ return text + ("\n[output truncated]" if cut else "")
555
+
556
+
557
+ async def git_status() -> str:
558
+ """Short `git status` of the root's repository."""
559
+ return await _git("status", "--short")
560
+
561
+
562
+ async def git_add(files: List[str]) -> str:
563
+ """Stage the given files."""
564
+ return await _git("add", "--", *(_rel(_resolve(f)) for f in files))
565
+
566
+
567
+ async def git_log(limit: int = 10) -> str:
568
+ """The most recent commits, one per line."""
569
+ return await _git("log", f"-n{int(limit)}", "--pretty=format:%h %s")
570
+
571
+
572
+ async def git_diff(
573
+ staged: bool = False, paths: Optional[List[str]] = None, rev: Optional[str] = None
574
+ ) -> str:
575
+ """Show changes as a unified diff. Default: unstaged changes; staged=true: what would be committed; rev: changes of the working tree relative to that revision. Optionally limited to paths."""
576
+ args = ["diff"]
577
+ if staged:
578
+ args.append("--cached")
579
+ if rev:
580
+ args.append(_ref(rev, "rev"))
581
+ return await _git(*args, *_git_paths(paths))
582
+
583
+
584
+ async def git_show(rev: str = "HEAD", stat_only: bool = False) -> str:
585
+ """Show a commit (message and diff); stat_only=true shows just the changed-files summary."""
586
+ args = ["show", _ref(rev, "rev")]
587
+ if stat_only:
588
+ args.append("--stat")
589
+ return await _git(*args)
590
+
591
+
592
+ async def git_branch() -> str:
593
+ """List local branches (the current one is starred)."""
594
+ return await _git("branch", "--list", "--verbose")
595
+
596
+
597
+ async def git_checkout(ref: str, create: bool = False) -> str:
598
+ """Switch to a branch or revision; create=true makes a new branch first. Fails rather than discarding uncommitted changes that would be overwritten."""
599
+ args = ["checkout"]
600
+ if create:
601
+ args.append("-b")
602
+ return await _git(*args, _ref(ref, "ref"))
603
+
604
+
605
+ async def git_commit(message: str) -> str:
606
+ """Commit the staged changes with the given message."""
607
+ return await _git("commit", "-m", message)
608
+
609
+
610
+ async def git_push() -> str:
611
+ """Push the current branch."""
612
+ return await _git("push")
613
+
614
+
615
+ # ---------- Long-running processes ------------------------------------------
616
+
617
+
618
+ @dataclass
619
+ class _Managed:
620
+ proc: "asyncio.subprocess.Process"
621
+ out: str = ""
622
+ err: str = ""
623
+ dropped: bool = False
624
+ tasks: List["asyncio.Task[None]"] = field(default_factory=list)
625
+
626
+
627
+ _processes: Dict[str, _Managed] = {}
628
+
629
+
630
+ def _kill_all_processes() -> None:
631
+ for m in _processes.values():
632
+ _kill_group(m.proc)
633
+
634
+
635
+ atexit.register(_kill_all_processes)
636
+
637
+
638
+ async def _pump(m: _Managed, stream: "asyncio.StreamReader", attr: str) -> None:
639
+ while True:
640
+ chunk = await stream.read(4096)
641
+ if not chunk:
642
+ return
643
+ text = getattr(m, attr) + chunk.decode(errors="replace")
644
+ if len(text) > PROCESS_BUFFER_LIMIT:
645
+ text = text[-PROCESS_BUFFER_LIMIT:]
646
+ m.dropped = True
647
+ setattr(m, attr, text)
648
+
649
+
650
+ def _managed(id: str) -> _Managed:
651
+ try:
652
+ return _processes[id]
653
+ except KeyError:
654
+ raise ValueError(f"unknown process id {id!r}") from None
655
+
656
+
657
+ async def process_start(
658
+ command: str, cwd: Optional[str] = None, env: Optional[Dict[str, str]] = None
659
+ ) -> ProcessStarted:
660
+ """Start a long-running shell command in the background (dev server, watcher, slow build) and return its id. Read its output with process_read, stop it with process_kill. At most 16 at once; all are killed when the harness exits."""
661
+ for pid, m in list(_processes.items()): # forget finished, fully-read processes
662
+ if m.proc.returncode is not None and not m.out and not m.err:
663
+ del _processes[pid]
664
+ if len(_processes) >= MAX_PROCESSES:
665
+ raise RuntimeError(f"too many background processes ({MAX_PROCESSES}); kill one first")
666
+ merged = {**os.environ, **env} if env else None
667
+ proc = await asyncio.create_subprocess_exec(
668
+ "bash",
669
+ "-c",
670
+ command,
671
+ cwd=str(_resolve(cwd) if cwd else _root),
672
+ env=merged,
673
+ stdin=asyncio.subprocess.DEVNULL,
674
+ stdout=asyncio.subprocess.PIPE,
675
+ stderr=asyncio.subprocess.PIPE,
676
+ start_new_session=True,
677
+ )
678
+ m = _Managed(proc)
679
+ m.tasks = [
680
+ asyncio.ensure_future(_pump(m, proc.stdout, "out")),
681
+ asyncio.ensure_future(_pump(m, proc.stderr, "err")),
682
+ ]
683
+ pid = uuid.uuid4().hex[:8]
684
+ _processes[pid] = m
685
+ return ProcessStarted(id=pid, pid=proc.pid)
686
+
687
+
688
+ async def process_read(id: str, wait: float = 0.0) -> ProcessOutput:
689
+ """Return the output a background process produced since the last read, and whether it is still running (with its exit code once done). wait (seconds, max 30) pauses first so a command can produce output."""
690
+ m = _managed(id)
691
+ if wait > 0:
692
+ try:
693
+ await asyncio.wait_for(asyncio.shield(m.proc.wait()), min(wait, 30.0))
694
+ except asyncio.TimeoutError:
695
+ pass
696
+ if m.proc.returncode is not None:
697
+ await asyncio.gather(*m.tasks, return_exceptions=True) # drain what is left
698
+ out, m.out = _cap(m.out)[0], m.out[_max_output:]
699
+ err, m.err = _cap(m.err)[0], m.err[_max_output:]
700
+ dropped, m.dropped = m.dropped, False
701
+ return ProcessOutput(
702
+ id=id, stdout=out, stderr=err, running=m.proc.returncode is None,
703
+ exit_code=m.proc.returncode, dropped=dropped,
704
+ )
705
+
706
+
707
+ async def process_kill(id: str) -> ProcessKilled:
708
+ """Kill a background process and everything it spawned. Unread output stays available to process_read."""
709
+ m = _managed(id)
710
+ if m.proc.returncode is not None:
711
+ return ProcessKilled(id=id, killed=False, message=f"already exited with code {m.proc.returncode}")
712
+ _kill_group(m.proc)
713
+ await m.proc.wait()
714
+ await asyncio.gather(*m.tasks, return_exceptions=True)
715
+ return ProcessKilled(id=id, killed=True, message="killed")
716
+
717
+
718
+ # ---------- Registration ------------------------------------------------------
719
+
720
+ # (function, readOnly, destructive, idempotent, openWorld). Clients use these
721
+ # hints to auto-approve reads and to confirm before anything destructive.
722
+ TOOLS: List[Tuple[Any, ToolAnnotations]] = [
723
+ (fn, ToolAnnotations(readOnlyHint=ro, destructiveHint=None if ro else destructive, idempotentHint=idem, openWorldHint=open_world))
724
+ for fn, ro, destructive, idem, open_world in [
725
+ # files
726
+ (file_read, True, False, True, False),
727
+ (dir_list, True, False, True, False),
728
+ (tree_of_files, True, False, True, False),
729
+ (find_files, True, False, True, False),
730
+ (ripgrep, True, False, True, False),
731
+ (read_lines, True, False, True, False),
732
+ (file_write, False, True, False, False), # overwrites
733
+ (edit_file, False, True, False, False),
734
+ (file_move, False, False, False, False), # never overwrites
735
+ (file_delete, False, True, True, False),
736
+ # code execution: anything can happen
737
+ (run_command, False, True, False, True),
738
+ (run_python, False, True, False, True),
739
+ (process_start, False, True, False, True),
740
+ (process_read, True, False, False, False), # consumes buffered output
741
+ (process_kill, False, True, True, False),
742
+ # git
743
+ (git_status, True, False, True, False),
744
+ (git_log, True, False, True, False),
745
+ (git_diff, True, False, True, False),
746
+ (git_show, True, False, True, False),
747
+ (git_branch, True, False, True, False),
748
+ (git_add, False, False, True, False),
749
+ (git_checkout, False, True, False, False),
750
+ (git_commit, False, False, False, False),
751
+ (git_push, False, True, False, True), # publishes to a remote
752
+ ]
753
+ ]
754
+
755
+
756
+ _EXPECTED_ERRORS = (OSError, ValueError, RuntimeError, UnicodeError)
757
+
758
+
759
+ def _report_errors(fn: Any) -> Any:
760
+ """Turn the errors a tool raises on purpose into ``ToolError``.
761
+
762
+ The SDK hides the message of any other exception from the model ("Error
763
+ executing tool X"), which would throw away exactly the reason it needs
764
+ ("outside the allowed root", "occurs 2 times"). ``ToolError`` is delivered
765
+ as the error text of the result. The wrapper keeps the signature (and
766
+ async-ness), so the advertised schemas are unchanged.
767
+ """
768
+
769
+ def describe(e: Exception) -> ToolError:
770
+ return ToolError(f"{type(e).__name__}: {e}" if not isinstance(e, RuntimeError) else str(e))
771
+
772
+ if inspect.iscoroutinefunction(fn):
773
+
774
+ @functools.wraps(fn)
775
+ async def wrapper(*args: Any, **kwargs: Any) -> Any:
776
+ try:
777
+ return await fn(*args, **kwargs)
778
+ except _EXPECTED_ERRORS as e:
779
+ raise describe(e) from e
780
+
781
+ else:
782
+
783
+ @functools.wraps(fn)
784
+ def wrapper(*args: Any, **kwargs: Any) -> Any:
785
+ try:
786
+ return fn(*args, **kwargs)
787
+ except _EXPECTED_ERRORS as e:
788
+ raise describe(e) from e
789
+
790
+ return wrapper
791
+
792
+
793
+ def build_server(name: str = "harness") -> MCPServer:
794
+ server = MCPServer(name, version=__version__)
795
+ for fn, annotations in TOOLS:
796
+ server.tool(annotations=annotations)(_report_errors(fn))
797
+ return server
798
+
799
+
800
+ def main() -> None:
801
+ import argparse
802
+
803
+ parser = argparse.ArgumentParser(description="MCP harness server (stdio)")
804
+ parser.add_argument("--name", default="harness")
805
+ parser.add_argument(
806
+ "--root",
807
+ default=os.environ.get(f"{ENV_PREFIX}ROOT"),
808
+ help=(
809
+ "Directory that file tools are confined to (default: the current directory, "
810
+ f"or {ENV_PREFIX}ROOT). Use / to lift the restriction."
811
+ ),
812
+ )
813
+ parser.add_argument(
814
+ "--max-output",
815
+ type=int,
816
+ default=int(os.environ.get(f"{ENV_PREFIX}MAX_OUTPUT", DEFAULT_MAX_OUTPUT)),
817
+ help=f"Cap, in characters, on each output stream or file read (default {DEFAULT_MAX_OUTPUT})",
818
+ )
819
+ args = parser.parse_args()
820
+ try:
821
+ configure(args.root, args.max_output)
822
+ except ValueError as e:
823
+ parser.error(str(e))
824
+ build_server(args.name).run("stdio")
825
+
826
+
827
+ if __name__ == "__main__":
828
+ main()
@@ -0,0 +1,60 @@
1
+ Metadata-Version: 2.5
2
+ Name: mcp-switchboard-server-harness
3
+ Version: 0.3.0.dev3
4
+ Summary: A stdio MCP server exposing filesystem, git and shell tools for coding agents
5
+ Author: Akos Papp
6
+ License: MIT
7
+ Keywords: coding-agent,mcp,model-context-protocol,switchboard
8
+ Classifier: License :: OSI Approved :: MIT License
9
+ Classifier: Operating System :: OS Independent
10
+ Classifier: Programming Language :: Python :: 3
11
+ Requires-Python: >=3.10
12
+ Requires-Dist: mcp<3,>=2.2
13
+ Provides-Extra: test
14
+ Requires-Dist: pytest>=8; extra == 'test'
15
+ Description-Content-Type: text/markdown
16
+
17
+ # mcp-switchboard-server-harness
18
+
19
+ A first-party stdio MCP server that gives a coding agent the basics on a
20
+ remote machine: filesystem, search, git and shell tools. It is a normal MCP
21
+ server, so [mcp-switchboard-client](../../client) tunnels it like any other.
22
+
23
+ Tools (24; full schemas and behaviour in [spec.md](../../spec.md#4-harness-server)):
24
+
25
+ - **Files:** `file_read` (paged), `file_write`, `file_delete`, `file_move`, `dir_list`, `tree_of_files`,
26
+ `find_files` (honours `.gitignore`), `ripgrep`, `read_lines`, `edit_file`
27
+ - **Run:** `run_command`, `run_python`, and `process_start` / `process_read` / `process_kill` for
28
+ long-running work
29
+ - **Git:** `git_status`, `git_diff`, `git_show`, `git_log`, `git_branch`, `git_add`, `git_checkout`,
30
+ `git_commit`, `git_push`
31
+
32
+ Every tool carries read-only / destructive annotations, output is capped and always flagged when
33
+ truncated, and commands run under a timeout that kills their whole process group.
34
+
35
+ File tools are confined to a **root** (default: the directory the client started in). Change it with
36
+ `--root DIR` or `MCP_SWITCHBOARD_HARNESS_ROOT`; `--root /` lifts it. Cap output with `--max-output N`
37
+ or `MCP_SWITCHBOARD_HARNESS_MAX_OUTPUT`.
38
+
39
+ ## Use
40
+
41
+ `mcp-switchboard-client` runs it by default as a server named `harness`; nothing to
42
+ configure (`--no-harness` turns it off). To run it standalone or pin your own
43
+ entry, add it to the `mcp.json` next to the client:
44
+
45
+ ```json
46
+ {
47
+ "mcpServers": {
48
+ "harness": { "command": "uvx", "args": ["mcp-switchboard-server-harness"] }
49
+ }
50
+ }
51
+ ```
52
+
53
+ or run it directly: `mcp-switchboard-server-harness --name harness`.
54
+
55
+ ## Security
56
+
57
+ The root only confines the *file* tools; `run_command`, `run_python` and `process_start`
58
+ still run arbitrary commands as the user that started the client. It is a guard against mistakes
59
+ and path tricks, not a sandbox. Keep the hub's private listener unexposed, or
60
+ set `MCP_SWITCHBOARD_PRIVATE_TOKEN`.
@@ -0,0 +1,7 @@
1
+ mcp_switchboard_server_harness/__init__.py,sha256=_C5oWQ9Js1JmrqAHWrPeJCefyCuabeNfd2i6tkZ1bqQ,108
2
+ mcp_switchboard_server_harness/__main__.py,sha256=3dYKHfmWsrdExFlTFlcR5a_icR9fAkn06Yh14TQkEd8,33
3
+ mcp_switchboard_server_harness/server.py,sha256=2uOOa7v0qaGNR6osOEFbfW8TGxDgrymjAxKIMwx8CYE,30493
4
+ mcp_switchboard_server_harness-0.3.0.dev3.dist-info/METADATA,sha256=ffaeIlmo2y572iqu17q3LjtIlpeHXZRT1pUmbRHQckI,2486
5
+ mcp_switchboard_server_harness-0.3.0.dev3.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
6
+ mcp_switchboard_server_harness-0.3.0.dev3.dist-info/entry_points.txt,sha256=O-HdIyZLDU-veaWi8f51U_juJbCHVERILqWlDqZ6aIw,94
7
+ mcp_switchboard_server_harness-0.3.0.dev3.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.4
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ mcp-switchboard-server-harness = mcp_switchboard_server_harness.server:main