Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 11 additions & 44 deletions docs/HTTP_DEPLOYMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,7 @@

The Zernio MCP server can be deployed over HTTP, allowing remote access from any MCP client. Each user provides their own Zernio API key when connecting.

The server exposes **two transports simultaneously**:

| Transport | Endpoint | When to use |
| --- | --- | --- |
| **Streamable HTTP** (recommended) | `POST /mcp` | Modern MCP transport. Survives proxies/bridges that drop idle connections. Use this for Claude Code, `mcp-remote`, and any new client. |
| **SSE** (legacy) | `GET /sse` + `POST /messages/` | Older two-endpoint transport. Kept for backwards compatibility. The long-idle `GET /sse` connection can be killed by load balancers or bridges after a few minutes idle. |

Pick Streamable HTTP unless you have a specific client that only speaks SSE.
The server's primary transport is **Streamable HTTP** (`POST /mcp`), the modern MCP transport supported by all current clients. Each request/response is self-contained (with optional chunked streaming), so it survives proxies and bridges that drop idle connections. The legacy SSE transport (`GET /sse` + `POST /messages/`) is kept for backward compatibility.

## Quick Start

Expand All @@ -32,17 +25,14 @@ uv run zernio-mcp-http
# Health check (no auth needed)
curl http://localhost:8080/health

# Server info (lists both transports, no auth needed)
# Server info (no auth needed)
curl http://localhost:8080/

# Streamable HTTP endpoint (requires your Zernio API key)
curl -H "Authorization: Bearer your_zernio_api_key" \
-H "Accept: application/json, text/event-stream" \
-X POST http://localhost:8080/mcp \
-d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-03-26","capabilities":{},"clientInfo":{"name":"curl","version":"0"}}}'

# SSE endpoint (legacy)
curl -H "Authorization: Bearer your_zernio_api_key" http://localhost:8080/sse
```

## Railway Deployment
Expand All @@ -61,6 +51,8 @@ The server doesn't require any environment variables. Users authenticate by prov
Optional variables:
- `HOST` (default: 0.0.0.0)
- `PORT` (default: 8080, Railway sets this automatically)
- `MCP_PUBLIC_URL` (default: https://mcp.zernio.com) — the server's canonical public URL, used as the OAuth resource identifier in the discovery metadata. Set it for non-prod deployments (e.g. a Railway preview URL).
- `MCP_ALLOWED_ORIGINS` — comma-separated hostnames appended to the browser Origin allowlist (DNS-rebinding protection).

## Connecting Clients

Expand All @@ -84,17 +76,6 @@ Configuration in MCP settings:
}
```

### Claude Code CLI (legacy: SSE)

If you need SSE for an older client, the endpoint is `/sse` instead of `/mcp`:

```bash
claude mcp add --transport sse zernio https://your-app.railway.app/sse \
--header "Authorization: Bearer your_zernio_api_key_here"
```

Note: SSE connections can be dropped by proxies after a few minutes idle. Prefer Streamable HTTP if your client supports it.

### Python Client (Streamable HTTP)

```python
Expand All @@ -112,23 +93,6 @@ async with streamablehttp_client(
pass
```

### Python Client (SSE, legacy)

```python
from mcp.client.sse import sse_client

headers = {
"Authorization": "Bearer your_zernio_api_key_here"
}

async with sse_client(
"https://your-app.railway.app/sse",
headers=headers
) as (read, write):
# Use MCP client
pass
```

## Authentication

Each user must provide their own Zernio API key when connecting using the standard HTTP Authorization header:
Expand All @@ -147,13 +111,16 @@ curl -H "Authorization: Bearer sk_your_api_key_here" \

The server validates the API key by making a test request to the Zernio API. If valid, the request is processed and the API key is used for all operations within that request.

The server is an OAuth 2.0 **resource server** (FastMCP's resource-server model): both plain Zernio API keys and OAuth access tokens issued by zernio.com are accepted as the bearer. Clients without a token (e.g. Claude's connector) receive a `401` whose `WWW-Authenticate` challenge points at the RFC 9728 discovery document at `/.well-known/oauth-protected-resource/mcp`, from which they discover the zernio.com authorization server and run the OAuth flow. The pre-FastMCP discovery path (`/.well-known/oauth-protected-resource`) permanently redirects to the canonical document, so clients holding the old URL keep working.

## Security

- Each user's API key is validated against the Zernio API
- API keys are stored per-request using Python's `contextvars` (Streamable HTTP runs in stateless mode, so keys never leak across requests)
- Each user's bearer (API key or OAuth token) is validated against the Zernio API on every request
- The validated bearer is scoped to its request via FastMCP's access-token context (Streamable HTTP runs in stateless mode, so credentials never leak across requests)
- No shared credentials or server-wide API keys
- Health check endpoint is public (no auth required)
- All other endpoints require authentication
- Browser requests are checked against an Origin allowlist for DNS-rebinding protection (extend with `MCP_ALLOWED_ORIGINS`)
- Health check, server info, and OAuth discovery endpoints are public (no auth required)
- The MCP and legacy SSE endpoints require authentication

## Get Your Zernio API Key

Expand Down
4 changes: 3 additions & 1 deletion docs/MCP.md
Original file line number Diff line number Diff line change
Expand Up @@ -88,8 +88,10 @@ Since Claude can't access local files, use the browser upload flow:
| `posts_retry` | Retry a failed post |
| `media_generate_upload_link` | Get link to upload media |

These core commands are always visible. The full Zernio API catalog (~400 tools) is reachable on demand through tool search: `search_tools` finds a capability by description and `call_tool` invokes it — and every underlying tool also stays directly callable by name.

## Remote Access (HTTP)

The remote MCP server exposes both **Streamable HTTP** (`POST /mcp`, recommended) and **SSE** (`GET /sse`, legacy) transports. Use Streamable HTTP unless your client only supports SSE — it survives idle timeouts on proxies and bridges like `mcp-remote`.
The remote MCP server speaks **Streamable HTTP** (`POST /mcp`) — the modern single-endpoint transport supported by all current MCP clients. It survives idle timeouts on proxies and bridges like `mcp-remote`. The legacy SSE transport (`GET /sse`) is kept for backward compatibility.

For deployment and client setup, see the [HTTP Deployment Guide](HTTP_DEPLOYMENT.md).
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ all = [
"mcp>=1.8.0",
]
mcp = [
"fastmcp>=3.1.0",
"mcp>=1.8.0",
"starlette>=0.42.0",
"uvicorn[standard]>=0.32.0",
Expand Down
64 changes: 44 additions & 20 deletions src/late/mcp/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,17 @@
from urllib.parse import urlparse

import httpx
from fastmcp.server.auth import AccessToken, RemoteAuthProvider, TokenVerifier
from starlette.requests import Request

from late.mcp.constants import (
DOCS_URL,
MCP_PUBLIC_URL,
OAUTH_AUTHORIZATION_SERVER,
OAUTH_SCOPES,
SERVICE_NAME,
)

# Origin allowlist for DNS-rebinding protection (MCP spec / Anthropic Connectors
# Directory requirement). Matched as exact host or subdomain suffix. Extend at
# runtime with MCP_ALLOWED_ORIGINS (comma-separated hostnames).
Expand Down Expand Up @@ -49,26 +58,6 @@ def is_allowed_origin(request: Request) -> bool:
return any(host == s or host.endswith("." + s) for s in _allowed_origin_suffixes())


def extract_late_api_key(request: Request) -> str | None:
"""
Extract Zernio API key from request Authorization header.

Expects: Authorization: Bearer <your_api_key>

Function name kept as extract_late_api_key for backwards compatibility.

Args:
request: The incoming Starlette request.

Returns:
The extracted API key, or None if not found.
"""
auth_header = request.headers.get("Authorization")
if auth_header and auth_header.startswith("Bearer "):
return auth_header[7:] # Remove "Bearer " prefix
return None


async def verify_late_api_key(api_key: str) -> bool:
"""
Verify Zernio API key by making a test request to Zernio API.
Expand All @@ -91,3 +80,38 @@ async def verify_late_api_key(api_key: str) -> bool:
return response.status_code == 200
except Exception:
return False


class ZernioTokenVerifier(TokenVerifier):
"""Resource-server token verification for the Zernio MCP server.

Accepts BOTH plain Zernio API keys and OAuth access tokens: both arrive as
the same bearer string and are validated the same way — a live GET to the
Zernio API (verify_late_api_key). HTTP 200 => valid. Deliberately
format-agnostic (no JWT decode), which is why a static API key works here
just as well as an issued OAuth token.
"""

async def verify_token(self, token: str) -> AccessToken | None:
if not await verify_late_api_key(token):
return None
return AccessToken(token=token, client_id="zernio", scopes=list(OAUTH_SCOPES))


def build_auth_provider() -> RemoteAuthProvider:
"""Build the FastMCP resource-server auth provider.

RemoteAuthProvider makes this server an OAuth 2.0 resource server: it
auto-serves /.well-known/oauth-protected-resource (RFC 9728), emits the 401
WWW-Authenticate challenge pointing clients back at that document, and
delegates token validation to ZernioTokenVerifier. The authorization server
itself (token / authorize / register) lives at zernio.com.
"""
return RemoteAuthProvider(
token_verifier=ZernioTokenVerifier(),
authorization_servers=[OAUTH_AUTHORIZATION_SERVER],
base_url=MCP_PUBLIC_URL,
scopes_supported=list(OAUTH_SCOPES),
resource_name=SERVICE_NAME,
resource_documentation=DOCS_URL,
)
39 changes: 15 additions & 24 deletions src/late/mcp/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,11 @@
# Server information
SERVICE_NAME = "Zernio MCP Server"
SERVICE_VERSION = "1.2.0"
# Both transports are exposed simultaneously. SSE is kept for backwards
# compatibility with older clients; Streamable HTTP is the modern transport
# (recommended for Claude Code, mcp-remote, and any client behind a proxy
# that closes long-idle connections).
# Streamable HTTP is the primary transport (single endpoint, request/response
# with optional chunked streaming — no long-idle connection for proxies or
# bridges like mcp-remote to kill). Legacy SSE is still served for backwards
# compatibility: production continues to receive GET /sse connections from
# older client configs. New clients should use Streamable HTTP.
TRANSPORT_TYPE = "sse+streamable-http"

# Default server configuration
Expand All @@ -22,26 +23,25 @@
# Endpoints
ENDPOINT_ROOT = "/"
ENDPOINT_HEALTH = "/health"
# Legacy SSE transport (two-endpoint protocol: GET /sse + POST /messages/).
# The GET /sse connection is held open for server -> client messages, which
# is the part that gets killed by idle timeouts on proxies / mcp-remote.
ENDPOINT_MCP = "/mcp"
# Legacy SSE transport (two-endpoint protocol: GET /sse + POST /messages/),
# deprecated but kept for backwards compatibility with older client configs.
ENDPOINT_SSE = "/sse"
ENDPOINT_MESSAGES = "/messages/"
# Modern Streamable HTTP transport (single endpoint, request/response with
# optional chunked streaming). No long-idle connection => survives proxies
# that drop idle TCP. This is the MCP-recommended transport going forward.
ENDPOINT_MCP = "/mcp"

# --- OAuth 2.0 protected-resource discovery (RFC 9728 / MCP authorization spec) ---
# This MCP server is a *resource server*: it does not mint tokens, it accepts
# bearer tokens issued by the Zernio authorization server at zernio.com (and
# also accepts plain Zernio API keys — see verify_late_api_key). A spec-
# compliant client (e.g. Claude's connector) handed only the /mcp URL discovers
# the authorization server by:
# 1. reading the `resource_metadata` parameter on our 401 WWW-Authenticate, or
# 2. fetching /.well-known/oauth-protected-resource on THIS origin.
# Both must be served from the resource server's own origin, which is why this
# metadata lives here on mcp.zernio.com and not only on zernio.com.
# 1. reading the `resource_metadata` parameter on the 401 WWW-Authenticate
# challenge (emitted by FastMCP's RemoteAuthProvider), or
# 2. fetching the path-inserted discovery document that FastMCP serves at
# /.well-known/oauth-protected-resource/mcp on THIS origin.
# The pre-FastMCP server served the document at the root well-known path below;
# it is kept as a permanent redirect to the canonical document so clients still
# holding the old URL keep working (see http_server).
ENDPOINT_OAUTH_PROTECTED_RESOURCE = "/.well-known/oauth-protected-resource"

# Public URL of this MCP server — the OAuth `resource` identifier. Must be the
Expand Down Expand Up @@ -69,14 +69,5 @@
"automations:write",
]

# Single source of truth for the 401 WWW-Authenticate challenge (used by both
# transports). The `resource_metadata` parameter (RFC 9728 §5.1) points clients
# at our protected-resource document so they can run OAuth discovery; clients
# that already hold a static API key just send it and skip discovery entirely.
WWW_AUTHENTICATE_BEARER = (
'Bearer realm="zernio-mcp", '
f'resource_metadata="{MCP_PUBLIC_URL}{ENDPOINT_OAUTH_PROTECTED_RESOURCE}"'
)

# Documentation
DOCS_URL = "https://docs.zernio.com"
Loading
Loading