Fixed workspace volume mount
This commit is contained in:
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
FROM python:3.11-slim
|
FROM python:3.11-slim
|
||||||
|
|
||||||
# Install system dependencies including git for version control operations
|
# 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 && \
|
RUN groupadd -g 1000 user && \
|
||||||
useradd -m -u 1000 -g 1000 -s /bin/bash user
|
useradd -m -u 1000 -g 1000 -s /bin/bash user
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ Provides 4 tools:
|
|||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
import re
|
import re
|
||||||
|
import json
|
||||||
import logging
|
import logging
|
||||||
import asyncio
|
import asyncio
|
||||||
import subprocess
|
import subprocess
|
||||||
@@ -26,12 +27,29 @@ from starlette.middleware.cors import CORSMiddleware
|
|||||||
from starlette.responses import JSONResponse
|
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(
|
logging.basicConfig(
|
||||||
level=logging.INFO,
|
level=logging.INFO,
|
||||||
format="%(asctime)s [%(levelname)s] %(name)s - %(message)s",
|
handlers=[json_handler],
|
||||||
handlers=[logging.StreamHandler(sys.stdout)],
|
force=True # Forces Uvicorn/FastAPI to also use this JSON handler
|
||||||
)
|
)
|
||||||
logger = logging.getLogger("turnstone-mcp")
|
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
|
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
|
# FastMCP app
|
||||||
@@ -76,8 +95,6 @@ class PlaywrightBrowserManager:
|
|||||||
|
|
||||||
logger.info("Starting headless Playwright session…")
|
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.context_manager = async_playwright()
|
||||||
self.playwright = await self.context_manager.start()
|
self.playwright = await self.context_manager.start()
|
||||||
|
|
||||||
@@ -120,16 +137,16 @@ class ToolObserverMiddleware(Middleware):
|
|||||||
async def on_call_tool(self, context: MiddlewareContext, call_next):
|
async def on_call_tool(self, context: MiddlewareContext, call_next):
|
||||||
tool_name = getattr(context.message, "name", "unknown")
|
tool_name = getattr(context.message, "name", "unknown")
|
||||||
arguments = getattr(context.message, "arguments", {})
|
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:
|
try:
|
||||||
result = await call_next(context)
|
result = await call_next(context)
|
||||||
content = getattr(result, "content", result)
|
content = getattr(result, "content", result)
|
||||||
logger.info("📤 [SERVER RESPONSE] Tool '%s' completed.", tool_name)
|
preview = str(content)[:250].replace('\n', ' ') # Clean up newlines for JSON reading
|
||||||
logger.info(" ↳ Content: %s\n", str(content)[:500])
|
logger.info(f"SERVER RESPONSE | Tool: '{tool_name}' completed | Preview: {preview}")
|
||||||
return result
|
return result
|
||||||
except Exception as e:
|
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
|
raise
|
||||||
|
|
||||||
mcp.add_middleware(ToolObserverMiddleware())
|
mcp.add_middleware(ToolObserverMiddleware())
|
||||||
@@ -176,9 +193,6 @@ def execute_python_code(code: str) -> str:
|
|||||||
if len(code) > 50_000:
|
if len(code) > 50_000:
|
||||||
return "Error: code exceeds maximum length (50,000 chars)"
|
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"""
|
memory_limit_code = f"""
|
||||||
try:
|
try:
|
||||||
import resource
|
import resource
|
||||||
@@ -189,7 +203,6 @@ except Exception:
|
|||||||
"""
|
"""
|
||||||
full_code = memory_limit_code.strip() + "\n\n" + code
|
full_code = memory_limit_code.strip() + "\n\n" + code
|
||||||
|
|
||||||
# --- Timeout via subprocess ---
|
|
||||||
try:
|
try:
|
||||||
with tempfile.NamedTemporaryFile(
|
with tempfile.NamedTemporaryFile(
|
||||||
mode="w", suffix=".py", delete=False
|
mode="w", suffix=".py", delete=False
|
||||||
@@ -213,7 +226,6 @@ except Exception:
|
|||||||
except OSError:
|
except OSError:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
# --- Output handling ---
|
|
||||||
stdout = proc.stdout[:PYTHON_MAX_OUTPUT]
|
stdout = proc.stdout[:PYTHON_MAX_OUTPUT]
|
||||||
stderr = proc.stderr[: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.
|
Navigation result including page title, or error message.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
# --- Validate URL ---
|
|
||||||
parsed = urlparse(url)
|
parsed = urlparse(url)
|
||||||
if not parsed.scheme or not parsed.netloc:
|
if not parsed.scheme or not parsed.netloc:
|
||||||
if re.match(r"^[a-zA-Z0-9]", url):
|
if re.match(r"^[a-zA-Z0-9]", url):
|
||||||
@@ -279,8 +290,6 @@ async def playwright_get_page_text() -> str:
|
|||||||
if not page:
|
if not page:
|
||||||
return "Error: no active browser session. Run playwright_navigate first."
|
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(
|
text_content = await asyncio.wait_for(
|
||||||
page.evaluate("() => document.body.innerText"),
|
page.evaluate("() => document.body.innerText"),
|
||||||
timeout=PLAYWRIGHT_TEXT_TIMEOUT / 1000.0,
|
timeout=PLAYWRIGHT_TEXT_TIMEOUT / 1000.0,
|
||||||
@@ -324,20 +333,16 @@ def extract_to_markdown(relative_path: str) -> str:
|
|||||||
if not os.path.isfile(target):
|
if not os.path.isfile(target):
|
||||||
return f"Error: file not found at {relative_path}"
|
return f"Error: file not found at {relative_path}"
|
||||||
|
|
||||||
# --- Size check ---
|
|
||||||
file_size = os.path.getsize(target)
|
file_size = os.path.getsize(target)
|
||||||
if file_size > EXTRACT_MAX_FILE_SIZE:
|
if file_size > EXTRACT_MAX_FILE_SIZE:
|
||||||
return f"Error: file size ({file_size} bytes) exceeds limit ({EXTRACT_MAX_FILE_SIZE} bytes)"
|
return f"Error: file size ({file_size} bytes) exceeds limit ({EXTRACT_MAX_FILE_SIZE} bytes)"
|
||||||
|
|
||||||
# --- Extension check ---
|
|
||||||
ext = os.path.splitext(target)[1].lower()
|
ext = os.path.splitext(target)[1].lower()
|
||||||
if ext and ext not in SUPPORTED_EXTENSIONS:
|
if ext and ext not in SUPPORTED_EXTENSIONS:
|
||||||
return f"Error: unsupported file type '{ext}'. Supported: {', '.join(sorted(SUPPORTED_EXTENSIONS))}"
|
return f"Error: unsupported file type '{ext}'. Supported: {', '.join(sorted(SUPPORTED_EXTENSIONS))}"
|
||||||
|
|
||||||
# --- Convert ---
|
|
||||||
try:
|
try:
|
||||||
from markitdown import MarkItDown
|
from markitdown import MarkItDown
|
||||||
|
|
||||||
mid = MarkItDown()
|
mid = MarkItDown()
|
||||||
result = mid.convert(target)
|
result = mid.convert(target)
|
||||||
return result.text_content
|
return result.text_content
|
||||||
|
|||||||
Reference in New Issue
Block a user