Initial commit
This commit is contained in:
+78
@@ -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
@@ -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"]
|
||||||
@@ -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
|
||||||
@@ -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
@@ -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()
|
||||||
|
|
||||||
Reference in New Issue
Block a user