Initial commit
This commit is contained in:
@@ -0,0 +1,37 @@
|
||||
# ============================================================================
|
||||
# mcp-linkgen — Environment Configuration
|
||||
# ============================================================================
|
||||
|
||||
# Server
|
||||
HOST=0.0.0.0
|
||||
PORT=8000
|
||||
|
||||
# Workspace — directory containing files to share via download links
|
||||
# Leave the below line commented out to automatically mount the Turnstone workspace volume
|
||||
# WORKSPACE_ROOT=/workspace
|
||||
|
||||
# Public base URL used when generating download links.
|
||||
# Set to your public hostname (with trailing slash path, no trailing slash).
|
||||
# Examples:
|
||||
# http://localhost:8000 (local dev)
|
||||
# https://linkgen.example.com (production behind TLS)
|
||||
PUBLIC_BASE_URL=http://localhost:8000
|
||||
|
||||
# Download link TTL (time-to-live) settings in minutes
|
||||
DOWNLOAD_DEFAULT_TTL=10
|
||||
DOWNLOAD_MIN_TTL=1
|
||||
DOWNLOAD_MAX_TTL=60
|
||||
|
||||
# CORS — Comma-separated list of allowed origins.
|
||||
# Use "*" to allow all origins (development default).
|
||||
# For production, restrict to your domain(s).
|
||||
# Examples:
|
||||
# CORS_ORIGINS=*
|
||||
# CORS_ORIGINS=https://myapp.com,https://admin.myapp.com
|
||||
CORS_ORIGINS=*
|
||||
|
||||
# Rate limiting — per-IP sliding window (0 = disabled).
|
||||
MCP_RATE_LIMIT=60 # max MCP tool requests per minute per IP
|
||||
DOWNLOAD_RATE_LIMIT=10 # max download requests per minute per IP
|
||||
RATE_LIMIT_WINDOW=60 # sliding window size in seconds
|
||||
MCP_ENDPOINT=/mcp # MCP HTTP endpoint path (FastMCP default)
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
# Byte-compiled / optimized / DLL files
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
|
||||
# Distribution / packaging
|
||||
build/
|
||||
dist/
|
||||
*.egg-info/
|
||||
*.egg
|
||||
|
||||
# Virtual environments
|
||||
venv/
|
||||
.venv/
|
||||
env/
|
||||
ENV/
|
||||
|
||||
# Environment files — may contain secrets
|
||||
.env
|
||||
.env.local
|
||||
.env.production
|
||||
|
||||
# IDEs
|
||||
.vscode/
|
||||
.idea/
|
||||
*.sublime-project
|
||||
*.sublime-workspace
|
||||
|
||||
# OS files
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Workspace (runtime data, not source)
|
||||
workspace/
|
||||
|
||||
# Docker
|
||||
docker-compose.override.yaml
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
FROM python:3.12-slim
|
||||
|
||||
ENV PYTHONUNBUFFERED=1
|
||||
|
||||
RUN pip install --no-cache-dir fastmcp python-dotenv
|
||||
|
||||
RUN groupadd -g 1000 app && \
|
||||
useradd -m -u 1000 -g 1000 -s /bin/bash app
|
||||
|
||||
WORKDIR /workspace
|
||||
|
||||
COPY server.py /app/server.py
|
||||
|
||||
USER 1000:1000
|
||||
|
||||
EXPOSE 8000
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=10s --retries=3 --start-period=10s \
|
||||
CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"
|
||||
|
||||
CMD ["python", "/app/server.py"]
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 Aaron Tomsett
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,123 @@
|
||||
# mcp-linkgen
|
||||
|
||||
A lightweight [Model Context Protocol](https://modelcontextprotocol.io/) server that generates ephemeral, single-use download links for files stored in a configurable workspace directory. Intended for use in the [Turnstone project](https://github.com/turnstonelabs/turnstone).
|
||||
|
||||
## Features
|
||||
|
||||
- **Single MCP tool** — `generate_download_link` mints a URL containing a cryptographically random token.
|
||||
- **Single-use tokens** — each link is consumed on first request; no replay possible.
|
||||
- **Time-limited** — links expire after a configurable TTL (default 10 min, clamped to 1–60 min).
|
||||
- **Path traversal protection** — `os.path.realpath()` resolves symlinks and `..` before validating against the workspace root.
|
||||
- **Directory blocking** — only files can be shared; directories are rejected.
|
||||
- **In-memory token store** — tokens are never written to disk.
|
||||
- **Configurable CORS** — restrict cross-origin access to specific domains.
|
||||
- **Per-IP rate limiting** — in-memory sliding-window limits on the MCP and download endpoints.
|
||||
- **Structured JSON logging** — every tool call and download is logged as a single JSON line.
|
||||
- **Docker + native** — runs in a ~120 MB container or directly via `python server.py`.
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Docker
|
||||
|
||||
Copy .env example and change any defaults as required:
|
||||
```bash
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
Build and run the server:
|
||||
```bash
|
||||
docker compose up --build
|
||||
```
|
||||
|
||||
The server listens on **port 8000** by default.
|
||||
|
||||
## Configuration
|
||||
|
||||
All settings are read from environment variables. A `.env.example` file is provided with sensible defaults — edit it before starting the server.
|
||||
|
||||
| Variable | Default | Description |
|
||||
|---|---|---|
|
||||
| `HOST` | `0.0.0.0` | Bind address |
|
||||
| `PORT` | `8000` | Listen port |
|
||||
| `WORKSPACE_ROOT` | `/workspace` | Absolute path to the directory containing files to share. Leave undefined to mount `turnstone_workspace` volume |
|
||||
| `PUBLIC_BASE_URL` | `http://localhost:8000` | User-facing base URL for generated links |
|
||||
| `DOWNLOAD_DEFAULT_TTL` | `10` | Default link lifetime in minutes |
|
||||
| `DOWNLOAD_MIN_TTL` | `1` | Minimum allowed TTL (minutes) |
|
||||
| `DOWNLOAD_MAX_TTL` | `60` | Maximum allowed TTL (minutes) |
|
||||
| `CORS_ORIGINS` | `*` | Comma-separated list of allowed origins, or `*` for all |
|
||||
| `MCP_RATE_LIMIT` | `60` | Max MCP tool requests per minute per IP (`0` disables) |
|
||||
| `DOWNLOAD_RATE_LIMIT` | `10` | Max download requests per minute per IP (`0` disables) |
|
||||
| `RATE_LIMIT_WINDOW` | `60` | Sliding window size in seconds |
|
||||
| `MCP_ENDPOINT` | `/mcp` | HTTP path for the MCP endpoint (used by the rate limiter) |
|
||||
|
||||
### CORS examples
|
||||
|
||||
```env
|
||||
# Open (development)
|
||||
CORS_ORIGINS=*
|
||||
|
||||
# Restricted to your domain
|
||||
CORS_ORIGINS=https://myapp.com,https://admin.myapp.com
|
||||
```
|
||||
|
||||
## Endpoints
|
||||
|
||||
| Method | Path | Description |
|
||||
|---|---|---|
|
||||
| `POST` | `/mcp` | MCP protocol endpoint (Streamable HTTP) |
|
||||
| `GET` | `/health` | Liveness probe — returns `{"status":"healthy"}` |
|
||||
| `GET` | `/download?t=<token>` | Streams the file, then invalidates the token |
|
||||
|
||||
## Security Model
|
||||
|
||||
### Strengths
|
||||
|
||||
| Property | Mechanism |
|
||||
|---|---|
|
||||
| **Single-use tokens** | Token is deleted from the in-memory store on first successful download, preventing replay attacks. |
|
||||
| **Time-limited links** | Tokens carry an `expires_at` timestamp. Expired tokens are rejected (HTTP 410) and periodically garbage-collected. |
|
||||
| **Path traversal protection** | `_validate_download_path()` calls `os.path.realpath()` to resolve symlinks and `..`, then verifies the result starts with `WORKSPACE_ROOT + "/"`. |
|
||||
| **Directory isolation** | `IsADirectoryError` is raised for directories — callers must archive them first. |
|
||||
| **In-memory store** | Tokens are never written to disk or logged, limiting exposure surface. |
|
||||
| **CORS restrictions** | Configurable `CORS_ORIGINS` prevents arbitrary cross-origin requests to the `/download` endpoint. |
|
||||
| **Rate limiting** | Per-IP sliding-window limits on the MCP (`POST /mcp`) and download (`GET /download`) endpoints slow brute-force token guessing and API abuse. Exceeding a limit returns HTTP 429 with a `Retry-After` header. |
|
||||
|
||||
### Drawbacks and precautions
|
||||
|
||||
| Concern | Details |
|
||||
|---|---|
|
||||
| **No transport encryption** | The server itself speaks plain HTTP. Deploy behind a TLS-terminating reverse proxy (nginx, Caddy, cloud LB) and set `PUBLIC_BASE_URL` to the `https://` address. |
|
||||
| **No client binding** | Anyone who obtains a token URL can use it — tokens are not tied to an IP, session, or API key. Treat generated URLs as secrets. |
|
||||
| **In-memory persistence** | All tokens are lost on server restart or crash. This is a feature (no state leakage) but means links do not survive restarts. |
|
||||
| **Rate limits are in-memory and per-instance** | Rate-limit counters live in process memory and are not shared across multiple server instances. For horizontally-scaled deployments, front with a shared rate limiter (e.g., a reverse proxy or Redis-backed middleware). |
|
||||
| **Rate limits key on IP** | Counters are keyed by the client IP (honoring a single `X-Forwarded-For` entry). Behind a proxy, ensure the proxy sets `X-Forwarded-For` correctly; otherwise all clients may share one bucket. A determined attacker can rotate IPs to bypass limits — this is a deterrent, not a hard boundary. |
|
||||
| **Workspace scope** | Any file inside `WORKSPACE_ROOT` is reachable by an attacker who knows the path. Limit `WORKSPACE_ROOT` to the narrowest directory needed. |
|
||||
|
||||
### Opaque query parameter
|
||||
|
||||
The download endpoint reads the token from a deliberately opaque single-character query parameter: `?t=<token>`. This naming avoids common credential-leak heuristics that flag parameters named `token`, `key`, `api_key`, etc., so links pass through guardrails without requiring an allow-list rule.
|
||||
|
||||
Two things to note:
|
||||
|
||||
- **No backward compatibility** — `?t=` is the only accepted parameter name. The old `?token=` is not read and will return HTTP 400. Links issued by prior versions of this server will no longer work; regenerate them.
|
||||
- **This is not a security control** — renaming the parameter is a guardrail workaround (security through obscurity), not a hardening measure. Anyone who obtains a link can still download the file. The actual protections remain token entropy (`secrets.token_urlsafe(32)`), single-use enforcement, TTL expiry, and per-IP rate limiting.
|
||||
|
||||
## Usage with an LLM client
|
||||
|
||||
1. The LLM calls `generate_download_link(path="/workspace/report.pdf", ttl_minutes=15)`.
|
||||
2. The server returns `http://localhost:8000/download?t=a1b2c3...`.
|
||||
3. The LLM presents the link to the user (or the user downloads it directly).
|
||||
4. On the first `GET /download?t=...`, the file is streamed and the token is destroyed.
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
mcp-linkgen/
|
||||
├── server.py # Single-module MCP server (all logic)
|
||||
├── .env.example # Environment configuration
|
||||
├── Dockerfile # Slim container build
|
||||
├── compose.yaml # Docker Compose service definition
|
||||
├── README.md # This file
|
||||
├── LICENSE # MIT license
|
||||
└── .gitignore
|
||||
```
|
||||
@@ -0,0 +1,22 @@
|
||||
---
|
||||
|
||||
volumes:
|
||||
workspace:
|
||||
external: true
|
||||
name: turnstone_workspace
|
||||
|
||||
services:
|
||||
mcp-linkgen:
|
||||
build: .
|
||||
env_file: .env
|
||||
ports:
|
||||
- "${PORT:-8000}:${PORT:-8000}"
|
||||
volumes:
|
||||
- "${WORKSPACE_ROOT:-workspace}:/workspace"
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:{PORT:-8000}/health')"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 10s
|
||||
@@ -0,0 +1,364 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
mcp-linkgen — Lightweight MCP server for generating ephemeral download links.
|
||||
|
||||
Exposes a single MCP tool (`generate_download_link`) that mints a single-use,
|
||||
time-limited HTTPS download URL for any file inside the configured workspace
|
||||
directory. An HTTP route (`/download`) serves the file once and invalidates
|
||||
the token.
|
||||
|
||||
Designed to run standalone via `python server.py` or inside a minimal Docker
|
||||
container.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import json
|
||||
import logging
|
||||
import secrets
|
||||
import time
|
||||
from collections import defaultdict
|
||||
from typing import Dict
|
||||
|
||||
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 FileResponse, JSONResponse
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Optional .env loading (convenience for native/local development)
|
||||
# ---------------------------------------------------------------------------
|
||||
try:
|
||||
from dotenv import load_dotenv
|
||||
load_dotenv()
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Logging — structured JSON to stdout
|
||||
# ---------------------------------------------------------------------------
|
||||
class JSONLogFormatter(logging.Formatter):
|
||||
"""Emit one JSON object per log line for machine-parseable output."""
|
||||
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,
|
||||
handlers=[_json_handler],
|
||||
force=True,
|
||||
)
|
||||
logger = logging.getLogger("mcp-linkgen")
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Configuration (all values overridable via environment variables)
|
||||
# ---------------------------------------------------------------------------
|
||||
HOST = os.environ.get("HOST", "0.0.0.0")
|
||||
PORT = int(os.environ.get("PORT", "8000"))
|
||||
|
||||
WORKSPACE_ROOT = os.path.abspath(os.environ.get("WORKSPACE_ROOT", "/workspace"))
|
||||
|
||||
PUBLIC_BASE_URL = os.environ.get("PUBLIC_BASE_URL", "http://localhost:8000")
|
||||
|
||||
DOWNLOAD_DEFAULT_TTL = int(os.environ.get("DOWNLOAD_DEFAULT_TTL", "10")) # minutes
|
||||
DOWNLOAD_MIN_TTL = int(os.environ.get("DOWNLOAD_MIN_TTL", "1")) # minutes
|
||||
DOWNLOAD_MAX_TTL = int(os.environ.get("DOWNLOAD_MAX_TTL", "60")) # minutes
|
||||
|
||||
# CORS origins — comma-separated list, or "*" for all.
|
||||
_cors_raw = os.environ.get("CORS_ORIGINS", "*").strip()
|
||||
CORS_ORIGINS: list[str] = (
|
||||
["*"] if _cors_raw == "*" else [o.strip() for o in _cors_raw.split(",") if o.strip()]
|
||||
)
|
||||
|
||||
# Rate limiting (per-IP sliding window; 0 = disabled).
|
||||
MCP_RATE_LIMIT = int(os.environ.get("MCP_RATE_LIMIT", "30")) # req/min per IP
|
||||
DOWNLOAD_RATE_LIMIT = int(os.environ.get("DOWNLOAD_RATE_LIMIT", "10")) # req/min per IP
|
||||
RATE_LIMIT_WINDOW = int(os.environ.get("RATE_LIMIT_WINDOW", "60")) # sliding window (s)
|
||||
MCP_ENDPOINT = os.environ.get("MCP_ENDPOINT", "/mcp") # MCP HTTP path (FastMCP default)
|
||||
|
||||
logger.info("Workspace root: %s", WORKSPACE_ROOT)
|
||||
logger.info("Public base URL: %s", PUBLIC_BASE_URL)
|
||||
logger.info("CORS origins: %s", CORS_ORIGINS)
|
||||
logger.info("Rate limits — MCP: %d/min, download: %d/min, window: %ds",
|
||||
MCP_RATE_LIMIT, DOWNLOAD_RATE_LIMIT, RATE_LIMIT_WINDOW)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# FastMCP application
|
||||
# ---------------------------------------------------------------------------
|
||||
mcp = FastMCP("mcp-linkgen")
|
||||
|
||||
# In-memory token store — tokens never touch disk.
|
||||
TOKEN_STORE: Dict[str, dict] = {}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _validate_download_path(raw_path: str) -> str:
|
||||
"""Resolve *raw_path* and ensure it lives inside ``WORKSPACE_ROOT``.
|
||||
|
||||
Returns the canonical absolute path on success.
|
||||
|
||||
Raises
|
||||
------
|
||||
PermissionError
|
||||
If the resolved path escapes the workspace root.
|
||||
FileNotFoundError
|
||||
If the file does not exist.
|
||||
IsADirectoryError
|
||||
If the target is a directory (archives must be used for directories).
|
||||
"""
|
||||
canonical_path = os.path.realpath(raw_path)
|
||||
if not canonical_path.startswith(WORKSPACE_ROOT + "/"):
|
||||
raise PermissionError("Access denied: path escapes WORKSPACE_ROOT")
|
||||
if not os.path.exists(canonical_path):
|
||||
raise FileNotFoundError(f"File not found: {raw_path}")
|
||||
if os.path.isdir(canonical_path):
|
||||
raise IsADirectoryError(
|
||||
"Target path is a directory. Please compress it to an archive first."
|
||||
)
|
||||
return canonical_path
|
||||
|
||||
|
||||
def _purge_expired_tokens():
|
||||
"""Remove all tokens whose TTL has elapsed."""
|
||||
now = time.time()
|
||||
expired = [k for k, v in TOKEN_STORE.items() if now > v["expires_at"]]
|
||||
for k in expired:
|
||||
TOKEN_STORE.pop(k, None)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# HTTP routes
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@mcp.custom_route("/health", methods=["GET"])
|
||||
async def health_check(request):
|
||||
"""Liveness probe — returns 200 when the server is running."""
|
||||
return JSONResponse({"status": "healthy", "service": "mcp-linkgen"})
|
||||
|
||||
|
||||
@mcp.custom_route("/download", methods=["GET"])
|
||||
async def download_file(request):
|
||||
"""Serve a file identified by a single-use token, then invalidate it."""
|
||||
_purge_expired_tokens()
|
||||
|
||||
token = request.query_params.get("t")
|
||||
if not token:
|
||||
return JSONResponse({"detail": "Missing token parameter."}, status_code=400)
|
||||
|
||||
record = TOKEN_STORE.get(token)
|
||||
if not record:
|
||||
return JSONResponse(
|
||||
{"detail": "Invalid or previously used download link."}, status_code=403
|
||||
)
|
||||
|
||||
# Immediately consume the token (single-use).
|
||||
del TOKEN_STORE[token]
|
||||
|
||||
if time.time() > record["expires_at"]:
|
||||
return JSONResponse({"detail": "Download link has expired."}, status_code=410)
|
||||
|
||||
file_path = record["file_path"]
|
||||
if not os.path.exists(file_path):
|
||||
return JSONResponse(
|
||||
{"detail": "File no longer exists on host."}, status_code=404
|
||||
)
|
||||
|
||||
return FileResponse(
|
||||
path=file_path,
|
||||
filename=os.path.basename(file_path),
|
||||
media_type="application/octet-stream",
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Middleware — rate limiting, structured logging, CORS
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class RateLimitMiddleware:
|
||||
"""Per-IP sliding-window rate limiter for selected routes.
|
||||
|
||||
Wraps a Starlette application. Routes matching ``MCP_ENDPOINT`` (POST)
|
||||
or ``/download`` (GET) are subject to their respective limits. All other
|
||||
requests pass through unconditionally.
|
||||
"""
|
||||
|
||||
def __init__(self, app, mcp_limit: int, download_limit: int, window: int, mcp_path: str):
|
||||
self.app = app
|
||||
self.mcp_limit = mcp_limit
|
||||
self.download_limit = download_limit
|
||||
self.window = window
|
||||
self.mcp_path = mcp_path
|
||||
self._hits: Dict[str, list[float]] = defaultdict(list)
|
||||
self._last_cleanup = time.time()
|
||||
|
||||
# -- internal helpers ---------------------------------------------------
|
||||
|
||||
def _client_ip(self, request) -> str:
|
||||
forwarded = request.headers.get("x-forwarded-for")
|
||||
if forwarded:
|
||||
return forwarded.split(",")[0].strip()
|
||||
return request.client.host if request.client else "unknown"
|
||||
|
||||
def _prune(self, timestamps: list[float], now: float) -> list[float]:
|
||||
cutoff = now - self.window
|
||||
return [t for t in timestamps if t > cutoff]
|
||||
|
||||
def _cleanup_if_stale(self, now: float):
|
||||
"""Periodically purge stale entries to bound memory usage."""
|
||||
if now - self._last_cleanup > self.window:
|
||||
stale_keys = [
|
||||
ip for ip, ts in self._hits.items()
|
||||
if not ts or ts[-1] <= now - self.window
|
||||
]
|
||||
for ip in stale_keys:
|
||||
del self._hits[ip]
|
||||
self._last_cleanup = now
|
||||
|
||||
# -- ASGI interface -----------------------------------------------------
|
||||
|
||||
async def __call__(self, scope, receive, send):
|
||||
if scope["type"] != "http":
|
||||
return await self.app(scope, receive, send)
|
||||
|
||||
now = time.time()
|
||||
self._cleanup_if_stale(now)
|
||||
|
||||
path = scope.get("path", "")
|
||||
method = scope.get("method", "")
|
||||
|
||||
limit = None
|
||||
if path == self.mcp_path and method == "POST" and self.mcp_limit > 0:
|
||||
limit = self.mcp_limit
|
||||
elif path == "/download" and method == "GET" and self.download_limit > 0:
|
||||
limit = self.download_limit
|
||||
|
||||
if limit is not None:
|
||||
ip = self._client_ip_from_scope(scope)
|
||||
self._hits[ip] = self._prune(self._hits[ip], now)
|
||||
if len(self._hits[ip]) >= limit:
|
||||
retry_after = int(self.window - (now - self._hits[ip][0])) + 1
|
||||
response = JSONResponse(
|
||||
{"detail": "Rate limit exceeded. Try again later."},
|
||||
status_code=429,
|
||||
headers={"Retry-After": str(max(retry_after, 1))},
|
||||
)
|
||||
return await response(scope, receive, send)
|
||||
self._hits[ip].append(now)
|
||||
|
||||
return await self.app(scope, receive, send)
|
||||
|
||||
def _client_ip_from_scope(self, scope) -> str:
|
||||
headers = dict(scope.get("headers", []))
|
||||
forwarded = headers.get(b"x-forwarded-for")
|
||||
if forwarded:
|
||||
return forwarded.decode().split(",")[0].strip()
|
||||
client = scope.get("client")
|
||||
return client[0] if client else "unknown"
|
||||
|
||||
|
||||
class ToolObserverMiddleware(Middleware):
|
||||
"""Log every MCP tool invocation and its result."""
|
||||
|
||||
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 | Tool: '%s' | Args: %s", tool_name, arguments)
|
||||
try:
|
||||
result = await call_next(context)
|
||||
content = getattr(result, "content", result)
|
||||
preview = str(content)[:250].replace("\n", " ")
|
||||
logger.info("SERVER RESPONSE | Tool: '%s' completed | Preview: %s", tool_name, preview)
|
||||
return result
|
||||
except Exception as exc:
|
||||
logger.error("SERVER ERROR | Tool '%s' crashed: %s", tool_name, exc)
|
||||
raise
|
||||
|
||||
mcp.add_middleware(ToolObserverMiddleware())
|
||||
|
||||
middleware_config = [
|
||||
StarletteMiddleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=CORS_ORIGINS,
|
||||
allow_methods=["GET", "POST", "DELETE", "OPTIONS"],
|
||||
allow_headers=[
|
||||
"mcp-protocol-version",
|
||||
"mcp-session-id",
|
||||
"Authorization",
|
||||
"Content-Type",
|
||||
],
|
||||
expose_headers=["mcp-session-id"],
|
||||
),
|
||||
StarletteMiddleware(
|
||||
RateLimitMiddleware,
|
||||
mcp_limit=MCP_RATE_LIMIT,
|
||||
download_limit=DOWNLOAD_RATE_LIMIT,
|
||||
window=RATE_LIMIT_WINDOW,
|
||||
mcp_path=MCP_ENDPOINT,
|
||||
),
|
||||
]
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# MCP tool — generate_download_link
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@mcp.tool()
|
||||
def generate_download_link(path: str, ttl_minutes: int = DOWNLOAD_DEFAULT_TTL) -> str:
|
||||
"""Generate a secure, single-use, temporary download link for a file.
|
||||
|
||||
The link points at the ``/download`` HTTP endpoint on this server.
|
||||
Each link is valid for exactly one request; after that the underlying token
|
||||
is destroyed. Tokens also expire after *ttl_minutes* even if unused.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
path : str
|
||||
Absolute path to the file. Must reside inside the configured
|
||||
workspace directory (default ``/workspace``).
|
||||
ttl_minutes : int, optional
|
||||
How many minutes the link stays valid (default: 10).
|
||||
Clamped to the range ``DOWNLOAD_MIN_TTL`` – ``DOWNLOAD_MAX_TTL``.
|
||||
|
||||
Returns
|
||||
-------
|
||||
str
|
||||
A full URL string on success, or an error message prefixed with
|
||||
``Error:``.
|
||||
"""
|
||||
try:
|
||||
abs_path = _validate_download_path(path)
|
||||
except (PermissionError, FileNotFoundError, IsADirectoryError) as exc:
|
||||
return f"Error: {exc}"
|
||||
|
||||
_purge_expired_tokens()
|
||||
|
||||
effective_ttl = max(DOWNLOAD_MIN_TTL, min(ttl_minutes, DOWNLOAD_MAX_TTL))
|
||||
token = secrets.token_urlsafe(32)
|
||||
expires_at = time.time() + (effective_ttl * 60)
|
||||
|
||||
TOKEN_STORE[token] = {
|
||||
"file_path": abs_path,
|
||||
"expires_at": expires_at,
|
||||
}
|
||||
|
||||
url = f"{PUBLIC_BASE_URL}/download?t={token}"
|
||||
logger.info("Generated download link for %s (ttl=%dm)", abs_path, effective_ttl)
|
||||
return url
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Entry point
|
||||
# ---------------------------------------------------------------------------
|
||||
if __name__ == "__main__":
|
||||
mcp.run(transport="http", host=HOST, port=PORT, middleware=middleware_config)
|
||||
Reference in New Issue
Block a user