commit 09b17505e87724439b0bbd69ddcec29df370e770 Author: Morpheus Sandmann Date: Wed Jul 15 14:42:24 2026 +0100 Initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4d627f9 --- /dev/null +++ b/.gitignore @@ -0,0 +1,78 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so +*.pyd +*.dll + +# Distribution / packaging +build/ +dist/ +*.egg-info/ +pip-wheel-metadata/ +*.egg +wheels/ + +# Virtual environments +venv/ +.env/ +.venv/ +env/ +ENV/ +local/ + +# PyInstaller +# Usually these files are written by a python script from a template +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +htmlcov/ +.pytest_cache/ + +# MyPy / pylance / pyright +.mypy_cache/ +.pyre/ +.pytype/ + +# IDEs and editors +.vscode/ +.idea/ +*.sublime-project +*.sublime-workspace + +# Jupyter +.ipynb_checkpoints +.ipynb_meta + +# Logs and databases +*.log +*.sqlite3 +*.db + +# OS files +.DS_Store +Thumbs.db +desktop.ini + +# Documentation +docs/_build/ + +# Misc +*.bak +*.swp + +workspace/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..89fc4e0 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,19 @@ +FROM python:3.11-slim + +# Install system dependencies including git for version control operations +RUN apt-get update && apt-get install -y git && rm -rf /var/lib/apt/lists/* && pip install fastmcp mcp requests playwright markitdown && playwright install chromium --with-deps + +RUN groupadd -g 1000 user && \ + useradd -m -u 1000 -g 1000 -s /bin/bash user + +WORKDIR /workspace + +COPY server.py /app/server.py + +USER 1000:1000 + +RUN playwright install + +EXPOSE 8000 + +CMD ["python", "/app/server.py"] diff --git a/compose.yaml b/compose.yaml new file mode 100644 index 0000000..1b4e42c --- /dev/null +++ b/compose.yaml @@ -0,0 +1,24 @@ +--- + +volumes: + workspace: + external: true + name: turnstone_workspace + +services: + mcp-server: + build: + context: . + dockerfile: Dockerfile + user: "1000:1000" + ports: + - "8000:8000" + volumes: + - ${WORKSPACE_MOUNT:-workspace}:/workspace + restart: unless-stopped + healthcheck: + test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 10s diff --git a/server.py b/server.py new file mode 100644 index 0000000..3ae23e6 --- /dev/null +++ b/server.py @@ -0,0 +1,353 @@ +#!/usr/bin/env python3 +""" +Turnstone MCP Server — Improved Multi-Tool Server + +Provides 4 tools: + 1. execute_python_code — sandboxed Python execution with timeouts & limits + 2. playwright_navigate — browser navigation with timeout & validation + 3. playwright_get_page_text — rendered page text extraction with timeout + 4. extract_to_markdown — format conversion with size limits & validation +""" + +import os +import sys +import re +import logging +import asyncio +import subprocess +import tempfile +from typing import Optional +from urllib.parse import urlparse + +from fastmcp import FastMCP +from fastmcp.server.middleware import Middleware, MiddlewareContext +from starlette.middleware import Middleware as StarletteMiddleware +from starlette.middleware.cors import CORSMiddleware +from starlette.responses import JSONResponse + +# --------------------------------------------------------------------------- +# Logging +# --------------------------------------------------------------------------- +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s [%(levelname)s] %(name)s - %(message)s", + handlers=[logging.StreamHandler(sys.stdout)], +) +logger = logging.getLogger("turnstone-mcp") + +# --------------------------------------------------------------------------- +# Configuration (env-overridable) +# --------------------------------------------------------------------------- +PYTHON_EXEC_TIMEOUT = int(os.environ.get("PYTHON_EXEC_TIMEOUT", "30")) # seconds +PYTHON_MAX_OUTPUT = int(os.environ.get("PYTHON_MAX_OUTPUT", "65536")) # bytes +PYTHON_MAX_MEMORY_MB = int(os.environ.get("PYTHON_MAX_MEMORY_MB", "256")) + +PLAYWRIGHT_NAV_TIMEOUT = int(os.environ.get("PLAYWRIGHT_NAV_TIMEOUT", "30000")) # ms +PLAYWRIGHT_TEXT_TIMEOUT = int(os.environ.get("PLAYWRIGHT_TEXT_TIMEOUT", "15000")) # ms + +EXTRACT_MAX_FILE_SIZE = int(os.environ.get("EXTRACT_MAX_FILE_SIZE", "10485760")) # 10 MB + +WORKSPACE_ROOT = os.path.abspath(os.environ.get("WORKSPACE_ROOT", os.getcwd())) + +logger.info("Workspace Root: %s", WORKSPACE_ROOT) + +# --------------------------------------------------------------------------- +# FastMCP app +# --------------------------------------------------------------------------- +mcp = FastMCP("turnstone-mcp") + +# --------------------------------------------------------------------------- +# Playwright lifecycle (stateful browser session) +# --------------------------------------------------------------------------- +class PlaywrightBrowserManager: + """Encapsulates Playwright state to prevent garbage collection teardowns.""" + + def __init__(self): + self.context_manager = None + self.playwright = None + self.browser = None + self.context = None + self.page = None + + async def get_page(self): + """Lazily initialize and return a headless Chromium page.""" + if self.page is None: + from playwright.async_api import async_playwright + + logger.info("Starting headless Playwright session…") + + # CRITICAL: We must store the context_manager as a class property. + # If it's garbage collected, it forces the Playwright driver to close. + self.context_manager = async_playwright() + self.playwright = await self.context_manager.start() + + self.browser = await self.playwright.chromium.launch(headless=True) + self.context = await self.browser.new_context( + viewport={"width": 1280, "height": 720}, + user_agent=( + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " + "AppleWebKit/537.36 (KHTML, like Gecko) " + "Chrome/124.0.0.0 Safari/537.36" + ), + ) + self.page = await self.context.new_page() + + return self.page + +# Initialize singleton manager +browser_manager = PlaywrightBrowserManager() + +def _resolve_safe_path(relative_path: str) -> str: + """Resolve a relative path against WORKSPACE_ROOT, blocking traversal.""" + target = os.path.abspath(os.path.join(WORKSPACE_ROOT, relative_path)) + if not target.startswith(WORKSPACE_ROOT): + raise PermissionError("Access denied: path escapes WORKSPACE_ROOT") + return target + +# --------------------------------------------------------------------------- +# Health endpoint +# --------------------------------------------------------------------------- +@mcp.custom_route("/health", methods=["GET"]) +async def health_check(request): + return JSONResponse({"status": "healthy", "service": "turnstone-mcp"}) + +# --------------------------------------------------------------------------- +# Semantic-logging middleware +# --------------------------------------------------------------------------- +class ToolObserverMiddleware(Middleware): + """Log incoming tool invocations and responses.""" + + async def on_call_tool(self, context: MiddlewareContext, call_next): + tool_name = getattr(context.message, "name", "unknown") + arguments = getattr(context.message, "arguments", {}) + logger.info("📥 [LLM REQUEST] Invoking tool: '%s'", tool_name) + logger.info(" ↳ Arguments: %s", arguments) + try: + result = await call_next(context) + content = getattr(result, "content", result) + logger.info("📤 [SERVER RESPONSE] Tool '%s' completed.", tool_name) + logger.info(" ↳ Content: %s\n", str(content)[:500]) + return result + except Exception as e: + logger.error("❌ [SERVER ERROR] Tool '%s' crashed: %s", tool_name, e) + raise + +mcp.add_middleware(ToolObserverMiddleware()) + +# --------------------------------------------------------------------------- +# CORS +# --------------------------------------------------------------------------- +middleware_config = [ + StarletteMiddleware( + CORSMiddleware, + allow_origins=["*"], + allow_methods=["GET", "POST", "DELETE", "OPTIONS"], + allow_headers=[ + "mcp-protocol-version", + "mcp-session-id", + "Authorization", + "Content-Type", + ], + expose_headers=["mcp-session-id"], + ) +] + +# =================================================================== +# Tool 1 — execute_python_code +# =================================================================== + +@mcp.tool() +def execute_python_code(code: str) -> str: + """Execute Python code in a sandboxed environment. + + Parameters + ---------- + code : str + The Python source to execute. + + Returns + ------- + str + stdout output, or error message if execution failed. + """ + if not code or not code.strip(): + return "Error: code cannot be empty" + + if len(code) > 50_000: + return "Error: code exceeds maximum length (50,000 chars)" + + # --- Memory limit via injected script --- + # We inject the resource limit directly into the subprocess script. + # Applying it in the main process would incorrectly limit the MCP server! + memory_limit_code = f""" +try: + import resource + desired = {PYTHON_MAX_MEMORY_MB} * 1024 * 1024 + resource.setrlimit(resource.RLIMIT_AS, (desired, desired)) +except Exception: + pass +""" + full_code = memory_limit_code.strip() + "\n\n" + code + + # --- Timeout via subprocess --- + try: + with tempfile.NamedTemporaryFile( + mode="w", suffix=".py", delete=False + ) as tmp: + tmp.write(full_code) + tmp_path = tmp.name + + proc = subprocess.run( + [sys.executable, tmp_path], + capture_output=True, + text=True, + timeout=PYTHON_EXEC_TIMEOUT, + ) + except subprocess.TimeoutExpired: + return f"Error: execution timed out after {PYTHON_EXEC_TIMEOUT}s" + except Exception as e: + return f"Error: {e}" + finally: + try: + os.unlink(tmp_path) + except OSError: + pass + + # --- Output handling --- + stdout = proc.stdout[:PYTHON_MAX_OUTPUT] + stderr = proc.stderr[:PYTHON_MAX_OUTPUT] + + if proc.returncode != 0: + return f"Exit code: {proc.returncode}\nStderr: {stderr}\nStdout: {stdout}" + if stdout: + return stdout + return "Code executed successfully with no stdout output." + +# =================================================================== +# Tool 2 — playwright_navigate +# =================================================================== + +@mcp.tool() +async def playwright_navigate(url: str) -> str: + """Navigate the persistent browser to a URL. + + Parameters + ---------- + url : str + Target URL. Will be prefixed with https:// if missing scheme. + + Returns + ------- + str + Navigation result including page title, or error message. + """ + try: + # --- Validate URL --- + parsed = urlparse(url) + if not parsed.scheme or not parsed.netloc: + if re.match(r"^[a-zA-Z0-9]", url): + url = "https://" + url + else: + return f"Error: invalid URL '{url}'" + + page = await browser_manager.get_page() + response = await page.goto( + url, wait_until="domcontentloaded", timeout=PLAYWRIGHT_NAV_TIMEOUT + ) + + title = await page.title() + status = response.status if response else "unknown" + return f"Successfully loaded {url} (status {status})\nPage Title: {title}" + except Exception as e: + return f"Navigation error: {e}" + +# =================================================================== +# Tool 3 — playwright_get_page_text +# =================================================================== + +@mcp.tool() +async def playwright_get_page_text() -> str: + """Retrieve the rendered page text from the current browser session. + + Returns + ------- + str + The innerText of document.body, or an error message. + """ + try: + page = browser_manager.page + if not page: + return "Error: no active browser session. Run playwright_navigate first." + + # CRITICAL FIX: page.evaluate does not support a `timeout` argument. + # We must wrap the call in asyncio.wait_for instead. + text_content = await asyncio.wait_for( + page.evaluate("() => document.body.innerText"), + timeout=PLAYWRIGHT_TEXT_TIMEOUT / 1000.0, + ) + return f"=== CURRENT BROWSER TEXT SCREEN ===\n\n{text_content.strip()}" + except asyncio.TimeoutError: + return "Error: Page text extraction timed out." + except Exception as e: + return f"Extraction error: {e}" + +# =================================================================== +# Tool 4 — extract_to_markdown +# =================================================================== + +SUPPORTED_EXTENSIONS = { + ".pdf", ".docx", ".pptx", ".xlsx", ".html", ".htm", + ".md", ".txt", ".csv", ".json", ".xml", ".epub", + ".odt", ".rtf", ".jpg", ".jpeg", ".png", ".gif", + ".webp", ".bmp", +} + +@mcp.tool() +def extract_to_markdown(relative_path: str) -> str: + """Convert a file to Markdown using markitdown. + + Parameters + ---------- + relative_path : str + Path relative to WORKSPACE_ROOT. + + Returns + ------- + str + Markdown content, or an error message. + """ + try: + target = _resolve_safe_path(relative_path) + except PermissionError as e: + return f"Error: {e}" + + if not os.path.isfile(target): + return f"Error: file not found at {relative_path}" + + # --- Size check --- + file_size = os.path.getsize(target) + if file_size > EXTRACT_MAX_FILE_SIZE: + return f"Error: file size ({file_size} bytes) exceeds limit ({EXTRACT_MAX_FILE_SIZE} bytes)" + + # --- Extension check --- + ext = os.path.splitext(target)[1].lower() + if ext and ext not in SUPPORTED_EXTENSIONS: + return f"Error: unsupported file type '{ext}'. Supported: {', '.join(sorted(SUPPORTED_EXTENSIONS))}" + + # --- Convert --- + try: + from markitdown import MarkItDown + + mid = MarkItDown() + result = mid.convert(target) + return result.text_content + except ImportError: + return "Error: markitdown library is not installed." + except Exception as e: + return f"Extraction error: {e}" + +# =================================================================== +# Execution +# =================================================================== +if __name__ == "__main__": + mcp.run(transport="http", host="0.0.0.0", port=8000, middleware=middleware_config) diff --git a/test_mcp.py b/test_mcp.py new file mode 100644 index 0000000..7a70e11 --- /dev/null +++ b/test_mcp.py @@ -0,0 +1,179 @@ +#!/usr/bin/env python3 +import argparse +import json +import sys +import urllib.request +import urllib.error + +DEFAULT_PROTOCOL_VERSIONS = ["2025-03-26", "2024-11-05"] + +SSE_ACCEPT = "application/json, text/event-stream" +CONTENT_TYPE = "application/json" + + +def sse_read_until_request_id(resp, want_id, timeout_seconds=10): + found = None + buf_data_lines = [] + + # SSE: event/data lines, with blank line separating messages. + while True: + line = resp.readline() + if not line: + break # EOF + + s = line.decode("utf-8", errors="replace").rstrip("\r\n") + + if s == "": + if buf_data_lines: + data_text = "\n".join(buf_data_lines) + buf_data_lines = [] + try: + obj = json.loads(data_text) + _id = obj.get("id") + if _id == want_id or str(_id) == str(want_id): + if "result" in obj or "error" in obj: + found = obj + break + except json.JSONDecodeError: + pass + continue + + if s.startswith("data:"): + buf_data_lines.append(s[len("data:"):].lstrip()) + # ignore "event:" and other fields + + # In case server ends without a trailing blank line, parse last buffered message + if found is None and buf_data_lines: + data_text = "\n".join(buf_data_lines) + try: + obj = json.loads(data_text) + _id = obj.get("id") + if _id == want_id or str(_id) == str(want_id): + if "result" in obj or "error" in obj: + found = obj + except json.JSONDecodeError: + pass + + return found + + +def post_and_read_sse(url, body_obj, headers, want_id, timeout=10): + data = json.dumps(body_obj).encode("utf-8") + req = urllib.request.Request(url, data=data, headers=headers, method="POST") + + with urllib.request.urlopen(req, timeout=timeout) as resp: + session_id = resp.headers.get("mcp-session-id") or resp.headers.get("Mcp-Session-Id") + msg = sse_read_until_request_id(resp, want_id=want_id, timeout_seconds=timeout) + return session_id, msg + + +def post_json_no_read(url, body_obj, headers, timeout=10): + data = json.dumps(body_obj).encode("utf-8") + req = urllib.request.Request(url, data=data, headers=headers, method="POST") + with urllib.request.urlopen(req, timeout=timeout) as resp: + # Consume at least headers; ignore body + _ = resp.status + + +def delete_no_read(url, headers, timeout=10): + req = urllib.request.Request(url, method="DELETE", headers=headers) + with urllib.request.urlopen(req, timeout=timeout) as resp: + return resp.status + + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("endpoint", help="Full MCP streamable HTTP endpoint URL, e.g. http://host:8010/mcp") + ap.add_argument("--tools", action="store_true", help="Also call tools/list after initialize") + args = ap.parse_args() + + endpoint = args.endpoint + base_headers = { + "Content-Type": CONTENT_TYPE, + "Accept": SSE_ACCEPT, + "User-Agent": "test-mcp/1.0", + } + + init_id = 1 + tools_id = 2 + init_result = None + session_id = None + + # ---- initialize (POST) ---- + last_err = None + for protocol_version in DEFAULT_PROTOCOL_VERSIONS: + init_body = { + "jsonrpc": "2.0", + "id": init_id, + "method": "initialize", + "params": { + "protocolVersion": protocol_version, + "capabilities": {}, + "clientInfo": {"name": "test-mcp", "version": "1.0.0"}, + }, + } + try: + session_id, init_result = post_and_read_sse(endpoint, init_body, base_headers, want_id=init_id, timeout=10) + if init_result is not None: + break + except urllib.error.HTTPError as e: + last_err = e + except Exception as e: + last_err = e + + if init_result is None: + if last_err: + print(f"Initialize failed: {last_err}") + else: + print("Initialize failed: no initialize response received") + sys.exit(1) + + print("Initialize response:") + print(json.dumps(init_result, indent=2, sort_keys=True)) + + if not session_id: + print("Warning: MCP session id header not found; termination and follow-up calls may fail.") + session_id = None + + # ---- initialized (notification, POST) ---- + if session_id: + headers = dict(base_headers) + headers["Mcp-Session-Id"] = session_id + initialized_body = {"jsonrpc": "2.0", "method": "initialized", "params": {}} + post_json_no_read(endpoint, initialized_body, headers, timeout=10) + + # ---- optional tools/list ---- + if args.tools and session_id: + headers = dict(base_headers) + headers["Mcp-Session-Id"] = session_id + tools_body = {"jsonrpc": "2.0", "id": tools_id, "method": "tools/list", "params": {}} + _, tools_result = post_and_read_sse(endpoint, tools_body, headers, want_id=tools_id, timeout=10) + print("tools/list response:") + if tools_result is None: + print("(no tools/list SSE response received)") + else: + print(json.dumps(tools_result, indent=2, sort_keys=True)) + + # ---- terminate session (DELETE) ---- + if session_id: + term_headers = { + "Accept": "application/json", + "User-Agent": "test-mcp/1.0", + "Mcp-Session-Id": session_id, + } + try: + status = delete_no_read(endpoint, term_headers, timeout=10) + print(f"Session termination: HTTP {status}") + except urllib.error.HTTPError as e: + print(f"Session termination failed: HTTP {e.code}") + try: + body = e.read().decode("utf-8", errors="replace") + if body: + print(body) + except Exception: + pass + + +if __name__ == "__main__": + main() +