Initial commit

This commit is contained in:
Morpheus Sandmann
2026-07-15 14:42:24 +01:00
commit 09b17505e8
5 changed files with 653 additions and 0 deletions
+78
View File
@@ -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/
+19
View File
@@ -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"]
+24
View File
@@ -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
+353
View File
@@ -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)
+179
View File
@@ -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()