model_context_protocol_demo 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,7 @@
1
+ Metadata-Version: 2.4
2
+ Name: model_context_protocol_demo
3
+ Version: 0.1.0
4
+ Summary: An MCP which adds terminal capabilities to an agent.
5
+ Requires-Python: >=3.11
6
+ Description-Content-Type: text/markdown
7
+ Requires-Dist: fastmcp>=3.4.3
File without changes
@@ -0,0 +1,19 @@
1
+ [project]
2
+ name = "model_context_protocol_demo" # make same name as pkg name
3
+ version = "0.1.0"
4
+ description = "An MCP which adds terminal capabilities to an agent."
5
+ readme = "README.md"
6
+ requires-python = ">=3.11"
7
+ dependencies = [
8
+ "fastmcp>=3.4.3",
9
+ ]
10
+
11
+ [build-system]
12
+ requires=["setuptools>=42", "wheel"]
13
+ build-backend = "setuptools.build_meta"
14
+
15
+ [tool.setuptools.packages.find]
16
+ where = ["src"]
17
+
18
+ [project.scripts]
19
+ model_context_protocol_demo = "model_context_protocol_demo.main:main"
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,7 @@
1
+ from model_context_protocol_demo.server import mcp
2
+
3
+ def main():
4
+ mcp.run(transport="stdio")
5
+
6
+ if __name__ == "__main__":
7
+ main()
@@ -0,0 +1,443 @@
1
+ from __future__ import annotations
2
+ import glob as glob_module
3
+ import logging
4
+ import logging.handlers
5
+ import os
6
+ import re
7
+ import shlex
8
+ import subprocess
9
+ import sys
10
+ import time
11
+ import uuid
12
+ from dataclasses import dataclass, field
13
+ from pathlib import Path
14
+ from typing import Any, Optional
15
+
16
+ from fastmcp import FastMCP
17
+
18
+ SANDBOX_ROOT = Path(os.environ.get("MCP_SANDBOX_ROOT", os.getcwd())).resolve()
19
+ CMD_TIMEOUT = float(os.environ.get("MCP_CMD_TIMEOUT", "30"))
20
+ MAX_OUTPUT_BYTES = int(os.environ.get("MCP_MAX_OUTPUT_BYTES", "200000"))
21
+ LOG_DIR = Path(os.environ.get("MCP_LOG_DIR", "./logs")).resolve()
22
+ LOG_LEVEL = os.environ.get("MCP_LOG_LEVEL", "INFO").upper()
23
+
24
+ SANDBOX_ROOT.mkdir(parents=True, exist_ok=True)
25
+ LOG_DIR.mkdir(parents=True, exist_ok=True)
26
+
27
+ logger = logging.getLogger("mcp_tool_server")
28
+ logger.setLevel(LOG_LEVEL)
29
+ logger.propagate = False
30
+
31
+ mcp = FastMCP()
32
+
33
+ if not logger.handlers:
34
+ fmt = logging.Formatter(
35
+ "%(asctime)s | %(levelname)-8s | %(name)s | %(message)s"
36
+ )
37
+
38
+ console_handler = logging.StreamHandler(sys.stderr)
39
+ console_handler.setFormatter(fmt)
40
+ logger.addHandler(console_handler)
41
+
42
+ file_handler = logging.handlers.RotatingFileHandler(
43
+ LOG_DIR / "mcp_tool_server.log",
44
+ maxBytes=10 * 1024 * 1024,
45
+ backupCount=5,
46
+ encoding="utf-8",
47
+ )
48
+ file_handler.setFormatter(fmt)
49
+ logger.addHandler(file_handler)
50
+
51
+
52
+ def new_request_id() -> str:
53
+ return uuid.uuid4().hex[:12]
54
+
55
+ @dataclass
56
+ class ToolResult:
57
+ ok: bool
58
+ output: str = ""
59
+ error: Optional[str] = None
60
+ meta: dict[str, Any] = field(default_factory=dict)
61
+
62
+ def to_dict(self) -> dict[str, Any]:
63
+ return {
64
+ "ok": self.ok,
65
+ "output": self.output,
66
+ "error": self.error,
67
+ "meta": self.meta,
68
+ }
69
+
70
+
71
+ def _truncate(text: str, limit: int = MAX_OUTPUT_BYTES) -> tuple[str, bool]:
72
+ encoded = text.encode("utf-8", errors="replace")
73
+ if len(encoded) <= limit:
74
+ return text, False
75
+ truncated = encoded[:limit].decode("utf-8", errors="ignore")
76
+ return truncated, True
77
+
78
+
79
+ class PathSecurityError(Exception):
80
+ pass
81
+
82
+
83
+ def resolve_safe_path(user_path: str) -> Path:
84
+ """Resolve `user_path` (relative or absolute) against SANDBOX_ROOT and
85
+ guarantee the result stays inside it. Raises PathSecurityError otherwise.
86
+ """
87
+ candidate = (SANDBOX_ROOT / user_path).resolve() if not os.path.isabs(user_path) \
88
+ else Path(user_path).resolve()
89
+
90
+ try:
91
+ candidate.relative_to(SANDBOX_ROOT)
92
+ except ValueError:
93
+ raise PathSecurityError(
94
+ f"Path '{user_path}' resolves outside the sandbox root "
95
+ f"'{SANDBOX_ROOT}'. Operation blocked."
96
+ )
97
+ return candidate
98
+
99
+ def run_subprocess(
100
+ args: list[str] | str,
101
+ *,
102
+ shell: bool,
103
+ timeout: float,
104
+ cwd: Path,
105
+ request_id: str,
106
+ ) -> ToolResult:
107
+ start = time.monotonic()
108
+ try:
109
+ proc = subprocess.run(
110
+ args,
111
+ shell=shell,
112
+ cwd=str(cwd),
113
+ stdout=subprocess.PIPE,
114
+ stderr=subprocess.PIPE,
115
+ timeout=timeout,
116
+ )
117
+ elapsed = round(time.monotonic() - start, 3)
118
+ stdout, out_trunc = _truncate(proc.stdout.decode("utf-8", errors="replace"))
119
+ stderr, err_trunc = _truncate(proc.stderr.decode("utf-8", errors="replace"))
120
+
121
+ meta = {
122
+ "request_id": request_id,
123
+ "return_code": proc.returncode,
124
+ "elapsed_seconds": elapsed,
125
+ "stdout_truncated": out_trunc,
126
+ "stderr_truncated": err_trunc,
127
+ "cwd": str(cwd),
128
+ }
129
+
130
+ if proc.returncode == 0:
131
+ logger.info("[%s] command succeeded in %ss", request_id, elapsed)
132
+ return ToolResult(ok=True, output=stdout, error=stderr or None, meta=meta)
133
+ else:
134
+ logger.warning(
135
+ "[%s] command exited %s in %ss", request_id, proc.returncode, elapsed
136
+ )
137
+ return ToolResult(
138
+ ok=False,
139
+ output=stdout,
140
+ error=stderr or f"Process exited with code {proc.returncode}",
141
+ meta=meta,
142
+ )
143
+
144
+ except subprocess.TimeoutExpired:
145
+ elapsed = round(time.monotonic() - start, 3)
146
+ logger.error("[%s] command timed out after %ss", request_id, elapsed)
147
+ return ToolResult(
148
+ ok=False,
149
+ error=f"Command timed out after {timeout} seconds.",
150
+ meta={"request_id": request_id, "elapsed_seconds": elapsed},
151
+ )
152
+ except FileNotFoundError as e:
153
+ logger.error("[%s] executable not found: %s", request_id, e)
154
+ return ToolResult(
155
+ ok=False, error=f"Executable not found: {e}", meta={"request_id": request_id}
156
+ )
157
+ except Exception as e: # noqa: BLE001 - last-resort guard, never leak raw tracebacks
158
+ logger.exception("[%s] unexpected error running command", request_id)
159
+ return ToolResult(
160
+ ok=False, error=f"Unexpected error: {e}", meta={"request_id": request_id}
161
+ )
162
+
163
+ @mcp.tool()
164
+ def bash(command: str, timeout: Optional[float] = None) -> dict:
165
+ """Execute a bash command inside the sandboxed working directory and
166
+ return a structured result: {ok, output, error, meta}.
167
+
168
+ Blocked: attempts to escape the sandbox root via path traversal in
169
+ obviously destructive patterns (e.g. `rm -rf /`) are not specially
170
+ parsed — the sandbox is enforced at the cwd/path-tool level, not by
171
+ command inspection. Treat this tool as trusted-user-only.
172
+ """
173
+ request_id = new_request_id()
174
+ logger.info("[%s] bash: %s", request_id, command)
175
+
176
+ if not command or not command.strip():
177
+ return ToolResult(ok=False, error="Empty command.").to_dict()
178
+
179
+ result = run_subprocess(
180
+ command,
181
+ shell=True,
182
+ timeout=timeout or CMD_TIMEOUT,
183
+ cwd=SANDBOX_ROOT,
184
+ request_id=request_id,
185
+ )
186
+ return result.to_dict()
187
+
188
+
189
+ @mcp.tool()
190
+ def python_code(code: str, timeout: Optional[float] = None) -> dict:
191
+ """Execute a Python code snippet in a subprocess and return a structured
192
+ result: {ok, output, error, meta}.
193
+ """
194
+ request_id = new_request_id()
195
+ logger.info("[%s] python_code: %d chars", request_id, len(code or ""))
196
+
197
+ if not code or not code.strip():
198
+ return ToolResult(ok=False, error="Empty code.").to_dict()
199
+
200
+ result = run_subprocess(
201
+ [sys.executable, "-c", code],
202
+ shell=False,
203
+ timeout=timeout or CMD_TIMEOUT,
204
+ cwd=SANDBOX_ROOT,
205
+ request_id=request_id,
206
+ )
207
+ return result.to_dict()
208
+
209
+
210
+ @mcp.tool()
211
+ def python_file(file: str, args: Optional[list[str]] = None, timeout: Optional[float] = None) -> dict:
212
+ """Execute a Python file (path relative to the sandbox root, or an
213
+ absolute path inside it) and return a structured result.
214
+ """
215
+ request_id = new_request_id()
216
+ try:
217
+ safe_path = resolve_safe_path(file)
218
+ except PathSecurityError as e:
219
+ logger.warning("[%s] blocked path: %s", request_id, e)
220
+ return ToolResult(ok=False, error=str(e)).to_dict()
221
+
222
+ if not safe_path.is_file():
223
+ return ToolResult(ok=False, error=f"File not found: {file}").to_dict()
224
+
225
+ cmd = [sys.executable, str(safe_path), *(args or [])]
226
+ logger.info("[%s] python_file: %s", request_id, " ".join(shlex.quote(c) for c in cmd))
227
+
228
+ result = run_subprocess(
229
+ cmd,
230
+ shell=False,
231
+ timeout=timeout or CMD_TIMEOUT,
232
+ cwd=SANDBOX_ROOT,
233
+ request_id=request_id,
234
+ )
235
+ return result.to_dict()
236
+
237
+
238
+ @mcp.tool()
239
+ def glob(pattern: str, recursive: bool = True) -> dict:
240
+ """Return files matching `pattern` within the sandbox root.
241
+ `pattern` is interpreted relative to the sandbox root unless absolute
242
+ (absolute paths outside the sandbox are rejected).
243
+ """
244
+ request_id = new_request_id()
245
+ try:
246
+ base = pattern if os.path.isabs(pattern) else str(SANDBOX_ROOT / pattern)
247
+ matches = glob_module.glob(base, recursive=recursive)
248
+
249
+ safe_matches = []
250
+ for m in matches:
251
+ try:
252
+ Path(m).resolve().relative_to(SANDBOX_ROOT)
253
+ safe_matches.append(m)
254
+ except ValueError:
255
+ continue # silently drop anything outside the sandbox
256
+
257
+ logger.info("[%s] glob(%s) -> %d matches", request_id, pattern, len(safe_matches))
258
+ return ToolResult(ok=True, output="\n".join(safe_matches), meta={
259
+ "request_id": request_id, "count": len(safe_matches)
260
+ }).to_dict()
261
+ except Exception as e: # noqa: BLE001
262
+ logger.exception("[%s] glob failed", request_id)
263
+ return ToolResult(ok=False, error=str(e)).to_dict()
264
+
265
+
266
+ @mcp.tool()
267
+ def grep(pattern: str, file: str, ignore_case: bool = False, max_matches: int = 500) -> dict:
268
+ """Search for a regex `pattern` in `file` (relative to sandbox root) and
269
+ return matching lines with their line numbers. Cross-platform (pure
270
+ Python regex, not shell-dependent like findstr/grep).
271
+ """
272
+ request_id = new_request_id()
273
+ try:
274
+ safe_path = resolve_safe_path(file)
275
+ except PathSecurityError as e:
276
+ return ToolResult(ok=False, error=str(e)).to_dict()
277
+
278
+ if not safe_path.is_file():
279
+ return ToolResult(ok=False, error=f"File not found: {file}").to_dict()
280
+
281
+ try:
282
+ flags = re.IGNORECASE if ignore_case else 0
283
+ regex = re.compile(pattern, flags)
284
+ except re.error as e:
285
+ return ToolResult(ok=False, error=f"Invalid regex: {e}").to_dict()
286
+
287
+ matches = []
288
+ try:
289
+ with safe_path.open("r", encoding="utf-8", errors="replace") as f:
290
+ for lineno, line in enumerate(f, start=1):
291
+ if regex.search(line):
292
+ matches.append(f"{lineno}:{line.rstrip(chr(10))}")
293
+ if len(matches) >= max_matches:
294
+ break
295
+ except Exception as e: # noqa: BLE001
296
+ logger.exception("[%s] grep failed", request_id)
297
+ return ToolResult(ok=False, error=str(e)).to_dict()
298
+
299
+ logger.info("[%s] grep(%s, %s) -> %d matches", request_id, pattern, file, len(matches))
300
+ return ToolResult(
301
+ ok=True,
302
+ output="\n".join(matches),
303
+ meta={"request_id": request_id, "count": len(matches)},
304
+ ).to_dict()
305
+
306
+
307
+ @mcp.tool()
308
+ def read_file(file: str, max_bytes: Optional[int] = None) -> dict:
309
+ """Read a file's contents (path relative to sandbox root)."""
310
+ request_id = new_request_id()
311
+ try:
312
+ safe_path = resolve_safe_path(file)
313
+ except PathSecurityError as e:
314
+ return ToolResult(ok=False, error=str(e)).to_dict()
315
+
316
+ if not safe_path.is_file():
317
+ return ToolResult(ok=False, error=f"File not found: {file}").to_dict()
318
+
319
+ try:
320
+ limit = max_bytes or MAX_OUTPUT_BYTES
321
+ with safe_path.open("rb") as f:
322
+ raw = f.read(limit + 1)
323
+ truncated = len(raw) > limit
324
+ text = raw[:limit].decode("utf-8", errors="replace")
325
+ return ToolResult(
326
+ ok=True, output=text,
327
+ meta={"request_id": request_id, "truncated": truncated, "size_bytes": safe_path.stat().st_size},
328
+ ).to_dict()
329
+ except Exception as e: # noqa: BLE001
330
+ logger.exception("[%s] read_file failed", request_id)
331
+ return ToolResult(ok=False, error=str(e)).to_dict()
332
+
333
+
334
+ @mcp.tool()
335
+ def write_file(file: str, content: str, overwrite: bool = True, append: bool = False) -> dict:
336
+ """Write `content` to `file` (relative to sandbox root). Creates parent
337
+ directories as needed. Set overwrite=False to fail if the file already
338
+ exists; set append=True to append instead of replacing.
339
+ """
340
+ request_id = new_request_id()
341
+ try:
342
+ safe_path = resolve_safe_path(file)
343
+ except PathSecurityError as e:
344
+ return ToolResult(ok=False, error=str(e)).to_dict()
345
+
346
+ if safe_path.exists() and not overwrite and not append:
347
+ return ToolResult(ok=False, error=f"File already exists: {file}").to_dict()
348
+
349
+ try:
350
+ safe_path.parent.mkdir(parents=True, exist_ok=True)
351
+ mode = "a" if append else "w"
352
+ with safe_path.open(mode, encoding="utf-8") as f:
353
+ f.write(content)
354
+ logger.info("[%s] wrote %d chars to %s", request_id, len(content), safe_path)
355
+ return ToolResult(
356
+ ok=True,
357
+ output=f"Content written to {file} successfully.",
358
+ meta={"request_id": request_id, "bytes_written": len(content.encode('utf-8'))},
359
+ ).to_dict()
360
+ except Exception as e: # noqa: BLE001
361
+ logger.exception("[%s] write_file failed", request_id)
362
+ return ToolResult(ok=False, error=str(e)).to_dict()
363
+
364
+
365
+ @mcp.tool()
366
+ def create_folder(folder: str) -> dict:
367
+ """Create a folder (relative to sandbox root), including parents."""
368
+ request_id = new_request_id()
369
+ try:
370
+ safe_path = resolve_safe_path(folder)
371
+ except PathSecurityError as e:
372
+ return ToolResult(ok=False, error=str(e)).to_dict()
373
+
374
+ try:
375
+ if safe_path.exists():
376
+ return ToolResult(
377
+ ok=True, output=f"Folder '{folder}' already exists.",
378
+ meta={"request_id": request_id, "created": False},
379
+ ).to_dict()
380
+ safe_path.mkdir(parents=True, exist_ok=False)
381
+ logger.info("[%s] created folder %s", request_id, safe_path)
382
+ return ToolResult(
383
+ ok=True, output=f"Folder '{folder}' created successfully.",
384
+ meta={"request_id": request_id, "created": True},
385
+ ).to_dict()
386
+ except Exception as e: # noqa: BLE001
387
+ logger.exception("[%s] create_folder failed", request_id)
388
+ return ToolResult(ok=False, error=str(e)).to_dict()
389
+
390
+
391
+ @mcp.tool()
392
+ def delete_folder(folder: str, confirm: bool = False) -> dict:
393
+ """Delete a folder (relative to sandbox root) and everything in it.
394
+ Requires confirm=True as a safety guard against accidental calls.
395
+ Refuses to delete the sandbox root itself.
396
+ """
397
+ request_id = new_request_id()
398
+ if not confirm:
399
+ return ToolResult(
400
+ ok=False,
401
+ error="Destructive operation requires confirm=True.",
402
+ ).to_dict()
403
+
404
+ try:
405
+ safe_path = resolve_safe_path(folder)
406
+ except PathSecurityError as e:
407
+ return ToolResult(ok=False, error=str(e)).to_dict()
408
+
409
+ if safe_path == SANDBOX_ROOT:
410
+ return ToolResult(ok=False, error="Refusing to delete the sandbox root.").to_dict()
411
+
412
+ if not safe_path.exists():
413
+ return ToolResult(ok=True, output=f"Folder '{folder}' does not exist.",
414
+ meta={"request_id": request_id, "deleted": False}).to_dict()
415
+
416
+ try:
417
+ import shutil
418
+ shutil.rmtree(safe_path)
419
+ logger.warning("[%s] deleted folder %s", request_id, safe_path)
420
+ return ToolResult(
421
+ ok=True, output=f"Folder '{folder}' deleted successfully.",
422
+ meta={"request_id": request_id, "deleted": True},
423
+ ).to_dict()
424
+ except Exception as e: # noqa: BLE001
425
+ logger.exception("[%s] delete_folder failed", request_id)
426
+ return ToolResult(ok=False, error=str(e)).to_dict()
427
+
428
+
429
+ @mcp.tool()
430
+ def health_check() -> dict:
431
+ """Report server health/config — useful for monitoring and smoke tests."""
432
+ return ToolResult(
433
+ ok=True,
434
+ output="healthy",
435
+ meta={
436
+ "sandbox_root": str(SANDBOX_ROOT),
437
+ "cmd_timeout": CMD_TIMEOUT,
438
+ "max_output_bytes": MAX_OUTPUT_BYTES,
439
+ "log_dir": str(LOG_DIR),
440
+ "log_level": LOG_LEVEL,
441
+ "pid": os.getpid(),
442
+ },
443
+ ).to_dict()
@@ -0,0 +1,7 @@
1
+ Metadata-Version: 2.4
2
+ Name: model_context_protocol_demo
3
+ Version: 0.1.0
4
+ Summary: An MCP which adds terminal capabilities to an agent.
5
+ Requires-Python: >=3.11
6
+ Description-Content-Type: text/markdown
7
+ Requires-Dist: fastmcp>=3.4.3
@@ -0,0 +1,11 @@
1
+ README.md
2
+ pyproject.toml
3
+ src/model_context_protocol_demo/__init__.py
4
+ src/model_context_protocol_demo/main.py
5
+ src/model_context_protocol_demo/server.py
6
+ src/model_context_protocol_demo.egg-info/PKG-INFO
7
+ src/model_context_protocol_demo.egg-info/SOURCES.txt
8
+ src/model_context_protocol_demo.egg-info/dependency_links.txt
9
+ src/model_context_protocol_demo.egg-info/entry_points.txt
10
+ src/model_context_protocol_demo.egg-info/requires.txt
11
+ src/model_context_protocol_demo.egg-info/top_level.txt
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ model_context_protocol_demo = model_context_protocol_demo.main:main