From 61898f33590cd935e46fff5616602ca435f21d25 Mon Sep 17 00:00:00 2001 From: Morpheus Sandmann Date: Wed, 15 Jul 2026 19:00:59 +0100 Subject: [PATCH] Fixed workspace volume mount --- Dockerfile | 2 +- server.py | 53 +++++++++++++++++++++++++++++------------------------ 2 files changed, 30 insertions(+), 25 deletions(-) diff --git a/Dockerfile b/Dockerfile index 89fc4e0..d9819e5 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,7 +1,7 @@ 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 apt-get update && apt-get install -y git && rm -rf /var/lib/apt/lists/* && pip install fastmcp mcp requests playwright markitdown[all] && playwright install chromium --with-deps RUN groupadd -g 1000 user && \ useradd -m -u 1000 -g 1000 -s /bin/bash user diff --git a/server.py b/server.py index 3ae23e6..7b98664 100644 --- a/server.py +++ b/server.py @@ -12,6 +12,7 @@ Provides 4 tools: import os import sys import re +import json import logging import asyncio import subprocess @@ -26,12 +27,29 @@ from starlette.middleware.cors import CORSMiddleware from starlette.responses import JSONResponse # --------------------------------------------------------------------------- -# Logging +# Logging (JSON Format) # --------------------------------------------------------------------------- +class JSONLogFormatter(logging.Formatter): + """Custom JSON formatter for professional structured logging.""" + def format(self, record): + log_record = { + "timestamp": self.formatTime(record, self.datefmt), + "level": record.levelname, + "logger": record.name, + "message": record.getMessage(), + "file": f"{record.filename}:{record.lineno}" + } + if record.exc_info: + log_record["exception"] = self.formatException(record.exc_info) + return json.dumps(log_record) + +json_handler = logging.StreamHandler(sys.stdout) +json_handler.setFormatter(JSONLogFormatter()) + logging.basicConfig( level=logging.INFO, - format="%(asctime)s [%(levelname)s] %(name)s - %(message)s", - handlers=[logging.StreamHandler(sys.stdout)], + handlers=[json_handler], + force=True # Forces Uvicorn/FastAPI to also use this JSON handler ) logger = logging.getLogger("turnstone-mcp") @@ -47,9 +65,10 @@ PLAYWRIGHT_TEXT_TIMEOUT = int(os.environ.get("PLAYWRIGHT_TEXT_TIMEOUT", "15000") 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())) +# Defaults to /workspace to match Docker volumes +WORKSPACE_ROOT = os.path.abspath(os.environ.get("WORKSPACE_ROOT", "/workspace")) -logger.info("Workspace Root: %s", WORKSPACE_ROOT) +logger.info(f"Workspace Root configured to: {WORKSPACE_ROOT}") # --------------------------------------------------------------------------- # FastMCP app @@ -76,8 +95,6 @@ class PlaywrightBrowserManager: 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() @@ -120,16 +137,16 @@ class ToolObserverMiddleware(Middleware): 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) + + logger.info(f"LLM REQUEST | Tool: '{tool_name}' | Args: {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]) + preview = str(content)[:250].replace('\n', ' ') # Clean up newlines for JSON reading + logger.info(f"SERVER RESPONSE | Tool: '{tool_name}' completed | Preview: {preview}") return result except Exception as e: - logger.error("❌ [SERVER ERROR] Tool '%s' crashed: %s", tool_name, e) + logger.error(f"SERVER ERROR | Tool '{tool_name}' crashed: {e}") raise mcp.add_middleware(ToolObserverMiddleware()) @@ -176,9 +193,6 @@ def execute_python_code(code: str) -> str: 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 @@ -189,7 +203,6 @@ except Exception: """ full_code = memory_limit_code.strip() + "\n\n" + code - # --- Timeout via subprocess --- try: with tempfile.NamedTemporaryFile( mode="w", suffix=".py", delete=False @@ -213,7 +226,6 @@ except Exception: except OSError: pass - # --- Output handling --- stdout = proc.stdout[:PYTHON_MAX_OUTPUT] stderr = proc.stderr[:PYTHON_MAX_OUTPUT] @@ -242,7 +254,6 @@ async def playwright_navigate(url: str) -> 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): @@ -279,8 +290,6 @@ async def playwright_get_page_text() -> str: 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, @@ -324,20 +333,16 @@ def extract_to_markdown(relative_path: str) -> str: 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