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.
- mcp_switchboard_server_harness/__init__.py +3 -0
- mcp_switchboard_server_harness/__main__.py +3 -0
- mcp_switchboard_server_harness/server.py +828 -0
- mcp_switchboard_server_harness-0.3.0.dev3.dist-info/METADATA +60 -0
- mcp_switchboard_server_harness-0.3.0.dev3.dist-info/RECORD +7 -0
- mcp_switchboard_server_harness-0.3.0.dev3.dist-info/WHEEL +4 -0
- mcp_switchboard_server_harness-0.3.0.dev3.dist-info/entry_points.txt +2 -0
|
@@ -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,,
|