From 1deb74b35b1d15ad2241bbaf7d33307d97d5f69f Mon Sep 17 00:00:00 2001 From: Morpheus Sandmann Date: Fri, 4 Sep 2026 18:30:15 +0100 Subject: [PATCH] Initial commit --- .env.example | 37 ++++++ .gitignore | 37 ++++++ Dockerfile | 21 +++ LICENSE | 21 +++ README.md | 123 +++++++++++++++++ compose.yaml | 22 ++++ server.py | 364 +++++++++++++++++++++++++++++++++++++++++++++++++++ 7 files changed, 625 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 LICENSE create mode 100644 README.md create mode 100644 compose.yaml create mode 100644 server.py diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..c82f5b0 --- /dev/null +++ b/.env.example @@ -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) diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..37cbb99 --- /dev/null +++ b/.gitignore @@ -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 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..21d0d68 --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..b8c3562 --- /dev/null +++ b/LICENSE @@ -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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..c910028 --- /dev/null +++ b/README.md @@ -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=` | 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=`. 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 +``` diff --git a/compose.yaml b/compose.yaml new file mode 100644 index 0000000..f445cf8 --- /dev/null +++ b/compose.yaml @@ -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 diff --git a/server.py b/server.py new file mode 100644 index 0000000..a417d7a --- /dev/null +++ b/server.py @@ -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)