From 435766468402be35b0e9d2491749d5929fef2a06 Mon Sep 17 00:00:00 2001 From: Olivier Chafik Date: Fri, 23 Jan 2026 10:43:14 +0000 Subject: [PATCH 01/23] fix(map-server): simplify location update message format --- examples/map-server/server.ts | 2 +- examples/map-server/src/mcp-app.ts | 55 ++++++++++++------------------ 2 files changed, 22 insertions(+), 35 deletions(-) diff --git a/examples/map-server/server.ts b/examples/map-server/server.ts index dbe2154e1..e6ff56422 100644 --- a/examples/map-server/server.ts +++ b/examples/map-server/server.ts @@ -185,7 +185,7 @@ export function createServer(): McpServer { }, ], _meta: { - viewUUID: randomUUID(), + widgetUUID: randomUUID(), }, }), ); diff --git a/examples/map-server/src/mcp-app.ts b/examples/map-server/src/mcp-app.ts index f6edfa56e..05fd88db6 100644 --- a/examples/map-server/src/mcp-app.ts +++ b/examples/map-server/src/mcp-app.ts @@ -71,7 +71,7 @@ let persistViewTimer: ReturnType | null = null; // Track whether tool input has been received (to know if we should restore persisted state) let hasReceivedToolInput = false; -let viewUUID: string | undefined = undefined; +let widgetUUID: string | undefined = undefined; /** * Persisted camera state for localStorage @@ -122,7 +122,7 @@ function schedulePersistViewState(cesiumViewer: any): void { * Persist current view state to localStorage */ function persistViewState(cesiumViewer: any): void { - if (!viewUUID) { + if (!widgetUUID) { log.info("No storage key available, skipping view persistence"); return; } @@ -132,8 +132,8 @@ function persistViewState(cesiumViewer: any): void { try { const value = JSON.stringify(state); - localStorage.setItem(viewUUID, value); - log.info("Persisted view state:", viewUUID, value); + localStorage.setItem(widgetUUID, value); + log.info("Persisted view state:", widgetUUID, value); } catch (e) { log.warn("Failed to persist view state:", e); } @@ -143,10 +143,10 @@ function persistViewState(cesiumViewer: any): void { * Load persisted view state from localStorage */ function loadPersistedViewState(): PersistedCameraState | null { - if (!viewUUID) return null; + if (!widgetUUID) return null; try { - const stored = localStorage.getItem(viewUUID); + const stored = localStorage.getItem(widgetUUID); if (!stored) { console.info("No persisted view state found"); return null; @@ -410,34 +410,19 @@ function scheduleLocationUpdate(cesiumViewer: any): void { } const { widthKm, heightKm } = getScaleDimensions(extent); - - log.info(`Extent: ${widthKm.toFixed(1)}km × ${heightKm.toFixed(1)}km`); - - // Get places visible in the extent (samples multiple points for large areas) const places = await getVisiblePlaces(extent); - // Build structured markdown with YAML frontmatter (like pdf-server) - // Note: tool name isn't in the notification protocol, so we hardcode it - const frontmatter = [ - "---", - `tool: show-map`, - center - ? `center: [${center.lat.toFixed(4)}, ${center.lon.toFixed(4)}]` - : null, - `extent: [${extent.west.toFixed(4)}, ${extent.south.toFixed(4)}, ${extent.east.toFixed(4)}, ${extent.north.toFixed(4)}]`, - `extent-size: ${widthKm.toFixed(1)}km × ${heightKm.toFixed(1)}km`, - places.length > 0 ? `visible-places: [${places.join(", ")}]` : null, - "---", - ] - .filter(Boolean) - .join("\n"); - - log.info("Updating model context:", frontmatter); - // Update the model's context with the current map location. // If the host doesn't support this, the request will silently fail. + const content = [ + `The map view of ${app.getHostContext()?.toolInfo?.id} is now ${widthKm.toFixed(1)}km wide × ${heightKm.toFixed(1)}km tall `, + `and has changed to the following location: `, + `[${places.join(", ")}] ` : '', + `lat. / long. of center of map = [${center.lat.toFixed(4)}, ${center.lon.toFixed(4)}]`, + ].join('\n') + log.info("Updating model context:", content); app.updateModelContext({ - content: [{ type: "text", text: frontmatter }], + content: [{ type: "text", text: content }], }); }, 1500); } @@ -938,14 +923,16 @@ app.ontoolinput = async (params) => { // }, // ); -// Handle tool result - extract viewUUID and restore persisted view if available +// Handle tool result - extract widgetUUID and restore persisted view if available app.ontoolresult = async (result) => { - viewUUID = result._meta?.viewUUID ? String(result._meta.viewUUID) : undefined; - log.info("Tool result received, viewUUID:", viewUUID); + widgetUUID = result._meta?.widgetUUID + ? String(result._meta.widgetUUID) + : undefined; + log.info("Tool result received, widgetUUID:", widgetUUID); - // Now that we have viewUUID, try to restore persisted view + // Now that we have widgetUUID, try to restore persisted view // This overrides the tool input position if a saved state exists - if (viewer && viewUUID) { + if (viewer && widgetUUID) { const restored = restorePersistedView(viewer); if (restored) { log.info("Restored persisted view from tool result handler"); From bcfa50ebfa1973cee738e671a8e2ecad94d7657d Mon Sep 17 00:00:00 2001 From: Olivier Chafik Date: Fri, 23 Jan 2026 10:43:33 +0000 Subject: [PATCH 02/23] fix(say-server): pass host to streamable_http_app for non-localhost deployments --- examples/say-server/.gcloudignore | 5 + examples/say-server/DEPLOYMENT.md | 246 ++++++++++ examples/say-server/Dockerfile | 34 ++ examples/say-server/README.md | 30 +- examples/say-server/SECURITY_REVIEW.md | 607 +++++++++++++++++++++++++ examples/say-server/mcp-app.html | 2 +- examples/say-server/server.py | 99 ++-- 7 files changed, 958 insertions(+), 65 deletions(-) create mode 100644 examples/say-server/.gcloudignore create mode 100644 examples/say-server/DEPLOYMENT.md create mode 100644 examples/say-server/Dockerfile create mode 100644 examples/say-server/SECURITY_REVIEW.md diff --git a/examples/say-server/.gcloudignore b/examples/say-server/.gcloudignore new file mode 100644 index 000000000..99cd4e22a --- /dev/null +++ b/examples/say-server/.gcloudignore @@ -0,0 +1,5 @@ +# Ignore everything except what's needed +* +!Dockerfile +!server.py +!mcp-app.html diff --git a/examples/say-server/DEPLOYMENT.md b/examples/say-server/DEPLOYMENT.md new file mode 100644 index 000000000..7758050d5 --- /dev/null +++ b/examples/say-server/DEPLOYMENT.md @@ -0,0 +1,246 @@ +# Say Server - GCP Cloud Run Deployment + +This document describes how to deploy the Say Server MCP application to Google Cloud Run with session-sticky load balancing. + +## Architecture + +``` +┌─────────────┐ ┌──────────────────┐ ┌─────────────────┐ ┌─────────────┐ +│ Client │────▶│ Load Balancer │────▶│ Serverless NEG │────▶│ Cloud Run │ +│ │ │ (HTTP, IP-based)│ │ │ │ say-server │ +└─────────────┘ └──────────────────┘ └─────────────────┘ └─────────────┘ + │ + ▼ + Session Affinity + (mcp-session-id header) +``` + +## Prerequisites + +- GCP Project with billing enabled +- `gcloud` CLI installed and authenticated +- Docker (for local builds) + +## Current Deployment + +- **Project**: `mcp-apps-say-server` +- **Region**: `us-east1` +- **Service URL**: `https://say-server-109024344223.us-east1.run.app` +- **Load Balancer IP**: `34.160.77.67` + +## Session Stickiness Configuration + +MCP's Streamable HTTP transport uses the `mcp-session-id` header for stateful sessions. The load balancer is configured to route requests with the same session ID to the same Cloud Run instance: + +```bash +# Backend service configuration +gcloud compute backend-services describe say-server-backend --global --format=json | jq '{ + sessionAffinity, + localityLbPolicy, + consistentHash +}' +``` + +Returns: +```json +{ + "sessionAffinity": "HEADER_FIELD", + "localityLbPolicy": "RING_HASH", + "consistentHash": { + "httpHeaderName": "mcp-session-id" + } +} +``` + +## Deployment Steps + +### 1. Set Project + +```bash +export PROJECT_ID=mcp-apps-say-server +gcloud config set project $PROJECT_ID +``` + +### 2. Enable APIs + +```bash +gcloud services enable \ + run.googleapis.com \ + cloudbuild.googleapis.com \ + artifactregistry.googleapis.com \ + compute.googleapis.com +``` + +### 3. Build and Push Docker Image + +```bash +cd examples/say-server + +# Build for linux/amd64 +docker build --platform linux/amd64 \ + -t us-east1-docker.pkg.dev/$PROJECT_ID/cloud-run-source-deploy/say-server:latest . + +# Configure docker auth +gcloud auth configure-docker us-east1-docker.pkg.dev --quiet + +# Push +docker push us-east1-docker.pkg.dev/$PROJECT_ID/cloud-run-source-deploy/say-server:latest +``` + +### 4. Deploy to Cloud Run + +```bash +gcloud run deploy say-server \ + --image us-east1-docker.pkg.dev/$PROJECT_ID/cloud-run-source-deploy/say-server:latest \ + --region us-east1 \ + --memory 4Gi \ + --cpu 2 \ + --timeout 300 \ + --concurrency 10 \ + --min-instances 0 \ + --max-instances 10 \ + --no-cpu-throttling \ + --ingress all +``` + +### 5. Set Up Load Balancer with Session Affinity + +```bash +# Create serverless NEG +gcloud compute network-endpoint-groups create say-server-neg \ + --region=us-east1 \ + --network-endpoint-type=serverless \ + --cloud-run-service=say-server + +# Create backend service +gcloud compute backend-services create say-server-backend \ + --global \ + --load-balancing-scheme=EXTERNAL_MANAGED + +# Add NEG to backend +gcloud compute backend-services add-backend say-server-backend \ + --global \ + --network-endpoint-group=say-server-neg \ + --network-endpoint-group-region=us-east1 + +# Configure session affinity (requires import/export for consistentHash) +gcloud compute backend-services describe say-server-backend --global --format=json | \ + jq 'del(.id, .kind, .selfLink, .creationTimestamp, .fingerprint) + { + "sessionAffinity": "HEADER_FIELD", + "localityLbPolicy": "RING_HASH", + "protocol": "HTTPS", + "consistentHash": {"httpHeaderName": "mcp-session-id"} + }' > /tmp/backend.json + +gcloud compute backend-services import say-server-backend \ + --global \ + --source=/tmp/backend.json \ + --quiet + +# Create URL map +gcloud compute url-maps create say-server-lb \ + --default-service=say-server-backend \ + --global + +# Create HTTP proxy +gcloud compute target-http-proxies create say-server-proxy \ + --url-map=say-server-lb \ + --global + +# Reserve static IP +gcloud compute addresses create say-server-ip \ + --global \ + --ip-version=IPV4 + +# Create forwarding rule +gcloud compute forwarding-rules create say-server-forwarding \ + --global \ + --target-http-proxy=say-server-proxy \ + --address=say-server-ip \ + --ports=80 \ + --load-balancing-scheme=EXTERNAL_MANAGED +``` + +## Access & Authentication + +### Current Issue: Org Policy Restrictions + +Anthropic's GCP org policies prevent: +- `allUsers` or `allAuthenticatedUsers` IAM bindings +- Service account key creation + +This means the service requires authentication. + +### Authenticated Access (Works Now) + +```bash +# Via gcloud proxy (recommended for testing) +gcloud run services proxy say-server \ + --region=us-east1 \ + --port=8888 \ + --project=mcp-apps-say-server + +# Then access at http://127.0.0.1:8888/mcp +curl -X POST http://127.0.0.1:8888/mcp \ + -H "Content-Type: application/json" \ + -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"test","version":"1.0"}}}' +``` + +### Requirements for Public Access + +To enable unauthenticated public access, one of: + +1. **Org Policy Exception** - Request IT to allow `allUsers` for this project: + - Policy: `constraints/iam.allowedPolicyMemberDomains` + - Need exception to add `allUsers` to Cloud Run invoker role + +2. **External Project** - Deploy to a GCP project outside Anthropic's org + +3. **MCP OAuth Auth** - Implement OAuth in the server: + ```python + from mcp.server.fastmcp import FastMCP, AuthSettings + + mcp = FastMCP( + "Say Demo", + auth=AuthSettings( + issuer_url="https://accounts.google.com", + required_scopes=["openid", "email"], + ), + token_verifier=..., # Configure token verification + ) + ``` + +## Files + +- `server.py` - Self-contained MCP server (runs with `uv run server.py`) +- `Dockerfile` - Cloud Run container definition +- `.gcloudignore` - Excludes unnecessary files from upload +- `mcp-app.html` - UI served as MCP resource + +## Updating the Deployment + +```bash +# Rebuild and push +docker build --platform linux/amd64 \ + -t us-east1-docker.pkg.dev/mcp-apps-say-server/cloud-run-source-deploy/say-server:latest . +docker push us-east1-docker.pkg.dev/mcp-apps-say-server/cloud-run-source-deploy/say-server:latest + +# Deploy new revision +gcloud run deploy say-server \ + --image us-east1-docker.pkg.dev/mcp-apps-say-server/cloud-run-source-deploy/say-server:latest \ + --region us-east1 \ + --project mcp-apps-say-server +``` + +## Cleanup + +```bash +# Delete all resources +gcloud compute forwarding-rules delete say-server-forwarding --global --quiet +gcloud compute target-http-proxies delete say-server-proxy --global --quiet +gcloud compute url-maps delete say-server-lb --global --quiet +gcloud compute backend-services delete say-server-backend --global --quiet +gcloud compute network-endpoint-groups delete say-server-neg --region=us-east1 --quiet +gcloud compute addresses delete say-server-ip --global --quiet +gcloud run services delete say-server --region=us-east1 --quiet +``` diff --git a/examples/say-server/Dockerfile b/examples/say-server/Dockerfile new file mode 100644 index 000000000..1b1630701 --- /dev/null +++ b/examples/say-server/Dockerfile @@ -0,0 +1,34 @@ +# Cloud Run Dockerfile for Say Server +# Uses uv for fast Python package management + +FROM ghcr.io/astral-sh/uv:python3.12-bookworm-slim + +# Install git for pip install from git repos +RUN apt-get update && apt-get install -y git && rm -rf /var/lib/apt/lists/* + +WORKDIR /app + +# Copy server file +COPY server.py . + +# Use isolated cache directory for HuggingFace to avoid permission issues +ENV HF_HOME=/app/.cache/huggingface +ENV TRANSFORMERS_CACHE=/app/.cache/huggingface + +# Pre-install dependencies for faster cold starts +RUN uv pip install --system --default-index https://pypi.org/simple \ + "mcp @ git+https://github.com/modelcontextprotocol/python-sdk@main" \ + "uvicorn>=0.34.0" \ + "starlette>=0.46.0" \ + "pocket-tts>=1.0.1" + +# Cloud Run sets PORT env var, server already respects it +# Default to 8080 for Cloud Run +ENV PORT=8080 +ENV HOST=0.0.0.0 + +# Expose port (informational, Cloud Run uses PORT env var) +EXPOSE 8080 + +# Run the server directly with python (not uv run, since deps are pre-installed) +CMD ["python", "server.py"] diff --git a/examples/say-server/README.md b/examples/say-server/README.md index c416755fe..cfe28a150 100644 --- a/examples/say-server/README.md +++ b/examples/say-server/README.md @@ -30,13 +30,13 @@ Add to your MCP client configuration (stdio transport): This example showcases several MCP App capabilities: - **Single-file executable**: Python server with embedded React UI - no build step required -- **Partial tool inputs** (`ontoolinputpartial`): The view receives streaming text as it's being generated +- **Partial tool inputs** (`ontoolinputpartial`): Widget receives streaming text as it's being generated - **Queue-based streaming**: Demonstrates how to stream text out and audio in via a polling tool (adds text to an input queue, retrieves audio chunks from an output queue) -- **Model context updates**: The view updates the LLM with playback progress ("Playing: ...snippet...") +- **Model context updates**: Widget updates the LLM with playback progress ("Playing: ...snippet...") - **Native theming**: Uses CSS variables for automatic dark/light mode adaptation - **Fullscreen mode**: Toggle fullscreen via `requestDisplayMode()` API, press Escape to exit -- **Multi-view speak lock**: Coordinates multiple TTS views via localStorage so only one plays at a time -- **Hidden tools** (`visibility: ["app"]`): Private tools only accessible to the view, not the model +- **Multi-widget speak lock**: Coordinates multiple TTS widgets via localStorage so only one plays at a time +- **Hidden tools** (`visibility: ["app"]`): Private tools only accessible to the widget, not the model - **External links** (`openLink`): Attribution popup uses `app.openLink()` to open external URLs - **CSP metadata**: Resource declares required domains (`esm.sh`) for in-browser transpilation @@ -132,29 +132,29 @@ See the [kyutai/tts-voices](https://huggingface.co/kyutai/tts-voices) repository The entire server is contained in a single `server.py` file: -1. **`say` tool**: Public tool that triggers the view with text to speak -2. **Private tools** (`create_tts_queue`, `add_tts_text`, `poll_tts_audio`, etc.): Hidden from the model, only callable by the view -3. **Embedded React view**: Uses [Babel standalone](https://babeljs.io/docs/babel-standalone) for in-browser JSX transpilation - no build step needed +1. **`say` tool**: Public tool that triggers the widget with text to speak +2. **Private tools** (`create_tts_queue`, `add_tts_text`, `poll_tts_audio`, etc.): Hidden from the model, only callable by the widget +3. **Embedded React widget**: Uses [Babel standalone](https://babeljs.io/docs/babel-standalone) for in-browser JSX transpilation - no build step needed 4. **TTS backend**: Manages per-request audio queues using Pocket TTS -The view communicates with the server via MCP tool calls: +The widget communicates with the server via MCP tool calls: - Receives streaming text via `ontoolinputpartial` callback - Incrementally sends new text to the server as it arrives (via `add_tts_text`) - Polls for generated audio chunks while TTS runs in parallel - Plays audio via Web Audio API with synchronized text highlighting -## Multi-view Speak Lock +## Multi-Widget Speak Lock -When multiple TTS views exist in the same browser (e.g., multiple chat messages each with their own say view), they coordinate via localStorage to ensure only one plays at a time: +When multiple TTS widgets exist in the same browser (e.g., multiple chat messages each with their own say widget), they coordinate via localStorage to ensure only one plays at a time: -1. **Unique view IDs**: Each view receives a UUID via `toolResult._meta.viewUUID` -2. **Announce on Play**: When starting, a view writes `{uuid, timestamp}` to `localStorage["mcp-tts-playing"]` -3. **Poll for Conflicts**: Every 200ms, playing views check if another view took the lock -4. **Yield Gracefully**: If another view started playing, pause and yield +1. **Unique Widget IDs**: Each widget receives a UUID via `toolResult._meta.widgetUUID` +2. **Announce on Play**: When starting, a widget writes `{uuid, timestamp}` to `localStorage["mcp-tts-playing"]` +3. **Poll for Conflicts**: Every 200ms, playing widgets check if another widget took the lock +4. **Yield Gracefully**: If another widget started playing, pause and yield 5. **Clean Up**: On pause/finish, clear the lock (only if owned) -This "last writer wins" protocol ensures a seamless experience: clicking play on any view immediately pauses others, without requiring cross-iframe postMessage coordination. +This "last writer wins" protocol ensures a seamless experience: clicking play on any widget immediately pauses others, without requiring cross-iframe postMessage coordination. ## TODO diff --git a/examples/say-server/SECURITY_REVIEW.md b/examples/say-server/SECURITY_REVIEW.md new file mode 100644 index 000000000..856b8bdc8 --- /dev/null +++ b/examples/say-server/SECURITY_REVIEW.md @@ -0,0 +1,607 @@ +# Say Server - Security Design Document + +**Service**: MCP Say Server (Text-to-Speech) +**Location**: GCP Cloud Run, `us-east1` +**Project**: `mcp-apps-say-server` +**Date**: January 2026 + +--- + +## 1. Overview + +The Say Server is an MCP (Model Context Protocol) application that provides real-time text-to-speech functionality. It demonstrates streaming audio generation with karaoke-style text highlighting. + +### What It Does + +``` +┌─────────────────────────────────────────────────────────────────────────────┐ +│ Say Server │ +│ │ +│ 1. Claude streams text to say() tool call arguments │ +│ 2. Host forwards partial input to MCP App (widget in iframe) │ +│ 3. Widget receives via ontoolinputpartial, sends to server queue │ +│ 4. Server generates audio chunks (CPU-bound TTS via pocket-tts) │ +│ 5. Widget polls for audio, plays via Web Audio API │ +│ 6. Text highlighting synced with audio playback (karaoke-style) │ +│ │ +└─────────────────────────────────────────────────────────────────────────────┘ +``` + +### Key Components + +| Component | Description | +|-----------|-------------| +| `server.py` | Self-contained MCP server with TTS tools | +| `pocket-tts` | Neural TTS model (Kyutai, Apache 2.0) | +| Widget HTML | React-based UI for playback control | +| MCP Protocol | Streamable HTTP transport with session support | + +--- + +## 2. Architecture Diagrams + +### 2.1 High-Level Data Flow + +``` +┌──────────┐ ┌──────────────┐ ┌─────────────────┐ ┌──────────────┐ +│ Claude │────▶│ MCP Host │────▶│ Say Server │────▶│ TTS Model │ +│ (LLM) │ │ (Client) │ │ (Cloud Run) │ │ (pocket-tts)│ +└──────────┘ └──────────────┘ └─────────────────┘ └──────────────┘ + │ │ │ + │ streams tool │ forwards to │ generates audio + │ call arguments │ MCP App (widget) │ + ▼ ▼ ▼ +┌──────────────────────────────────────────────────────────────────────────────┐ +│ │ +│ Claude streams text ──▶ Host forwards partial ──▶ Widget receives via │ +│ to say() tool input tool input to iframe ontoolinputpartial() │ +│ │ +│ Widget calls server: │ +│ create_tts_queue(voice) ──▶ queue_id │ +│ add_tts_text(queue_id, "Hello wor...") │ +│ add_tts_text(queue_id, "ld!") │ +│ end_tts_queue(queue_id) │ +│ │ +│ Widget polls for audio: │ +│ poll_tts_audio(queue_id) ◀─── {chunks: [{audio_base64, ...}], done} │ +│ │ +└──────────────────────────────────────────────────────────────────────────────┘ +``` + +**Key insight**: The widget (MCP App) is the active party - it receives streamed text from Claude via the host, then independently calls server tools to manage TTS generation. + +### 2.2 Queue Architecture + +``` +┌─────────────────────────────────────────────────────────────────────────────┐ +│ Server Process Memory │ +│ │ +│ tts_queues: Dict[str, TTSQueueState] │ +│ ┌─────────────────────────────────────────────────────────────────────┐ │ +│ │ │ │ +│ │ "a1b2c3d4e5f6" ──▶ TTSQueueState { │ │ +│ │ id: "a1b2c3d4e5f6" │ │ +│ │ text_queue: AsyncQueue ◀── text chunks │ │ +│ │ audio_chunks: List ──▶ generated audio │ │ +│ │ chunks_delivered: int │ │ +│ │ status: "active" | "complete" | "error" │ │ +│ │ task: AsyncTask (background TTS) │ │ +│ │ } │ │ +│ │ │ │ +│ │ "x7y8z9a0b1c2" ──▶ TTSQueueState { ... } (different session) │ │ +│ │ │ │ +│ └─────────────────────────────────────────────────────────────────────┘ │ +│ │ +└─────────────────────────────────────────────────────────────────────────────┘ +``` + +### 2.3 Information Flow: Text → Audio + +``` +┌─────────────────────────────────────────────────────────────────────────────┐ +│ │ +│ CLIENT (Widget) SERVER (Cloud Run) │ +│ ────────────────── ────────────────── │ +│ │ +│ 1. create_tts_queue(voice) ─────▶ Creates TTSQueueState │ +│ ◀───────────────────────────── Returns {queue_id, sample_rate} │ +│ │ +│ 2. add_tts_text(queue_id, "He") ─▶ Queues text │ +│ add_tts_text(queue_id, "llo")─▶ Queues text │ +│ add_tts_text(queue_id, " ") ──▶ Queues text │ +│ │ │ +│ ▼ │ +│ ┌──────────────────┐ │ +│ │ Background Task │ │ +│ │ ─────────────────│ │ +│ │ StreamingChunker │ │ +│ │ buffers text │ │ +│ │ until sentence │ │ +│ │ boundary │ │ +│ │ │ │ │ +│ │ ▼ │ │ +│ │ TTS Model │ │ +│ │ generates audio │ │ +│ │ (run_in_executor)│ │ +│ │ │ │ │ +│ │ ▼ │ │ +│ │ audio_chunks[] │ │ +│ └──────────────────┘ │ +│ │ +│ 3. poll_tts_audio(queue_id) ────▶ Returns new chunks since last poll │ +│ ◀──────────────────────────── {chunks: [...], done: false} │ +│ poll_tts_audio(queue_id) ────▶ │ +│ ◀──────────────────────────── {chunks: [...], done: true} │ +│ │ +│ 4. end_tts_queue(queue_id) ─────▶ Signals EOF, flushes remaining text │ +│ │ +└─────────────────────────────────────────────────────────────────────────────┘ +``` + +### 2.4 Polling Mechanism + +``` +┌─────────────────────────────────────────────────────────────────────────────┐ +│ Widget Polling Loop │ +│ │ +│ while (!done) { │ +│ response = await callServerTool("poll_tts_audio", {queue_id}) │ +│ │ +│ for (chunk of response.chunks) { │ +│ // Decode base64 audio │ +│ // Schedule on Web Audio API │ +│ // Track timing for text sync │ +│ } │ +│ │ +│ if (response.chunks.length > 0) { │ +│ await sleep(20ms) // Fast poll during active streaming │ +│ } else { │ +│ await sleep(50-150ms) // Exponential backoff when waiting │ +│ } │ +│ } │ +│ │ +│ Server-side: │ +│ ───────────── │ +│ - chunks_delivered tracks what client has seen │ +│ - poll returns audio_chunks[chunks_delivered:] │ +│ - Updates chunks_delivered after each poll │ +│ │ +└─────────────────────────────────────────────────────────────────────────────┘ +``` + +--- + +## 3. Session & Queue Isolation + +### 3.1 Session Isolation Model + +``` +┌─────────────────────────────────────────────────────────────────────────────┐ +│ │ +│ Session A (User 1) Session B (User 2) │ +│ ────────────────── ────────────────── │ +│ │ +│ queue_id: "a1b2c3d4e5f6" queue_id: "x7y8z9a0b1c2" │ +│ │ │ │ +│ ▼ ▼ │ +│ ┌─────────────────┐ ┌─────────────────┐ │ +│ │ TTSQueueState A │ │ TTSQueueState B │ │ +│ │ │ │ │ │ +│ │ text: "Hello" │ │ text: "Goodbye" │ │ +│ │ audio: [...] │ │ audio: [...] │ │ +│ │ voice: cosette │ │ voice: alba │ │ +│ └─────────────────┘ └─────────────────┘ │ +│ │ +│ ✓ Each queue is completely independent │ +│ ✓ Queue ID is the only "key" to access data │ +│ ✓ No shared state between queues │ +│ │ +└─────────────────────────────────────────────────────────────────────────────┘ +``` + +### 3.2 Queue ID as Access Token + +```python +# Queue creation generates random 12-char hex ID +queue_id = uuid.uuid4().hex[:12] # e.g., "a1b2c3d4e5f6" + +# All operations require queue_id +add_tts_text(queue_id, text) # Only works if you know the ID +poll_tts_audio(queue_id) # Only returns YOUR queue's audio +end_tts_queue(queue_id) # Only ends YOUR queue +``` + +**Entropy**: 12 hex chars = 48 bits = 281 trillion possible values + +--- + +## 4. CPU Isolation (TTS Processing) + +### 4.1 Thread Pool Isolation + +``` +┌─────────────────────────────────────────────────────────────────────────────┐ +│ │ +│ Main Event Loop (asyncio) │ +│ ───────────────────────── │ +│ - Handles HTTP requests │ +│ - Manages queue state │ +│ - Non-blocking operations │ +│ │ +│ │ │ +│ │ run_in_executor() │ +│ ▼ │ +│ ┌─────────────────────────────────────────────────────────────────────┐ │ +│ │ Thread Pool Executor │ │ +│ │ │ │ +│ │ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ │ +│ │ │ Thread 1 │ │ Thread 2 │ │ Thread 3 │ ... │ │ +│ │ │ Queue A │ │ Queue B │ │ Queue C │ │ │ +│ │ │ TTS work │ │ TTS work │ │ TTS work │ │ │ +│ │ └─────────────┘ └─────────────┘ └─────────────┘ │ │ +│ │ │ │ +│ │ - Each queue's TTS runs in separate thread │ │ +│ │ - CPU-bound work doesn't block event loop │ │ +│ │ - Natural isolation via thread boundaries │ │ +│ │ │ │ +│ └─────────────────────────────────────────────────────────────────────┘ │ +│ │ +└─────────────────────────────────────────────────────────────────────────────┘ +``` + +### 4.2 No Shared TTS State + +```python +# Each queue gets its own model state copy +model_state = tts_model._cached_get_state_for_audio_prompt(voice, truncate=True) + +# Audio generation uses copy_state=True +for audio_chunk in tts_model._generate_audio_stream_short_text( + model_state=model_state, + text_to_generate=text, + copy_state=True, # ← Ensures isolation +): + ... +``` + +--- + +## 5. Need for Session Stickiness + +### 5.1 Why Stickiness is Required + +``` +┌─────────────────────────────────────────────────────────────────────────────┐ +│ │ +│ WITHOUT Stickiness (BROKEN) │ +│ ─────────────────────────── │ +│ │ +│ Request 1: create_tts_queue() ──▶ Instance A ──▶ queue_id: "abc123" │ +│ Request 2: add_tts_text("abc123") ──▶ Instance B ──▶ "Queue not found!" ✗ │ +│ │ +│ The queue exists only in Instance A's memory! │ +│ │ +├─────────────────────────────────────────────────────────────────────────────┤ +│ │ +│ WITH Stickiness (WORKING) │ +│ ───────────────────────── │ +│ │ +│ Request 1: create_tts_queue() │ +│ mcp-session-id: xyz ──▶ Instance A ──▶ queue_id: "abc123" │ +│ │ +│ Request 2: add_tts_text("abc123") │ +│ mcp-session-id: xyz ──▶ Instance A ──▶ Text queued ✓ │ +│ (same session ID → same instance) │ +│ │ +│ Request 3: poll_tts_audio("abc123") │ +│ mcp-session-id: xyz ──▶ Instance A ──▶ Audio chunks ✓ │ +│ │ +└─────────────────────────────────────────────────────────────────────────────┘ +``` + +### 5.2 MCP Session Protocol + +``` +┌─────────────────────────────────────────────────────────────────────────────┐ +│ │ +│ First Request (no session) │ +│ ────────────────────────── │ +│ │ +│ POST /mcp │ +│ Content-Type: application/json │ +│ (no mcp-session-id header) │ +│ │ +│ Response: │ +│ mcp-session-id: sess_abc123xyz ◀── Server generates session ID │ +│ │ +├─────────────────────────────────────────────────────────────────────────────┤ +│ │ +│ Subsequent Requests │ +│ ─────────────────── │ +│ │ +│ POST /mcp │ +│ Content-Type: application/json │ +│ mcp-session-id: sess_abc123xyz ◀── Client sends back session ID │ +│ │ +│ Load Balancer: │ +│ - Hashes "sess_abc123xyz" │ +│ - Routes to same instance via consistent hashing │ +│ │ +└─────────────────────────────────────────────────────────────────────────────┘ +``` + +--- + +## 6. Security Analysis + +### 6.1 Attack: Accessing Another User's Queue + +``` +┌─────────────────────────────────────────────────────────────────────────────┐ +│ │ +│ ATTACK SCENARIO │ +│ ─────────────── │ +│ │ +│ Attacker wants to: │ +│ 1. Read audio from victim's queue │ +│ 2. Inject text into victim's queue │ +│ 3. Cancel victim's queue │ +│ │ +├─────────────────────────────────────────────────────────────────────────────┤ +│ │ +│ ATTACK REQUIREMENTS │ +│ ─────────────────── │ +│ │ +│ 1. Know victim's queue_id (12-char hex = 48 bits entropy) │ +│ - Not exposed in any API response │ +│ - Not in URLs, logs, or error messages │ +│ - Only returned to queue creator │ +│ │ +│ 2. Be routed to same Cloud Run instance (for in-memory access) │ +│ - Requires matching mcp-session-id hash │ +│ - Session IDs are also random and not exposed │ +│ │ +├─────────────────────────────────────────────────────────────────────────────┤ +│ │ +│ WHY IT'S NOT POSSIBLE │ +│ ───────────────────── │ +│ │ +│ ┌────────────────────────────────────────────────────────────────┐ │ +│ │ │ │ +│ │ Brute Force Analysis: │ │ +│ │ │ │ +│ │ Queue ID space: 16^12 = 281,474,976,710,656 possibilities │ │ +│ │ Queue lifetime: ~30 seconds (timeout) to ~5 minutes (usage) │ │ +│ │ Concurrent queues: typically 1-10 per instance │ │ +│ │ │ │ +│ │ Probability of guessing valid queue_id: │ │ +│ │ P = active_queues / total_space │ │ +│ │ P = 10 / 281,474,976,710,656 │ │ +│ │ P ≈ 3.5 × 10^-14 │ │ +│ │ │ │ +│ │ At 1000 requests/second, expected time to find valid ID: │ │ +│ │ T = 281,474,976,710,656 / 10 / 1000 seconds │ │ +│ │ T ≈ 891 years │ │ +│ │ │ │ +│ └────────────────────────────────────────────────────────────────┘ │ +│ │ +│ Additional Barriers: │ +│ - Rate limiting would kick in │ +│ - Queue expires before brute force succeeds │ +│ - Attacker's requests go to different instances (session affinity) │ +│ │ +└─────────────────────────────────────────────────────────────────────────────┘ +``` + +### 6.2 Attack: Session ID Enumeration + +``` +┌─────────────────────────────────────────────────────────────────────────────┐ +│ │ +│ ATTACK: Guess mcp-session-id to route to victim's instance │ +│ │ +│ WHY IT FAILS: │ +│ ───────────── │ +│ │ +│ 1. Session IDs are server-generated (not predictable) │ +│ 2. Even if routed to same instance, still need queue_id │ +│ 3. Session ID ≠ Queue ID (they're independent) │ +│ │ +│ ┌────────────────────────────────────────────────────────────────┐ │ +│ │ │ │ +│ │ Attacker sends: │ │ +│ │ mcp-session-id: guessed_value │ │ +│ │ │ │ │ +│ │ ▼ │ │ +│ │ Load Balancer routes to Instance X (based on hash) │ │ +│ │ │ │ │ +│ │ ▼ │ │ +│ │ Attacker calls poll_tts_audio(guessed_queue_id) │ │ +│ │ │ │ │ +│ │ ▼ │ │ +│ │ Server: "Queue not found" (queue_id is still wrong) │ │ +│ │ │ │ +│ └────────────────────────────────────────────────────────────────┘ │ +│ │ +└─────────────────────────────────────────────────────────────────────────────┘ +``` + +### 6.3 Data Exposure Summary + +| Data | Exposed To | Risk Level | +|------|------------|------------| +| Queue ID | Only queue creator | 🟢 Low | +| Session ID | Only session holder | 🟢 Low | +| Input text | Only queue owner (via poll) | 🟢 Low | +| Audio data | Only queue owner (via poll) | 🟢 Low | +| Voice name | Only queue owner | 🟢 Low | + +### 6.4 Potential Improvements (Not Required) + +| Enhancement | Benefit | Complexity | +|-------------|---------|------------| +| Sign queue IDs with HMAC | Prevent any forged IDs | Medium | +| Bind queue to session ID | Defense in depth | Low | +| Encrypt audio in transit | Already HTTPS | N/A | +| Add queue access logging | Audit trail | Low | + +--- + +## 7. Deployment Security + +### 7.1 Current Controls + +| Control | Status | Notes | +|---------|--------|-------| +| HTTPS (Cloud Run) | ✅ | Enforced by default | +| Container sandbox | ✅ | gVisor isolation | +| No persistent storage | ✅ | Stateless design | +| No secrets in code | ✅ | Uses public HuggingFace models | +| Queue auto-cleanup | ✅ | 30s timeout, 60s post-completion | + +### 7.2 Pending for Public Access + +| Requirement | Status | Action Needed | +|-------------|--------|---------------| +| Org policy exception | ❌ | Add `allUsersAccess` tag + `allUsers` invoker | +| HTTPS on Load Balancer | ❌ | Add SSL certificate | +| Rate limiting | ⚠️ | Consider Cloud Armor | +| Max instances limit | ⚠️ | Set scaling constraints for cost control | + +### 7.3 Enabling Public Access (Reference: mcp-server-everything) + +Based on the [Hosted Everything MCP Server](https://docs.google.com/document/d/138rvE5iLeSAJKljo9mNMftvUyjIuvf4tn20oVz7hojY) deployment, public access requires: + +```bash +# Step 1: Add allUsersAccess tag to exempt from Domain Restricted Sharing +# Requires: roles/resourcemanager.tagUser at org level (or "GCP Org - Tag Admin Access" 2PC role) +gcloud resource-manager tags bindings create \ + --tag-value=tagValues/281479845332531 \ + --parent=//run.googleapis.com/projects/mcp-apps-say-server/locations/us-east1/services/say-server \ + --location=us-east1 + +# Step 2: Allow unauthenticated invocations +gcloud run services add-iam-policy-binding say-server \ + --project=mcp-apps-say-server \ + --member="allUsers" \ + --role="roles/run.invoker" \ + --region=us-east1 + +# Step 3: Set max instances for cost control +gcloud run services update say-server \ + --max-instances=5 \ + --region=us-east1 \ + --project=mcp-apps-say-server +``` + +**Prerequisites**: +- `GCP Org - Tag Admin Access` 2PC role (or `roles/resourcemanager.tagUser`) +- `roles/run.admin` or security admin permissions + +### 7.4 Recommended Application-Level Security (from mcp-server-everything) + +Once public, implement these hardening measures: + +**Priority 1 (Critical)**: +```javascript +// Rate limiting per IP +const rateLimit = require('express-rate-limit'); +app.use('/mcp', rateLimit({ + windowMs: 15 * 60 * 1000, // 15 minutes + max: 100, // limit each IP to 100 requests per windowMs +})); + +// Request size limits +app.use(express.json({ limit: '10mb' })); + +// Request timeout +app.use(timeout('30s')); +``` + +**Priority 2 (Important)**: +- Budget alerts configured +- Security monitoring and alerting +- Periodic queue cleanup (already implemented: 30s timeout, 60s post-cleanup) + +### 7.5 Security Verdict (Aligned with mcp-server-everything) + +**✅ SECURE for Testing/Demo Purposes** because: +1. **No sensitive data** processed or stored +2. **Infrastructure properly isolated** (Cloud Run sandbox) +3. **Worst-case scenario** is cost incurrence or service disruption +4. **Purpose-built for testing** with clear boundaries +5. **Queue auto-cleanup** prevents data accumulation + +**Comparison with mcp-server-everything**: + +| Aspect | mcp-server-everything | say-server | +|--------|----------------------|------------| +| State storage | Redis (VPC) | In-memory (per instance) | +| Session mgmt | Redis-backed | Queue ID + session affinity | +| Public access | ✅ Enabled | ❌ Pending | +| Rate limiting | Application-level | Not yet implemented | +| Max instances | 5 | 10 (should reduce) | + +--- + +## 8. Appendix: Queue Lifecycle + +``` +┌─────────────────────────────────────────────────────────────────────────────┐ +│ │ +│ QUEUE STATES │ +│ ──────────── │ +│ │ +│ create_tts_queue() │ +│ │ │ +│ ▼ │ +│ ┌─────────────┐ │ +│ │ ACTIVE │◀─── add_tts_text() ───┐ │ +│ │ │ │ │ +│ │ Processing │────────────────────────┘ │ +│ └─────────────┘ │ +│ │ │ +│ │ end_tts_queue() or timeout │ +│ ▼ │ +│ ┌─────────────┐ ┌─────────────┐ │ +│ │ COMPLETE │ or │ ERROR │ │ +│ │ │ │ │ │ +│ │ All audio │ │ Timeout or │ │ +│ │ generated │ │ exception │ │ +│ └─────────────┘ └─────────────┘ │ +│ │ │ │ +│ └─────────┬─────────┘ │ +│ │ │ +│ ▼ │ +│ 60 seconds after done │ +│ │ │ +│ ▼ │ +│ [Queue Removed] │ +│ │ +└─────────────────────────────────────────────────────────────────────────────┘ +``` + +--- + +## 9. References + +- **[Hosted Everything MCP Server](https://docs.google.com/document/d/138rvE5iLeSAJKljo9mNMftvUyjIuvf4tn20oVz7hojY)** - Jerome's deployment guide for `mcp-server-everything`, used as reference for security patterns and public access setup +- **[How to set up public Cloud Run services](https://outline.ant.dev/doc/how-to-set-up-public-cloud-run-services-zv7t2CPClu)** - Anthropic internal guide for org policy exemptions +- **[MCP Apps SDK Specification](../../specification/draft/apps.mdx)** - Protocol spec for MCP Apps + +--- + +## 10. Contact & Approval + +**Owner**: ochafik@anthropic.com +**Repository**: github.com/modelcontextprotocol/ext-apps +**Component**: examples/say-server + +### Approval Checklist + +- [ ] Security review completed +- [ ] Org policy exception approved (`allUsersAccess` tag applied) +- [ ] HTTPS configured on load balancer +- [ ] Max instances set to 5 (cost control) +- [ ] Rate limiting configured (optional) +- [ ] Monitoring/alerting set up diff --git a/examples/say-server/mcp-app.html b/examples/say-server/mcp-app.html index 1e2389bda..c51777c71 100644 --- a/examples/say-server/mcp-app.html +++ b/examples/say-server/mcp-app.html @@ -4,7 +4,7 @@ - Say View + Say Widget diff --git a/examples/say-server/server.py b/examples/say-server/server.py index 02ae45ad2..0b0326846 100755 --- a/examples/say-server/server.py +++ b/examples/say-server/server.py @@ -12,17 +12,17 @@ Say Demo - MCP App for streaming text-to-speech. This MCP server provides a "say" tool that speaks text using TTS. -The view receives streaming partial input and starts speaking immediately. +The widget receives streaming partial input and starts speaking immediately. Architecture: -- The `say` tool itself is a no-op - it just triggers the view -- The view uses `ontoolinputpartial` to receive text as it streams -- view calls private tools to create TTS queue, add text, and poll audio -- Audio plays in the view using Web Audio API +- The `say` tool itself is a no-op - it just triggers the widget +- The widget uses `ontoolinputpartial` to receive text as it streams +- Widget calls private tools to create TTS queue, add text, and poll audio +- Audio plays in the widget using Web Audio API - Model context updates show playback progress to the LLM - Native theming adapts to dark/light mode automatically - Fullscreen mode with Escape key to exit -- Multi-View speak lock coordinates playback across instances +- Multi-widget speak lock coordinates playback across instances Usage: # Start the MCP server @@ -56,7 +56,7 @@ logger = logging.getLogger(__name__) -VIEW_URI = "ui://say-demo/view.html" +WIDGET_URI = "ui://say-demo/widget.html" HOST = os.environ.get("HOST", "0.0.0.0") PORT = int(os.environ.get("PORT", "3109")) @@ -167,8 +167,8 @@ def list_voices() -> list[types.TextContent]: @mcp.tool(meta={ - "ui":{"resourceUri": VIEW_URI}, - "ui/resourceUri": VIEW_URI, # legacy support + "ui":{"resourceUri": WIDGET_URI}, + "ui/resourceUri": WIDGET_URI, # legacy support }) def say( text: Annotated[str, Field(description="The English text to speak aloud")] = DEFAULT_TEXT, @@ -192,19 +192,19 @@ def say( Note: English only. Non-English text may produce poor or garbled results. """ - # Generate a unique ID for this view instance (used for speak lock coordination) - view_uuid = uuid.uuid4().hex[:12] + # Generate a unique ID for this widget instance (used for speak lock coordination) + widget_uuid = uuid.uuid4().hex[:12] - # This is a no-op - the view handles everything via ontoolinputpartial + # This is a no-op - the widget handles everything via ontoolinputpartial # The tool exists to: - # 1. Trigger the view to load + # 1. Trigger the widget to load # 2. Provide the resourceUri metadata # 3. Show the final text in the tool result - # 4. Provide view UUID for multi-player coordination + # 4. Provide widget UUID for multi-player coordination return [types.TextContent( type="text", - text=f"Displayed a TTS view with voice '{voice}'. Click to play/pause, use toolbar to restart or fullscreen.", - _meta={"viewUUID": view_uuid}, + text=f"Displayed a TTS widget with voice '{voice}'. Click to play/pause, use toolbar to restart or fullscreen.", + _meta={"widgetUUID": widget_uuid}, )] @@ -348,7 +348,7 @@ def poll_tts_audio(queue_id: str) -> list[types.TextContent]: new_chunks = state.audio_chunks[state.chunks_delivered:] state.chunks_delivered = len(state.audio_chunks) - # Consider queues with errors as "done" so view stops polling + # Consider queues with errors as "done" so widget stops polling done = (state.status == "complete" or state.status == "error") and state.chunks_delivered >= len(state.audio_chunks) response = { @@ -661,18 +661,18 @@ def generate_sync(): # ------------------------------------------------------ -# View Resource +# Widget Resource # ------------------------------------------------------ -# Embedded View HTML for standalone execution via `uv run ` +# Embedded widget HTML for standalone execution via `uv run ` # Uses Babel standalone for in-browser JSX transpilation -# This is a copy of view.html - keep them in sync! -EMBEDDED_VIEW_HTML = """ +# This is a copy of widget.html - keep them in sync! +EMBEDDED_WIDGET_HTML = """ - Say View + Say Widget """ -def get_view_html() -> str: - """Get the View HTML, preferring built version from dist/.""" +def get_widget_html() -> str: + """Get the widget HTML, preferring built version from dist/.""" # Prefer built version from dist/ (local development with npm run build) dist_path = Path(__file__).parent / "dist" / "mcp-app.html" if dist_path.exists(): return dist_path.read_text() - # Fallback to embedded View (for `uv run ` or unbundled usage) - return EMBEDDED_VIEW_HTML + # Fallback to embedded widget (for `uv run ` or unbundled usage) + return EMBEDDED_WIDGET_HTML # IMPORTANT: all the external domains used by app must be listed # in the meta.ui.csp.resourceDomains - otherwise they will be blocked by CSP policy @mcp.resource( - VIEW_URI, + WIDGET_URI, mime_type="text/html;profile=mcp-app", meta={"ui": {"csp": {"resourceDomains": ["https://esm.sh", "https://unpkg.com"]}}}, ) -def view() -> str: - """View HTML resource with CSP metadata for external dependencies.""" - return get_view_html() +def widget() -> str: + """Widget HTML resource with CSP metadata for external dependencies.""" + return get_widget_html() # ------------------------------------------------------ @@ -1429,7 +1429,8 @@ def load_tts_model(): def create_app(): """Create the ASGI app (for uvicorn reload mode).""" load_tts_model() - app = mcp.streamable_http_app(stateless_http=True) + # Pass host=HOST to disable DNS rebinding protection for non-localhost deployments + app = mcp.streamable_http_app(stateless_http=True, host=HOST) app.add_middleware( CORSMiddleware, allow_origins=["*"], From 2d4e33f65933a210fc1293885f44e9491badb29d Mon Sep 17 00:00:00 2001 From: Olivier Chafik Date: Fri, 23 Jan 2026 10:43:43 +0000 Subject: [PATCH 03/23] refactor(pdf-server): simplify to stateless range-query architecture - Remove caching/indexing in favor of direct HTTP Range requests - Use HEAD requests to get file size (no full download) - Use Range requests to proxy byte chunks (no caching) - Local files: fs.read with offset/length - Remote files: HTTP Range header - Zero memory footprint (no PDF caching) - Truly stateless (no session management needed) - Single server.ts (~350 lines) + main.ts CLI entry point - Instant startup (no preloading) - Security: whitelist local files, allowed origins Removed files: - src/pdf-indexer.ts - src/pdf-loader.ts - src/types.ts --- examples/pdf-server/main.ts | 120 +++---- examples/pdf-server/package.json | 2 +- examples/pdf-server/server.ts | 425 +++++++++++++++++-------- examples/pdf-server/src/mcp-app.css | 3 +- examples/pdf-server/src/mcp-app.ts | 18 +- examples/pdf-server/src/pdf-indexer.ts | 57 ---- examples/pdf-server/src/pdf-loader.ts | 136 -------- examples/pdf-server/src/types.ts | 51 --- 8 files changed, 362 insertions(+), 450 deletions(-) delete mode 100644 examples/pdf-server/src/pdf-indexer.ts delete mode 100644 examples/pdf-server/src/pdf-loader.ts delete mode 100644 examples/pdf-server/src/types.ts diff --git a/examples/pdf-server/main.ts b/examples/pdf-server/main.ts index ac0b8ccbf..ed8ce0d45 100644 --- a/examples/pdf-server/main.ts +++ b/examples/pdf-server/main.ts @@ -1,46 +1,39 @@ /** - * Entry point for running the MCP server. - * Run with: npx mcp-pdf-server - * Or: node dist/index.js [--stdio] [pdf-urls...] - */ - -/** - * Shared utilities for running MCP servers with Streamable HTTP transport. + * PDF MCP Server - CLI Entry Point */ +import fs from "node:fs"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; -import { createMcpExpressApp } from "@modelcontextprotocol/sdk/server/express.js"; -import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; +import express from "express"; import cors from "cors"; -import type { Request, Response } from "express"; -import { createServer, initializePdfIndex } from "./server.js"; -import { isArxivUrl, toFileUrl, normalizeArxivUrl } from "./src/pdf-indexer.js"; - -export interface ServerOptions { - port: number; - name?: string; -} -/** - * Starts an MCP server with Streamable HTTP transport in stateless mode. - * - * @param createServer - Factory function that creates a new McpServer instance per request. - * @param options - Server configuration options. - */ -export async function startServer( - createServer: () => McpServer, - options: ServerOptions, -): Promise { - const { port, name = "MCP Server" } = options; - - const app = createMcpExpressApp({ host: "0.0.0.0" }); +import { + createServer, + isArxivUrl, + isFileUrl, + normalizeArxivUrl, + pathToFileUrl, + fileUrlToPath, + allowedLocalFiles, + allowedRemoteOrigins, + DEFAULT_PDF, +} from "./server.js"; + +// ============================================================================= +// Server Startup +// ============================================================================= + +async function startHttpServer(port: number): Promise { + const app = express(); app.use(cors()); + app.use(express.json()); - app.all("/mcp", async (req: Request, res: Response) => { + // Stateless mode - no session management needed! + app.all("/mcp", async (req, res) => { const server = createServer(); const transport = new StreamableHTTPServerTransport({ - sessionIdGenerator: undefined, + sessionIdGenerator: undefined, // Stateless is fine now - no shared state! }); res.on("close", () => { @@ -63,24 +56,25 @@ export async function startServer( } }); - const httpServer = app.listen(port, (err) => { - if (err) { - console.error("Failed to start server:", err); - process.exit(1); - } - console.log(`${name} listening on http://localhost:${port}/mcp`); - }); + return new Promise((resolve) => { + const httpServer = app.listen(port, () => { + console.log(`PDF Server (range-based) listening on http://localhost:${port}/mcp`); + resolve(); + }); - const shutdown = () => { - console.log("\nShutting down..."); - httpServer.close(() => process.exit(0)); - }; + const shutdown = () => { + console.log("\nShutting down..."); + httpServer.close(() => process.exit(0)); + }; - process.on("SIGINT", shutdown); - process.on("SIGTERM", shutdown); + process.on("SIGINT", shutdown); + process.on("SIGTERM", shutdown); + }); } -const DEFAULT_PDF = "https://arxiv.org/pdf/1706.03762"; // Attention Is All You Need +// ============================================================================= +// CLI Argument Parsing +// ============================================================================= function parseArgs(): { urls: string[]; stdio: boolean } { const args = process.argv.slice(2); @@ -91,14 +85,10 @@ function parseArgs(): { urls: string[]; stdio: boolean } { if (arg === "--stdio") { stdio = true; } else if (!arg.startsWith("-")) { - // Convert local paths to file:// URLs, normalize arxiv URLs let url = arg; - if ( - !arg.startsWith("http://") && - !arg.startsWith("https://") && - !arg.startsWith("file://") - ) { - url = toFileUrl(arg); + if (!arg.startsWith("http://") && !arg.startsWith("https://") && !arg.startsWith("file://")) { + // Convert local path to file:// URL + url = pathToFileUrl(arg); } else if (isArxivUrl(arg)) { url = normalizeArxivUrl(arg); } @@ -109,18 +99,34 @@ function parseArgs(): { urls: string[]; stdio: boolean } { return { urls: urls.length > 0 ? urls : [DEFAULT_PDF], stdio }; } +// ============================================================================= +// Main +// ============================================================================= + async function main() { const { urls, stdio } = parseArgs(); - console.error(`[pdf-server] Initializing with ${urls.length} PDF(s)...`); - await initializePdfIndex(urls); - console.error(`[pdf-server] Ready`); + // Register local files in whitelist + for (const url of urls) { + if (isFileUrl(url)) { + const filePath = fileUrlToPath(url); + if (fs.existsSync(filePath)) { + allowedLocalFiles.add(filePath); + console.error(`[pdf-server] Registered local file: ${filePath}`); + } else { + console.error(`[pdf-server] Warning: File not found: ${filePath}`); + } + } + } + + console.error(`[pdf-server] Ready (${urls.length} URL(s) configured)`); + console.error(`[pdf-server] Allowed origins: ${[...allowedRemoteOrigins].join(", ")}`); if (stdio) { await createServer().connect(new StdioServerTransport()); } else { const port = parseInt(process.env.PORT ?? "3120", 10); - await startServer(createServer, { port, name: "PDF Server" }); + await startHttpServer(port); } } diff --git a/examples/pdf-server/package.json b/examples/pdf-server/package.json index 61d266973..5d61a443f 100644 --- a/examples/pdf-server/package.json +++ b/examples/pdf-server/package.json @@ -14,7 +14,7 @@ "dist" ], "scripts": { - "build": "tsc --noEmit && cross-env INPUT=mcp-app.html vite build && tsc -p tsconfig.server.json && bun build server.ts --outdir dist --target node --external pdfjs-dist && bun build main.ts --outfile dist/index.js --target node --external \"./server.js\" --external pdfjs-dist --banner \"#!/usr/bin/env node\"", + "build": "tsc --noEmit && cross-env INPUT=mcp-app.html vite build && tsc -p tsconfig.server.json && bun build server.ts --outdir dist --target node && bun build main.ts --outfile dist/index.js --target node --external \"./server.js\" --banner \"#!/usr/bin/env node\"", "watch": "cross-env INPUT=mcp-app.html vite build --watch", "serve": "bun --watch main.ts", "start": "cross-env NODE_ENV=development npm run build && npm run serve", diff --git a/examples/pdf-server/server.ts b/examples/pdf-server/server.ts index ae32c44bd..2ba34f0a9 100644 --- a/examples/pdf-server/server.ts +++ b/examples/pdf-server/server.ts @@ -1,195 +1,344 @@ /** - * PDF MCP Server - Didactic Example + * PDF MCP Server - Range Query Based (Library Entry Point) * - * Demonstrates: - * - Chunked data through size-limited tool responses - * - Model context updates (current page text + selection) - * - Display modes: fullscreen with scrolling vs inline with resize - * - External link opening (openLink) + * No caching, no indexing, no sessions - just proxies range requests. + * - Remote URLs (arxiv): HTTP Range requests + * - Local files: fs.createReadStream with start/end */ + +import { randomUUID } from "crypto"; +import fs from "node:fs"; +import path from "node:path"; +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { registerAppResource, registerAppTool, RESOURCE_MIME_TYPE, } from "@modelcontextprotocol/ext-apps/server"; -import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; -import type { - CallToolResult, - ReadResourceResult, -} from "@modelcontextprotocol/sdk/types.js"; -import fs from "node:fs/promises"; -import path from "node:path"; +import type { CallToolResult, ReadResourceResult } from "@modelcontextprotocol/sdk/types.js"; import { z } from "zod"; -import { randomUUID } from "crypto"; -import { - buildPdfIndex, - findEntryByUrl, - createEntry, - isArxivUrl, - isFileUrl, - normalizeArxivUrl, -} from "./src/pdf-indexer.js"; -import { loadPdfBytesChunk, populatePdfMetadata } from "./src/pdf-loader.js"; -import { - ReadPdfBytesInputSchema, - PdfBytesChunkSchema, - type PdfIndex, -} from "./src/types.js"; +// ============================================================================= +// Configuration +// ============================================================================= + +export const DEFAULT_PDF = "https://arxiv.org/pdf/1706.03762"; // Attention Is All You Need +export const MAX_CHUNK_BYTES = 512 * 1024; // 512KB max per request +export const RESOURCE_URI = "ui://pdf-viewer/mcp-app.html"; + +/** Allowed remote origins (security whitelist) */ +export const allowedRemoteOrigins = new Set([ + "https://arxiv.org", + "http://arxiv.org", +]); + +/** Allowed local file paths (populated from CLI args) */ +export const allowedLocalFiles = new Set(); + +/** Add a remote origin to the whitelist */ +export function addAllowedOrigin(origin: string): void { + allowedRemoteOrigins.add(origin); +} + +/** Add a local file to the whitelist */ +export function addAllowedLocalFile(filePath: string): void { + allowedLocalFiles.add(path.resolve(filePath)); +} // Works both from source (server.ts) and compiled (dist/server.js) const DIST_DIR = import.meta.filename.endsWith(".ts") ? path.join(import.meta.dirname, "dist") : import.meta.dirname; -const RESOURCE_URI = "ui://pdf-viewer/mcp-app.html"; -const DEFAULT_PDF = "https://arxiv.org/pdf/1706.03762"; // Attention Is All You Need -let pdfIndex: PdfIndex | null = null; +// ============================================================================= +// URL Validation & Normalization +// ============================================================================= -/** - * Initialize the PDF index with the given URLs. - * Must be called before createServer(). - */ -export async function initializePdfIndex(urls: string[]): Promise { - pdfIndex = await buildPdfIndex(urls); +export function isFileUrl(url: string): boolean { + return url.startsWith("file://"); } -/** - * Creates a new MCP server instance with tools and resources registered. - * Each HTTP session needs its own server instance because McpServer only supports one transport. - */ +export function isArxivUrl(url: string): boolean { + try { + const parsed = new URL(url); + return parsed.hostname === "arxiv.org" || parsed.hostname === "www.arxiv.org"; + } catch { + return false; + } +} + +export function normalizeArxivUrl(url: string): string { + // Convert arxiv abstract URLs to PDF URLs + // https://arxiv.org/abs/1706.03762 -> https://arxiv.org/pdf/1706.03762 + return url.replace("/abs/", "/pdf/").replace(/\.pdf$/, ""); +} + +export function fileUrlToPath(fileUrl: string): string { + return decodeURIComponent(fileUrl.replace("file://", "")); +} + +export function pathToFileUrl(filePath: string): string { + const absolutePath = path.resolve(filePath); + return `file://${encodeURIComponent(absolutePath).replace(/%2F/g, "/")}`; +} + +export function validateUrl(url: string): { valid: boolean; error?: string } { + if (isFileUrl(url)) { + const filePath = fileUrlToPath(url); + if (!allowedLocalFiles.has(filePath)) { + return { valid: false, error: `Local file not in allowed list: ${filePath}` }; + } + if (!fs.existsSync(filePath)) { + return { valid: false, error: `File not found: ${filePath}` }; + } + return { valid: true }; + } + + // Remote URL - check against allowed origins + try { + const parsed = new URL(url); + const origin = `${parsed.protocol}//${parsed.hostname}`; + if (![...allowedRemoteOrigins].some(allowed => origin.startsWith(allowed))) { + return { valid: false, error: `Origin not allowed: ${origin}` }; + } + return { valid: true }; + } catch { + return { valid: false, error: `Invalid URL: ${url}` }; + } +} + +// ============================================================================= +// Range Request Helpers +// ============================================================================= + +export interface PdfInfo { + url: string; + totalBytes: number; + contentType: string; +} + +export async function getPdfInfo(url: string): Promise { + const normalized = isArxivUrl(url) ? normalizeArxivUrl(url) : url; + + if (isFileUrl(normalized)) { + const filePath = fileUrlToPath(normalized); + const stats = await fs.promises.stat(filePath); + return { + url: normalized, + totalBytes: stats.size, + contentType: "application/pdf", + }; + } + + // Remote URL - HEAD request + const response = await fetch(normalized, { method: "HEAD" }); + if (!response.ok) { + throw new Error(`HEAD request failed: ${response.status} ${response.statusText}`); + } + + const contentLength = response.headers.get("content-length"); + if (!contentLength) { + throw new Error("Server did not return Content-Length"); + } + + return { + url: normalized, + totalBytes: parseInt(contentLength, 10), + contentType: response.headers.get("content-type") || "application/pdf", + }; +} + +export async function readPdfRange( + url: string, + offset: number, + byteCount: number, +): Promise<{ data: Uint8Array; totalBytes: number }> { + const normalized = isArxivUrl(url) ? normalizeArxivUrl(url) : url; + const clampedByteCount = Math.min(byteCount, MAX_CHUNK_BYTES); + + if (isFileUrl(normalized)) { + const filePath = fileUrlToPath(normalized); + const stats = await fs.promises.stat(filePath); + const totalBytes = stats.size; + + // Clamp to file bounds + const start = Math.min(offset, totalBytes); + const end = Math.min(start + clampedByteCount, totalBytes); + + if (start >= totalBytes) { + return { data: new Uint8Array(0), totalBytes }; + } + + // Read range from local file + const buffer = Buffer.alloc(end - start); + const fd = await fs.promises.open(filePath, "r"); + try { + await fd.read(buffer, 0, end - start, start); + } finally { + await fd.close(); + } + + return { data: new Uint8Array(buffer), totalBytes }; + } + + // Remote URL - Range request + const response = await fetch(normalized, { + headers: { + Range: `bytes=${offset}-${offset + clampedByteCount - 1}`, + }, + }); + + if (!response.ok && response.status !== 206) { + throw new Error(`Range request failed: ${response.status} ${response.statusText}`); + } + + // Parse total size from Content-Range header + const contentRange = response.headers.get("content-range"); + let totalBytes = 0; + if (contentRange) { + const match = contentRange.match(/bytes \d+-\d+\/(\d+)/); + if (match) { + totalBytes = parseInt(match[1], 10); + } + } + + const data = new Uint8Array(await response.arrayBuffer()); + return { data, totalBytes }; +} + +// ============================================================================= +// MCP Server Factory +// ============================================================================= + export function createServer(): McpServer { - const server = new McpServer({ name: "PDF Server", version: "1.0.0" }); + const server = new McpServer({ name: "PDF Server", version: "2.0.0" }); - // Tool: list_pdfs + // Tool: get_pdf_info - HEAD request to get size server.tool( - "list_pdfs", - "List indexed PDFs", - {}, - async (): Promise => { - if (!pdfIndex) throw new Error("Not initialized"); - return { - content: [ - { type: "text", text: JSON.stringify(pdfIndex.entries, null, 2) }, - ], - structuredContent: { entries: pdfIndex.entries }, - }; + "get_pdf_info", + "Get PDF file information (size, type) without downloading", + { + url: z.string().describe("PDF URL (https:// or file://)"), + }, + async ({ url }): Promise => { + const validation = validateUrl(url); + if (!validation.valid) { + return { + content: [{ type: "text", text: validation.error! }], + isError: true, + }; + } + + try { + const info = await getPdfInfo(url); + return { + content: [{ type: "text", text: `PDF: ${info.totalBytes} bytes` }], + structuredContent: { ...info }, + }; + } catch (err) { + return { + content: [{ type: "text", text: `Error: ${err instanceof Error ? err.message : String(err)}` }], + isError: true, + }; + } }, ); - // Tool: read_pdf_bytes (app-only) - Chunked binary loading + // Tool: read_pdf_bytes (app-only) - Range request for chunks registerAppTool( server, "read_pdf_bytes", { title: "Read PDF Bytes", - description: "Load binary data in chunks", - inputSchema: ReadPdfBytesInputSchema.shape, - outputSchema: PdfBytesChunkSchema, + description: "Read a range of bytes from a PDF (max 512KB per request)", + inputSchema: { + url: z.string().describe("PDF URL"), + offset: z.number().min(0).default(0).describe("Byte offset"), + byteCount: z.number().min(1).max(MAX_CHUNK_BYTES).default(MAX_CHUNK_BYTES).describe("Bytes to read"), + }, + outputSchema: z.object({ + url: z.string(), + bytes: z.string().describe("Base64 encoded bytes"), + offset: z.number(), + byteCount: z.number(), + totalBytes: z.number(), + hasMore: z.boolean(), + }), _meta: { ui: { visibility: ["app"] } }, }, - async (args: unknown): Promise => { - if (!pdfIndex) throw new Error("Not initialized"); - const { - url: rawUrl, - offset, - byteCount, - } = ReadPdfBytesInputSchema.parse(args); - const url = isArxivUrl(rawUrl) ? normalizeArxivUrl(rawUrl) : rawUrl; - let entry = findEntryByUrl(pdfIndex, url); - - // Dynamically add arxiv URLs (handles server restart between display_pdf and read_pdf_bytes) - if (!entry) { - if (isFileUrl(url)) { - throw new Error("File URLs must be in the initial list"); - } - if (!isArxivUrl(url)) { - throw new Error(`PDF not found: ${url}`); - } - entry = createEntry(url); - await populatePdfMetadata(entry); - pdfIndex.entries.push(entry); + async ({ url, offset, byteCount }): Promise => { + const validation = validateUrl(url); + if (!validation.valid) { + return { + content: [{ type: "text", text: validation.error! }], + isError: true, + }; } - const chunk = await loadPdfBytesChunk(entry, offset, byteCount); - return { - content: [ - { - type: "text", - text: `${chunk.byteCount} bytes at ${chunk.offset}/${chunk.totalBytes}`, + try { + const normalized = isArxivUrl(url) ? normalizeArxivUrl(url) : url; + const { data, totalBytes } = await readPdfRange(url, offset, byteCount); + + // Base64 encode for JSON transport + const bytes = Buffer.from(data).toString("base64"); + const hasMore = offset + data.length < totalBytes; + + return { + content: [{ type: "text", text: `${data.length} bytes at ${offset}/${totalBytes}` }], + structuredContent: { + url: normalized, + bytes, + offset, + byteCount: data.length, + totalBytes, + hasMore, }, - ], - structuredContent: chunk, - }; + }; + } catch (err) { + return { + content: [{ type: "text", text: `Error: ${err instanceof Error ? err.message : String(err)}` }], + isError: true, + }; + } }, ); - // Tool: display_pdf - Interactive viewer with UI + // Tool: display_pdf - Show interactive viewer registerAppTool( server, "display_pdf", { title: "Display PDF", - description: `Display an interactive PDF viewer in the chat. - -Use this tool when the user asks to view, display, read, or open a PDF. Accepts: -- URLs from list_pdfs (preloaded PDFs) -- Any arxiv.org URL (loaded dynamically) - -The viewer supports zoom, navigation, text selection, and fullscreen mode.`, + description: `Display an interactive PDF viewer. Accepts arxiv.org URLs or pre-registered local files.`, inputSchema: { - url: z - .string() - .default(DEFAULT_PDF) - .describe("PDF URL (arxiv.org for dynamic loading)"), + url: z.string().default(DEFAULT_PDF).describe("PDF URL"), page: z.number().min(1).default(1).describe("Initial page"), }, outputSchema: z.object({ url: z.string(), - title: z.string().optional(), - pageCount: z.number(), initialPage: z.number(), }), _meta: { ui: { resourceUri: RESOURCE_URI } }, }, - async ({ url: rawUrl, page }): Promise => { - if (!pdfIndex) throw new Error("Not initialized"); - - // Normalize arxiv URLs to PDF format - const url = isArxivUrl(rawUrl) ? normalizeArxivUrl(rawUrl) : rawUrl; - - let entry = findEntryByUrl(pdfIndex, url); - - if (!entry) { - if (isFileUrl(url)) { - throw new Error("File URLs must be in the initial list"); - } - if (!isArxivUrl(url)) { - throw new Error(`Only arxiv.org URLs can be loaded dynamically`); - } + async ({ url, page }): Promise => { + const normalized = isArxivUrl(url) ? normalizeArxivUrl(url) : url; + const validation = validateUrl(normalized); - entry = createEntry(url); - await populatePdfMetadata(entry); - pdfIndex.entries.push(entry); + if (!validation.valid) { + return { + content: [{ type: "text", text: validation.error! }], + isError: true, + }; } - const result = { - url: entry.url, - title: entry.metadata.title, - pageCount: entry.metadata.pageCount, - initialPage: Math.min(page, entry.metadata.pageCount), - }; - return { - content: [ - { - type: "text", - text: `Displaying interactive PDF viewer${entry.metadata.title ? ` for "${entry.metadata.title}"` : ""} (${entry.url}, ${entry.metadata.pageCount} pages)`, - }, - ], - structuredContent: result, + content: [{ type: "text", text: `Displaying PDF: ${normalized}` }], + structuredContent: { + url: normalized, + initialPage: page, + }, _meta: { - viewUUID: randomUUID(), + widgetUUID: randomUUID(), }, }; }, @@ -202,14 +351,12 @@ The viewer supports zoom, navigation, text selection, and fullscreen mode.`, RESOURCE_URI, { mimeType: RESOURCE_MIME_TYPE }, async (): Promise => { - const html = await fs.readFile( + const html = await fs.promises.readFile( path.join(DIST_DIR, "mcp-app.html"), "utf-8", ); return { - contents: [ - { uri: RESOURCE_URI, mimeType: RESOURCE_MIME_TYPE, text: html }, - ], + contents: [{ uri: RESOURCE_URI, mimeType: RESOURCE_MIME_TYPE, text: html }], }; }, ); diff --git a/examples/pdf-server/src/mcp-app.css b/examples/pdf-server/src/mcp-app.css index 8242ca041..a625e4815 100644 --- a/examples/pdf-server/src/mcp-app.css +++ b/examples/pdf-server/src/mcp-app.css @@ -1,11 +1,12 @@ body { overscroll-behavior-x: none; + display: flex; } .main { display: flex; flex-direction: column; - min-height: 100%; + flex: 1; width: 100%; background: var(--bg100, #f5f5f5); color: var(--text000, #1a1a1a); diff --git a/examples/pdf-server/src/mcp-app.ts b/examples/pdf-server/src/mcp-app.ts index 6cb41928d..28bf93670 100644 --- a/examples/pdf-server/src/mcp-app.ts +++ b/examples/pdf-server/src/mcp-app.ts @@ -35,7 +35,7 @@ let totalPages = 0; let scale = 1.0; let pdfUrl = ""; let pdfTitle: string | undefined; -let viewUUID: string | undefined; +let widgetUUID: string | undefined; let currentRenderTask: { cancel: () => void } | null = null; // DOM Elements @@ -404,10 +404,10 @@ async function renderPage() { } function saveCurrentPage() { - log.info("saveCurrentPage: key=", viewUUID, "page=", currentPage); - if (viewUUID) { + log.info("saveCurrentPage: key=", widgetUUID, "page=", currentPage); + if (widgetUUID) { try { - localStorage.setItem(viewUUID, String(currentPage)); + localStorage.setItem(widgetUUID, String(currentPage)); log.info("saveCurrentPage: saved successfully"); } catch (err) { log.error("saveCurrentPage: error", err); @@ -416,10 +416,10 @@ function saveCurrentPage() { } function loadSavedPage(): number | null { - log.info("loadSavedPage: key=", viewUUID); - if (!viewUUID) return null; + log.info("loadSavedPage: key=", widgetUUID); + if (!widgetUUID) return null; try { - const saved = localStorage.getItem(viewUUID); + const saved = localStorage.getItem(widgetUUID); log.info("loadSavedPage: saved value=", saved); if (saved) { const page = parseInt(saved, 10); @@ -706,7 +706,9 @@ app.ontoolresult = async (result) => { pdfUrl = parsed.url; pdfTitle = parsed.title; totalPages = parsed.pageCount; - viewUUID = result._meta?.viewUUID ? String(result._meta.viewUUID) : undefined; + widgetUUID = result._meta?.widgetUUID + ? String(result._meta.widgetUUID) + : undefined; // Restore saved page or use initial page const savedPage = loadSavedPage(); diff --git a/examples/pdf-server/src/pdf-indexer.ts b/examples/pdf-server/src/pdf-indexer.ts deleted file mode 100644 index 9dc1f9933..000000000 --- a/examples/pdf-server/src/pdf-indexer.ts +++ /dev/null @@ -1,57 +0,0 @@ -/** - * PDF Indexer - */ -import path from "node:path"; -import type { PdfIndex, PdfEntry } from "./types.js"; -import { populatePdfMetadata } from "./pdf-loader.js"; - -/** Check if URL is from arxiv.org */ -export function isArxivUrl(url: string): boolean { - return /^https?:\/\/arxiv\.org\//.test(url); -} - -/** Normalize arxiv URL to PDF format */ -export function normalizeArxivUrl(url: string): string { - return url.replace(/arxiv\.org\/abs\//, "arxiv.org/pdf/"); -} - -/** Check if URL is a file:// URL */ -export function isFileUrl(url: string): boolean { - return url.startsWith("file://"); -} - -/** Convert local path to file:// URL */ -export function toFileUrl(filePath: string): string { - return `file://${path.resolve(filePath)}`; -} - -/** Create a PdfEntry from a URL */ -export function createEntry(url: string): PdfEntry { - return { - url, - metadata: { pageCount: 0, fileSizeBytes: 0 }, - }; -} - -/** Build index from a list of URLs */ -export async function buildPdfIndex(urls: string[]): Promise { - const entries: PdfEntry[] = []; - - for (const url of urls) { - console.error(`[indexer] Loading: ${url}`); - const entry = createEntry(url); - await populatePdfMetadata(entry); - entries.push(entry); - } - - console.error(`[indexer] Indexed ${entries.length} PDFs`); - return { entries }; -} - -/** Find entry by URL */ -export function findEntryByUrl( - index: PdfIndex, - url: string, -): PdfEntry | undefined { - return index.entries.find((e) => e.url === url); -} diff --git a/examples/pdf-server/src/pdf-loader.ts b/examples/pdf-server/src/pdf-loader.ts deleted file mode 100644 index c5c700052..000000000 --- a/examples/pdf-server/src/pdf-loader.ts +++ /dev/null @@ -1,136 +0,0 @@ -/** - * PDF Loader - Loads PDFs and extracts content in chunks - * - * Demonstrates: - * - Chunked data loading with size limits - * - HTTP Range requests for streaming - * - Caching for repeated requests - */ -import fs from "node:fs/promises"; -import type { PdfEntry, PdfBytesChunk } from "./types.js"; -import { MAX_CHUNK_BYTES } from "./types.js"; -import { isFileUrl } from "./pdf-indexer.js"; - -// Cache for loaded PDFs -const pdfCache = new Map(); - -// Lazy-load pdfjs -let pdfjs: typeof import("pdfjs-dist"); -async function getPdfjs() { - if (!pdfjs) { - pdfjs = await import("pdfjs-dist/legacy/build/pdf.mjs"); - } - return pdfjs; -} - -// ============================================================================ -// PDF Data Loading -// ============================================================================ - -/** Fetch PDF data (with caching) */ -export async function loadPdfData(entry: PdfEntry): Promise { - const cached = pdfCache.get(entry.url); - if (cached) return cached; - - console.error(`[loader] Fetching: ${entry.url}`); - - let data: Uint8Array; - if (isFileUrl(entry.url)) { - const filePath = entry.url.replace("file://", ""); - data = new Uint8Array(await fs.readFile(filePath)); - } else { - const response = await fetch(entry.url); - if (!response.ok) { - throw new Error(`Failed to fetch: ${response.status}`); - } - data = new Uint8Array(await response.arrayBuffer()); - } - - pdfCache.set(entry.url, data); - return data; -} - -/** Try HTTP Range request for partial content */ -async function fetchRange( - url: string, - start: number, - end: number, -): Promise<{ data: Uint8Array; total: number } | null> { - try { - const res = await fetch(url, { - headers: { Range: `bytes=${start}-${end}` }, - }); - if (res.status !== 206) return null; - - const total = parseInt( - res.headers.get("Content-Range")?.split("/")[1] || "0", - ); - return { data: new Uint8Array(await res.arrayBuffer()), total }; - } catch { - return null; - } -} - -// ============================================================================ -// Chunked Binary Loading (demonstrates size-limited responses) -// ============================================================================ - -export async function loadPdfBytesChunk( - entry: PdfEntry, - offset = 0, - byteCount = MAX_CHUNK_BYTES, -): Promise { - // Try Range request first (streaming without full download) - if (!pdfCache.has(entry.url)) { - const range = await fetchRange(entry.url, offset, offset + byteCount - 1); - if (range) { - return { - url: entry.url, - bytes: Buffer.from(range.data).toString("base64"), - offset, - byteCount: range.data.length, - totalBytes: range.total, - hasMore: offset + range.data.length < range.total, - }; - } - } - - // Fallback: load full PDF and slice - const data = await loadPdfData(entry); - const chunk = data.slice(offset, offset + byteCount); - - return { - url: entry.url, - bytes: Buffer.from(chunk).toString("base64"), - offset, - byteCount: chunk.length, - totalBytes: data.length, - hasMore: offset + chunk.length < data.length, - }; -} - -// ============================================================================ -// Metadata Extraction -// ============================================================================ - -export async function populatePdfMetadata(entry: PdfEntry): Promise { - try { - const lib = await getPdfjs(); - const data = await loadPdfData(entry); - - entry.metadata.fileSizeBytes = data.length; - - const pdf = await lib.getDocument({ data: new Uint8Array(data) }).promise; - entry.metadata.pageCount = pdf.numPages; - - const info = (await pdf.getMetadata()).info as - | Record - | undefined; - if (info?.Title) entry.metadata.title = String(info.Title); - if (info?.Author) entry.metadata.author = String(info.Author); - - await pdf.destroy(); - } catch (err) { - console.error(`[loader] Metadata error: ${err}`); - } -} diff --git a/examples/pdf-server/src/types.ts b/examples/pdf-server/src/types.ts deleted file mode 100644 index efbfdbd29..000000000 --- a/examples/pdf-server/src/types.ts +++ /dev/null @@ -1,51 +0,0 @@ -/** - * PDF Server Types - Simplified for didactic purposes - */ -import { z } from "zod"; - -// ============================================================================ -// Core Types -// ============================================================================ - -export const PdfMetadataSchema = z.object({ - title: z.string().optional(), - author: z.string().optional(), - pageCount: z.number(), - fileSizeBytes: z.number(), -}); -export type PdfMetadata = z.infer; - -export const PdfEntrySchema = z.object({ - url: z.string(), // Also serves as unique ID - metadata: PdfMetadataSchema, -}); -export type PdfEntry = z.infer; - -export const PdfIndexSchema = z.object({ - entries: z.array(PdfEntrySchema), -}); -export type PdfIndex = z.infer; - -// ============================================================================ -// Chunked Binary Loading -// ============================================================================ - -/** Max bytes per response chunk */ -export const MAX_CHUNK_BYTES = 500 * 1024; // 500KB - -export const PdfBytesChunkSchema = z.object({ - url: z.string(), - bytes: z.string(), // base64 - offset: z.number(), - byteCount: z.number(), - totalBytes: z.number(), - hasMore: z.boolean(), -}); -export type PdfBytesChunk = z.infer; - -export const ReadPdfBytesInputSchema = z.object({ - url: z.string().describe("PDF URL"), - offset: z.number().min(0).default(0).describe("Byte offset"), - byteCount: z.number().default(MAX_CHUNK_BYTES).describe("Bytes to read"), -}); -export type ReadPdfBytesInput = z.infer; From 6ad6dd3e67b038b13d043c80458e1f329e8bc4b7 Mon Sep 17 00:00:00 2001 From: Olivier Chafik Date: Fri, 23 Jan 2026 10:47:27 +0000 Subject: [PATCH 04/23] feat(pdf-server): add dark mode support - Add CSS variables with light-dark() function for automatic theme switching - Sync theme with host via applyDocumentTheme() - Apply host CSS variables via applyHostStyleVariables() --- examples/pdf-server/src/mcp-app.css | 71 ++++++++++++++++++----------- examples/pdf-server/src/mcp-app.ts | 12 ++++- 2 files changed, 56 insertions(+), 27 deletions(-) diff --git a/examples/pdf-server/src/mcp-app.css b/examples/pdf-server/src/mcp-app.css index a625e4815..7600be515 100644 --- a/examples/pdf-server/src/mcp-app.css +++ b/examples/pdf-server/src/mcp-app.css @@ -1,3 +1,22 @@ +:root { + color-scheme: light dark; + + /* Background colors */ + --bg000: light-dark(#ffffff, #1a1a1a); + --bg100: light-dark(#f5f5f5, #252525); + --bg200: light-dark(#e0e0e0, #333333); + --bg300: light-dark(#cccccc, #444444); + + /* Text colors */ + --text000: light-dark(#1a1a1a, #f0f0f0); + --text100: light-dark(#666666, #aaaaaa); + --text200: light-dark(#999999, #888888); + + /* Shadows */ + --shadow-page: light-dark(0 2px 8px rgba(0, 0, 0, 0.15), 0 2px 8px rgba(0, 0, 0, 0.4)); + --selection-bg: light-dark(rgba(0, 0, 255, 0.3), rgba(100, 150, 255, 0.4)); +} + body { overscroll-behavior-x: none; display: flex; @@ -8,11 +27,11 @@ body { flex-direction: column; flex: 1; width: 100%; - background: var(--bg100, #f5f5f5); - color: var(--text000, #1a1a1a); + background: var(--bg100); + color: var(--text000); overflow: hidden; /* Prevent scrollbars in inline mode - we request exact size */ border-radius: 0.75rem; - border: 1px solid var(--bg200, rgba(0, 0, 0, 0.08)); + border: 1px solid var(--bg200); } /* Loading State */ @@ -29,8 +48,8 @@ body { .spinner { width: 24px; height: 24px; - border: 2px solid var(--bg200, #e0e0e0); - border-top-color: var(--text100, #888); + border: 2px solid var(--bg200); + border-top-color: var(--text100); border-radius: 50%; animation: spin 0.8s linear infinite; } @@ -43,7 +62,7 @@ body { #loading-text { font-size: 0.8rem; - color: var(--text100, #888); + color: var(--text100); } /* Progress Bar */ @@ -51,21 +70,21 @@ body { width: 100%; max-width: 200px; height: 3px; - background: var(--bg200, #e0e0e0); + background: var(--bg200); border-radius: 2px; overflow: hidden; } .progress-bar { height: 100%; - background: var(--text100, #888); + background: var(--text100); width: 0%; transition: width 0.15s ease-out; } .progress-text { font-size: 0.7rem; - color: var(--text200, #aaa); + color: var(--text200); } /* Error State */ @@ -85,7 +104,7 @@ body { } #error-message { - color: var(--text200, #999); + color: var(--text200); max-width: 400px; } @@ -103,8 +122,8 @@ body { align-items: center; justify-content: space-between; padding: 0.5rem 1rem; - background: var(--bg000, #ffffff); - border-bottom: 1px solid var(--bg200, #e0e0e0); + background: var(--bg000); + border-bottom: 1px solid var(--bg200); flex-shrink: 0; gap: 0.5rem; height: 48px; @@ -151,17 +170,17 @@ body { .page-input { width: 50px; padding: 0.25rem 0.5rem; - border: 1px solid var(--bg200, #e0e0e0); + border: 1px solid var(--bg200); border-radius: 4px; font-size: 0.85rem; text-align: center; - background: var(--bg000, #ffffff); - color: var(--text000, #1a1a1a); + background: var(--bg000); + color: var(--text000); } .page-input:focus { outline: none; - border-color: var(--text100, #666); + border-color: var(--text100); } .page-input::-webkit-outer-spin-button, @@ -175,7 +194,7 @@ body { .total-pages { font-size: 0.85rem; - color: var(--text100, #666); + color: var(--text100); white-space: nowrap; } @@ -187,10 +206,10 @@ body { justify-content: center; width: 32px; height: 32px; - border: 1px solid var(--bg200, #e0e0e0); + border: 1px solid var(--bg200); border-radius: 4px; - background: var(--bg000, #ffffff); - color: var(--text000, #1a1a1a); + background: var(--bg000); + color: var(--text000); cursor: pointer; font-size: 1rem; transition: all 0.15s ease; @@ -199,8 +218,8 @@ body { .nav-btn:hover:not(:disabled), .zoom-btn:hover:not(:disabled), .fullscreen-btn:hover { - background: var(--bg100, #f5f5f5); - border-color: var(--bg300, #ccc); + background: var(--bg100); + border-color: var(--bg300); } .nav-btn:disabled, @@ -211,7 +230,7 @@ body { .zoom-level { font-size: 0.8rem; - color: var(--text100, #666); + color: var(--text100); min-width: 50px; text-align: center; } @@ -224,12 +243,12 @@ body { justify-content: center; align-items: flex-start; padding: 1rem; - background: var(--bg200, #e0e0e0); + background: var(--bg200); } .page-wrapper { position: relative; - box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15); + box-shadow: var(--shadow-page); background: white; } @@ -263,7 +282,7 @@ body { } .text-layer ::selection { - background: rgba(0, 0, 255, 0.3); + background: var(--selection-bg); } .text-layer > span { diff --git a/examples/pdf-server/src/mcp-app.ts b/examples/pdf-server/src/mcp-app.ts index 28bf93670..2314ad7c7 100644 --- a/examples/pdf-server/src/mcp-app.ts +++ b/examples/pdf-server/src/mcp-app.ts @@ -6,7 +6,7 @@ * - Text selection via PDF.js TextLayer * - Page navigation, zoom */ -import { App, type McpUiHostContext } from "@modelcontextprotocol/ext-apps"; +import { App, type McpUiHostContext, applyDocumentTheme, applyHostStyleVariables } from "@modelcontextprotocol/ext-apps"; import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; import * as pdfjsLib from "pdfjs-dist"; import { TextLayer } from "pdfjs-dist"; @@ -752,6 +752,16 @@ app.onerror = (err) => { function handleHostContextChanged(ctx: McpUiHostContext) { log.info("Host context changed:", ctx); + // Apply theme from host + if (ctx.theme) { + applyDocumentTheme(ctx.theme); + } + + // Apply host CSS variables + if (ctx.styles?.variables) { + applyHostStyleVariables(ctx.styles.variables); + } + // Apply safe area insets if (ctx.safeAreaInsets) { mainEl.style.paddingTop = `${ctx.safeAreaInsets.top}px`; From 50bde380a87c8128a930b59633a86f8d124c59ed Mon Sep 17 00:00:00 2001 From: Olivier Chafik Date: Fri, 23 Jan 2026 10:51:49 +0000 Subject: [PATCH 05/23] feat(pdf-server): add list_pdfs tool back Lists local files and allowed remote origins. Also moves CHUNK_SIZE to module level constant. --- examples/pdf-server/server.ts | 47 +++++++++++++++++++++++++++++++++-- 1 file changed, 45 insertions(+), 2 deletions(-) diff --git a/examples/pdf-server/server.ts b/examples/pdf-server/server.ts index 2ba34f0a9..f0d201ea8 100644 --- a/examples/pdf-server/server.ts +++ b/examples/pdf-server/server.ts @@ -26,10 +26,25 @@ export const DEFAULT_PDF = "https://arxiv.org/pdf/1706.03762"; // Attention Is A export const MAX_CHUNK_BYTES = 512 * 1024; // 512KB max per request export const RESOURCE_URI = "ui://pdf-viewer/mcp-app.html"; -/** Allowed remote origins (security whitelist) */ +/** Allowed remote origins (security allowlist) */ export const allowedRemoteOrigins = new Set([ "https://arxiv.org", - "http://arxiv.org", + "https://ssrn.com", + "https://www.researchsquare.com", + "https://www.preprints.org", + "https://osf.io", + "https://zenodo.org", + "https://www.biorxiv.org", + "https://www.medrxiv.org", + "https://chemrxiv.org", + "https://www.eartharxiv.org", + "https://psyarxiv.com", + "https://osf.io/preprints/socarxiv", + "https://engrxiv.org", + "https://www.sportarxiv.org", + "https://agrirxiv.org", + "https://edarxiv.org", + "https://hal.science", ]); /** Allowed local file paths (populated from CLI args) */ @@ -213,6 +228,34 @@ export async function readPdfRange( export function createServer(): McpServer { const server = new McpServer({ name: "PDF Server", version: "2.0.0" }); + // Tool: list_pdfs - List available PDFs (local files + allowed origins) + server.tool( + "list_pdfs", + "List available PDFs that can be displayed", + {}, + async (): Promise => { + const pdfs: Array<{ url: string; type: "local" | "remote" }> = []; + + // Add local files + for (const filePath of allowedLocalFiles) { + pdfs.push({ url: pathToFileUrl(filePath), type: "local" }); + } + + // Note: Remote URLs from allowed origins can be loaded dynamically + const text = pdfs.length > 0 + ? `Available PDFs:\n${pdfs.map(p => `- ${p.url} (${p.type})`).join("\n")}\n\nRemote PDFs from ${[...allowedRemoteOrigins].join(", ")} can also be loaded dynamically.` + : `No local PDFs configured. Remote PDFs from ${[...allowedRemoteOrigins].join(", ")} can be loaded dynamically.`; + + return { + content: [{ type: "text", text }], + structuredContent: { + localFiles: pdfs.filter(p => p.type === "local").map(p => p.url), + allowedOrigins: [...allowedRemoteOrigins], + }, + }; + }, + ); + // Tool: get_pdf_info - HEAD request to get size server.tool( "get_pdf_info", From 146b106e08cbf0e9bcae0144568fb8f77dc67a80 Mon Sep 17 00:00:00 2001 From: Olivier Chafik Date: Fri, 23 Jan 2026 10:54:47 +0000 Subject: [PATCH 06/23] feat(pdf-server): improve tool descriptions and increase context limit - display_pdf: list all allowed domains in description - Increase MAX_MODEL_CONTEXT_LENGTH to 15000 - Move CHUNK_SIZE to module level constant --- examples/pdf-server/server.ts | 6 +++++- examples/pdf-server/src/mcp-app.ts | 7 +++---- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/examples/pdf-server/server.ts b/examples/pdf-server/server.ts index f0d201ea8..b915e68fc 100644 --- a/examples/pdf-server/server.ts +++ b/examples/pdf-server/server.ts @@ -352,7 +352,11 @@ export function createServer(): McpServer { "display_pdf", { title: "Display PDF", - description: `Display an interactive PDF viewer. Accepts arxiv.org URLs or pre-registered local files.`, + description: `Display an interactive PDF viewer. + +Accepts: +- Local files explicitly added to the server (use list_pdfs to see available files) +- Remote PDFs from allowed services: arxiv.org, ssrn.com, biorxiv.org, medrxiv.org, chemrxiv.org, zenodo.org, osf.io, hal.science, researchsquare.com, preprints.org, eartharxiv.org, psyarxiv.com, engrxiv.org, sportarxiv.org, agrirxiv.org, edarxiv.org`, inputSchema: { url: z.string().default(DEFAULT_PDF).describe("PDF URL"), page: z.number().min(1).default(1).describe("Initial page"), diff --git a/examples/pdf-server/src/mcp-app.ts b/examples/pdf-server/src/mcp-app.ts index 2314ad7c7..f50e86ee7 100644 --- a/examples/pdf-server/src/mcp-app.ts +++ b/examples/pdf-server/src/mcp-app.ts @@ -13,9 +13,9 @@ import { TextLayer } from "pdfjs-dist"; import "./global.css"; import "./mcp-app.css"; -// const MAX_MODEL_CONTEXT_LENGTH = 5000; -const MAX_MODEL_CONTEXT_LENGTH = 1500; - +const MAX_MODEL_CONTEXT_LENGTH = 15000; +const CHUNK_SIZE = 500 * 1024; // 500KB chunks + // Configure PDF.js worker pdfjsLib.GlobalWorkerOptions.workerSrc = new URL( "pdfjs-dist/build/pdf.worker.mjs", @@ -635,7 +635,6 @@ function updateProgress(loaded: number, total: number) { // Load PDF in chunks with progress async function loadPdfInChunks(urlToLoad: string): Promise { - const CHUNK_SIZE = 500 * 1024; // 500KB chunks const chunks: Uint8Array[] = []; let offset = 0; let totalBytes = 0; From 9416b31af6dc770f9b488160c4de02a1e643b0bd Mon Sep 17 00:00:00 2001 From: Olivier Chafik Date: Fri, 23 Jan 2026 10:58:20 +0000 Subject: [PATCH 07/23] chore(say-server): remove deployment docs from repo Keep server.py fix for non-localhost deployments. Deployment docs kept locally but not in repo. --- examples/say-server/.gcloudignore | 5 - examples/say-server/DEPLOYMENT.md | 246 ---------- examples/say-server/Dockerfile | 34 -- examples/say-server/SECURITY_REVIEW.md | 607 ------------------------- 4 files changed, 892 deletions(-) delete mode 100644 examples/say-server/.gcloudignore delete mode 100644 examples/say-server/DEPLOYMENT.md delete mode 100644 examples/say-server/Dockerfile delete mode 100644 examples/say-server/SECURITY_REVIEW.md diff --git a/examples/say-server/.gcloudignore b/examples/say-server/.gcloudignore deleted file mode 100644 index 99cd4e22a..000000000 --- a/examples/say-server/.gcloudignore +++ /dev/null @@ -1,5 +0,0 @@ -# Ignore everything except what's needed -* -!Dockerfile -!server.py -!mcp-app.html diff --git a/examples/say-server/DEPLOYMENT.md b/examples/say-server/DEPLOYMENT.md deleted file mode 100644 index 7758050d5..000000000 --- a/examples/say-server/DEPLOYMENT.md +++ /dev/null @@ -1,246 +0,0 @@ -# Say Server - GCP Cloud Run Deployment - -This document describes how to deploy the Say Server MCP application to Google Cloud Run with session-sticky load balancing. - -## Architecture - -``` -┌─────────────┐ ┌──────────────────┐ ┌─────────────────┐ ┌─────────────┐ -│ Client │────▶│ Load Balancer │────▶│ Serverless NEG │────▶│ Cloud Run │ -│ │ │ (HTTP, IP-based)│ │ │ │ say-server │ -└─────────────┘ └──────────────────┘ └─────────────────┘ └─────────────┘ - │ - ▼ - Session Affinity - (mcp-session-id header) -``` - -## Prerequisites - -- GCP Project with billing enabled -- `gcloud` CLI installed and authenticated -- Docker (for local builds) - -## Current Deployment - -- **Project**: `mcp-apps-say-server` -- **Region**: `us-east1` -- **Service URL**: `https://say-server-109024344223.us-east1.run.app` -- **Load Balancer IP**: `34.160.77.67` - -## Session Stickiness Configuration - -MCP's Streamable HTTP transport uses the `mcp-session-id` header for stateful sessions. The load balancer is configured to route requests with the same session ID to the same Cloud Run instance: - -```bash -# Backend service configuration -gcloud compute backend-services describe say-server-backend --global --format=json | jq '{ - sessionAffinity, - localityLbPolicy, - consistentHash -}' -``` - -Returns: -```json -{ - "sessionAffinity": "HEADER_FIELD", - "localityLbPolicy": "RING_HASH", - "consistentHash": { - "httpHeaderName": "mcp-session-id" - } -} -``` - -## Deployment Steps - -### 1. Set Project - -```bash -export PROJECT_ID=mcp-apps-say-server -gcloud config set project $PROJECT_ID -``` - -### 2. Enable APIs - -```bash -gcloud services enable \ - run.googleapis.com \ - cloudbuild.googleapis.com \ - artifactregistry.googleapis.com \ - compute.googleapis.com -``` - -### 3. Build and Push Docker Image - -```bash -cd examples/say-server - -# Build for linux/amd64 -docker build --platform linux/amd64 \ - -t us-east1-docker.pkg.dev/$PROJECT_ID/cloud-run-source-deploy/say-server:latest . - -# Configure docker auth -gcloud auth configure-docker us-east1-docker.pkg.dev --quiet - -# Push -docker push us-east1-docker.pkg.dev/$PROJECT_ID/cloud-run-source-deploy/say-server:latest -``` - -### 4. Deploy to Cloud Run - -```bash -gcloud run deploy say-server \ - --image us-east1-docker.pkg.dev/$PROJECT_ID/cloud-run-source-deploy/say-server:latest \ - --region us-east1 \ - --memory 4Gi \ - --cpu 2 \ - --timeout 300 \ - --concurrency 10 \ - --min-instances 0 \ - --max-instances 10 \ - --no-cpu-throttling \ - --ingress all -``` - -### 5. Set Up Load Balancer with Session Affinity - -```bash -# Create serverless NEG -gcloud compute network-endpoint-groups create say-server-neg \ - --region=us-east1 \ - --network-endpoint-type=serverless \ - --cloud-run-service=say-server - -# Create backend service -gcloud compute backend-services create say-server-backend \ - --global \ - --load-balancing-scheme=EXTERNAL_MANAGED - -# Add NEG to backend -gcloud compute backend-services add-backend say-server-backend \ - --global \ - --network-endpoint-group=say-server-neg \ - --network-endpoint-group-region=us-east1 - -# Configure session affinity (requires import/export for consistentHash) -gcloud compute backend-services describe say-server-backend --global --format=json | \ - jq 'del(.id, .kind, .selfLink, .creationTimestamp, .fingerprint) + { - "sessionAffinity": "HEADER_FIELD", - "localityLbPolicy": "RING_HASH", - "protocol": "HTTPS", - "consistentHash": {"httpHeaderName": "mcp-session-id"} - }' > /tmp/backend.json - -gcloud compute backend-services import say-server-backend \ - --global \ - --source=/tmp/backend.json \ - --quiet - -# Create URL map -gcloud compute url-maps create say-server-lb \ - --default-service=say-server-backend \ - --global - -# Create HTTP proxy -gcloud compute target-http-proxies create say-server-proxy \ - --url-map=say-server-lb \ - --global - -# Reserve static IP -gcloud compute addresses create say-server-ip \ - --global \ - --ip-version=IPV4 - -# Create forwarding rule -gcloud compute forwarding-rules create say-server-forwarding \ - --global \ - --target-http-proxy=say-server-proxy \ - --address=say-server-ip \ - --ports=80 \ - --load-balancing-scheme=EXTERNAL_MANAGED -``` - -## Access & Authentication - -### Current Issue: Org Policy Restrictions - -Anthropic's GCP org policies prevent: -- `allUsers` or `allAuthenticatedUsers` IAM bindings -- Service account key creation - -This means the service requires authentication. - -### Authenticated Access (Works Now) - -```bash -# Via gcloud proxy (recommended for testing) -gcloud run services proxy say-server \ - --region=us-east1 \ - --port=8888 \ - --project=mcp-apps-say-server - -# Then access at http://127.0.0.1:8888/mcp -curl -X POST http://127.0.0.1:8888/mcp \ - -H "Content-Type: application/json" \ - -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"test","version":"1.0"}}}' -``` - -### Requirements for Public Access - -To enable unauthenticated public access, one of: - -1. **Org Policy Exception** - Request IT to allow `allUsers` for this project: - - Policy: `constraints/iam.allowedPolicyMemberDomains` - - Need exception to add `allUsers` to Cloud Run invoker role - -2. **External Project** - Deploy to a GCP project outside Anthropic's org - -3. **MCP OAuth Auth** - Implement OAuth in the server: - ```python - from mcp.server.fastmcp import FastMCP, AuthSettings - - mcp = FastMCP( - "Say Demo", - auth=AuthSettings( - issuer_url="https://accounts.google.com", - required_scopes=["openid", "email"], - ), - token_verifier=..., # Configure token verification - ) - ``` - -## Files - -- `server.py` - Self-contained MCP server (runs with `uv run server.py`) -- `Dockerfile` - Cloud Run container definition -- `.gcloudignore` - Excludes unnecessary files from upload -- `mcp-app.html` - UI served as MCP resource - -## Updating the Deployment - -```bash -# Rebuild and push -docker build --platform linux/amd64 \ - -t us-east1-docker.pkg.dev/mcp-apps-say-server/cloud-run-source-deploy/say-server:latest . -docker push us-east1-docker.pkg.dev/mcp-apps-say-server/cloud-run-source-deploy/say-server:latest - -# Deploy new revision -gcloud run deploy say-server \ - --image us-east1-docker.pkg.dev/mcp-apps-say-server/cloud-run-source-deploy/say-server:latest \ - --region us-east1 \ - --project mcp-apps-say-server -``` - -## Cleanup - -```bash -# Delete all resources -gcloud compute forwarding-rules delete say-server-forwarding --global --quiet -gcloud compute target-http-proxies delete say-server-proxy --global --quiet -gcloud compute url-maps delete say-server-lb --global --quiet -gcloud compute backend-services delete say-server-backend --global --quiet -gcloud compute network-endpoint-groups delete say-server-neg --region=us-east1 --quiet -gcloud compute addresses delete say-server-ip --global --quiet -gcloud run services delete say-server --region=us-east1 --quiet -``` diff --git a/examples/say-server/Dockerfile b/examples/say-server/Dockerfile deleted file mode 100644 index 1b1630701..000000000 --- a/examples/say-server/Dockerfile +++ /dev/null @@ -1,34 +0,0 @@ -# Cloud Run Dockerfile for Say Server -# Uses uv for fast Python package management - -FROM ghcr.io/astral-sh/uv:python3.12-bookworm-slim - -# Install git for pip install from git repos -RUN apt-get update && apt-get install -y git && rm -rf /var/lib/apt/lists/* - -WORKDIR /app - -# Copy server file -COPY server.py . - -# Use isolated cache directory for HuggingFace to avoid permission issues -ENV HF_HOME=/app/.cache/huggingface -ENV TRANSFORMERS_CACHE=/app/.cache/huggingface - -# Pre-install dependencies for faster cold starts -RUN uv pip install --system --default-index https://pypi.org/simple \ - "mcp @ git+https://github.com/modelcontextprotocol/python-sdk@main" \ - "uvicorn>=0.34.0" \ - "starlette>=0.46.0" \ - "pocket-tts>=1.0.1" - -# Cloud Run sets PORT env var, server already respects it -# Default to 8080 for Cloud Run -ENV PORT=8080 -ENV HOST=0.0.0.0 - -# Expose port (informational, Cloud Run uses PORT env var) -EXPOSE 8080 - -# Run the server directly with python (not uv run, since deps are pre-installed) -CMD ["python", "server.py"] diff --git a/examples/say-server/SECURITY_REVIEW.md b/examples/say-server/SECURITY_REVIEW.md deleted file mode 100644 index 856b8bdc8..000000000 --- a/examples/say-server/SECURITY_REVIEW.md +++ /dev/null @@ -1,607 +0,0 @@ -# Say Server - Security Design Document - -**Service**: MCP Say Server (Text-to-Speech) -**Location**: GCP Cloud Run, `us-east1` -**Project**: `mcp-apps-say-server` -**Date**: January 2026 - ---- - -## 1. Overview - -The Say Server is an MCP (Model Context Protocol) application that provides real-time text-to-speech functionality. It demonstrates streaming audio generation with karaoke-style text highlighting. - -### What It Does - -``` -┌─────────────────────────────────────────────────────────────────────────────┐ -│ Say Server │ -│ │ -│ 1. Claude streams text to say() tool call arguments │ -│ 2. Host forwards partial input to MCP App (widget in iframe) │ -│ 3. Widget receives via ontoolinputpartial, sends to server queue │ -│ 4. Server generates audio chunks (CPU-bound TTS via pocket-tts) │ -│ 5. Widget polls for audio, plays via Web Audio API │ -│ 6. Text highlighting synced with audio playback (karaoke-style) │ -│ │ -└─────────────────────────────────────────────────────────────────────────────┘ -``` - -### Key Components - -| Component | Description | -|-----------|-------------| -| `server.py` | Self-contained MCP server with TTS tools | -| `pocket-tts` | Neural TTS model (Kyutai, Apache 2.0) | -| Widget HTML | React-based UI for playback control | -| MCP Protocol | Streamable HTTP transport with session support | - ---- - -## 2. Architecture Diagrams - -### 2.1 High-Level Data Flow - -``` -┌──────────┐ ┌──────────────┐ ┌─────────────────┐ ┌──────────────┐ -│ Claude │────▶│ MCP Host │────▶│ Say Server │────▶│ TTS Model │ -│ (LLM) │ │ (Client) │ │ (Cloud Run) │ │ (pocket-tts)│ -└──────────┘ └──────────────┘ └─────────────────┘ └──────────────┘ - │ │ │ - │ streams tool │ forwards to │ generates audio - │ call arguments │ MCP App (widget) │ - ▼ ▼ ▼ -┌──────────────────────────────────────────────────────────────────────────────┐ -│ │ -│ Claude streams text ──▶ Host forwards partial ──▶ Widget receives via │ -│ to say() tool input tool input to iframe ontoolinputpartial() │ -│ │ -│ Widget calls server: │ -│ create_tts_queue(voice) ──▶ queue_id │ -│ add_tts_text(queue_id, "Hello wor...") │ -│ add_tts_text(queue_id, "ld!") │ -│ end_tts_queue(queue_id) │ -│ │ -│ Widget polls for audio: │ -│ poll_tts_audio(queue_id) ◀─── {chunks: [{audio_base64, ...}], done} │ -│ │ -└──────────────────────────────────────────────────────────────────────────────┘ -``` - -**Key insight**: The widget (MCP App) is the active party - it receives streamed text from Claude via the host, then independently calls server tools to manage TTS generation. - -### 2.2 Queue Architecture - -``` -┌─────────────────────────────────────────────────────────────────────────────┐ -│ Server Process Memory │ -│ │ -│ tts_queues: Dict[str, TTSQueueState] │ -│ ┌─────────────────────────────────────────────────────────────────────┐ │ -│ │ │ │ -│ │ "a1b2c3d4e5f6" ──▶ TTSQueueState { │ │ -│ │ id: "a1b2c3d4e5f6" │ │ -│ │ text_queue: AsyncQueue ◀── text chunks │ │ -│ │ audio_chunks: List ──▶ generated audio │ │ -│ │ chunks_delivered: int │ │ -│ │ status: "active" | "complete" | "error" │ │ -│ │ task: AsyncTask (background TTS) │ │ -│ │ } │ │ -│ │ │ │ -│ │ "x7y8z9a0b1c2" ──▶ TTSQueueState { ... } (different session) │ │ -│ │ │ │ -│ └─────────────────────────────────────────────────────────────────────┘ │ -│ │ -└─────────────────────────────────────────────────────────────────────────────┘ -``` - -### 2.3 Information Flow: Text → Audio - -``` -┌─────────────────────────────────────────────────────────────────────────────┐ -│ │ -│ CLIENT (Widget) SERVER (Cloud Run) │ -│ ────────────────── ────────────────── │ -│ │ -│ 1. create_tts_queue(voice) ─────▶ Creates TTSQueueState │ -│ ◀───────────────────────────── Returns {queue_id, sample_rate} │ -│ │ -│ 2. add_tts_text(queue_id, "He") ─▶ Queues text │ -│ add_tts_text(queue_id, "llo")─▶ Queues text │ -│ add_tts_text(queue_id, " ") ──▶ Queues text │ -│ │ │ -│ ▼ │ -│ ┌──────────────────┐ │ -│ │ Background Task │ │ -│ │ ─────────────────│ │ -│ │ StreamingChunker │ │ -│ │ buffers text │ │ -│ │ until sentence │ │ -│ │ boundary │ │ -│ │ │ │ │ -│ │ ▼ │ │ -│ │ TTS Model │ │ -│ │ generates audio │ │ -│ │ (run_in_executor)│ │ -│ │ │ │ │ -│ │ ▼ │ │ -│ │ audio_chunks[] │ │ -│ └──────────────────┘ │ -│ │ -│ 3. poll_tts_audio(queue_id) ────▶ Returns new chunks since last poll │ -│ ◀──────────────────────────── {chunks: [...], done: false} │ -│ poll_tts_audio(queue_id) ────▶ │ -│ ◀──────────────────────────── {chunks: [...], done: true} │ -│ │ -│ 4. end_tts_queue(queue_id) ─────▶ Signals EOF, flushes remaining text │ -│ │ -└─────────────────────────────────────────────────────────────────────────────┘ -``` - -### 2.4 Polling Mechanism - -``` -┌─────────────────────────────────────────────────────────────────────────────┐ -│ Widget Polling Loop │ -│ │ -│ while (!done) { │ -│ response = await callServerTool("poll_tts_audio", {queue_id}) │ -│ │ -│ for (chunk of response.chunks) { │ -│ // Decode base64 audio │ -│ // Schedule on Web Audio API │ -│ // Track timing for text sync │ -│ } │ -│ │ -│ if (response.chunks.length > 0) { │ -│ await sleep(20ms) // Fast poll during active streaming │ -│ } else { │ -│ await sleep(50-150ms) // Exponential backoff when waiting │ -│ } │ -│ } │ -│ │ -│ Server-side: │ -│ ───────────── │ -│ - chunks_delivered tracks what client has seen │ -│ - poll returns audio_chunks[chunks_delivered:] │ -│ - Updates chunks_delivered after each poll │ -│ │ -└─────────────────────────────────────────────────────────────────────────────┘ -``` - ---- - -## 3. Session & Queue Isolation - -### 3.1 Session Isolation Model - -``` -┌─────────────────────────────────────────────────────────────────────────────┐ -│ │ -│ Session A (User 1) Session B (User 2) │ -│ ────────────────── ────────────────── │ -│ │ -│ queue_id: "a1b2c3d4e5f6" queue_id: "x7y8z9a0b1c2" │ -│ │ │ │ -│ ▼ ▼ │ -│ ┌─────────────────┐ ┌─────────────────┐ │ -│ │ TTSQueueState A │ │ TTSQueueState B │ │ -│ │ │ │ │ │ -│ │ text: "Hello" │ │ text: "Goodbye" │ │ -│ │ audio: [...] │ │ audio: [...] │ │ -│ │ voice: cosette │ │ voice: alba │ │ -│ └─────────────────┘ └─────────────────┘ │ -│ │ -│ ✓ Each queue is completely independent │ -│ ✓ Queue ID is the only "key" to access data │ -│ ✓ No shared state between queues │ -│ │ -└─────────────────────────────────────────────────────────────────────────────┘ -``` - -### 3.2 Queue ID as Access Token - -```python -# Queue creation generates random 12-char hex ID -queue_id = uuid.uuid4().hex[:12] # e.g., "a1b2c3d4e5f6" - -# All operations require queue_id -add_tts_text(queue_id, text) # Only works if you know the ID -poll_tts_audio(queue_id) # Only returns YOUR queue's audio -end_tts_queue(queue_id) # Only ends YOUR queue -``` - -**Entropy**: 12 hex chars = 48 bits = 281 trillion possible values - ---- - -## 4. CPU Isolation (TTS Processing) - -### 4.1 Thread Pool Isolation - -``` -┌─────────────────────────────────────────────────────────────────────────────┐ -│ │ -│ Main Event Loop (asyncio) │ -│ ───────────────────────── │ -│ - Handles HTTP requests │ -│ - Manages queue state │ -│ - Non-blocking operations │ -│ │ -│ │ │ -│ │ run_in_executor() │ -│ ▼ │ -│ ┌─────────────────────────────────────────────────────────────────────┐ │ -│ │ Thread Pool Executor │ │ -│ │ │ │ -│ │ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ │ -│ │ │ Thread 1 │ │ Thread 2 │ │ Thread 3 │ ... │ │ -│ │ │ Queue A │ │ Queue B │ │ Queue C │ │ │ -│ │ │ TTS work │ │ TTS work │ │ TTS work │ │ │ -│ │ └─────────────┘ └─────────────┘ └─────────────┘ │ │ -│ │ │ │ -│ │ - Each queue's TTS runs in separate thread │ │ -│ │ - CPU-bound work doesn't block event loop │ │ -│ │ - Natural isolation via thread boundaries │ │ -│ │ │ │ -│ └─────────────────────────────────────────────────────────────────────┘ │ -│ │ -└─────────────────────────────────────────────────────────────────────────────┘ -``` - -### 4.2 No Shared TTS State - -```python -# Each queue gets its own model state copy -model_state = tts_model._cached_get_state_for_audio_prompt(voice, truncate=True) - -# Audio generation uses copy_state=True -for audio_chunk in tts_model._generate_audio_stream_short_text( - model_state=model_state, - text_to_generate=text, - copy_state=True, # ← Ensures isolation -): - ... -``` - ---- - -## 5. Need for Session Stickiness - -### 5.1 Why Stickiness is Required - -``` -┌─────────────────────────────────────────────────────────────────────────────┐ -│ │ -│ WITHOUT Stickiness (BROKEN) │ -│ ─────────────────────────── │ -│ │ -│ Request 1: create_tts_queue() ──▶ Instance A ──▶ queue_id: "abc123" │ -│ Request 2: add_tts_text("abc123") ──▶ Instance B ──▶ "Queue not found!" ✗ │ -│ │ -│ The queue exists only in Instance A's memory! │ -│ │ -├─────────────────────────────────────────────────────────────────────────────┤ -│ │ -│ WITH Stickiness (WORKING) │ -│ ───────────────────────── │ -│ │ -│ Request 1: create_tts_queue() │ -│ mcp-session-id: xyz ──▶ Instance A ──▶ queue_id: "abc123" │ -│ │ -│ Request 2: add_tts_text("abc123") │ -│ mcp-session-id: xyz ──▶ Instance A ──▶ Text queued ✓ │ -│ (same session ID → same instance) │ -│ │ -│ Request 3: poll_tts_audio("abc123") │ -│ mcp-session-id: xyz ──▶ Instance A ──▶ Audio chunks ✓ │ -│ │ -└─────────────────────────────────────────────────────────────────────────────┘ -``` - -### 5.2 MCP Session Protocol - -``` -┌─────────────────────────────────────────────────────────────────────────────┐ -│ │ -│ First Request (no session) │ -│ ────────────────────────── │ -│ │ -│ POST /mcp │ -│ Content-Type: application/json │ -│ (no mcp-session-id header) │ -│ │ -│ Response: │ -│ mcp-session-id: sess_abc123xyz ◀── Server generates session ID │ -│ │ -├─────────────────────────────────────────────────────────────────────────────┤ -│ │ -│ Subsequent Requests │ -│ ─────────────────── │ -│ │ -│ POST /mcp │ -│ Content-Type: application/json │ -│ mcp-session-id: sess_abc123xyz ◀── Client sends back session ID │ -│ │ -│ Load Balancer: │ -│ - Hashes "sess_abc123xyz" │ -│ - Routes to same instance via consistent hashing │ -│ │ -└─────────────────────────────────────────────────────────────────────────────┘ -``` - ---- - -## 6. Security Analysis - -### 6.1 Attack: Accessing Another User's Queue - -``` -┌─────────────────────────────────────────────────────────────────────────────┐ -│ │ -│ ATTACK SCENARIO │ -│ ─────────────── │ -│ │ -│ Attacker wants to: │ -│ 1. Read audio from victim's queue │ -│ 2. Inject text into victim's queue │ -│ 3. Cancel victim's queue │ -│ │ -├─────────────────────────────────────────────────────────────────────────────┤ -│ │ -│ ATTACK REQUIREMENTS │ -│ ─────────────────── │ -│ │ -│ 1. Know victim's queue_id (12-char hex = 48 bits entropy) │ -│ - Not exposed in any API response │ -│ - Not in URLs, logs, or error messages │ -│ - Only returned to queue creator │ -│ │ -│ 2. Be routed to same Cloud Run instance (for in-memory access) │ -│ - Requires matching mcp-session-id hash │ -│ - Session IDs are also random and not exposed │ -│ │ -├─────────────────────────────────────────────────────────────────────────────┤ -│ │ -│ WHY IT'S NOT POSSIBLE │ -│ ───────────────────── │ -│ │ -│ ┌────────────────────────────────────────────────────────────────┐ │ -│ │ │ │ -│ │ Brute Force Analysis: │ │ -│ │ │ │ -│ │ Queue ID space: 16^12 = 281,474,976,710,656 possibilities │ │ -│ │ Queue lifetime: ~30 seconds (timeout) to ~5 minutes (usage) │ │ -│ │ Concurrent queues: typically 1-10 per instance │ │ -│ │ │ │ -│ │ Probability of guessing valid queue_id: │ │ -│ │ P = active_queues / total_space │ │ -│ │ P = 10 / 281,474,976,710,656 │ │ -│ │ P ≈ 3.5 × 10^-14 │ │ -│ │ │ │ -│ │ At 1000 requests/second, expected time to find valid ID: │ │ -│ │ T = 281,474,976,710,656 / 10 / 1000 seconds │ │ -│ │ T ≈ 891 years │ │ -│ │ │ │ -│ └────────────────────────────────────────────────────────────────┘ │ -│ │ -│ Additional Barriers: │ -│ - Rate limiting would kick in │ -│ - Queue expires before brute force succeeds │ -│ - Attacker's requests go to different instances (session affinity) │ -│ │ -└─────────────────────────────────────────────────────────────────────────────┘ -``` - -### 6.2 Attack: Session ID Enumeration - -``` -┌─────────────────────────────────────────────────────────────────────────────┐ -│ │ -│ ATTACK: Guess mcp-session-id to route to victim's instance │ -│ │ -│ WHY IT FAILS: │ -│ ───────────── │ -│ │ -│ 1. Session IDs are server-generated (not predictable) │ -│ 2. Even if routed to same instance, still need queue_id │ -│ 3. Session ID ≠ Queue ID (they're independent) │ -│ │ -│ ┌────────────────────────────────────────────────────────────────┐ │ -│ │ │ │ -│ │ Attacker sends: │ │ -│ │ mcp-session-id: guessed_value │ │ -│ │ │ │ │ -│ │ ▼ │ │ -│ │ Load Balancer routes to Instance X (based on hash) │ │ -│ │ │ │ │ -│ │ ▼ │ │ -│ │ Attacker calls poll_tts_audio(guessed_queue_id) │ │ -│ │ │ │ │ -│ │ ▼ │ │ -│ │ Server: "Queue not found" (queue_id is still wrong) │ │ -│ │ │ │ -│ └────────────────────────────────────────────────────────────────┘ │ -│ │ -└─────────────────────────────────────────────────────────────────────────────┘ -``` - -### 6.3 Data Exposure Summary - -| Data | Exposed To | Risk Level | -|------|------------|------------| -| Queue ID | Only queue creator | 🟢 Low | -| Session ID | Only session holder | 🟢 Low | -| Input text | Only queue owner (via poll) | 🟢 Low | -| Audio data | Only queue owner (via poll) | 🟢 Low | -| Voice name | Only queue owner | 🟢 Low | - -### 6.4 Potential Improvements (Not Required) - -| Enhancement | Benefit | Complexity | -|-------------|---------|------------| -| Sign queue IDs with HMAC | Prevent any forged IDs | Medium | -| Bind queue to session ID | Defense in depth | Low | -| Encrypt audio in transit | Already HTTPS | N/A | -| Add queue access logging | Audit trail | Low | - ---- - -## 7. Deployment Security - -### 7.1 Current Controls - -| Control | Status | Notes | -|---------|--------|-------| -| HTTPS (Cloud Run) | ✅ | Enforced by default | -| Container sandbox | ✅ | gVisor isolation | -| No persistent storage | ✅ | Stateless design | -| No secrets in code | ✅ | Uses public HuggingFace models | -| Queue auto-cleanup | ✅ | 30s timeout, 60s post-completion | - -### 7.2 Pending for Public Access - -| Requirement | Status | Action Needed | -|-------------|--------|---------------| -| Org policy exception | ❌ | Add `allUsersAccess` tag + `allUsers` invoker | -| HTTPS on Load Balancer | ❌ | Add SSL certificate | -| Rate limiting | ⚠️ | Consider Cloud Armor | -| Max instances limit | ⚠️ | Set scaling constraints for cost control | - -### 7.3 Enabling Public Access (Reference: mcp-server-everything) - -Based on the [Hosted Everything MCP Server](https://docs.google.com/document/d/138rvE5iLeSAJKljo9mNMftvUyjIuvf4tn20oVz7hojY) deployment, public access requires: - -```bash -# Step 1: Add allUsersAccess tag to exempt from Domain Restricted Sharing -# Requires: roles/resourcemanager.tagUser at org level (or "GCP Org - Tag Admin Access" 2PC role) -gcloud resource-manager tags bindings create \ - --tag-value=tagValues/281479845332531 \ - --parent=//run.googleapis.com/projects/mcp-apps-say-server/locations/us-east1/services/say-server \ - --location=us-east1 - -# Step 2: Allow unauthenticated invocations -gcloud run services add-iam-policy-binding say-server \ - --project=mcp-apps-say-server \ - --member="allUsers" \ - --role="roles/run.invoker" \ - --region=us-east1 - -# Step 3: Set max instances for cost control -gcloud run services update say-server \ - --max-instances=5 \ - --region=us-east1 \ - --project=mcp-apps-say-server -``` - -**Prerequisites**: -- `GCP Org - Tag Admin Access` 2PC role (or `roles/resourcemanager.tagUser`) -- `roles/run.admin` or security admin permissions - -### 7.4 Recommended Application-Level Security (from mcp-server-everything) - -Once public, implement these hardening measures: - -**Priority 1 (Critical)**: -```javascript -// Rate limiting per IP -const rateLimit = require('express-rate-limit'); -app.use('/mcp', rateLimit({ - windowMs: 15 * 60 * 1000, // 15 minutes - max: 100, // limit each IP to 100 requests per windowMs -})); - -// Request size limits -app.use(express.json({ limit: '10mb' })); - -// Request timeout -app.use(timeout('30s')); -``` - -**Priority 2 (Important)**: -- Budget alerts configured -- Security monitoring and alerting -- Periodic queue cleanup (already implemented: 30s timeout, 60s post-cleanup) - -### 7.5 Security Verdict (Aligned with mcp-server-everything) - -**✅ SECURE for Testing/Demo Purposes** because: -1. **No sensitive data** processed or stored -2. **Infrastructure properly isolated** (Cloud Run sandbox) -3. **Worst-case scenario** is cost incurrence or service disruption -4. **Purpose-built for testing** with clear boundaries -5. **Queue auto-cleanup** prevents data accumulation - -**Comparison with mcp-server-everything**: - -| Aspect | mcp-server-everything | say-server | -|--------|----------------------|------------| -| State storage | Redis (VPC) | In-memory (per instance) | -| Session mgmt | Redis-backed | Queue ID + session affinity | -| Public access | ✅ Enabled | ❌ Pending | -| Rate limiting | Application-level | Not yet implemented | -| Max instances | 5 | 10 (should reduce) | - ---- - -## 8. Appendix: Queue Lifecycle - -``` -┌─────────────────────────────────────────────────────────────────────────────┐ -│ │ -│ QUEUE STATES │ -│ ──────────── │ -│ │ -│ create_tts_queue() │ -│ │ │ -│ ▼ │ -│ ┌─────────────┐ │ -│ │ ACTIVE │◀─── add_tts_text() ───┐ │ -│ │ │ │ │ -│ │ Processing │────────────────────────┘ │ -│ └─────────────┘ │ -│ │ │ -│ │ end_tts_queue() or timeout │ -│ ▼ │ -│ ┌─────────────┐ ┌─────────────┐ │ -│ │ COMPLETE │ or │ ERROR │ │ -│ │ │ │ │ │ -│ │ All audio │ │ Timeout or │ │ -│ │ generated │ │ exception │ │ -│ └─────────────┘ └─────────────┘ │ -│ │ │ │ -│ └─────────┬─────────┘ │ -│ │ │ -│ ▼ │ -│ 60 seconds after done │ -│ │ │ -│ ▼ │ -│ [Queue Removed] │ -│ │ -└─────────────────────────────────────────────────────────────────────────────┘ -``` - ---- - -## 9. References - -- **[Hosted Everything MCP Server](https://docs.google.com/document/d/138rvE5iLeSAJKljo9mNMftvUyjIuvf4tn20oVz7hojY)** - Jerome's deployment guide for `mcp-server-everything`, used as reference for security patterns and public access setup -- **[How to set up public Cloud Run services](https://outline.ant.dev/doc/how-to-set-up-public-cloud-run-services-zv7t2CPClu)** - Anthropic internal guide for org policy exemptions -- **[MCP Apps SDK Specification](../../specification/draft/apps.mdx)** - Protocol spec for MCP Apps - ---- - -## 10. Contact & Approval - -**Owner**: ochafik@anthropic.com -**Repository**: github.com/modelcontextprotocol/ext-apps -**Component**: examples/say-server - -### Approval Checklist - -- [ ] Security review completed -- [ ] Org policy exception approved (`allUsersAccess` tag applied) -- [ ] HTTPS configured on load balancer -- [ ] Max instances set to 5 (cost control) -- [ ] Rate limiting configured (optional) -- [ ] Monitoring/alerting set up From 6ae56757ad43cfe6251b7c9f1918477d9d630ddf Mon Sep 17 00:00:00 2001 From: Olivier Chafik Date: Fri, 23 Jan 2026 11:03:11 +0000 Subject: [PATCH 08/23] refactor(pdf-server): simplify updateModelContext format - Add widget ID for multi-widget disambiguation - Use concise header: 'PDF viewer (id) | title | Current Page: X/Y' - Add 'Page content:' prefix before text - Keep selection markers (...) --- examples/pdf-server/src/mcp-app.ts | 20 +++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/examples/pdf-server/src/mcp-app.ts b/examples/pdf-server/src/mcp-app.ts index f50e86ee7..7b8013964 100644 --- a/examples/pdf-server/src/mcp-app.ts +++ b/examples/pdf-server/src/mcp-app.ts @@ -247,7 +247,7 @@ function findSelectionInText( return undefined; } -// Extract text from current page and update model context as markdown +// Extract text from current page and update model context async function updatePageContext() { if (!pdfDocument) return; @@ -276,22 +276,24 @@ async function updatePageContext() { ); } - // Format content with selection and truncation + // Format content with selection markers and truncation const content = formatPageContent( pageText, MAX_MODEL_CONTEXT_LENGTH, selection, ); - const markdown = `--- -title: ${pdfTitle || ""} -url: ${pdfUrl} -current-page: ${currentPage}/${totalPages} ---- + // Build context with widget ID for multi-widget disambiguation + const widgetId = app.getHostContext()?.toolInfo?.id; + const header = [ + `PDF viewer${widgetId ? ` (${widgetId})` : ""}`, + pdfTitle ? `"${pdfTitle}"` : pdfUrl, + `Current Page: ${currentPage}/${totalPages}`, + ].join(" | "); -${content}`; + const contextText = `${header}\n\nPage content:\n${content}`; - app.updateModelContext({ content: [{ type: "text", text: markdown }] }); + app.updateModelContext({ content: [{ type: "text", text: contextText }] }); } catch (err) { log.error("Error updating context:", err); } From a96925ccac7d0776a6d55eca42e72aa6d6ec571b Mon Sep 17 00:00:00 2001 From: Olivier Chafik Date: Fri, 23 Jan 2026 11:10:21 +0000 Subject: [PATCH 09/23] refactor: rename widgetUUID to viewUUID for consistency Aligns with main's standardization on 'view' terminology (PR #325). Also dynamically generate allowed domains in display_pdf description. --- examples/map-server/src/mcp-app.ts | 24 ++++++++++++------------ examples/pdf-server/server.ts | 9 +++++++-- examples/pdf-server/src/mcp-app.ts | 18 +++++++++--------- examples/say-server/server.py | 18 +++++++++--------- 4 files changed, 37 insertions(+), 32 deletions(-) diff --git a/examples/map-server/src/mcp-app.ts b/examples/map-server/src/mcp-app.ts index 05fd88db6..81b858224 100644 --- a/examples/map-server/src/mcp-app.ts +++ b/examples/map-server/src/mcp-app.ts @@ -71,7 +71,7 @@ let persistViewTimer: ReturnType | null = null; // Track whether tool input has been received (to know if we should restore persisted state) let hasReceivedToolInput = false; -let widgetUUID: string | undefined = undefined; +let viewUUID: string | undefined = undefined; /** * Persisted camera state for localStorage @@ -122,7 +122,7 @@ function schedulePersistViewState(cesiumViewer: any): void { * Persist current view state to localStorage */ function persistViewState(cesiumViewer: any): void { - if (!widgetUUID) { + if (!viewUUID) { log.info("No storage key available, skipping view persistence"); return; } @@ -132,8 +132,8 @@ function persistViewState(cesiumViewer: any): void { try { const value = JSON.stringify(state); - localStorage.setItem(widgetUUID, value); - log.info("Persisted view state:", widgetUUID, value); + localStorage.setItem(viewUUID, value); + log.info("Persisted view state:", viewUUID, value); } catch (e) { log.warn("Failed to persist view state:", e); } @@ -143,10 +143,10 @@ function persistViewState(cesiumViewer: any): void { * Load persisted view state from localStorage */ function loadPersistedViewState(): PersistedCameraState | null { - if (!widgetUUID) return null; + if (!viewUUID) return null; try { - const stored = localStorage.getItem(widgetUUID); + const stored = localStorage.getItem(viewUUID); if (!stored) { console.info("No persisted view state found"); return null; @@ -923,16 +923,16 @@ app.ontoolinput = async (params) => { // }, // ); -// Handle tool result - extract widgetUUID and restore persisted view if available +// Handle tool result - extract viewUUID and restore persisted view if available app.ontoolresult = async (result) => { - widgetUUID = result._meta?.widgetUUID - ? String(result._meta.widgetUUID) + viewUUID = result._meta?.viewUUID + ? String(result._meta.viewUUID) : undefined; - log.info("Tool result received, widgetUUID:", widgetUUID); + log.info("Tool result received, viewUUID:", viewUUID); - // Now that we have widgetUUID, try to restore persisted view + // Now that we have viewUUID, try to restore persisted view // This overrides the tool input position if a saved state exists - if (viewer && widgetUUID) { + if (viewer && viewUUID) { const restored = restorePersistedView(viewer); if (restored) { log.info("Restored persisted view from tool result handler"); diff --git a/examples/pdf-server/server.ts b/examples/pdf-server/server.ts index b915e68fc..3f122e42e 100644 --- a/examples/pdf-server/server.ts +++ b/examples/pdf-server/server.ts @@ -346,6 +346,11 @@ export function createServer(): McpServer { }, ); + // Build allowed domains list for tool description (strip https:// and www.) + const allowedDomains = [...allowedRemoteOrigins] + .map(origin => origin.replace(/^https?:\/\/(www\.)?/, "")) + .join(", "); + // Tool: display_pdf - Show interactive viewer registerAppTool( server, @@ -356,7 +361,7 @@ export function createServer(): McpServer { Accepts: - Local files explicitly added to the server (use list_pdfs to see available files) -- Remote PDFs from allowed services: arxiv.org, ssrn.com, biorxiv.org, medrxiv.org, chemrxiv.org, zenodo.org, osf.io, hal.science, researchsquare.com, preprints.org, eartharxiv.org, psyarxiv.com, engrxiv.org, sportarxiv.org, agrirxiv.org, edarxiv.org`, +- Remote PDFs from: ${allowedDomains}`, inputSchema: { url: z.string().default(DEFAULT_PDF).describe("PDF URL"), page: z.number().min(1).default(1).describe("Initial page"), @@ -385,7 +390,7 @@ Accepts: initialPage: page, }, _meta: { - widgetUUID: randomUUID(), + viewUUID: randomUUID(), }, }; }, diff --git a/examples/pdf-server/src/mcp-app.ts b/examples/pdf-server/src/mcp-app.ts index 7b8013964..c32077902 100644 --- a/examples/pdf-server/src/mcp-app.ts +++ b/examples/pdf-server/src/mcp-app.ts @@ -35,7 +35,7 @@ let totalPages = 0; let scale = 1.0; let pdfUrl = ""; let pdfTitle: string | undefined; -let widgetUUID: string | undefined; +let viewUUID: string | undefined; let currentRenderTask: { cancel: () => void } | null = null; // DOM Elements @@ -406,10 +406,10 @@ async function renderPage() { } function saveCurrentPage() { - log.info("saveCurrentPage: key=", widgetUUID, "page=", currentPage); - if (widgetUUID) { + log.info("saveCurrentPage: key=", viewUUID, "page=", currentPage); + if (viewUUID) { try { - localStorage.setItem(widgetUUID, String(currentPage)); + localStorage.setItem(viewUUID, String(currentPage)); log.info("saveCurrentPage: saved successfully"); } catch (err) { log.error("saveCurrentPage: error", err); @@ -418,10 +418,10 @@ function saveCurrentPage() { } function loadSavedPage(): number | null { - log.info("loadSavedPage: key=", widgetUUID); - if (!widgetUUID) return null; + log.info("loadSavedPage: key=", viewUUID); + if (!viewUUID) return null; try { - const saved = localStorage.getItem(widgetUUID); + const saved = localStorage.getItem(viewUUID); log.info("loadSavedPage: saved value=", saved); if (saved) { const page = parseInt(saved, 10); @@ -707,8 +707,8 @@ app.ontoolresult = async (result) => { pdfUrl = parsed.url; pdfTitle = parsed.title; totalPages = parsed.pageCount; - widgetUUID = result._meta?.widgetUUID - ? String(result._meta.widgetUUID) + viewUUID = result._meta?.viewUUID + ? String(result._meta.viewUUID) : undefined; // Restore saved page or use initial page diff --git a/examples/say-server/server.py b/examples/say-server/server.py index 0b0326846..662fe4f78 100755 --- a/examples/say-server/server.py +++ b/examples/say-server/server.py @@ -204,7 +204,7 @@ def say( return [types.TextContent( type="text", text=f"Displayed a TTS widget with voice '{voice}'. Click to play/pause, use toolbar to restart or fullscreen.", - _meta={"widgetUUID": widget_uuid}, + _meta={"viewUUID": widget_uuid}, )] @@ -796,7 +796,7 @@ def generate_sync(): const [showInfo, setShowInfo] = useState(false); const voiceRef = useRef("cosette"); // Current voice, updated from tool input - const widgetUuidRef = useRef(null); // Widget UUID for speak lock coordination + const viewUUIDRef = useRef(null); // Widget UUID for speak lock coordination const speakLockIntervalRef = useRef(null); // Polling interval for speak lock const queueIdRef = useRef(null); const audioContextRef = useRef(null); @@ -820,18 +820,18 @@ def generate_sync(): const SPEAK_LOCK_KEY = "mcp-tts-playing"; const announcePlayback = useCallback(() => { - if (!widgetUuidRef.current) return; + if (!viewUUIDRef.current) return; localStorage.setItem(SPEAK_LOCK_KEY, JSON.stringify({ - uuid: widgetUuidRef.current, + uuid: viewUUIDRef.current, timestamp: Date.now() })); }, []); const clearSpeakLock = useCallback(() => { - if (!widgetUuidRef.current) return; + if (!viewUUIDRef.current) return; try { const current = JSON.parse(localStorage.getItem(SPEAK_LOCK_KEY) || "null"); - if (current?.uuid === widgetUuidRef.current) { + if (current?.uuid === viewUUIDRef.current) { localStorage.removeItem(SPEAK_LOCK_KEY); } } catch {} @@ -842,7 +842,7 @@ def generate_sync(): speakLockIntervalRef.current = setInterval(() => { try { const current = JSON.parse(localStorage.getItem(SPEAK_LOCK_KEY) || "null"); - if (current && current.uuid !== widgetUuidRef.current) { + if (current && current.uuid !== viewUUIDRef.current) { // Someone else started playing - yield console.log('[TTS] Another widget started playing, pausing'); onStolenCallback(); @@ -1220,9 +1220,9 @@ def generate_sync(): console.log('[TTS] ontoolresult called, queueId:', queueIdRef.current); fullTextRef.current = lastTextRef.current; // Read widget UUID from tool result _meta for speak lock coordination - const resultUuid = params.content?.[0]?._meta?.widgetUUID; + const resultUuid = params.content?.[0]?._meta?.viewUUID; if (resultUuid) { - widgetUuidRef.current = resultUuid; + viewUUIDRef.current = resultUuid; console.log('[TTS] Widget UUID:', resultUuid); } if (queueIdRef.current) { From b1fb282f2dcfbc13c0ba22689ed6308bd37330de Mon Sep 17 00:00:00 2001 From: Olivier Chafik Date: Fri, 23 Jan 2026 11:12:59 +0000 Subject: [PATCH 10/23] viewUUID --- examples/map-server/server.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/map-server/server.ts b/examples/map-server/server.ts index e6ff56422..dbe2154e1 100644 --- a/examples/map-server/server.ts +++ b/examples/map-server/server.ts @@ -185,7 +185,7 @@ export function createServer(): McpServer { }, ], _meta: { - widgetUUID: randomUUID(), + viewUUID: randomUUID(), }, }), ); From dbe7980c0484c472af804e3c1856b3d8b31cc638 Mon Sep 17 00:00:00 2001 From: Olivier Chafik Date: Fri, 23 Jan 2026 11:15:30 +0000 Subject: [PATCH 11/23] fix(map-server): fix syntax error in location update message --- examples/map-server/src/mcp-app.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/examples/map-server/src/mcp-app.ts b/examples/map-server/src/mcp-app.ts index 81b858224..6aadf60d8 100644 --- a/examples/map-server/src/mcp-app.ts +++ b/examples/map-server/src/mcp-app.ts @@ -416,8 +416,7 @@ function scheduleLocationUpdate(cesiumViewer: any): void { // If the host doesn't support this, the request will silently fail. const content = [ `The map view of ${app.getHostContext()?.toolInfo?.id} is now ${widthKm.toFixed(1)}km wide × ${heightKm.toFixed(1)}km tall `, - `and has changed to the following location: `, - `[${places.join(", ")}] ` : '', + `and has changed to the following location: [${places.join(", ")}] `, `lat. / long. of center of map = [${center.lat.toFixed(4)}, ${center.lon.toFixed(4)}]`, ].join('\n') log.info("Updating model context:", content); From 50069b2047aa7cc216f35f34625a0faeb1a71ecd Mon Sep 17 00:00:00 2001 From: Olivier Chafik Date: Fri, 23 Jan 2026 11:18:26 +0000 Subject: [PATCH 12/23] fix(pdf-server): restore package.json, improve server docs - Restore --external pdfjs-dist in build - Rewrite server.ts header for newcomers (list tools, not implementation details) - Minimize main.ts diff --- examples/pdf-server/main.ts | 68 +++++---- examples/pdf-server/package.json | 2 +- examples/pdf-server/server copy.ts_ | 223 ++++++++++++++++++++++++++++ examples/pdf-server/server.ts | 13 +- 4 files changed, 269 insertions(+), 37 deletions(-) create mode 100644 examples/pdf-server/server copy.ts_ diff --git a/examples/pdf-server/main.ts b/examples/pdf-server/main.ts index ed8ce0d45..38830ca7f 100644 --- a/examples/pdf-server/main.ts +++ b/examples/pdf-server/main.ts @@ -1,13 +1,16 @@ /** - * PDF MCP Server - CLI Entry Point + * Entry point for running the MCP server. + * Run with: npx mcp-pdf-server + * Or: node dist/index.js [--stdio] [pdf-urls...] */ import fs from "node:fs"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; import express from "express"; import cors from "cors"; - +import type { Request, Response } from "express"; import { createServer, isArxivUrl, @@ -20,20 +23,28 @@ import { DEFAULT_PDF, } from "./server.js"; -// ============================================================================= -// Server Startup -// ============================================================================= +export interface ServerOptions { + port: number; + name?: string; +} + +/** + * Starts an MCP server with Streamable HTTP transport in stateless mode. + */ +export async function startServer( + createServer: () => McpServer, + options: ServerOptions, +): Promise { + const { port, name = "MCP Server" } = options; -async function startHttpServer(port: number): Promise { const app = express(); app.use(cors()); app.use(express.json()); - // Stateless mode - no session management needed! - app.all("/mcp", async (req, res) => { + app.all("/mcp", async (req: Request, res: Response) => { const server = createServer(); const transport = new StreamableHTTPServerTransport({ - sessionIdGenerator: undefined, // Stateless is fine now - no shared state! + sessionIdGenerator: undefined, }); res.on("close", () => { @@ -56,26 +67,19 @@ async function startHttpServer(port: number): Promise { } }); - return new Promise((resolve) => { - const httpServer = app.listen(port, () => { - console.log(`PDF Server (range-based) listening on http://localhost:${port}/mcp`); - resolve(); - }); + const httpServer = app.listen(port, () => { + console.log(`${name} listening on http://localhost:${port}/mcp`); + }); - const shutdown = () => { - console.log("\nShutting down..."); - httpServer.close(() => process.exit(0)); - }; + const shutdown = () => { + console.log("\nShutting down..."); + httpServer.close(() => process.exit(0)); + }; - process.on("SIGINT", shutdown); - process.on("SIGTERM", shutdown); - }); + process.on("SIGINT", shutdown); + process.on("SIGTERM", shutdown); } -// ============================================================================= -// CLI Argument Parsing -// ============================================================================= - function parseArgs(): { urls: string[]; stdio: boolean } { const args = process.argv.slice(2); const urls: string[] = []; @@ -85,9 +89,13 @@ function parseArgs(): { urls: string[]; stdio: boolean } { if (arg === "--stdio") { stdio = true; } else if (!arg.startsWith("-")) { + // Convert local paths to file:// URLs, normalize arxiv URLs let url = arg; - if (!arg.startsWith("http://") && !arg.startsWith("https://") && !arg.startsWith("file://")) { - // Convert local path to file:// URL + if ( + !arg.startsWith("http://") && + !arg.startsWith("https://") && + !arg.startsWith("file://") + ) { url = pathToFileUrl(arg); } else if (isArxivUrl(arg)) { url = normalizeArxivUrl(arg); @@ -99,10 +107,6 @@ function parseArgs(): { urls: string[]; stdio: boolean } { return { urls: urls.length > 0 ? urls : [DEFAULT_PDF], stdio }; } -// ============================================================================= -// Main -// ============================================================================= - async function main() { const { urls, stdio } = parseArgs(); @@ -126,7 +130,7 @@ async function main() { await createServer().connect(new StdioServerTransport()); } else { const port = parseInt(process.env.PORT ?? "3120", 10); - await startHttpServer(port); + await startServer(createServer, { port, name: "PDF Server" }); } } diff --git a/examples/pdf-server/package.json b/examples/pdf-server/package.json index 5d61a443f..61d266973 100644 --- a/examples/pdf-server/package.json +++ b/examples/pdf-server/package.json @@ -14,7 +14,7 @@ "dist" ], "scripts": { - "build": "tsc --noEmit && cross-env INPUT=mcp-app.html vite build && tsc -p tsconfig.server.json && bun build server.ts --outdir dist --target node && bun build main.ts --outfile dist/index.js --target node --external \"./server.js\" --banner \"#!/usr/bin/env node\"", + "build": "tsc --noEmit && cross-env INPUT=mcp-app.html vite build && tsc -p tsconfig.server.json && bun build server.ts --outdir dist --target node --external pdfjs-dist && bun build main.ts --outfile dist/index.js --target node --external \"./server.js\" --external pdfjs-dist --banner \"#!/usr/bin/env node\"", "watch": "cross-env INPUT=mcp-app.html vite build --watch", "serve": "bun --watch main.ts", "start": "cross-env NODE_ENV=development npm run build && npm run serve", diff --git a/examples/pdf-server/server copy.ts_ b/examples/pdf-server/server copy.ts_ new file mode 100644 index 000000000..0e0b0e790 --- /dev/null +++ b/examples/pdf-server/server copy.ts_ @@ -0,0 +1,223 @@ +/** + * PDF MCP Server - Didactic Example + * + * Demonstrates: + * - Chunked data through size-limited tool responses + * - Model context updates (current page text + selection) + * - Display modes: fullscreen with scrolling vs inline with resize + * - External link opening (openLink) + */ +import { + registerAppResource, + registerAppTool, + RESOURCE_MIME_TYPE, +} from "@modelcontextprotocol/ext-apps/server"; +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import type { + CallToolResult, + ReadResourceResult, +} from "@modelcontextprotocol/sdk/types.js"; +import fs from "node:fs/promises"; +import path from "node:path"; +import { z } from "zod"; +import { randomUUID } from "crypto"; + +import { + addUrlsToPdfIndex, + findEntryByUrl, + isArxivUrl, + isFileUrl, + normalizeArxivUrl, +} from "./src/pdf-indexer.js"; +import { loadPdfBytesChunk } from "./src/pdf-loader.js"; +import { + ReadPdfBytesInputSchema, + PdfBytesChunkSchema, + type PdfEntry, + type PdfIndex, +} from "./src/types.js"; + +// Works both from source (server.ts) and compiled (dist/server.js) +const DIST_DIR = import.meta.filename.endsWith(".ts") + ? path.join(import.meta.dirname, "dist") + : import.meta.dirname; +const RESOURCE_URI = "ui://pdf-viewer/mcp-app.html"; +const DEFAULT_PDF = "https://arxiv.org/pdf/1706.03762"; // Attention Is All You Need + +export const pdfIndex: PdfIndex = { + entries: [], +} + +/** + * Creates a new MCP server instance with tools and resources registered. + * Each HTTP session needs its own server instance because McpServer only supports one transport. + */ +export function createServer(): McpServer { + const server = new McpServer({ name: "PDF Server", version: "1.0.0" }); + + // Tool: list_pdfs + if (pdfIndex.entries.length > 0) { + server.tool( + "list_preindexed_pdfs", + "List preindexed PDFs", + {}, + async (): Promise => { + return { + content: [ + { type: "text", text: JSON.stringify(pdfIndex.entries, null, 2) }, + ], + structuredContent: { entries: pdfIndex.entries }, + }; + }, + ); + } + + const getCachedEntry = async (rawUrl: string): Promise => { + const url = isArxivUrl(rawUrl) ? normalizeArxivUrl(rawUrl) : rawUrl; + + let entry = findEntryByUrl(pdfIndex, url); + + if (!entry) { + if (isFileUrl(url)) { + throw new Error("File URLs must be in the initial list"); + } + if (!isArxivUrl(url)) { + throw new Error(`Only arxiv.org URLs can be loaded dynamically`); + } + + await addUrlsToPdfIndex([url], pdfIndex); + entry = findEntryByUrl(pdfIndex, url); + if (!entry) { + throw new Error(`Failed to load PDF: ${url}`); + } + } + return entry; + }; + + + // Tool: read_pdf_bytes (app-only) - Chunked binary loading + registerAppTool( + server, + "read_pdf_bytes", + { + title: "Read PDF Bytes", + description: "Load binary data in chunks", + inputSchema: ReadPdfBytesInputSchema, + outputSchema: PdfBytesChunkSchema, + _meta: { ui: { visibility: ["app"] } }, + }, + async ({ url, offset, byteCount }): Promise => { + try { + const entry = await getCachedEntry(url); + const chunk = await loadPdfBytesChunk(entry, offset, byteCount); + return { + content: [ + { + type: "text", + text: `${chunk.byteCount} bytes at ${chunk.offset}/${chunk.totalBytes}`, + }, + ], + structuredContent: chunk, + }; + } catch (err) { + console.error(`Error in read_pdf_bytes for URL ${url}:`, err); + return { + content: [ + { + type: "text", + text: `Error loading PDF bytes: ${err instanceof Error ? err.message : String(err)}`, + }, + ], + isError: true, + }; + } + }, + ); + + // Tool: display_pdf - Interactive viewer with UI + registerAppTool( + server, + "display_pdf", + { + title: "Display PDF", + description: `Display an interactive PDF viewer in the chat. + +Use this tool when the user asks to view, display, read, or open a PDF. Accepts: +- URLs from list_pdfs (preloaded PDFs) +- Any arxiv.org URL (loaded dynamically) + +The viewer supports zoom, navigation, text selection, and fullscreen mode.`, + inputSchema: { + url: z + .string() + .default(DEFAULT_PDF) + .describe("PDF URL (arxiv.org for dynamic loading)"), + page: z.number().min(1).default(1).describe("Initial page"), + }, + outputSchema: z.object({ + url: z.string(), + title: z.string().optional(), + pageCount: z.number(), + initialPage: z.number(), + }), + _meta: { ui: { resourceUri: RESOURCE_URI } }, + }, + async ({ url, page }): Promise => { + try { + const entry = await getCachedEntry(url); + + const result = { + url: entry.url, + title: entry.metadata.title, + pageCount: entry.metadata.pageCount, + initialPage: Math.min(page, entry.metadata.pageCount), + }; + + return { + content: [ + { + type: "text", + text: `Displaying interactive PDF viewer${entry.metadata.title ? ` for "${entry.metadata.title}"` : ""} (${entry.url}, ${entry.metadata.pageCount} pages)`, + }, + ], + structuredContent: result, + _meta: { + viewUUID: randomUUID(), + }, + }; + } catch (err) { + console.error(`Error in display_pdf for URL ${url}:`, err); + return { + content: [ + { + type: "text", + text: `Error displaying PDF: ${err instanceof Error ? err.message : String(err)}`, + }, + ], + isError: true, + }; + } + }, + ); + + // Resource: UI HTML + registerAppResource( + server, + RESOURCE_URI, + RESOURCE_URI, + { mimeType: RESOURCE_MIME_TYPE }, + async (): Promise => { + const html = await fs.readFile( + path.join(DIST_DIR, "mcp-app.html"), + "utf-8", + ); + return { + contents: [ + { uri: RESOURCE_URI, mimeType: RESOURCE_MIME_TYPE, text: html }, + ], + }; + }, + ); + + return server; +} diff --git a/examples/pdf-server/server.ts b/examples/pdf-server/server.ts index 3f122e42e..607d08233 100644 --- a/examples/pdf-server/server.ts +++ b/examples/pdf-server/server.ts @@ -1,9 +1,14 @@ /** - * PDF MCP Server - Range Query Based (Library Entry Point) + * PDF MCP Server * - * No caching, no indexing, no sessions - just proxies range requests. - * - Remote URLs (arxiv): HTTP Range requests - * - Local files: fs.createReadStream with start/end + * An MCP server that displays PDFs in an interactive viewer. + * Supports local files and remote URLs from academic sources (arxiv, biorxiv, etc). + * + * Tools: + * - list_pdfs: List available PDFs + * - display_pdf: Show interactive PDF viewer + * - get_pdf_info: Get PDF metadata (size) + * - read_pdf_bytes: Stream PDF data in chunks (used by viewer) */ import { randomUUID } from "crypto"; From 52c9e6cd89c2cc009cb533dea064b11ba3f71c83 Mon Sep 17 00:00:00 2001 From: Olivier Chafik Date: Fri, 23 Jan 2026 11:19:08 +0000 Subject: [PATCH 13/23] chore(pdf-server): remove unused addAllowedOrigin/addAllowedLocalFile --- examples/pdf-server/server.ts | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/examples/pdf-server/server.ts b/examples/pdf-server/server.ts index 607d08233..b34e9cb50 100644 --- a/examples/pdf-server/server.ts +++ b/examples/pdf-server/server.ts @@ -55,16 +55,6 @@ export const allowedRemoteOrigins = new Set([ /** Allowed local file paths (populated from CLI args) */ export const allowedLocalFiles = new Set(); -/** Add a remote origin to the whitelist */ -export function addAllowedOrigin(origin: string): void { - allowedRemoteOrigins.add(origin); -} - -/** Add a local file to the whitelist */ -export function addAllowedLocalFile(filePath: string): void { - allowedLocalFiles.add(path.resolve(filePath)); -} - // Works both from source (server.ts) and compiled (dist/server.js) const DIST_DIR = import.meta.filename.endsWith(".ts") ? path.join(import.meta.dirname, "dist") From 1c274528240cd19297b2b3285b011c186d2908e5 Mon Sep 17 00:00:00 2001 From: Olivier Chafik Date: Fri, 23 Jan 2026 11:22:00 +0000 Subject: [PATCH 14/23] docs(pdf-server): update README for new architecture --- examples/pdf-server/README.md | 147 ++++++++++++---------------------- 1 file changed, 49 insertions(+), 98 deletions(-) diff --git a/examples/pdf-server/README.md b/examples/pdf-server/README.md index bdc83bec0..c4d9bde16 100644 --- a/examples/pdf-server/README.md +++ b/examples/pdf-server/README.md @@ -2,7 +2,7 @@ ![Screenshot](screenshot.png) -A simple interactive PDF viewer that uses [PDF.js](https://mozilla.github.io/pdf.js/). Launch it w/ a few PDF files and/or URLs as CLI args (+ support loading any additional pdf from arxiv.org). +An interactive PDF viewer using [PDF.js](https://mozilla.github.io/pdf.js/). Supports local files and remote URLs from academic sources (arxiv, biorxiv, zenodo, etc). ## MCP Client Configuration @@ -25,134 +25,85 @@ Add to your MCP client configuration (stdio transport): } ``` -## What This Example Demonstrates +## Usage -### 1. Chunked Data Through Size-Limited Tool Calls +```bash +# Default: loads a sample arxiv paper +bun examples/pdf-server/main.ts -On some host platforms, tool calls have size limits, so large PDFs cannot be sent in a single response. This example shows a possible workaround: +# Load local files +bun examples/pdf-server/main.ts ./docs/paper.pdf /path/to/thesis.pdf -**Server side** (`pdf-loader.ts`): +# Load from URLs (arxiv, biorxiv, zenodo, etc) +bun examples/pdf-server/main.ts https://arxiv.org/pdf/2401.00001.pdf -```typescript -// Returns chunks with pagination metadata -async function loadPdfBytesChunk(entry, offset, byteCount) { - return { - bytes: base64Chunk, - offset, - byteCount, - totalBytes, - hasMore: offset + byteCount < totalBytes, - }; -} +# stdio mode for MCP clients +bun examples/pdf-server/main.ts --stdio ./papers/ ``` -**Client side** (`mcp-app.ts`): +## Tools + +| Tool | Visibility | Purpose | +| ---------------- | ---------- | ---------------------------------------- | +| `list_pdfs` | Model | List available local files and origins | +| `display_pdf` | Model + UI | Display interactive viewer | +| `read_pdf_bytes` | App only | Stream PDF data in chunks (used by viewer) | + +## Allowed Sources + +- **Local files**: Must be passed as CLI arguments +- **Remote URLs**: arxiv.org, biorxiv.org, medrxiv.org, chemrxiv.org, zenodo.org, osf.io, hal.science, ssrn.com, and more + +## What This Example Demonstrates + +### 1. Chunked Data Loading + +PDFs are streamed in chunks using HTTP Range requests: ```typescript -// Load in chunks with progress +// Server: read_pdf_bytes returns chunks with pagination +{ bytes, offset, byteCount, totalBytes, hasMore } + +// Client: loads chunks with progress while (hasMore) { - const chunk = await app.callServerTool("read_pdf_bytes", { pdfId, offset }); + const chunk = await app.callServerTool("read_pdf_bytes", { url, offset }); chunks.push(base64ToBytes(chunk.bytes)); offset += chunk.byteCount; - hasMore = chunk.hasMore; - updateProgress(offset, chunk.totalBytes); } ``` ### 2. Model Context Updates -The viewer keeps the model informed about what the user is seeing: +The viewer keeps the model informed about the current page and selection: ```typescript app.updateModelContext({ - structuredContent: { - title: pdfTitle, - currentPage, - totalPages, - pageText: pageText.slice(0, 5000), - selection: selectedText ? { text, start, end } : undefined, - }, + content: [{ + type: "text", + text: `PDF viewer | "${title}" | Current Page: ${page}/${total}\n\nPage content:\n${text}` + }] }); ``` -This enables the model to answer questions about the current page or selected text. - -### 3. Display Modes: Fullscreen vs Inline - -- **Inline mode**: App requests height changes to fit content -- **Fullscreen mode**: App fills the screen with internal scrolling - -```typescript -// Request fullscreen -app.requestDisplayMode({ mode: "fullscreen" }); - -// Listen for mode changes -app.ondisplaymodechange = (mode) => { - if (mode === "fullscreen") enableScrolling(); - else disableScrolling(); -}; -``` - -### 4. External Links (openLink) - -The viewer demonstrates opening external links (e.g., to the original arxiv page): - -```typescript -titleEl.onclick = () => app.openLink(sourceUrl); -``` - -## Usage - -```bash -# Default: loads a sample arxiv paper -bun examples/pdf-server/server.ts - -# Load local files (converted to file:// URLs) -bun examples/pdf-server/server.ts ./docs/paper.pdf /path/to/thesis.pdf - -# Load from URLs -bun examples/pdf-server/server.ts https://arxiv.org/pdf/2401.00001.pdf - -# Mix local and remote -bun examples/pdf-server/server.ts ./local.pdf https://arxiv.org/pdf/2401.00001.pdf - -# stdio mode for MCP clients -bun examples/pdf-server/server.ts --stdio ./papers/ -``` +### 3. Display Modes -**Security**: Dynamic URLs (via `view_pdf` tool) are restricted to arxiv.org. Local files must be in the initial list. +- **Inline mode**: Fits content, no scrolling +- **Fullscreen mode**: Fills screen with internal scrolling -## Tools +### 4. View Persistence -| Tool | Visibility | Purpose | -| ---------------- | ---------- | ---------------------------------- | -| `list_pdfs` | Model | List indexed PDFs | -| `display_pdf` | Model + UI | Display interactive viewer in chat | -| `read_pdf_bytes` | App only | Chunked binary loading | +Page position is saved per-widget using `viewUUID` and localStorage. ## Architecture ``` -server.ts # MCP server (233 lines) -├── src/ -│ ├── types.ts # Zod schemas (75 lines) -│ ├── pdf-indexer.ts # URL-based indexing (44 lines) -│ ├── pdf-loader.ts # Chunked loading (171 lines) -│ └── mcp-app.ts # Interactive viewer UI +server.ts # MCP server + tools +main.ts # CLI entry point +src/ +└── mcp-app.ts # Interactive viewer UI (PDF.js) ``` -## Key Patterns Shown - -| Pattern | Implementation | -| ----------------- | ---------------------------------------- | -| App-only tools | `_meta: { ui: { visibility: ["app"] } }` | -| Chunked responses | `hasMore` + `offset` pagination | -| Model context | `app.updateModelContext()` | -| Display modes | `app.requestDisplayMode()` | -| External links | `app.openLink()` | -| Size negotiation | `app.sendSizeChanged()` | - ## Dependencies -- `pdfjs-dist`: PDF rendering +- `pdfjs-dist`: PDF rendering (frontend only) - `@modelcontextprotocol/ext-apps`: MCP Apps SDK From 88cb16674f3bc7060ea47e57dade393f727784d8 Mon Sep 17 00:00:00 2001 From: Olivier Chafik Date: Fri, 23 Jan 2026 11:24:52 +0000 Subject: [PATCH 15/23] docs(pdf-server): restore full README, add theming section, remove get_pdf_info --- examples/pdf-server/README.md | 133 ++++++++++++++++++++++++---------- examples/pdf-server/server.ts | 69 ------------------ 2 files changed, 94 insertions(+), 108 deletions(-) diff --git a/examples/pdf-server/README.md b/examples/pdf-server/README.md index c4d9bde16..50ab9f7f9 100644 --- a/examples/pdf-server/README.md +++ b/examples/pdf-server/README.md @@ -25,74 +25,117 @@ Add to your MCP client configuration (stdio transport): } ``` -## Usage +## What This Example Demonstrates -```bash -# Default: loads a sample arxiv paper -bun examples/pdf-server/main.ts +### 1. Chunked Data Through Size-Limited Tool Calls -# Load local files -bun examples/pdf-server/main.ts ./docs/paper.pdf /path/to/thesis.pdf +On some host platforms, tool calls have size limits, so large PDFs cannot be sent in a single response. This example streams PDFs in chunks using HTTP Range requests: -# Load from URLs (arxiv, biorxiv, zenodo, etc) -bun examples/pdf-server/main.ts https://arxiv.org/pdf/2401.00001.pdf +**Server side** (`server.ts`): -# stdio mode for MCP clients -bun examples/pdf-server/main.ts --stdio ./papers/ +```typescript +// Returns chunks with pagination metadata +{ bytes, offset, byteCount, totalBytes, hasMore } ``` -## Tools - -| Tool | Visibility | Purpose | -| ---------------- | ---------- | ---------------------------------------- | -| `list_pdfs` | Model | List available local files and origins | -| `display_pdf` | Model + UI | Display interactive viewer | -| `read_pdf_bytes` | App only | Stream PDF data in chunks (used by viewer) | - -## Allowed Sources - -- **Local files**: Must be passed as CLI arguments -- **Remote URLs**: arxiv.org, biorxiv.org, medrxiv.org, chemrxiv.org, zenodo.org, osf.io, hal.science, ssrn.com, and more - -## What This Example Demonstrates - -### 1. Chunked Data Loading - -PDFs are streamed in chunks using HTTP Range requests: +**Client side** (`mcp-app.ts`): ```typescript -// Server: read_pdf_bytes returns chunks with pagination -{ bytes, offset, byteCount, totalBytes, hasMore } - -// Client: loads chunks with progress +// Load in chunks with progress while (hasMore) { const chunk = await app.callServerTool("read_pdf_bytes", { url, offset }); chunks.push(base64ToBytes(chunk.bytes)); offset += chunk.byteCount; + hasMore = chunk.hasMore; + updateProgress(offset, chunk.totalBytes); } ``` ### 2. Model Context Updates -The viewer keeps the model informed about the current page and selection: +The viewer keeps the model informed about what the user is seeing: ```typescript app.updateModelContext({ content: [{ type: "text", - text: `PDF viewer | "${title}" | Current Page: ${page}/${total}\n\nPage content:\n${text}` + text: `PDF viewer | "${title}" | Current Page: ${page}/${total}\n\nPage content:\n${pageText}` }] }); ``` -### 3. Display Modes +This enables the model to answer questions about the current page or selected text. + +### 3. Display Modes: Fullscreen vs Inline + +- **Inline mode**: App requests height changes to fit content +- **Fullscreen mode**: App fills the screen with internal scrolling + +```typescript +// Request fullscreen +app.requestDisplayMode({ mode: "fullscreen" }); + +// Listen for mode changes +app.ondisplaymodechange = (mode) => { + if (mode === "fullscreen") enableScrolling(); + else disableScrolling(); +}; +``` + +### 4. External Links (openLink) + +The viewer demonstrates opening external links (e.g., to the original arxiv page): + +```typescript +titleEl.onclick = () => app.openLink(sourceUrl); +``` + +### 5. View Persistence + +Page position is saved per-view using `viewUUID` and localStorage. + +### 6. Dark Mode / Theming + +The viewer syncs with the host's theme using CSS `light-dark()` and the SDK's theming APIs: + +```typescript +app.onhostcontextchanged = (ctx) => { + if (ctx.theme) applyDocumentTheme(ctx.theme); + if (ctx.styles?.variables) applyHostStyleVariables(ctx.styles.variables); +}; +``` + +## Usage -- **Inline mode**: Fits content, no scrolling -- **Fullscreen mode**: Fills screen with internal scrolling +```bash +# Default: loads a sample arxiv paper +bun examples/pdf-server/main.ts + +# Load local files (converted to file:// URLs) +bun examples/pdf-server/main.ts ./docs/paper.pdf /path/to/thesis.pdf -### 4. View Persistence +# Load from URLs +bun examples/pdf-server/main.ts https://arxiv.org/pdf/2401.00001.pdf -Page position is saved per-widget using `viewUUID` and localStorage. +# Mix local and remote +bun examples/pdf-server/main.ts ./local.pdf https://arxiv.org/pdf/2401.00001.pdf + +# stdio mode for MCP clients +bun examples/pdf-server/main.ts --stdio ./papers/ +``` + +## Allowed Sources + +- **Local files**: Must be passed as CLI arguments +- **Remote URLs**: arxiv.org, biorxiv.org, medrxiv.org, chemrxiv.org, zenodo.org, osf.io, hal.science, ssrn.com, and more + +## Tools + +| Tool | Visibility | Purpose | +| ---------------- | ---------- | ---------------------------------------- | +| `list_pdfs` | Model | List available local files and origins | +| `display_pdf` | Model + UI | Display interactive viewer | +| `read_pdf_bytes` | App only | Stream PDF data in chunks | ## Architecture @@ -103,6 +146,18 @@ src/ └── mcp-app.ts # Interactive viewer UI (PDF.js) ``` +## Key Patterns Shown + +| Pattern | Implementation | +| ----------------- | ---------------------------------------- | +| App-only tools | `_meta: { ui: { visibility: ["app"] } }` | +| Chunked responses | `hasMore` + `offset` pagination | +| Model context | `app.updateModelContext()` | +| Display modes | `app.requestDisplayMode()` | +| External links | `app.openLink()` | +| View persistence | `viewUUID` + localStorage | +| Theming | `applyDocumentTheme()` + CSS `light-dark()` | + ## Dependencies - `pdfjs-dist`: PDF rendering (frontend only) diff --git a/examples/pdf-server/server.ts b/examples/pdf-server/server.ts index b34e9cb50..47bb8e2a2 100644 --- a/examples/pdf-server/server.ts +++ b/examples/pdf-server/server.ts @@ -7,7 +7,6 @@ * Tools: * - list_pdfs: List available PDFs * - display_pdf: Show interactive PDF viewer - * - get_pdf_info: Get PDF metadata (size) * - read_pdf_bytes: Stream PDF data in chunks (used by viewer) */ @@ -121,43 +120,6 @@ export function validateUrl(url: string): { valid: boolean; error?: string } { // Range Request Helpers // ============================================================================= -export interface PdfInfo { - url: string; - totalBytes: number; - contentType: string; -} - -export async function getPdfInfo(url: string): Promise { - const normalized = isArxivUrl(url) ? normalizeArxivUrl(url) : url; - - if (isFileUrl(normalized)) { - const filePath = fileUrlToPath(normalized); - const stats = await fs.promises.stat(filePath); - return { - url: normalized, - totalBytes: stats.size, - contentType: "application/pdf", - }; - } - - // Remote URL - HEAD request - const response = await fetch(normalized, { method: "HEAD" }); - if (!response.ok) { - throw new Error(`HEAD request failed: ${response.status} ${response.statusText}`); - } - - const contentLength = response.headers.get("content-length"); - if (!contentLength) { - throw new Error("Server did not return Content-Length"); - } - - return { - url: normalized, - totalBytes: parseInt(contentLength, 10), - contentType: response.headers.get("content-type") || "application/pdf", - }; -} - export async function readPdfRange( url: string, offset: number, @@ -251,37 +213,6 @@ export function createServer(): McpServer { }, ); - // Tool: get_pdf_info - HEAD request to get size - server.tool( - "get_pdf_info", - "Get PDF file information (size, type) without downloading", - { - url: z.string().describe("PDF URL (https:// or file://)"), - }, - async ({ url }): Promise => { - const validation = validateUrl(url); - if (!validation.valid) { - return { - content: [{ type: "text", text: validation.error! }], - isError: true, - }; - } - - try { - const info = await getPdfInfo(url); - return { - content: [{ type: "text", text: `PDF: ${info.totalBytes} bytes` }], - structuredContent: { ...info }, - }; - } catch (err) { - return { - content: [{ type: "text", text: `Error: ${err instanceof Error ? err.message : String(err)}` }], - isError: true, - }; - } - }, - ); - // Tool: read_pdf_bytes (app-only) - Range request for chunks registerAppTool( server, From a3b626a33d9a8b0d5a40b98ddbd16acf915b40c4 Mon Sep 17 00:00:00 2001 From: Olivier Chafik Date: Fri, 23 Jan 2026 11:33:05 +0000 Subject: [PATCH 16/23] rm --- examples/pdf-server/server copy.ts_ | 223 ---------------------------- 1 file changed, 223 deletions(-) delete mode 100644 examples/pdf-server/server copy.ts_ diff --git a/examples/pdf-server/server copy.ts_ b/examples/pdf-server/server copy.ts_ deleted file mode 100644 index 0e0b0e790..000000000 --- a/examples/pdf-server/server copy.ts_ +++ /dev/null @@ -1,223 +0,0 @@ -/** - * PDF MCP Server - Didactic Example - * - * Demonstrates: - * - Chunked data through size-limited tool responses - * - Model context updates (current page text + selection) - * - Display modes: fullscreen with scrolling vs inline with resize - * - External link opening (openLink) - */ -import { - registerAppResource, - registerAppTool, - RESOURCE_MIME_TYPE, -} from "@modelcontextprotocol/ext-apps/server"; -import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; -import type { - CallToolResult, - ReadResourceResult, -} from "@modelcontextprotocol/sdk/types.js"; -import fs from "node:fs/promises"; -import path from "node:path"; -import { z } from "zod"; -import { randomUUID } from "crypto"; - -import { - addUrlsToPdfIndex, - findEntryByUrl, - isArxivUrl, - isFileUrl, - normalizeArxivUrl, -} from "./src/pdf-indexer.js"; -import { loadPdfBytesChunk } from "./src/pdf-loader.js"; -import { - ReadPdfBytesInputSchema, - PdfBytesChunkSchema, - type PdfEntry, - type PdfIndex, -} from "./src/types.js"; - -// Works both from source (server.ts) and compiled (dist/server.js) -const DIST_DIR = import.meta.filename.endsWith(".ts") - ? path.join(import.meta.dirname, "dist") - : import.meta.dirname; -const RESOURCE_URI = "ui://pdf-viewer/mcp-app.html"; -const DEFAULT_PDF = "https://arxiv.org/pdf/1706.03762"; // Attention Is All You Need - -export const pdfIndex: PdfIndex = { - entries: [], -} - -/** - * Creates a new MCP server instance with tools and resources registered. - * Each HTTP session needs its own server instance because McpServer only supports one transport. - */ -export function createServer(): McpServer { - const server = new McpServer({ name: "PDF Server", version: "1.0.0" }); - - // Tool: list_pdfs - if (pdfIndex.entries.length > 0) { - server.tool( - "list_preindexed_pdfs", - "List preindexed PDFs", - {}, - async (): Promise => { - return { - content: [ - { type: "text", text: JSON.stringify(pdfIndex.entries, null, 2) }, - ], - structuredContent: { entries: pdfIndex.entries }, - }; - }, - ); - } - - const getCachedEntry = async (rawUrl: string): Promise => { - const url = isArxivUrl(rawUrl) ? normalizeArxivUrl(rawUrl) : rawUrl; - - let entry = findEntryByUrl(pdfIndex, url); - - if (!entry) { - if (isFileUrl(url)) { - throw new Error("File URLs must be in the initial list"); - } - if (!isArxivUrl(url)) { - throw new Error(`Only arxiv.org URLs can be loaded dynamically`); - } - - await addUrlsToPdfIndex([url], pdfIndex); - entry = findEntryByUrl(pdfIndex, url); - if (!entry) { - throw new Error(`Failed to load PDF: ${url}`); - } - } - return entry; - }; - - - // Tool: read_pdf_bytes (app-only) - Chunked binary loading - registerAppTool( - server, - "read_pdf_bytes", - { - title: "Read PDF Bytes", - description: "Load binary data in chunks", - inputSchema: ReadPdfBytesInputSchema, - outputSchema: PdfBytesChunkSchema, - _meta: { ui: { visibility: ["app"] } }, - }, - async ({ url, offset, byteCount }): Promise => { - try { - const entry = await getCachedEntry(url); - const chunk = await loadPdfBytesChunk(entry, offset, byteCount); - return { - content: [ - { - type: "text", - text: `${chunk.byteCount} bytes at ${chunk.offset}/${chunk.totalBytes}`, - }, - ], - structuredContent: chunk, - }; - } catch (err) { - console.error(`Error in read_pdf_bytes for URL ${url}:`, err); - return { - content: [ - { - type: "text", - text: `Error loading PDF bytes: ${err instanceof Error ? err.message : String(err)}`, - }, - ], - isError: true, - }; - } - }, - ); - - // Tool: display_pdf - Interactive viewer with UI - registerAppTool( - server, - "display_pdf", - { - title: "Display PDF", - description: `Display an interactive PDF viewer in the chat. - -Use this tool when the user asks to view, display, read, or open a PDF. Accepts: -- URLs from list_pdfs (preloaded PDFs) -- Any arxiv.org URL (loaded dynamically) - -The viewer supports zoom, navigation, text selection, and fullscreen mode.`, - inputSchema: { - url: z - .string() - .default(DEFAULT_PDF) - .describe("PDF URL (arxiv.org for dynamic loading)"), - page: z.number().min(1).default(1).describe("Initial page"), - }, - outputSchema: z.object({ - url: z.string(), - title: z.string().optional(), - pageCount: z.number(), - initialPage: z.number(), - }), - _meta: { ui: { resourceUri: RESOURCE_URI } }, - }, - async ({ url, page }): Promise => { - try { - const entry = await getCachedEntry(url); - - const result = { - url: entry.url, - title: entry.metadata.title, - pageCount: entry.metadata.pageCount, - initialPage: Math.min(page, entry.metadata.pageCount), - }; - - return { - content: [ - { - type: "text", - text: `Displaying interactive PDF viewer${entry.metadata.title ? ` for "${entry.metadata.title}"` : ""} (${entry.url}, ${entry.metadata.pageCount} pages)`, - }, - ], - structuredContent: result, - _meta: { - viewUUID: randomUUID(), - }, - }; - } catch (err) { - console.error(`Error in display_pdf for URL ${url}:`, err); - return { - content: [ - { - type: "text", - text: `Error displaying PDF: ${err instanceof Error ? err.message : String(err)}`, - }, - ], - isError: true, - }; - } - }, - ); - - // Resource: UI HTML - registerAppResource( - server, - RESOURCE_URI, - RESOURCE_URI, - { mimeType: RESOURCE_MIME_TYPE }, - async (): Promise => { - const html = await fs.readFile( - path.join(DIST_DIR, "mcp-app.html"), - "utf-8", - ); - return { - contents: [ - { uri: RESOURCE_URI, mimeType: RESOURCE_MIME_TYPE, text: html }, - ], - }; - }, - ); - - return server; -} From e01fef37548c991803f7e208f956e01b54cd8d92 Mon Sep 17 00:00:00 2001 From: Olivier Chafik Date: Fri, 23 Jan 2026 11:33:35 +0000 Subject: [PATCH 17/23] fix(pdf-server): restore createMcpExpressApp and error handling - Use createMcpExpressApp for DNS rebinding protection - Restore error callback in app.listen() --- examples/pdf-server/main.ts | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/examples/pdf-server/main.ts b/examples/pdf-server/main.ts index 38830ca7f..620cb09fd 100644 --- a/examples/pdf-server/main.ts +++ b/examples/pdf-server/main.ts @@ -6,9 +6,9 @@ import fs from "node:fs"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; +import { createMcpExpressApp } from "@modelcontextprotocol/sdk/server/express.js"; import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; -import express from "express"; import cors from "cors"; import type { Request, Response } from "express"; import { @@ -37,9 +37,8 @@ export async function startServer( ): Promise { const { port, name = "MCP Server" } = options; - const app = express(); + const app = createMcpExpressApp({ host: "0.0.0.0" }); app.use(cors()); - app.use(express.json()); app.all("/mcp", async (req: Request, res: Response) => { const server = createServer(); @@ -67,7 +66,11 @@ export async function startServer( } }); - const httpServer = app.listen(port, () => { + const httpServer = app.listen(port, (err) => { + if (err) { + console.error("Failed to start server:", err); + process.exit(1); + } console.log(`${name} listening on http://localhost:${port}/mcp`); }); From c958d4202d88b1ff70d37c40a759778a7a2f4a72 Mon Sep 17 00:00:00 2001 From: Olivier Chafik Date: Fri, 23 Jan 2026 11:38:03 +0000 Subject: [PATCH 18/23] fix(say-server): pass host to streamable_http_app for non-localhost deployments --- examples/say-server/DEPLOYMENT.md | 246 ++++++++++ examples/say-server/Dockerfile | 34 ++ examples/say-server/README.md | 30 +- examples/say-server/SECURITY_REVIEW.md | 607 +++++++++++++++++++++++++ examples/say-server/mcp-app.html | 2 +- examples/say-server/server.py | 94 ++-- 6 files changed, 950 insertions(+), 63 deletions(-) create mode 100644 examples/say-server/DEPLOYMENT.md create mode 100644 examples/say-server/Dockerfile create mode 100644 examples/say-server/SECURITY_REVIEW.md diff --git a/examples/say-server/DEPLOYMENT.md b/examples/say-server/DEPLOYMENT.md new file mode 100644 index 000000000..7758050d5 --- /dev/null +++ b/examples/say-server/DEPLOYMENT.md @@ -0,0 +1,246 @@ +# Say Server - GCP Cloud Run Deployment + +This document describes how to deploy the Say Server MCP application to Google Cloud Run with session-sticky load balancing. + +## Architecture + +``` +┌─────────────┐ ┌──────────────────┐ ┌─────────────────┐ ┌─────────────┐ +│ Client │────▶│ Load Balancer │────▶│ Serverless NEG │────▶│ Cloud Run │ +│ │ │ (HTTP, IP-based)│ │ │ │ say-server │ +└─────────────┘ └──────────────────┘ └─────────────────┘ └─────────────┘ + │ + ▼ + Session Affinity + (mcp-session-id header) +``` + +## Prerequisites + +- GCP Project with billing enabled +- `gcloud` CLI installed and authenticated +- Docker (for local builds) + +## Current Deployment + +- **Project**: `mcp-apps-say-server` +- **Region**: `us-east1` +- **Service URL**: `https://say-server-109024344223.us-east1.run.app` +- **Load Balancer IP**: `34.160.77.67` + +## Session Stickiness Configuration + +MCP's Streamable HTTP transport uses the `mcp-session-id` header for stateful sessions. The load balancer is configured to route requests with the same session ID to the same Cloud Run instance: + +```bash +# Backend service configuration +gcloud compute backend-services describe say-server-backend --global --format=json | jq '{ + sessionAffinity, + localityLbPolicy, + consistentHash +}' +``` + +Returns: +```json +{ + "sessionAffinity": "HEADER_FIELD", + "localityLbPolicy": "RING_HASH", + "consistentHash": { + "httpHeaderName": "mcp-session-id" + } +} +``` + +## Deployment Steps + +### 1. Set Project + +```bash +export PROJECT_ID=mcp-apps-say-server +gcloud config set project $PROJECT_ID +``` + +### 2. Enable APIs + +```bash +gcloud services enable \ + run.googleapis.com \ + cloudbuild.googleapis.com \ + artifactregistry.googleapis.com \ + compute.googleapis.com +``` + +### 3. Build and Push Docker Image + +```bash +cd examples/say-server + +# Build for linux/amd64 +docker build --platform linux/amd64 \ + -t us-east1-docker.pkg.dev/$PROJECT_ID/cloud-run-source-deploy/say-server:latest . + +# Configure docker auth +gcloud auth configure-docker us-east1-docker.pkg.dev --quiet + +# Push +docker push us-east1-docker.pkg.dev/$PROJECT_ID/cloud-run-source-deploy/say-server:latest +``` + +### 4. Deploy to Cloud Run + +```bash +gcloud run deploy say-server \ + --image us-east1-docker.pkg.dev/$PROJECT_ID/cloud-run-source-deploy/say-server:latest \ + --region us-east1 \ + --memory 4Gi \ + --cpu 2 \ + --timeout 300 \ + --concurrency 10 \ + --min-instances 0 \ + --max-instances 10 \ + --no-cpu-throttling \ + --ingress all +``` + +### 5. Set Up Load Balancer with Session Affinity + +```bash +# Create serverless NEG +gcloud compute network-endpoint-groups create say-server-neg \ + --region=us-east1 \ + --network-endpoint-type=serverless \ + --cloud-run-service=say-server + +# Create backend service +gcloud compute backend-services create say-server-backend \ + --global \ + --load-balancing-scheme=EXTERNAL_MANAGED + +# Add NEG to backend +gcloud compute backend-services add-backend say-server-backend \ + --global \ + --network-endpoint-group=say-server-neg \ + --network-endpoint-group-region=us-east1 + +# Configure session affinity (requires import/export for consistentHash) +gcloud compute backend-services describe say-server-backend --global --format=json | \ + jq 'del(.id, .kind, .selfLink, .creationTimestamp, .fingerprint) + { + "sessionAffinity": "HEADER_FIELD", + "localityLbPolicy": "RING_HASH", + "protocol": "HTTPS", + "consistentHash": {"httpHeaderName": "mcp-session-id"} + }' > /tmp/backend.json + +gcloud compute backend-services import say-server-backend \ + --global \ + --source=/tmp/backend.json \ + --quiet + +# Create URL map +gcloud compute url-maps create say-server-lb \ + --default-service=say-server-backend \ + --global + +# Create HTTP proxy +gcloud compute target-http-proxies create say-server-proxy \ + --url-map=say-server-lb \ + --global + +# Reserve static IP +gcloud compute addresses create say-server-ip \ + --global \ + --ip-version=IPV4 + +# Create forwarding rule +gcloud compute forwarding-rules create say-server-forwarding \ + --global \ + --target-http-proxy=say-server-proxy \ + --address=say-server-ip \ + --ports=80 \ + --load-balancing-scheme=EXTERNAL_MANAGED +``` + +## Access & Authentication + +### Current Issue: Org Policy Restrictions + +Anthropic's GCP org policies prevent: +- `allUsers` or `allAuthenticatedUsers` IAM bindings +- Service account key creation + +This means the service requires authentication. + +### Authenticated Access (Works Now) + +```bash +# Via gcloud proxy (recommended for testing) +gcloud run services proxy say-server \ + --region=us-east1 \ + --port=8888 \ + --project=mcp-apps-say-server + +# Then access at http://127.0.0.1:8888/mcp +curl -X POST http://127.0.0.1:8888/mcp \ + -H "Content-Type: application/json" \ + -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"test","version":"1.0"}}}' +``` + +### Requirements for Public Access + +To enable unauthenticated public access, one of: + +1. **Org Policy Exception** - Request IT to allow `allUsers` for this project: + - Policy: `constraints/iam.allowedPolicyMemberDomains` + - Need exception to add `allUsers` to Cloud Run invoker role + +2. **External Project** - Deploy to a GCP project outside Anthropic's org + +3. **MCP OAuth Auth** - Implement OAuth in the server: + ```python + from mcp.server.fastmcp import FastMCP, AuthSettings + + mcp = FastMCP( + "Say Demo", + auth=AuthSettings( + issuer_url="https://accounts.google.com", + required_scopes=["openid", "email"], + ), + token_verifier=..., # Configure token verification + ) + ``` + +## Files + +- `server.py` - Self-contained MCP server (runs with `uv run server.py`) +- `Dockerfile` - Cloud Run container definition +- `.gcloudignore` - Excludes unnecessary files from upload +- `mcp-app.html` - UI served as MCP resource + +## Updating the Deployment + +```bash +# Rebuild and push +docker build --platform linux/amd64 \ + -t us-east1-docker.pkg.dev/mcp-apps-say-server/cloud-run-source-deploy/say-server:latest . +docker push us-east1-docker.pkg.dev/mcp-apps-say-server/cloud-run-source-deploy/say-server:latest + +# Deploy new revision +gcloud run deploy say-server \ + --image us-east1-docker.pkg.dev/mcp-apps-say-server/cloud-run-source-deploy/say-server:latest \ + --region us-east1 \ + --project mcp-apps-say-server +``` + +## Cleanup + +```bash +# Delete all resources +gcloud compute forwarding-rules delete say-server-forwarding --global --quiet +gcloud compute target-http-proxies delete say-server-proxy --global --quiet +gcloud compute url-maps delete say-server-lb --global --quiet +gcloud compute backend-services delete say-server-backend --global --quiet +gcloud compute network-endpoint-groups delete say-server-neg --region=us-east1 --quiet +gcloud compute addresses delete say-server-ip --global --quiet +gcloud run services delete say-server --region=us-east1 --quiet +``` diff --git a/examples/say-server/Dockerfile b/examples/say-server/Dockerfile new file mode 100644 index 000000000..1b1630701 --- /dev/null +++ b/examples/say-server/Dockerfile @@ -0,0 +1,34 @@ +# Cloud Run Dockerfile for Say Server +# Uses uv for fast Python package management + +FROM ghcr.io/astral-sh/uv:python3.12-bookworm-slim + +# Install git for pip install from git repos +RUN apt-get update && apt-get install -y git && rm -rf /var/lib/apt/lists/* + +WORKDIR /app + +# Copy server file +COPY server.py . + +# Use isolated cache directory for HuggingFace to avoid permission issues +ENV HF_HOME=/app/.cache/huggingface +ENV TRANSFORMERS_CACHE=/app/.cache/huggingface + +# Pre-install dependencies for faster cold starts +RUN uv pip install --system --default-index https://pypi.org/simple \ + "mcp @ git+https://github.com/modelcontextprotocol/python-sdk@main" \ + "uvicorn>=0.34.0" \ + "starlette>=0.46.0" \ + "pocket-tts>=1.0.1" + +# Cloud Run sets PORT env var, server already respects it +# Default to 8080 for Cloud Run +ENV PORT=8080 +ENV HOST=0.0.0.0 + +# Expose port (informational, Cloud Run uses PORT env var) +EXPOSE 8080 + +# Run the server directly with python (not uv run, since deps are pre-installed) +CMD ["python", "server.py"] diff --git a/examples/say-server/README.md b/examples/say-server/README.md index cfe28a150..c416755fe 100644 --- a/examples/say-server/README.md +++ b/examples/say-server/README.md @@ -30,13 +30,13 @@ Add to your MCP client configuration (stdio transport): This example showcases several MCP App capabilities: - **Single-file executable**: Python server with embedded React UI - no build step required -- **Partial tool inputs** (`ontoolinputpartial`): Widget receives streaming text as it's being generated +- **Partial tool inputs** (`ontoolinputpartial`): The view receives streaming text as it's being generated - **Queue-based streaming**: Demonstrates how to stream text out and audio in via a polling tool (adds text to an input queue, retrieves audio chunks from an output queue) -- **Model context updates**: Widget updates the LLM with playback progress ("Playing: ...snippet...") +- **Model context updates**: The view updates the LLM with playback progress ("Playing: ...snippet...") - **Native theming**: Uses CSS variables for automatic dark/light mode adaptation - **Fullscreen mode**: Toggle fullscreen via `requestDisplayMode()` API, press Escape to exit -- **Multi-widget speak lock**: Coordinates multiple TTS widgets via localStorage so only one plays at a time -- **Hidden tools** (`visibility: ["app"]`): Private tools only accessible to the widget, not the model +- **Multi-view speak lock**: Coordinates multiple TTS views via localStorage so only one plays at a time +- **Hidden tools** (`visibility: ["app"]`): Private tools only accessible to the view, not the model - **External links** (`openLink`): Attribution popup uses `app.openLink()` to open external URLs - **CSP metadata**: Resource declares required domains (`esm.sh`) for in-browser transpilation @@ -132,29 +132,29 @@ See the [kyutai/tts-voices](https://huggingface.co/kyutai/tts-voices) repository The entire server is contained in a single `server.py` file: -1. **`say` tool**: Public tool that triggers the widget with text to speak -2. **Private tools** (`create_tts_queue`, `add_tts_text`, `poll_tts_audio`, etc.): Hidden from the model, only callable by the widget -3. **Embedded React widget**: Uses [Babel standalone](https://babeljs.io/docs/babel-standalone) for in-browser JSX transpilation - no build step needed +1. **`say` tool**: Public tool that triggers the view with text to speak +2. **Private tools** (`create_tts_queue`, `add_tts_text`, `poll_tts_audio`, etc.): Hidden from the model, only callable by the view +3. **Embedded React view**: Uses [Babel standalone](https://babeljs.io/docs/babel-standalone) for in-browser JSX transpilation - no build step needed 4. **TTS backend**: Manages per-request audio queues using Pocket TTS -The widget communicates with the server via MCP tool calls: +The view communicates with the server via MCP tool calls: - Receives streaming text via `ontoolinputpartial` callback - Incrementally sends new text to the server as it arrives (via `add_tts_text`) - Polls for generated audio chunks while TTS runs in parallel - Plays audio via Web Audio API with synchronized text highlighting -## Multi-Widget Speak Lock +## Multi-view Speak Lock -When multiple TTS widgets exist in the same browser (e.g., multiple chat messages each with their own say widget), they coordinate via localStorage to ensure only one plays at a time: +When multiple TTS views exist in the same browser (e.g., multiple chat messages each with their own say view), they coordinate via localStorage to ensure only one plays at a time: -1. **Unique Widget IDs**: Each widget receives a UUID via `toolResult._meta.widgetUUID` -2. **Announce on Play**: When starting, a widget writes `{uuid, timestamp}` to `localStorage["mcp-tts-playing"]` -3. **Poll for Conflicts**: Every 200ms, playing widgets check if another widget took the lock -4. **Yield Gracefully**: If another widget started playing, pause and yield +1. **Unique view IDs**: Each view receives a UUID via `toolResult._meta.viewUUID` +2. **Announce on Play**: When starting, a view writes `{uuid, timestamp}` to `localStorage["mcp-tts-playing"]` +3. **Poll for Conflicts**: Every 200ms, playing views check if another view took the lock +4. **Yield Gracefully**: If another view started playing, pause and yield 5. **Clean Up**: On pause/finish, clear the lock (only if owned) -This "last writer wins" protocol ensures a seamless experience: clicking play on any widget immediately pauses others, without requiring cross-iframe postMessage coordination. +This "last writer wins" protocol ensures a seamless experience: clicking play on any view immediately pauses others, without requiring cross-iframe postMessage coordination. ## TODO diff --git a/examples/say-server/SECURITY_REVIEW.md b/examples/say-server/SECURITY_REVIEW.md new file mode 100644 index 000000000..856b8bdc8 --- /dev/null +++ b/examples/say-server/SECURITY_REVIEW.md @@ -0,0 +1,607 @@ +# Say Server - Security Design Document + +**Service**: MCP Say Server (Text-to-Speech) +**Location**: GCP Cloud Run, `us-east1` +**Project**: `mcp-apps-say-server` +**Date**: January 2026 + +--- + +## 1. Overview + +The Say Server is an MCP (Model Context Protocol) application that provides real-time text-to-speech functionality. It demonstrates streaming audio generation with karaoke-style text highlighting. + +### What It Does + +``` +┌─────────────────────────────────────────────────────────────────────────────┐ +│ Say Server │ +│ │ +│ 1. Claude streams text to say() tool call arguments │ +│ 2. Host forwards partial input to MCP App (widget in iframe) │ +│ 3. Widget receives via ontoolinputpartial, sends to server queue │ +│ 4. Server generates audio chunks (CPU-bound TTS via pocket-tts) │ +│ 5. Widget polls for audio, plays via Web Audio API │ +│ 6. Text highlighting synced with audio playback (karaoke-style) │ +│ │ +└─────────────────────────────────────────────────────────────────────────────┘ +``` + +### Key Components + +| Component | Description | +|-----------|-------------| +| `server.py` | Self-contained MCP server with TTS tools | +| `pocket-tts` | Neural TTS model (Kyutai, Apache 2.0) | +| Widget HTML | React-based UI for playback control | +| MCP Protocol | Streamable HTTP transport with session support | + +--- + +## 2. Architecture Diagrams + +### 2.1 High-Level Data Flow + +``` +┌──────────┐ ┌──────────────┐ ┌─────────────────┐ ┌──────────────┐ +│ Claude │────▶│ MCP Host │────▶│ Say Server │────▶│ TTS Model │ +│ (LLM) │ │ (Client) │ │ (Cloud Run) │ │ (pocket-tts)│ +└──────────┘ └──────────────┘ └─────────────────┘ └──────────────┘ + │ │ │ + │ streams tool │ forwards to │ generates audio + │ call arguments │ MCP App (widget) │ + ▼ ▼ ▼ +┌──────────────────────────────────────────────────────────────────────────────┐ +│ │ +│ Claude streams text ──▶ Host forwards partial ──▶ Widget receives via │ +│ to say() tool input tool input to iframe ontoolinputpartial() │ +│ │ +│ Widget calls server: │ +│ create_tts_queue(voice) ──▶ queue_id │ +│ add_tts_text(queue_id, "Hello wor...") │ +│ add_tts_text(queue_id, "ld!") │ +│ end_tts_queue(queue_id) │ +│ │ +│ Widget polls for audio: │ +│ poll_tts_audio(queue_id) ◀─── {chunks: [{audio_base64, ...}], done} │ +│ │ +└──────────────────────────────────────────────────────────────────────────────┘ +``` + +**Key insight**: The widget (MCP App) is the active party - it receives streamed text from Claude via the host, then independently calls server tools to manage TTS generation. + +### 2.2 Queue Architecture + +``` +┌─────────────────────────────────────────────────────────────────────────────┐ +│ Server Process Memory │ +│ │ +│ tts_queues: Dict[str, TTSQueueState] │ +│ ┌─────────────────────────────────────────────────────────────────────┐ │ +│ │ │ │ +│ │ "a1b2c3d4e5f6" ──▶ TTSQueueState { │ │ +│ │ id: "a1b2c3d4e5f6" │ │ +│ │ text_queue: AsyncQueue ◀── text chunks │ │ +│ │ audio_chunks: List ──▶ generated audio │ │ +│ │ chunks_delivered: int │ │ +│ │ status: "active" | "complete" | "error" │ │ +│ │ task: AsyncTask (background TTS) │ │ +│ │ } │ │ +│ │ │ │ +│ │ "x7y8z9a0b1c2" ──▶ TTSQueueState { ... } (different session) │ │ +│ │ │ │ +│ └─────────────────────────────────────────────────────────────────────┘ │ +│ │ +└─────────────────────────────────────────────────────────────────────────────┘ +``` + +### 2.3 Information Flow: Text → Audio + +``` +┌─────────────────────────────────────────────────────────────────────────────┐ +│ │ +│ CLIENT (Widget) SERVER (Cloud Run) │ +│ ────────────────── ────────────────── │ +│ │ +│ 1. create_tts_queue(voice) ─────▶ Creates TTSQueueState │ +│ ◀───────────────────────────── Returns {queue_id, sample_rate} │ +│ │ +│ 2. add_tts_text(queue_id, "He") ─▶ Queues text │ +│ add_tts_text(queue_id, "llo")─▶ Queues text │ +│ add_tts_text(queue_id, " ") ──▶ Queues text │ +│ │ │ +│ ▼ │ +│ ┌──────────────────┐ │ +│ │ Background Task │ │ +│ │ ─────────────────│ │ +│ │ StreamingChunker │ │ +│ │ buffers text │ │ +│ │ until sentence │ │ +│ │ boundary │ │ +│ │ │ │ │ +│ │ ▼ │ │ +│ │ TTS Model │ │ +│ │ generates audio │ │ +│ │ (run_in_executor)│ │ +│ │ │ │ │ +│ │ ▼ │ │ +│ │ audio_chunks[] │ │ +│ └──────────────────┘ │ +│ │ +│ 3. poll_tts_audio(queue_id) ────▶ Returns new chunks since last poll │ +│ ◀──────────────────────────── {chunks: [...], done: false} │ +│ poll_tts_audio(queue_id) ────▶ │ +│ ◀──────────────────────────── {chunks: [...], done: true} │ +│ │ +│ 4. end_tts_queue(queue_id) ─────▶ Signals EOF, flushes remaining text │ +│ │ +└─────────────────────────────────────────────────────────────────────────────┘ +``` + +### 2.4 Polling Mechanism + +``` +┌─────────────────────────────────────────────────────────────────────────────┐ +│ Widget Polling Loop │ +│ │ +│ while (!done) { │ +│ response = await callServerTool("poll_tts_audio", {queue_id}) │ +│ │ +│ for (chunk of response.chunks) { │ +│ // Decode base64 audio │ +│ // Schedule on Web Audio API │ +│ // Track timing for text sync │ +│ } │ +│ │ +│ if (response.chunks.length > 0) { │ +│ await sleep(20ms) // Fast poll during active streaming │ +│ } else { │ +│ await sleep(50-150ms) // Exponential backoff when waiting │ +│ } │ +│ } │ +│ │ +│ Server-side: │ +│ ───────────── │ +│ - chunks_delivered tracks what client has seen │ +│ - poll returns audio_chunks[chunks_delivered:] │ +│ - Updates chunks_delivered after each poll │ +│ │ +└─────────────────────────────────────────────────────────────────────────────┘ +``` + +--- + +## 3. Session & Queue Isolation + +### 3.1 Session Isolation Model + +``` +┌─────────────────────────────────────────────────────────────────────────────┐ +│ │ +│ Session A (User 1) Session B (User 2) │ +│ ────────────────── ────────────────── │ +│ │ +│ queue_id: "a1b2c3d4e5f6" queue_id: "x7y8z9a0b1c2" │ +│ │ │ │ +│ ▼ ▼ │ +│ ┌─────────────────┐ ┌─────────────────┐ │ +│ │ TTSQueueState A │ │ TTSQueueState B │ │ +│ │ │ │ │ │ +│ │ text: "Hello" │ │ text: "Goodbye" │ │ +│ │ audio: [...] │ │ audio: [...] │ │ +│ │ voice: cosette │ │ voice: alba │ │ +│ └─────────────────┘ └─────────────────┘ │ +│ │ +│ ✓ Each queue is completely independent │ +│ ✓ Queue ID is the only "key" to access data │ +│ ✓ No shared state between queues │ +│ │ +└─────────────────────────────────────────────────────────────────────────────┘ +``` + +### 3.2 Queue ID as Access Token + +```python +# Queue creation generates random 12-char hex ID +queue_id = uuid.uuid4().hex[:12] # e.g., "a1b2c3d4e5f6" + +# All operations require queue_id +add_tts_text(queue_id, text) # Only works if you know the ID +poll_tts_audio(queue_id) # Only returns YOUR queue's audio +end_tts_queue(queue_id) # Only ends YOUR queue +``` + +**Entropy**: 12 hex chars = 48 bits = 281 trillion possible values + +--- + +## 4. CPU Isolation (TTS Processing) + +### 4.1 Thread Pool Isolation + +``` +┌─────────────────────────────────────────────────────────────────────────────┐ +│ │ +│ Main Event Loop (asyncio) │ +│ ───────────────────────── │ +│ - Handles HTTP requests │ +│ - Manages queue state │ +│ - Non-blocking operations │ +│ │ +│ │ │ +│ │ run_in_executor() │ +│ ▼ │ +│ ┌─────────────────────────────────────────────────────────────────────┐ │ +│ │ Thread Pool Executor │ │ +│ │ │ │ +│ │ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ │ +│ │ │ Thread 1 │ │ Thread 2 │ │ Thread 3 │ ... │ │ +│ │ │ Queue A │ │ Queue B │ │ Queue C │ │ │ +│ │ │ TTS work │ │ TTS work │ │ TTS work │ │ │ +│ │ └─────────────┘ └─────────────┘ └─────────────┘ │ │ +│ │ │ │ +│ │ - Each queue's TTS runs in separate thread │ │ +│ │ - CPU-bound work doesn't block event loop │ │ +│ │ - Natural isolation via thread boundaries │ │ +│ │ │ │ +│ └─────────────────────────────────────────────────────────────────────┘ │ +│ │ +└─────────────────────────────────────────────────────────────────────────────┘ +``` + +### 4.2 No Shared TTS State + +```python +# Each queue gets its own model state copy +model_state = tts_model._cached_get_state_for_audio_prompt(voice, truncate=True) + +# Audio generation uses copy_state=True +for audio_chunk in tts_model._generate_audio_stream_short_text( + model_state=model_state, + text_to_generate=text, + copy_state=True, # ← Ensures isolation +): + ... +``` + +--- + +## 5. Need for Session Stickiness + +### 5.1 Why Stickiness is Required + +``` +┌─────────────────────────────────────────────────────────────────────────────┐ +│ │ +│ WITHOUT Stickiness (BROKEN) │ +│ ─────────────────────────── │ +│ │ +│ Request 1: create_tts_queue() ──▶ Instance A ──▶ queue_id: "abc123" │ +│ Request 2: add_tts_text("abc123") ──▶ Instance B ──▶ "Queue not found!" ✗ │ +│ │ +│ The queue exists only in Instance A's memory! │ +│ │ +├─────────────────────────────────────────────────────────────────────────────┤ +│ │ +│ WITH Stickiness (WORKING) │ +│ ───────────────────────── │ +│ │ +│ Request 1: create_tts_queue() │ +│ mcp-session-id: xyz ──▶ Instance A ──▶ queue_id: "abc123" │ +│ │ +│ Request 2: add_tts_text("abc123") │ +│ mcp-session-id: xyz ──▶ Instance A ──▶ Text queued ✓ │ +│ (same session ID → same instance) │ +│ │ +│ Request 3: poll_tts_audio("abc123") │ +│ mcp-session-id: xyz ──▶ Instance A ──▶ Audio chunks ✓ │ +│ │ +└─────────────────────────────────────────────────────────────────────────────┘ +``` + +### 5.2 MCP Session Protocol + +``` +┌─────────────────────────────────────────────────────────────────────────────┐ +│ │ +│ First Request (no session) │ +│ ────────────────────────── │ +│ │ +│ POST /mcp │ +│ Content-Type: application/json │ +│ (no mcp-session-id header) │ +│ │ +│ Response: │ +│ mcp-session-id: sess_abc123xyz ◀── Server generates session ID │ +│ │ +├─────────────────────────────────────────────────────────────────────────────┤ +│ │ +│ Subsequent Requests │ +│ ─────────────────── │ +│ │ +│ POST /mcp │ +│ Content-Type: application/json │ +│ mcp-session-id: sess_abc123xyz ◀── Client sends back session ID │ +│ │ +│ Load Balancer: │ +│ - Hashes "sess_abc123xyz" │ +│ - Routes to same instance via consistent hashing │ +│ │ +└─────────────────────────────────────────────────────────────────────────────┘ +``` + +--- + +## 6. Security Analysis + +### 6.1 Attack: Accessing Another User's Queue + +``` +┌─────────────────────────────────────────────────────────────────────────────┐ +│ │ +│ ATTACK SCENARIO │ +│ ─────────────── │ +│ │ +│ Attacker wants to: │ +│ 1. Read audio from victim's queue │ +│ 2. Inject text into victim's queue │ +│ 3. Cancel victim's queue │ +│ │ +├─────────────────────────────────────────────────────────────────────────────┤ +│ │ +│ ATTACK REQUIREMENTS │ +│ ─────────────────── │ +│ │ +│ 1. Know victim's queue_id (12-char hex = 48 bits entropy) │ +│ - Not exposed in any API response │ +│ - Not in URLs, logs, or error messages │ +│ - Only returned to queue creator │ +│ │ +│ 2. Be routed to same Cloud Run instance (for in-memory access) │ +│ - Requires matching mcp-session-id hash │ +│ - Session IDs are also random and not exposed │ +│ │ +├─────────────────────────────────────────────────────────────────────────────┤ +│ │ +│ WHY IT'S NOT POSSIBLE │ +│ ───────────────────── │ +│ │ +│ ┌────────────────────────────────────────────────────────────────┐ │ +│ │ │ │ +│ │ Brute Force Analysis: │ │ +│ │ │ │ +│ │ Queue ID space: 16^12 = 281,474,976,710,656 possibilities │ │ +│ │ Queue lifetime: ~30 seconds (timeout) to ~5 minutes (usage) │ │ +│ │ Concurrent queues: typically 1-10 per instance │ │ +│ │ │ │ +│ │ Probability of guessing valid queue_id: │ │ +│ │ P = active_queues / total_space │ │ +│ │ P = 10 / 281,474,976,710,656 │ │ +│ │ P ≈ 3.5 × 10^-14 │ │ +│ │ │ │ +│ │ At 1000 requests/second, expected time to find valid ID: │ │ +│ │ T = 281,474,976,710,656 / 10 / 1000 seconds │ │ +│ │ T ≈ 891 years │ │ +│ │ │ │ +│ └────────────────────────────────────────────────────────────────┘ │ +│ │ +│ Additional Barriers: │ +│ - Rate limiting would kick in │ +│ - Queue expires before brute force succeeds │ +│ - Attacker's requests go to different instances (session affinity) │ +│ │ +└─────────────────────────────────────────────────────────────────────────────┘ +``` + +### 6.2 Attack: Session ID Enumeration + +``` +┌─────────────────────────────────────────────────────────────────────────────┐ +│ │ +│ ATTACK: Guess mcp-session-id to route to victim's instance │ +│ │ +│ WHY IT FAILS: │ +│ ───────────── │ +│ │ +│ 1. Session IDs are server-generated (not predictable) │ +│ 2. Even if routed to same instance, still need queue_id │ +│ 3. Session ID ≠ Queue ID (they're independent) │ +│ │ +│ ┌────────────────────────────────────────────────────────────────┐ │ +│ │ │ │ +│ │ Attacker sends: │ │ +│ │ mcp-session-id: guessed_value │ │ +│ │ │ │ │ +│ │ ▼ │ │ +│ │ Load Balancer routes to Instance X (based on hash) │ │ +│ │ │ │ │ +│ │ ▼ │ │ +│ │ Attacker calls poll_tts_audio(guessed_queue_id) │ │ +│ │ │ │ │ +│ │ ▼ │ │ +│ │ Server: "Queue not found" (queue_id is still wrong) │ │ +│ │ │ │ +│ └────────────────────────────────────────────────────────────────┘ │ +│ │ +└─────────────────────────────────────────────────────────────────────────────┘ +``` + +### 6.3 Data Exposure Summary + +| Data | Exposed To | Risk Level | +|------|------------|------------| +| Queue ID | Only queue creator | 🟢 Low | +| Session ID | Only session holder | 🟢 Low | +| Input text | Only queue owner (via poll) | 🟢 Low | +| Audio data | Only queue owner (via poll) | 🟢 Low | +| Voice name | Only queue owner | 🟢 Low | + +### 6.4 Potential Improvements (Not Required) + +| Enhancement | Benefit | Complexity | +|-------------|---------|------------| +| Sign queue IDs with HMAC | Prevent any forged IDs | Medium | +| Bind queue to session ID | Defense in depth | Low | +| Encrypt audio in transit | Already HTTPS | N/A | +| Add queue access logging | Audit trail | Low | + +--- + +## 7. Deployment Security + +### 7.1 Current Controls + +| Control | Status | Notes | +|---------|--------|-------| +| HTTPS (Cloud Run) | ✅ | Enforced by default | +| Container sandbox | ✅ | gVisor isolation | +| No persistent storage | ✅ | Stateless design | +| No secrets in code | ✅ | Uses public HuggingFace models | +| Queue auto-cleanup | ✅ | 30s timeout, 60s post-completion | + +### 7.2 Pending for Public Access + +| Requirement | Status | Action Needed | +|-------------|--------|---------------| +| Org policy exception | ❌ | Add `allUsersAccess` tag + `allUsers` invoker | +| HTTPS on Load Balancer | ❌ | Add SSL certificate | +| Rate limiting | ⚠️ | Consider Cloud Armor | +| Max instances limit | ⚠️ | Set scaling constraints for cost control | + +### 7.3 Enabling Public Access (Reference: mcp-server-everything) + +Based on the [Hosted Everything MCP Server](https://docs.google.com/document/d/138rvE5iLeSAJKljo9mNMftvUyjIuvf4tn20oVz7hojY) deployment, public access requires: + +```bash +# Step 1: Add allUsersAccess tag to exempt from Domain Restricted Sharing +# Requires: roles/resourcemanager.tagUser at org level (or "GCP Org - Tag Admin Access" 2PC role) +gcloud resource-manager tags bindings create \ + --tag-value=tagValues/281479845332531 \ + --parent=//run.googleapis.com/projects/mcp-apps-say-server/locations/us-east1/services/say-server \ + --location=us-east1 + +# Step 2: Allow unauthenticated invocations +gcloud run services add-iam-policy-binding say-server \ + --project=mcp-apps-say-server \ + --member="allUsers" \ + --role="roles/run.invoker" \ + --region=us-east1 + +# Step 3: Set max instances for cost control +gcloud run services update say-server \ + --max-instances=5 \ + --region=us-east1 \ + --project=mcp-apps-say-server +``` + +**Prerequisites**: +- `GCP Org - Tag Admin Access` 2PC role (or `roles/resourcemanager.tagUser`) +- `roles/run.admin` or security admin permissions + +### 7.4 Recommended Application-Level Security (from mcp-server-everything) + +Once public, implement these hardening measures: + +**Priority 1 (Critical)**: +```javascript +// Rate limiting per IP +const rateLimit = require('express-rate-limit'); +app.use('/mcp', rateLimit({ + windowMs: 15 * 60 * 1000, // 15 minutes + max: 100, // limit each IP to 100 requests per windowMs +})); + +// Request size limits +app.use(express.json({ limit: '10mb' })); + +// Request timeout +app.use(timeout('30s')); +``` + +**Priority 2 (Important)**: +- Budget alerts configured +- Security monitoring and alerting +- Periodic queue cleanup (already implemented: 30s timeout, 60s post-cleanup) + +### 7.5 Security Verdict (Aligned with mcp-server-everything) + +**✅ SECURE for Testing/Demo Purposes** because: +1. **No sensitive data** processed or stored +2. **Infrastructure properly isolated** (Cloud Run sandbox) +3. **Worst-case scenario** is cost incurrence or service disruption +4. **Purpose-built for testing** with clear boundaries +5. **Queue auto-cleanup** prevents data accumulation + +**Comparison with mcp-server-everything**: + +| Aspect | mcp-server-everything | say-server | +|--------|----------------------|------------| +| State storage | Redis (VPC) | In-memory (per instance) | +| Session mgmt | Redis-backed | Queue ID + session affinity | +| Public access | ✅ Enabled | ❌ Pending | +| Rate limiting | Application-level | Not yet implemented | +| Max instances | 5 | 10 (should reduce) | + +--- + +## 8. Appendix: Queue Lifecycle + +``` +┌─────────────────────────────────────────────────────────────────────────────┐ +│ │ +│ QUEUE STATES │ +│ ──────────── │ +│ │ +│ create_tts_queue() │ +│ │ │ +│ ▼ │ +│ ┌─────────────┐ │ +│ │ ACTIVE │◀─── add_tts_text() ───┐ │ +│ │ │ │ │ +│ │ Processing │────────────────────────┘ │ +│ └─────────────┘ │ +│ │ │ +│ │ end_tts_queue() or timeout │ +│ ▼ │ +│ ┌─────────────┐ ┌─────────────┐ │ +│ │ COMPLETE │ or │ ERROR │ │ +│ │ │ │ │ │ +│ │ All audio │ │ Timeout or │ │ +│ │ generated │ │ exception │ │ +│ └─────────────┘ └─────────────┘ │ +│ │ │ │ +│ └─────────┬─────────┘ │ +│ │ │ +│ ▼ │ +│ 60 seconds after done │ +│ │ │ +│ ▼ │ +│ [Queue Removed] │ +│ │ +└─────────────────────────────────────────────────────────────────────────────┘ +``` + +--- + +## 9. References + +- **[Hosted Everything MCP Server](https://docs.google.com/document/d/138rvE5iLeSAJKljo9mNMftvUyjIuvf4tn20oVz7hojY)** - Jerome's deployment guide for `mcp-server-everything`, used as reference for security patterns and public access setup +- **[How to set up public Cloud Run services](https://outline.ant.dev/doc/how-to-set-up-public-cloud-run-services-zv7t2CPClu)** - Anthropic internal guide for org policy exemptions +- **[MCP Apps SDK Specification](../../specification/draft/apps.mdx)** - Protocol spec for MCP Apps + +--- + +## 10. Contact & Approval + +**Owner**: ochafik@anthropic.com +**Repository**: github.com/modelcontextprotocol/ext-apps +**Component**: examples/say-server + +### Approval Checklist + +- [ ] Security review completed +- [ ] Org policy exception approved (`allUsersAccess` tag applied) +- [ ] HTTPS configured on load balancer +- [ ] Max instances set to 5 (cost control) +- [ ] Rate limiting configured (optional) +- [ ] Monitoring/alerting set up diff --git a/examples/say-server/mcp-app.html b/examples/say-server/mcp-app.html index c51777c71..1e2389bda 100644 --- a/examples/say-server/mcp-app.html +++ b/examples/say-server/mcp-app.html @@ -4,7 +4,7 @@ - Say Widget + Say View diff --git a/examples/say-server/server.py b/examples/say-server/server.py index 662fe4f78..3cad71fc0 100755 --- a/examples/say-server/server.py +++ b/examples/say-server/server.py @@ -12,17 +12,17 @@ Say Demo - MCP App for streaming text-to-speech. This MCP server provides a "say" tool that speaks text using TTS. -The widget receives streaming partial input and starts speaking immediately. +The view receives streaming partial input and starts speaking immediately. Architecture: -- The `say` tool itself is a no-op - it just triggers the widget -- The widget uses `ontoolinputpartial` to receive text as it streams -- Widget calls private tools to create TTS queue, add text, and poll audio -- Audio plays in the widget using Web Audio API +- The `say` tool itself is a no-op - it just triggers the view +- The view uses `ontoolinputpartial` to receive text as it streams +- view calls private tools to create TTS queue, add text, and poll audio +- Audio plays in the view using Web Audio API - Model context updates show playback progress to the LLM - Native theming adapts to dark/light mode automatically - Fullscreen mode with Escape key to exit -- Multi-widget speak lock coordinates playback across instances +- Multi-View speak lock coordinates playback across instances Usage: # Start the MCP server @@ -56,7 +56,7 @@ logger = logging.getLogger(__name__) -WIDGET_URI = "ui://say-demo/widget.html" +VIEW_URI = "ui://say-demo/view.html" HOST = os.environ.get("HOST", "0.0.0.0") PORT = int(os.environ.get("PORT", "3109")) @@ -167,8 +167,8 @@ def list_voices() -> list[types.TextContent]: @mcp.tool(meta={ - "ui":{"resourceUri": WIDGET_URI}, - "ui/resourceUri": WIDGET_URI, # legacy support + "ui":{"resourceUri": VIEW_URI}, + "ui/resourceUri": VIEW_URI, # legacy support }) def say( text: Annotated[str, Field(description="The English text to speak aloud")] = DEFAULT_TEXT, @@ -192,19 +192,19 @@ def say( Note: English only. Non-English text may produce poor or garbled results. """ - # Generate a unique ID for this widget instance (used for speak lock coordination) - widget_uuid = uuid.uuid4().hex[:12] + # Generate a unique ID for this view instance (used for speak lock coordination) + view_uuid = uuid.uuid4().hex[:12] - # This is a no-op - the widget handles everything via ontoolinputpartial + # This is a no-op - the view handles everything via ontoolinputpartial # The tool exists to: - # 1. Trigger the widget to load + # 1. Trigger the view to load # 2. Provide the resourceUri metadata # 3. Show the final text in the tool result - # 4. Provide widget UUID for multi-player coordination + # 4. Provide view UUID for multi-player coordination return [types.TextContent( type="text", - text=f"Displayed a TTS widget with voice '{voice}'. Click to play/pause, use toolbar to restart or fullscreen.", - _meta={"viewUUID": widget_uuid}, + text=f"Displayed a TTS view with voice '{voice}'. Click to play/pause, use toolbar to restart or fullscreen.", + _meta={"viewUUID": view_uuid}, )] @@ -348,7 +348,7 @@ def poll_tts_audio(queue_id: str) -> list[types.TextContent]: new_chunks = state.audio_chunks[state.chunks_delivered:] state.chunks_delivered = len(state.audio_chunks) - # Consider queues with errors as "done" so widget stops polling + # Consider queues with errors as "done" so view stops polling done = (state.status == "complete" or state.status == "error") and state.chunks_delivered >= len(state.audio_chunks) response = { @@ -661,18 +661,18 @@ def generate_sync(): # ------------------------------------------------------ -# Widget Resource +# View Resource # ------------------------------------------------------ -# Embedded widget HTML for standalone execution via `uv run ` +# Embedded View HTML for standalone execution via `uv run ` # Uses Babel standalone for in-browser JSX transpilation -# This is a copy of widget.html - keep them in sync! -EMBEDDED_WIDGET_HTML = """ +# This is a copy of view.html - keep them in sync! +EMBEDDED_VIEW_HTML = """ - Say Widget + Say View """ -def get_widget_html() -> str: - """Get the widget HTML, preferring built version from dist/.""" +def get_view_html() -> str: + """Get the View HTML, preferring built version from dist/.""" # Prefer built version from dist/ (local development with npm run build) dist_path = Path(__file__).parent / "dist" / "mcp-app.html" if dist_path.exists(): return dist_path.read_text() - # Fallback to embedded widget (for `uv run ` or unbundled usage) - return EMBEDDED_WIDGET_HTML + # Fallback to embedded View (for `uv run ` or unbundled usage) + return EMBEDDED_VIEW_HTML # IMPORTANT: all the external domains used by app must be listed # in the meta.ui.csp.resourceDomains - otherwise they will be blocked by CSP policy @mcp.resource( - WIDGET_URI, + VIEW_URI, mime_type="text/html;profile=mcp-app", meta={"ui": {"csp": {"resourceDomains": ["https://esm.sh", "https://unpkg.com"]}}}, ) -def widget() -> str: - """Widget HTML resource with CSP metadata for external dependencies.""" - return get_widget_html() +def view() -> str: + """View HTML resource with CSP metadata for external dependencies.""" + return get_view_html() # ------------------------------------------------------ From cc1e83ff5f769a46b7876e35ca76e7afb9612259 Mon Sep 17 00:00:00 2001 From: Olivier Chafik Date: Fri, 23 Jan 2026 11:38:28 +0000 Subject: [PATCH 19/23] chore(say-server): remove deployment docs from repo --- examples/say-server/DEPLOYMENT.md | 246 ---------- examples/say-server/Dockerfile | 34 -- examples/say-server/SECURITY_REVIEW.md | 607 ------------------------- 3 files changed, 887 deletions(-) delete mode 100644 examples/say-server/DEPLOYMENT.md delete mode 100644 examples/say-server/Dockerfile delete mode 100644 examples/say-server/SECURITY_REVIEW.md diff --git a/examples/say-server/DEPLOYMENT.md b/examples/say-server/DEPLOYMENT.md deleted file mode 100644 index 7758050d5..000000000 --- a/examples/say-server/DEPLOYMENT.md +++ /dev/null @@ -1,246 +0,0 @@ -# Say Server - GCP Cloud Run Deployment - -This document describes how to deploy the Say Server MCP application to Google Cloud Run with session-sticky load balancing. - -## Architecture - -``` -┌─────────────┐ ┌──────────────────┐ ┌─────────────────┐ ┌─────────────┐ -│ Client │────▶│ Load Balancer │────▶│ Serverless NEG │────▶│ Cloud Run │ -│ │ │ (HTTP, IP-based)│ │ │ │ say-server │ -└─────────────┘ └──────────────────┘ └─────────────────┘ └─────────────┘ - │ - ▼ - Session Affinity - (mcp-session-id header) -``` - -## Prerequisites - -- GCP Project with billing enabled -- `gcloud` CLI installed and authenticated -- Docker (for local builds) - -## Current Deployment - -- **Project**: `mcp-apps-say-server` -- **Region**: `us-east1` -- **Service URL**: `https://say-server-109024344223.us-east1.run.app` -- **Load Balancer IP**: `34.160.77.67` - -## Session Stickiness Configuration - -MCP's Streamable HTTP transport uses the `mcp-session-id` header for stateful sessions. The load balancer is configured to route requests with the same session ID to the same Cloud Run instance: - -```bash -# Backend service configuration -gcloud compute backend-services describe say-server-backend --global --format=json | jq '{ - sessionAffinity, - localityLbPolicy, - consistentHash -}' -``` - -Returns: -```json -{ - "sessionAffinity": "HEADER_FIELD", - "localityLbPolicy": "RING_HASH", - "consistentHash": { - "httpHeaderName": "mcp-session-id" - } -} -``` - -## Deployment Steps - -### 1. Set Project - -```bash -export PROJECT_ID=mcp-apps-say-server -gcloud config set project $PROJECT_ID -``` - -### 2. Enable APIs - -```bash -gcloud services enable \ - run.googleapis.com \ - cloudbuild.googleapis.com \ - artifactregistry.googleapis.com \ - compute.googleapis.com -``` - -### 3. Build and Push Docker Image - -```bash -cd examples/say-server - -# Build for linux/amd64 -docker build --platform linux/amd64 \ - -t us-east1-docker.pkg.dev/$PROJECT_ID/cloud-run-source-deploy/say-server:latest . - -# Configure docker auth -gcloud auth configure-docker us-east1-docker.pkg.dev --quiet - -# Push -docker push us-east1-docker.pkg.dev/$PROJECT_ID/cloud-run-source-deploy/say-server:latest -``` - -### 4. Deploy to Cloud Run - -```bash -gcloud run deploy say-server \ - --image us-east1-docker.pkg.dev/$PROJECT_ID/cloud-run-source-deploy/say-server:latest \ - --region us-east1 \ - --memory 4Gi \ - --cpu 2 \ - --timeout 300 \ - --concurrency 10 \ - --min-instances 0 \ - --max-instances 10 \ - --no-cpu-throttling \ - --ingress all -``` - -### 5. Set Up Load Balancer with Session Affinity - -```bash -# Create serverless NEG -gcloud compute network-endpoint-groups create say-server-neg \ - --region=us-east1 \ - --network-endpoint-type=serverless \ - --cloud-run-service=say-server - -# Create backend service -gcloud compute backend-services create say-server-backend \ - --global \ - --load-balancing-scheme=EXTERNAL_MANAGED - -# Add NEG to backend -gcloud compute backend-services add-backend say-server-backend \ - --global \ - --network-endpoint-group=say-server-neg \ - --network-endpoint-group-region=us-east1 - -# Configure session affinity (requires import/export for consistentHash) -gcloud compute backend-services describe say-server-backend --global --format=json | \ - jq 'del(.id, .kind, .selfLink, .creationTimestamp, .fingerprint) + { - "sessionAffinity": "HEADER_FIELD", - "localityLbPolicy": "RING_HASH", - "protocol": "HTTPS", - "consistentHash": {"httpHeaderName": "mcp-session-id"} - }' > /tmp/backend.json - -gcloud compute backend-services import say-server-backend \ - --global \ - --source=/tmp/backend.json \ - --quiet - -# Create URL map -gcloud compute url-maps create say-server-lb \ - --default-service=say-server-backend \ - --global - -# Create HTTP proxy -gcloud compute target-http-proxies create say-server-proxy \ - --url-map=say-server-lb \ - --global - -# Reserve static IP -gcloud compute addresses create say-server-ip \ - --global \ - --ip-version=IPV4 - -# Create forwarding rule -gcloud compute forwarding-rules create say-server-forwarding \ - --global \ - --target-http-proxy=say-server-proxy \ - --address=say-server-ip \ - --ports=80 \ - --load-balancing-scheme=EXTERNAL_MANAGED -``` - -## Access & Authentication - -### Current Issue: Org Policy Restrictions - -Anthropic's GCP org policies prevent: -- `allUsers` or `allAuthenticatedUsers` IAM bindings -- Service account key creation - -This means the service requires authentication. - -### Authenticated Access (Works Now) - -```bash -# Via gcloud proxy (recommended for testing) -gcloud run services proxy say-server \ - --region=us-east1 \ - --port=8888 \ - --project=mcp-apps-say-server - -# Then access at http://127.0.0.1:8888/mcp -curl -X POST http://127.0.0.1:8888/mcp \ - -H "Content-Type: application/json" \ - -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"test","version":"1.0"}}}' -``` - -### Requirements for Public Access - -To enable unauthenticated public access, one of: - -1. **Org Policy Exception** - Request IT to allow `allUsers` for this project: - - Policy: `constraints/iam.allowedPolicyMemberDomains` - - Need exception to add `allUsers` to Cloud Run invoker role - -2. **External Project** - Deploy to a GCP project outside Anthropic's org - -3. **MCP OAuth Auth** - Implement OAuth in the server: - ```python - from mcp.server.fastmcp import FastMCP, AuthSettings - - mcp = FastMCP( - "Say Demo", - auth=AuthSettings( - issuer_url="https://accounts.google.com", - required_scopes=["openid", "email"], - ), - token_verifier=..., # Configure token verification - ) - ``` - -## Files - -- `server.py` - Self-contained MCP server (runs with `uv run server.py`) -- `Dockerfile` - Cloud Run container definition -- `.gcloudignore` - Excludes unnecessary files from upload -- `mcp-app.html` - UI served as MCP resource - -## Updating the Deployment - -```bash -# Rebuild and push -docker build --platform linux/amd64 \ - -t us-east1-docker.pkg.dev/mcp-apps-say-server/cloud-run-source-deploy/say-server:latest . -docker push us-east1-docker.pkg.dev/mcp-apps-say-server/cloud-run-source-deploy/say-server:latest - -# Deploy new revision -gcloud run deploy say-server \ - --image us-east1-docker.pkg.dev/mcp-apps-say-server/cloud-run-source-deploy/say-server:latest \ - --region us-east1 \ - --project mcp-apps-say-server -``` - -## Cleanup - -```bash -# Delete all resources -gcloud compute forwarding-rules delete say-server-forwarding --global --quiet -gcloud compute target-http-proxies delete say-server-proxy --global --quiet -gcloud compute url-maps delete say-server-lb --global --quiet -gcloud compute backend-services delete say-server-backend --global --quiet -gcloud compute network-endpoint-groups delete say-server-neg --region=us-east1 --quiet -gcloud compute addresses delete say-server-ip --global --quiet -gcloud run services delete say-server --region=us-east1 --quiet -``` diff --git a/examples/say-server/Dockerfile b/examples/say-server/Dockerfile deleted file mode 100644 index 1b1630701..000000000 --- a/examples/say-server/Dockerfile +++ /dev/null @@ -1,34 +0,0 @@ -# Cloud Run Dockerfile for Say Server -# Uses uv for fast Python package management - -FROM ghcr.io/astral-sh/uv:python3.12-bookworm-slim - -# Install git for pip install from git repos -RUN apt-get update && apt-get install -y git && rm -rf /var/lib/apt/lists/* - -WORKDIR /app - -# Copy server file -COPY server.py . - -# Use isolated cache directory for HuggingFace to avoid permission issues -ENV HF_HOME=/app/.cache/huggingface -ENV TRANSFORMERS_CACHE=/app/.cache/huggingface - -# Pre-install dependencies for faster cold starts -RUN uv pip install --system --default-index https://pypi.org/simple \ - "mcp @ git+https://github.com/modelcontextprotocol/python-sdk@main" \ - "uvicorn>=0.34.0" \ - "starlette>=0.46.0" \ - "pocket-tts>=1.0.1" - -# Cloud Run sets PORT env var, server already respects it -# Default to 8080 for Cloud Run -ENV PORT=8080 -ENV HOST=0.0.0.0 - -# Expose port (informational, Cloud Run uses PORT env var) -EXPOSE 8080 - -# Run the server directly with python (not uv run, since deps are pre-installed) -CMD ["python", "server.py"] diff --git a/examples/say-server/SECURITY_REVIEW.md b/examples/say-server/SECURITY_REVIEW.md deleted file mode 100644 index 856b8bdc8..000000000 --- a/examples/say-server/SECURITY_REVIEW.md +++ /dev/null @@ -1,607 +0,0 @@ -# Say Server - Security Design Document - -**Service**: MCP Say Server (Text-to-Speech) -**Location**: GCP Cloud Run, `us-east1` -**Project**: `mcp-apps-say-server` -**Date**: January 2026 - ---- - -## 1. Overview - -The Say Server is an MCP (Model Context Protocol) application that provides real-time text-to-speech functionality. It demonstrates streaming audio generation with karaoke-style text highlighting. - -### What It Does - -``` -┌─────────────────────────────────────────────────────────────────────────────┐ -│ Say Server │ -│ │ -│ 1. Claude streams text to say() tool call arguments │ -│ 2. Host forwards partial input to MCP App (widget in iframe) │ -│ 3. Widget receives via ontoolinputpartial, sends to server queue │ -│ 4. Server generates audio chunks (CPU-bound TTS via pocket-tts) │ -│ 5. Widget polls for audio, plays via Web Audio API │ -│ 6. Text highlighting synced with audio playback (karaoke-style) │ -│ │ -└─────────────────────────────────────────────────────────────────────────────┘ -``` - -### Key Components - -| Component | Description | -|-----------|-------------| -| `server.py` | Self-contained MCP server with TTS tools | -| `pocket-tts` | Neural TTS model (Kyutai, Apache 2.0) | -| Widget HTML | React-based UI for playback control | -| MCP Protocol | Streamable HTTP transport with session support | - ---- - -## 2. Architecture Diagrams - -### 2.1 High-Level Data Flow - -``` -┌──────────┐ ┌──────────────┐ ┌─────────────────┐ ┌──────────────┐ -│ Claude │────▶│ MCP Host │────▶│ Say Server │────▶│ TTS Model │ -│ (LLM) │ │ (Client) │ │ (Cloud Run) │ │ (pocket-tts)│ -└──────────┘ └──────────────┘ └─────────────────┘ └──────────────┘ - │ │ │ - │ streams tool │ forwards to │ generates audio - │ call arguments │ MCP App (widget) │ - ▼ ▼ ▼ -┌──────────────────────────────────────────────────────────────────────────────┐ -│ │ -│ Claude streams text ──▶ Host forwards partial ──▶ Widget receives via │ -│ to say() tool input tool input to iframe ontoolinputpartial() │ -│ │ -│ Widget calls server: │ -│ create_tts_queue(voice) ──▶ queue_id │ -│ add_tts_text(queue_id, "Hello wor...") │ -│ add_tts_text(queue_id, "ld!") │ -│ end_tts_queue(queue_id) │ -│ │ -│ Widget polls for audio: │ -│ poll_tts_audio(queue_id) ◀─── {chunks: [{audio_base64, ...}], done} │ -│ │ -└──────────────────────────────────────────────────────────────────────────────┘ -``` - -**Key insight**: The widget (MCP App) is the active party - it receives streamed text from Claude via the host, then independently calls server tools to manage TTS generation. - -### 2.2 Queue Architecture - -``` -┌─────────────────────────────────────────────────────────────────────────────┐ -│ Server Process Memory │ -│ │ -│ tts_queues: Dict[str, TTSQueueState] │ -│ ┌─────────────────────────────────────────────────────────────────────┐ │ -│ │ │ │ -│ │ "a1b2c3d4e5f6" ──▶ TTSQueueState { │ │ -│ │ id: "a1b2c3d4e5f6" │ │ -│ │ text_queue: AsyncQueue ◀── text chunks │ │ -│ │ audio_chunks: List ──▶ generated audio │ │ -│ │ chunks_delivered: int │ │ -│ │ status: "active" | "complete" | "error" │ │ -│ │ task: AsyncTask (background TTS) │ │ -│ │ } │ │ -│ │ │ │ -│ │ "x7y8z9a0b1c2" ──▶ TTSQueueState { ... } (different session) │ │ -│ │ │ │ -│ └─────────────────────────────────────────────────────────────────────┘ │ -│ │ -└─────────────────────────────────────────────────────────────────────────────┘ -``` - -### 2.3 Information Flow: Text → Audio - -``` -┌─────────────────────────────────────────────────────────────────────────────┐ -│ │ -│ CLIENT (Widget) SERVER (Cloud Run) │ -│ ────────────────── ────────────────── │ -│ │ -│ 1. create_tts_queue(voice) ─────▶ Creates TTSQueueState │ -│ ◀───────────────────────────── Returns {queue_id, sample_rate} │ -│ │ -│ 2. add_tts_text(queue_id, "He") ─▶ Queues text │ -│ add_tts_text(queue_id, "llo")─▶ Queues text │ -│ add_tts_text(queue_id, " ") ──▶ Queues text │ -│ │ │ -│ ▼ │ -│ ┌──────────────────┐ │ -│ │ Background Task │ │ -│ │ ─────────────────│ │ -│ │ StreamingChunker │ │ -│ │ buffers text │ │ -│ │ until sentence │ │ -│ │ boundary │ │ -│ │ │ │ │ -│ │ ▼ │ │ -│ │ TTS Model │ │ -│ │ generates audio │ │ -│ │ (run_in_executor)│ │ -│ │ │ │ │ -│ │ ▼ │ │ -│ │ audio_chunks[] │ │ -│ └──────────────────┘ │ -│ │ -│ 3. poll_tts_audio(queue_id) ────▶ Returns new chunks since last poll │ -│ ◀──────────────────────────── {chunks: [...], done: false} │ -│ poll_tts_audio(queue_id) ────▶ │ -│ ◀──────────────────────────── {chunks: [...], done: true} │ -│ │ -│ 4. end_tts_queue(queue_id) ─────▶ Signals EOF, flushes remaining text │ -│ │ -└─────────────────────────────────────────────────────────────────────────────┘ -``` - -### 2.4 Polling Mechanism - -``` -┌─────────────────────────────────────────────────────────────────────────────┐ -│ Widget Polling Loop │ -│ │ -│ while (!done) { │ -│ response = await callServerTool("poll_tts_audio", {queue_id}) │ -│ │ -│ for (chunk of response.chunks) { │ -│ // Decode base64 audio │ -│ // Schedule on Web Audio API │ -│ // Track timing for text sync │ -│ } │ -│ │ -│ if (response.chunks.length > 0) { │ -│ await sleep(20ms) // Fast poll during active streaming │ -│ } else { │ -│ await sleep(50-150ms) // Exponential backoff when waiting │ -│ } │ -│ } │ -│ │ -│ Server-side: │ -│ ───────────── │ -│ - chunks_delivered tracks what client has seen │ -│ - poll returns audio_chunks[chunks_delivered:] │ -│ - Updates chunks_delivered after each poll │ -│ │ -└─────────────────────────────────────────────────────────────────────────────┘ -``` - ---- - -## 3. Session & Queue Isolation - -### 3.1 Session Isolation Model - -``` -┌─────────────────────────────────────────────────────────────────────────────┐ -│ │ -│ Session A (User 1) Session B (User 2) │ -│ ────────────────── ────────────────── │ -│ │ -│ queue_id: "a1b2c3d4e5f6" queue_id: "x7y8z9a0b1c2" │ -│ │ │ │ -│ ▼ ▼ │ -│ ┌─────────────────┐ ┌─────────────────┐ │ -│ │ TTSQueueState A │ │ TTSQueueState B │ │ -│ │ │ │ │ │ -│ │ text: "Hello" │ │ text: "Goodbye" │ │ -│ │ audio: [...] │ │ audio: [...] │ │ -│ │ voice: cosette │ │ voice: alba │ │ -│ └─────────────────┘ └─────────────────┘ │ -│ │ -│ ✓ Each queue is completely independent │ -│ ✓ Queue ID is the only "key" to access data │ -│ ✓ No shared state between queues │ -│ │ -└─────────────────────────────────────────────────────────────────────────────┘ -``` - -### 3.2 Queue ID as Access Token - -```python -# Queue creation generates random 12-char hex ID -queue_id = uuid.uuid4().hex[:12] # e.g., "a1b2c3d4e5f6" - -# All operations require queue_id -add_tts_text(queue_id, text) # Only works if you know the ID -poll_tts_audio(queue_id) # Only returns YOUR queue's audio -end_tts_queue(queue_id) # Only ends YOUR queue -``` - -**Entropy**: 12 hex chars = 48 bits = 281 trillion possible values - ---- - -## 4. CPU Isolation (TTS Processing) - -### 4.1 Thread Pool Isolation - -``` -┌─────────────────────────────────────────────────────────────────────────────┐ -│ │ -│ Main Event Loop (asyncio) │ -│ ───────────────────────── │ -│ - Handles HTTP requests │ -│ - Manages queue state │ -│ - Non-blocking operations │ -│ │ -│ │ │ -│ │ run_in_executor() │ -│ ▼ │ -│ ┌─────────────────────────────────────────────────────────────────────┐ │ -│ │ Thread Pool Executor │ │ -│ │ │ │ -│ │ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ │ -│ │ │ Thread 1 │ │ Thread 2 │ │ Thread 3 │ ... │ │ -│ │ │ Queue A │ │ Queue B │ │ Queue C │ │ │ -│ │ │ TTS work │ │ TTS work │ │ TTS work │ │ │ -│ │ └─────────────┘ └─────────────┘ └─────────────┘ │ │ -│ │ │ │ -│ │ - Each queue's TTS runs in separate thread │ │ -│ │ - CPU-bound work doesn't block event loop │ │ -│ │ - Natural isolation via thread boundaries │ │ -│ │ │ │ -│ └─────────────────────────────────────────────────────────────────────┘ │ -│ │ -└─────────────────────────────────────────────────────────────────────────────┘ -``` - -### 4.2 No Shared TTS State - -```python -# Each queue gets its own model state copy -model_state = tts_model._cached_get_state_for_audio_prompt(voice, truncate=True) - -# Audio generation uses copy_state=True -for audio_chunk in tts_model._generate_audio_stream_short_text( - model_state=model_state, - text_to_generate=text, - copy_state=True, # ← Ensures isolation -): - ... -``` - ---- - -## 5. Need for Session Stickiness - -### 5.1 Why Stickiness is Required - -``` -┌─────────────────────────────────────────────────────────────────────────────┐ -│ │ -│ WITHOUT Stickiness (BROKEN) │ -│ ─────────────────────────── │ -│ │ -│ Request 1: create_tts_queue() ──▶ Instance A ──▶ queue_id: "abc123" │ -│ Request 2: add_tts_text("abc123") ──▶ Instance B ──▶ "Queue not found!" ✗ │ -│ │ -│ The queue exists only in Instance A's memory! │ -│ │ -├─────────────────────────────────────────────────────────────────────────────┤ -│ │ -│ WITH Stickiness (WORKING) │ -│ ───────────────────────── │ -│ │ -│ Request 1: create_tts_queue() │ -│ mcp-session-id: xyz ──▶ Instance A ──▶ queue_id: "abc123" │ -│ │ -│ Request 2: add_tts_text("abc123") │ -│ mcp-session-id: xyz ──▶ Instance A ──▶ Text queued ✓ │ -│ (same session ID → same instance) │ -│ │ -│ Request 3: poll_tts_audio("abc123") │ -│ mcp-session-id: xyz ──▶ Instance A ──▶ Audio chunks ✓ │ -│ │ -└─────────────────────────────────────────────────────────────────────────────┘ -``` - -### 5.2 MCP Session Protocol - -``` -┌─────────────────────────────────────────────────────────────────────────────┐ -│ │ -│ First Request (no session) │ -│ ────────────────────────── │ -│ │ -│ POST /mcp │ -│ Content-Type: application/json │ -│ (no mcp-session-id header) │ -│ │ -│ Response: │ -│ mcp-session-id: sess_abc123xyz ◀── Server generates session ID │ -│ │ -├─────────────────────────────────────────────────────────────────────────────┤ -│ │ -│ Subsequent Requests │ -│ ─────────────────── │ -│ │ -│ POST /mcp │ -│ Content-Type: application/json │ -│ mcp-session-id: sess_abc123xyz ◀── Client sends back session ID │ -│ │ -│ Load Balancer: │ -│ - Hashes "sess_abc123xyz" │ -│ - Routes to same instance via consistent hashing │ -│ │ -└─────────────────────────────────────────────────────────────────────────────┘ -``` - ---- - -## 6. Security Analysis - -### 6.1 Attack: Accessing Another User's Queue - -``` -┌─────────────────────────────────────────────────────────────────────────────┐ -│ │ -│ ATTACK SCENARIO │ -│ ─────────────── │ -│ │ -│ Attacker wants to: │ -│ 1. Read audio from victim's queue │ -│ 2. Inject text into victim's queue │ -│ 3. Cancel victim's queue │ -│ │ -├─────────────────────────────────────────────────────────────────────────────┤ -│ │ -│ ATTACK REQUIREMENTS │ -│ ─────────────────── │ -│ │ -│ 1. Know victim's queue_id (12-char hex = 48 bits entropy) │ -│ - Not exposed in any API response │ -│ - Not in URLs, logs, or error messages │ -│ - Only returned to queue creator │ -│ │ -│ 2. Be routed to same Cloud Run instance (for in-memory access) │ -│ - Requires matching mcp-session-id hash │ -│ - Session IDs are also random and not exposed │ -│ │ -├─────────────────────────────────────────────────────────────────────────────┤ -│ │ -│ WHY IT'S NOT POSSIBLE │ -│ ───────────────────── │ -│ │ -│ ┌────────────────────────────────────────────────────────────────┐ │ -│ │ │ │ -│ │ Brute Force Analysis: │ │ -│ │ │ │ -│ │ Queue ID space: 16^12 = 281,474,976,710,656 possibilities │ │ -│ │ Queue lifetime: ~30 seconds (timeout) to ~5 minutes (usage) │ │ -│ │ Concurrent queues: typically 1-10 per instance │ │ -│ │ │ │ -│ │ Probability of guessing valid queue_id: │ │ -│ │ P = active_queues / total_space │ │ -│ │ P = 10 / 281,474,976,710,656 │ │ -│ │ P ≈ 3.5 × 10^-14 │ │ -│ │ │ │ -│ │ At 1000 requests/second, expected time to find valid ID: │ │ -│ │ T = 281,474,976,710,656 / 10 / 1000 seconds │ │ -│ │ T ≈ 891 years │ │ -│ │ │ │ -│ └────────────────────────────────────────────────────────────────┘ │ -│ │ -│ Additional Barriers: │ -│ - Rate limiting would kick in │ -│ - Queue expires before brute force succeeds │ -│ - Attacker's requests go to different instances (session affinity) │ -│ │ -└─────────────────────────────────────────────────────────────────────────────┘ -``` - -### 6.2 Attack: Session ID Enumeration - -``` -┌─────────────────────────────────────────────────────────────────────────────┐ -│ │ -│ ATTACK: Guess mcp-session-id to route to victim's instance │ -│ │ -│ WHY IT FAILS: │ -│ ───────────── │ -│ │ -│ 1. Session IDs are server-generated (not predictable) │ -│ 2. Even if routed to same instance, still need queue_id │ -│ 3. Session ID ≠ Queue ID (they're independent) │ -│ │ -│ ┌────────────────────────────────────────────────────────────────┐ │ -│ │ │ │ -│ │ Attacker sends: │ │ -│ │ mcp-session-id: guessed_value │ │ -│ │ │ │ │ -│ │ ▼ │ │ -│ │ Load Balancer routes to Instance X (based on hash) │ │ -│ │ │ │ │ -│ │ ▼ │ │ -│ │ Attacker calls poll_tts_audio(guessed_queue_id) │ │ -│ │ │ │ │ -│ │ ▼ │ │ -│ │ Server: "Queue not found" (queue_id is still wrong) │ │ -│ │ │ │ -│ └────────────────────────────────────────────────────────────────┘ │ -│ │ -└─────────────────────────────────────────────────────────────────────────────┘ -``` - -### 6.3 Data Exposure Summary - -| Data | Exposed To | Risk Level | -|------|------------|------------| -| Queue ID | Only queue creator | 🟢 Low | -| Session ID | Only session holder | 🟢 Low | -| Input text | Only queue owner (via poll) | 🟢 Low | -| Audio data | Only queue owner (via poll) | 🟢 Low | -| Voice name | Only queue owner | 🟢 Low | - -### 6.4 Potential Improvements (Not Required) - -| Enhancement | Benefit | Complexity | -|-------------|---------|------------| -| Sign queue IDs with HMAC | Prevent any forged IDs | Medium | -| Bind queue to session ID | Defense in depth | Low | -| Encrypt audio in transit | Already HTTPS | N/A | -| Add queue access logging | Audit trail | Low | - ---- - -## 7. Deployment Security - -### 7.1 Current Controls - -| Control | Status | Notes | -|---------|--------|-------| -| HTTPS (Cloud Run) | ✅ | Enforced by default | -| Container sandbox | ✅ | gVisor isolation | -| No persistent storage | ✅ | Stateless design | -| No secrets in code | ✅ | Uses public HuggingFace models | -| Queue auto-cleanup | ✅ | 30s timeout, 60s post-completion | - -### 7.2 Pending for Public Access - -| Requirement | Status | Action Needed | -|-------------|--------|---------------| -| Org policy exception | ❌ | Add `allUsersAccess` tag + `allUsers` invoker | -| HTTPS on Load Balancer | ❌ | Add SSL certificate | -| Rate limiting | ⚠️ | Consider Cloud Armor | -| Max instances limit | ⚠️ | Set scaling constraints for cost control | - -### 7.3 Enabling Public Access (Reference: mcp-server-everything) - -Based on the [Hosted Everything MCP Server](https://docs.google.com/document/d/138rvE5iLeSAJKljo9mNMftvUyjIuvf4tn20oVz7hojY) deployment, public access requires: - -```bash -# Step 1: Add allUsersAccess tag to exempt from Domain Restricted Sharing -# Requires: roles/resourcemanager.tagUser at org level (or "GCP Org - Tag Admin Access" 2PC role) -gcloud resource-manager tags bindings create \ - --tag-value=tagValues/281479845332531 \ - --parent=//run.googleapis.com/projects/mcp-apps-say-server/locations/us-east1/services/say-server \ - --location=us-east1 - -# Step 2: Allow unauthenticated invocations -gcloud run services add-iam-policy-binding say-server \ - --project=mcp-apps-say-server \ - --member="allUsers" \ - --role="roles/run.invoker" \ - --region=us-east1 - -# Step 3: Set max instances for cost control -gcloud run services update say-server \ - --max-instances=5 \ - --region=us-east1 \ - --project=mcp-apps-say-server -``` - -**Prerequisites**: -- `GCP Org - Tag Admin Access` 2PC role (or `roles/resourcemanager.tagUser`) -- `roles/run.admin` or security admin permissions - -### 7.4 Recommended Application-Level Security (from mcp-server-everything) - -Once public, implement these hardening measures: - -**Priority 1 (Critical)**: -```javascript -// Rate limiting per IP -const rateLimit = require('express-rate-limit'); -app.use('/mcp', rateLimit({ - windowMs: 15 * 60 * 1000, // 15 minutes - max: 100, // limit each IP to 100 requests per windowMs -})); - -// Request size limits -app.use(express.json({ limit: '10mb' })); - -// Request timeout -app.use(timeout('30s')); -``` - -**Priority 2 (Important)**: -- Budget alerts configured -- Security monitoring and alerting -- Periodic queue cleanup (already implemented: 30s timeout, 60s post-cleanup) - -### 7.5 Security Verdict (Aligned with mcp-server-everything) - -**✅ SECURE for Testing/Demo Purposes** because: -1. **No sensitive data** processed or stored -2. **Infrastructure properly isolated** (Cloud Run sandbox) -3. **Worst-case scenario** is cost incurrence or service disruption -4. **Purpose-built for testing** with clear boundaries -5. **Queue auto-cleanup** prevents data accumulation - -**Comparison with mcp-server-everything**: - -| Aspect | mcp-server-everything | say-server | -|--------|----------------------|------------| -| State storage | Redis (VPC) | In-memory (per instance) | -| Session mgmt | Redis-backed | Queue ID + session affinity | -| Public access | ✅ Enabled | ❌ Pending | -| Rate limiting | Application-level | Not yet implemented | -| Max instances | 5 | 10 (should reduce) | - ---- - -## 8. Appendix: Queue Lifecycle - -``` -┌─────────────────────────────────────────────────────────────────────────────┐ -│ │ -│ QUEUE STATES │ -│ ──────────── │ -│ │ -│ create_tts_queue() │ -│ │ │ -│ ▼ │ -│ ┌─────────────┐ │ -│ │ ACTIVE │◀─── add_tts_text() ───┐ │ -│ │ │ │ │ -│ │ Processing │────────────────────────┘ │ -│ └─────────────┘ │ -│ │ │ -│ │ end_tts_queue() or timeout │ -│ ▼ │ -│ ┌─────────────┐ ┌─────────────┐ │ -│ │ COMPLETE │ or │ ERROR │ │ -│ │ │ │ │ │ -│ │ All audio │ │ Timeout or │ │ -│ │ generated │ │ exception │ │ -│ └─────────────┘ └─────────────┘ │ -│ │ │ │ -│ └─────────┬─────────┘ │ -│ │ │ -│ ▼ │ -│ 60 seconds after done │ -│ │ │ -│ ▼ │ -│ [Queue Removed] │ -│ │ -└─────────────────────────────────────────────────────────────────────────────┘ -``` - ---- - -## 9. References - -- **[Hosted Everything MCP Server](https://docs.google.com/document/d/138rvE5iLeSAJKljo9mNMftvUyjIuvf4tn20oVz7hojY)** - Jerome's deployment guide for `mcp-server-everything`, used as reference for security patterns and public access setup -- **[How to set up public Cloud Run services](https://outline.ant.dev/doc/how-to-set-up-public-cloud-run-services-zv7t2CPClu)** - Anthropic internal guide for org policy exemptions -- **[MCP Apps SDK Specification](../../specification/draft/apps.mdx)** - Protocol spec for MCP Apps - ---- - -## 10. Contact & Approval - -**Owner**: ochafik@anthropic.com -**Repository**: github.com/modelcontextprotocol/ext-apps -**Component**: examples/say-server - -### Approval Checklist - -- [ ] Security review completed -- [ ] Org policy exception approved (`allUsersAccess` tag applied) -- [ ] HTTPS configured on load balancer -- [ ] Max instances set to 5 (cost control) -- [ ] Rate limiting configured (optional) -- [ ] Monitoring/alerting set up From 01b4a84f76e57aff744c0ae2a4cc8bf592c2f4e9 Mon Sep 17 00:00:00 2001 From: Olivier Chafik Date: Fri, 23 Jan 2026 11:39:30 +0000 Subject: [PATCH 20/23] widgetId->tool id --- examples/pdf-server/src/mcp-app.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/examples/pdf-server/src/mcp-app.ts b/examples/pdf-server/src/mcp-app.ts index c32077902..6c065d154 100644 --- a/examples/pdf-server/src/mcp-app.ts +++ b/examples/pdf-server/src/mcp-app.ts @@ -283,10 +283,10 @@ async function updatePageContext() { selection, ); - // Build context with widget ID for multi-widget disambiguation - const widgetId = app.getHostContext()?.toolInfo?.id; + // Build context with tool ID for multi-tool disambiguation + const toolId = app.getHostContext()?.toolInfo?.id; const header = [ - `PDF viewer${widgetId ? ` (${widgetId})` : ""}`, + `PDF viewer${toolId ? ` (${toolId})` : ""}`, pdfTitle ? `"${pdfTitle}"` : pdfUrl, `Current Page: ${currentPage}/${totalPages}`, ].join(" | "); From 1a01a1f41b5b848ba4bdd39efbc658af470683c7 Mon Sep 17 00:00:00 2001 From: Olivier Chafik Date: Fri, 23 Jan 2026 11:46:23 +0000 Subject: [PATCH 21/23] fix(map-server): add null check for center in location update chore(pdf-server): dedupe and sort allowed origins --- examples/map-server/src/mcp-app.ts | 4 ++-- examples/pdf-server/server.ts | 23 +++++++++++------------ 2 files changed, 13 insertions(+), 14 deletions(-) diff --git a/examples/map-server/src/mcp-app.ts b/examples/map-server/src/mcp-app.ts index 6aadf60d8..0dbfef5c1 100644 --- a/examples/map-server/src/mcp-app.ts +++ b/examples/map-server/src/mcp-app.ts @@ -404,8 +404,8 @@ function scheduleLocationUpdate(cesiumViewer: any): void { const center = getCameraCenter(cesiumViewer); const extent = getVisibleExtent(cesiumViewer); - if (!extent) { - log.info("No visible extent (camera looking at sky?)"); + if (!extent || !center) { + log.info("No visible extent or center (camera looking at sky?)"); return; } diff --git a/examples/pdf-server/server.ts b/examples/pdf-server/server.ts index 47bb8e2a2..4147a6079 100644 --- a/examples/pdf-server/server.ts +++ b/examples/pdf-server/server.ts @@ -32,23 +32,22 @@ export const RESOURCE_URI = "ui://pdf-viewer/mcp-app.html"; /** Allowed remote origins (security allowlist) */ export const allowedRemoteOrigins = new Set([ + "https://agrirxiv.org", "https://arxiv.org", - "https://ssrn.com", - "https://www.researchsquare.com", - "https://www.preprints.org", + "https://chemrxiv.org", + "https://edarxiv.org", + "https://engrxiv.org", + "https://hal.science", "https://osf.io", - "https://zenodo.org", + "https://psyarxiv.com", + "https://ssrn.com", "https://www.biorxiv.org", - "https://www.medrxiv.org", - "https://chemrxiv.org", "https://www.eartharxiv.org", - "https://psyarxiv.com", - "https://osf.io/preprints/socarxiv", - "https://engrxiv.org", + "https://www.medrxiv.org", + "https://www.preprints.org", + "https://www.researchsquare.com", "https://www.sportarxiv.org", - "https://agrirxiv.org", - "https://edarxiv.org", - "https://hal.science", + "https://zenodo.org", ]); /** Allowed local file paths (populated from CLI args) */ From 7e3de4613f00feef7b0ee8e3f0d6d3685fabbbc8 Mon Sep 17 00:00:00 2001 From: Olivier Chafik Date: Fri, 23 Jan 2026 11:51:57 +0000 Subject: [PATCH 22/23] fix(map-server): use transparent background for rounded corners --- examples/map-server/mcp-app.html | 1 + 1 file changed, 1 insertion(+) diff --git a/examples/map-server/mcp-app.html b/examples/map-server/mcp-app.html index e46c7ebba..8cba83947 100644 --- a/examples/map-server/mcp-app.html +++ b/examples/map-server/mcp-app.html @@ -14,6 +14,7 @@ padding: 0; overflow: hidden; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; + background: transparent; } #cesiumContainer { width: 100%; From 38861a9fcef32e72318b152e3c09e49ca37f73a6 Mon Sep 17 00:00:00 2001 From: Olivier Chafik Date: Fri, 23 Jan 2026 12:21:59 +0000 Subject: [PATCH 23/23] prettier --- examples/map-server/src/mcp-app.ts | 6 ++-- examples/pdf-server/README.md | 40 +++++++++++---------- examples/pdf-server/main.ts | 4 ++- examples/pdf-server/server.ts | 58 ++++++++++++++++++++++-------- examples/pdf-server/src/mcp-app.ts | 13 ++++--- 5 files changed, 79 insertions(+), 42 deletions(-) diff --git a/examples/map-server/src/mcp-app.ts b/examples/map-server/src/mcp-app.ts index 0dbfef5c1..73d7aa168 100644 --- a/examples/map-server/src/mcp-app.ts +++ b/examples/map-server/src/mcp-app.ts @@ -418,7 +418,7 @@ function scheduleLocationUpdate(cesiumViewer: any): void { `The map view of ${app.getHostContext()?.toolInfo?.id} is now ${widthKm.toFixed(1)}km wide × ${heightKm.toFixed(1)}km tall `, `and has changed to the following location: [${places.join(", ")}] `, `lat. / long. of center of map = [${center.lat.toFixed(4)}, ${center.lon.toFixed(4)}]`, - ].join('\n') + ].join("\n"); log.info("Updating model context:", content); app.updateModelContext({ content: [{ type: "text", text: content }], @@ -924,9 +924,7 @@ app.ontoolinput = async (params) => { // Handle tool result - extract viewUUID and restore persisted view if available app.ontoolresult = async (result) => { - viewUUID = result._meta?.viewUUID - ? String(result._meta.viewUUID) - : undefined; + viewUUID = result._meta?.viewUUID ? String(result._meta.viewUUID) : undefined; log.info("Tool result received, viewUUID:", viewUUID); // Now that we have viewUUID, try to restore persisted view diff --git a/examples/pdf-server/README.md b/examples/pdf-server/README.md index 50ab9f7f9..420fc99ba 100644 --- a/examples/pdf-server/README.md +++ b/examples/pdf-server/README.md @@ -35,7 +35,9 @@ On some host platforms, tool calls have size limits, so large PDFs cannot be sen ```typescript // Returns chunks with pagination metadata -{ bytes, offset, byteCount, totalBytes, hasMore } +{ + (bytes, offset, byteCount, totalBytes, hasMore); +} ``` **Client side** (`mcp-app.ts`): @@ -57,10 +59,12 @@ The viewer keeps the model informed about what the user is seeing: ```typescript app.updateModelContext({ - content: [{ - type: "text", - text: `PDF viewer | "${title}" | Current Page: ${page}/${total}\n\nPage content:\n${pageText}` - }] + content: [ + { + type: "text", + text: `PDF viewer | "${title}" | Current Page: ${page}/${total}\n\nPage content:\n${pageText}`, + }, + ], }); ``` @@ -131,11 +135,11 @@ bun examples/pdf-server/main.ts --stdio ./papers/ ## Tools -| Tool | Visibility | Purpose | -| ---------------- | ---------- | ---------------------------------------- | -| `list_pdfs` | Model | List available local files and origins | -| `display_pdf` | Model + UI | Display interactive viewer | -| `read_pdf_bytes` | App only | Stream PDF data in chunks | +| Tool | Visibility | Purpose | +| ---------------- | ---------- | -------------------------------------- | +| `list_pdfs` | Model | List available local files and origins | +| `display_pdf` | Model + UI | Display interactive viewer | +| `read_pdf_bytes` | App only | Stream PDF data in chunks | ## Architecture @@ -148,14 +152,14 @@ src/ ## Key Patterns Shown -| Pattern | Implementation | -| ----------------- | ---------------------------------------- | -| App-only tools | `_meta: { ui: { visibility: ["app"] } }` | -| Chunked responses | `hasMore` + `offset` pagination | -| Model context | `app.updateModelContext()` | -| Display modes | `app.requestDisplayMode()` | -| External links | `app.openLink()` | -| View persistence | `viewUUID` + localStorage | +| Pattern | Implementation | +| ----------------- | ------------------------------------------- | +| App-only tools | `_meta: { ui: { visibility: ["app"] } }` | +| Chunked responses | `hasMore` + `offset` pagination | +| Model context | `app.updateModelContext()` | +| Display modes | `app.requestDisplayMode()` | +| External links | `app.openLink()` | +| View persistence | `viewUUID` + localStorage | | Theming | `applyDocumentTheme()` + CSS `light-dark()` | ## Dependencies diff --git a/examples/pdf-server/main.ts b/examples/pdf-server/main.ts index 620cb09fd..2b0e3ff9f 100644 --- a/examples/pdf-server/main.ts +++ b/examples/pdf-server/main.ts @@ -127,7 +127,9 @@ async function main() { } console.error(`[pdf-server] Ready (${urls.length} URL(s) configured)`); - console.error(`[pdf-server] Allowed origins: ${[...allowedRemoteOrigins].join(", ")}`); + console.error( + `[pdf-server] Allowed origins: ${[...allowedRemoteOrigins].join(", ")}`, + ); if (stdio) { await createServer().connect(new StdioServerTransport()); diff --git a/examples/pdf-server/server.ts b/examples/pdf-server/server.ts index 4147a6079..40de9aff4 100644 --- a/examples/pdf-server/server.ts +++ b/examples/pdf-server/server.ts @@ -19,7 +19,10 @@ import { registerAppTool, RESOURCE_MIME_TYPE, } from "@modelcontextprotocol/ext-apps/server"; -import type { CallToolResult, ReadResourceResult } from "@modelcontextprotocol/sdk/types.js"; +import type { + CallToolResult, + ReadResourceResult, +} from "@modelcontextprotocol/sdk/types.js"; import { z } from "zod"; // ============================================================================= @@ -69,7 +72,9 @@ export function isFileUrl(url: string): boolean { export function isArxivUrl(url: string): boolean { try { const parsed = new URL(url); - return parsed.hostname === "arxiv.org" || parsed.hostname === "www.arxiv.org"; + return ( + parsed.hostname === "arxiv.org" || parsed.hostname === "www.arxiv.org" + ); } catch { return false; } @@ -94,7 +99,10 @@ export function validateUrl(url: string): { valid: boolean; error?: string } { if (isFileUrl(url)) { const filePath = fileUrlToPath(url); if (!allowedLocalFiles.has(filePath)) { - return { valid: false, error: `Local file not in allowed list: ${filePath}` }; + return { + valid: false, + error: `Local file not in allowed list: ${filePath}`, + }; } if (!fs.existsSync(filePath)) { return { valid: false, error: `File not found: ${filePath}` }; @@ -106,7 +114,9 @@ export function validateUrl(url: string): { valid: boolean; error?: string } { try { const parsed = new URL(url); const origin = `${parsed.protocol}//${parsed.hostname}`; - if (![...allowedRemoteOrigins].some(allowed => origin.startsWith(allowed))) { + if ( + ![...allowedRemoteOrigins].some((allowed) => origin.startsWith(allowed)) + ) { return { valid: false, error: `Origin not allowed: ${origin}` }; } return { valid: true }; @@ -160,7 +170,9 @@ export async function readPdfRange( }); if (!response.ok && response.status !== 206) { - throw new Error(`Range request failed: ${response.status} ${response.statusText}`); + throw new Error( + `Range request failed: ${response.status} ${response.statusText}`, + ); } // Parse total size from Content-Range header @@ -198,14 +210,15 @@ export function createServer(): McpServer { } // Note: Remote URLs from allowed origins can be loaded dynamically - const text = pdfs.length > 0 - ? `Available PDFs:\n${pdfs.map(p => `- ${p.url} (${p.type})`).join("\n")}\n\nRemote PDFs from ${[...allowedRemoteOrigins].join(", ")} can also be loaded dynamically.` - : `No local PDFs configured. Remote PDFs from ${[...allowedRemoteOrigins].join(", ")} can be loaded dynamically.`; + const text = + pdfs.length > 0 + ? `Available PDFs:\n${pdfs.map((p) => `- ${p.url} (${p.type})`).join("\n")}\n\nRemote PDFs from ${[...allowedRemoteOrigins].join(", ")} can also be loaded dynamically.` + : `No local PDFs configured. Remote PDFs from ${[...allowedRemoteOrigins].join(", ")} can be loaded dynamically.`; return { content: [{ type: "text", text }], structuredContent: { - localFiles: pdfs.filter(p => p.type === "local").map(p => p.url), + localFiles: pdfs.filter((p) => p.type === "local").map((p) => p.url), allowedOrigins: [...allowedRemoteOrigins], }, }; @@ -222,7 +235,12 @@ export function createServer(): McpServer { inputSchema: { url: z.string().describe("PDF URL"), offset: z.number().min(0).default(0).describe("Byte offset"), - byteCount: z.number().min(1).max(MAX_CHUNK_BYTES).default(MAX_CHUNK_BYTES).describe("Bytes to read"), + byteCount: z + .number() + .min(1) + .max(MAX_CHUNK_BYTES) + .default(MAX_CHUNK_BYTES) + .describe("Bytes to read"), }, outputSchema: z.object({ url: z.string(), @@ -252,7 +270,12 @@ export function createServer(): McpServer { const hasMore = offset + data.length < totalBytes; return { - content: [{ type: "text", text: `${data.length} bytes at ${offset}/${totalBytes}` }], + content: [ + { + type: "text", + text: `${data.length} bytes at ${offset}/${totalBytes}`, + }, + ], structuredContent: { url: normalized, bytes, @@ -264,7 +287,12 @@ export function createServer(): McpServer { }; } catch (err) { return { - content: [{ type: "text", text: `Error: ${err instanceof Error ? err.message : String(err)}` }], + content: [ + { + type: "text", + text: `Error: ${err instanceof Error ? err.message : String(err)}`, + }, + ], isError: true, }; } @@ -273,7 +301,7 @@ export function createServer(): McpServer { // Build allowed domains list for tool description (strip https:// and www.) const allowedDomains = [...allowedRemoteOrigins] - .map(origin => origin.replace(/^https?:\/\/(www\.)?/, "")) + .map((origin) => origin.replace(/^https?:\/\/(www\.)?/, "")) .join(", "); // Tool: display_pdf - Show interactive viewer @@ -333,7 +361,9 @@ Accepts: "utf-8", ); return { - contents: [{ uri: RESOURCE_URI, mimeType: RESOURCE_MIME_TYPE, text: html }], + contents: [ + { uri: RESOURCE_URI, mimeType: RESOURCE_MIME_TYPE, text: html }, + ], }; }, ); diff --git a/examples/pdf-server/src/mcp-app.ts b/examples/pdf-server/src/mcp-app.ts index 6c065d154..7c190a79e 100644 --- a/examples/pdf-server/src/mcp-app.ts +++ b/examples/pdf-server/src/mcp-app.ts @@ -6,7 +6,12 @@ * - Text selection via PDF.js TextLayer * - Page navigation, zoom */ -import { App, type McpUiHostContext, applyDocumentTheme, applyHostStyleVariables } from "@modelcontextprotocol/ext-apps"; +import { + App, + type McpUiHostContext, + applyDocumentTheme, + applyHostStyleVariables, +} from "@modelcontextprotocol/ext-apps"; import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; import * as pdfjsLib from "pdfjs-dist"; import { TextLayer } from "pdfjs-dist"; @@ -15,7 +20,7 @@ import "./mcp-app.css"; const MAX_MODEL_CONTEXT_LENGTH = 15000; const CHUNK_SIZE = 500 * 1024; // 500KB chunks - + // Configure PDF.js worker pdfjsLib.GlobalWorkerOptions.workerSrc = new URL( "pdfjs-dist/build/pdf.worker.mjs", @@ -707,9 +712,7 @@ app.ontoolresult = async (result) => { pdfUrl = parsed.url; pdfTitle = parsed.title; totalPages = parsed.pageCount; - viewUUID = result._meta?.viewUUID - ? String(result._meta.viewUUID) - : undefined; + viewUUID = result._meta?.viewUUID ? String(result._meta.viewUUID) : undefined; // Restore saved page or use initial page const savedPage = loadSavedPage();