Skip to content

Commit 5d0e11c

Browse files
feat: add HTTP/SSE transport for remote MCP access (#5)
* feat: add HTTP/SSE transport for remote MCP access Add HTTP server with Server-Sent Events (SSE) transport to enable remote access to the MCP server from Claude Code CLI and other clients. Features: - Modular architecture with separated concerns (auth, config, routes) - API key authentication with multiple methods (Bearer, X-API-Key, query) - Health check endpoint for monitoring - Dual transport support (STDIO + HTTP/SSE) - Railway deployment ready with Dockerfile - Comprehensive documentation New files: - src/late/mcp/constants.py - Global constants - src/late/mcp/config.py - Configuration management - src/late/mcp/auth.py - API key authentication - src/late/mcp/routes.py - Route handlers - src/late/mcp/http_server.py - Main HTTP server - Dockerfile - Container configuration for Railway - .dockerignore - Docker build optimization - docs/HTTP_DEPLOYMENT.md - Deployment guide Modified: - pyproject.toml - Added starlette, uvicorn dependencies - server.py - Added main() function for STDIO entry point - README.md - Added HTTP deployment documentation - .env.example - Added MCP server configuration * fix: resolve linting errors (UP045, ARG001) - Replace Optional[X] with X | None for modern type hints - Prefix unused request parameters with underscore --------- Co-authored-by: Carlos Martínez <carlimvg02@gmail.com>
1 parent 4a19cac commit 5d0e11c

14 files changed

Lines changed: 916 additions & 3 deletions

‎.dockerignore‎

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
# Python cache
2+
__pycache__/
3+
*.py[cod]
4+
*$py.class
5+
*.so
6+
.Python
7+
8+
# Virtual environments
9+
.venv/
10+
venv/
11+
ENV/
12+
env/
13+
14+
# Environment variables
15+
.env
16+
.env.local
17+
.env.*.local
18+
19+
# Git
20+
.git/
21+
.gitignore
22+
.gitattributes
23+
24+
# Documentation
25+
*.md
26+
docs/
27+
examples/
28+
29+
# Tests
30+
tests/
31+
.pytest_cache/
32+
.coverage
33+
htmlcov/
34+
.tox/
35+
36+
# IDE
37+
.vscode/
38+
.idea/
39+
*.swp
40+
*.swo
41+
*~
42+
43+
# Build artifacts
44+
build/
45+
dist/
46+
*.egg-info/
47+
.eggs/
48+
49+
# Type checking
50+
.mypy_cache/
51+
.pytype/
52+
.pyre/
53+
.dmypy.json
54+
55+
# Linting
56+
.ruff_cache/
57+
58+
# CI/CD
59+
.github/
60+
.gitlab-ci.yml
61+
62+
# Misc
63+
*.log
64+
.DS_Store

‎.env.example‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,16 @@
11
# Late API Configuration
22
LATE_API_KEY=your_api_key_here
33

4+
# MCP Server Configuration (for HTTP/SSE deployment)
5+
MCP_SERVER_API_KEY=your_secure_random_key_here
6+
7+
# Server Configuration (optional, has defaults)
8+
HOST=0.0.0.0
9+
PORT=8080
10+
411
# AI Provider (optional - for content generation)
512
OPENAI_API_KEY=sk-your_openai_key_here
613
# ANTHROPIC_API_KEY=sk-ant-your_anthropic_key_here
14+
15+
# Generate a secure MCP API key with:
16+
# python -c "import secrets; print(secrets.token_urlsafe(32))"

‎Dockerfile‎

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
# Late MCP HTTP Server - Railway Deployment
2+
# Uses uv for fast, reliable dependency management
3+
4+
FROM ghcr.io/astral-sh/uv:python3.12-slim
5+
6+
WORKDIR /app
7+
8+
# Copy dependency files
9+
COPY pyproject.toml uv.lock README.md ./
10+
COPY src ./src
11+
12+
# Install dependencies (no dev dependencies in production)
13+
RUN --mount=type=cache,target=/root/.cache/uv \
14+
uv sync --frozen --no-dev --extra mcp
15+
16+
# Expose port (Railway will set PORT env var)
17+
EXPOSE 8080
18+
19+
# Health check for Railway monitoring
20+
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
21+
CMD python -c "import httpx; httpx.get('http://localhost:8080/health', timeout=2.0)"
22+
23+
# Run HTTP server
24+
CMD ["uv", "run", "late-mcp-http", "--host", "0.0.0.0", "--port", "8080"]

‎README.md‎

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,57 @@ Since Claude can't access local files, use the browser upload flow:
128128

129129
---
130130

131+
## 🌐 Remote Access (HTTP/SSE)
132+
133+
Deploy the MCP server to access it remotely from Claude Code CLI or custom clients.
134+
135+
### Quick Deploy to Railway
136+
137+
1. **Push to GitHub** and connect to Railway
138+
2. **Set environment variables:**
139+
- `LATE_API_KEY` - Your Late API key
140+
- `MCP_SERVER_API_KEY` - Secure random key (generate with command below)
141+
142+
3. **Generate secure key:**
143+
```bash
144+
python -c "import secrets; print(secrets.token_urlsafe(32))"
145+
```
146+
147+
4. **Railway auto-deploys** using the Dockerfile
148+
149+
### Connect from Claude Code CLI
150+
151+
```bash
152+
# Add your deployed server
153+
claude mcp add --transport http late https://your-app.railway.app/sse
154+
155+
# Authenticate
156+
/mcp
157+
```
158+
159+
### Local HTTP Server
160+
161+
```bash
162+
# Set environment variables
163+
export LATE_API_KEY=your_api_key
164+
export MCP_SERVER_API_KEY=your_secure_key
165+
166+
# Install with HTTP support
167+
uv sync --extra mcp
168+
169+
# Run HTTP server
170+
uv run late-mcp-http
171+
```
172+
173+
Server runs on `http://0.0.0.0:8080` with endpoints:
174+
- `/health` - Health check (public)
175+
- `/sse` - SSE connection (requires API key)
176+
- `/messages/` - Message handler (requires API key)
177+
178+
📖 [Full HTTP deployment guide →](docs/HTTP_DEPLOYMENT.md)
179+
180+
---
181+
131182
## SDK Features
132183

133184
### Async Support

‎docs/HTTP_DEPLOYMENT.md‎

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
# HTTP/SSE Deployment Guide
2+
3+
## Quick Start
4+
5+
### Local Testing
6+
7+
1. Install dependencies:
8+
```bash
9+
uv sync --extra mcp
10+
```
11+
12+
2. Set environment variables:
13+
```bash
14+
export LATE_API_KEY=your_late_api_key
15+
export MCP_SERVER_API_KEY=$(python -c "import secrets; print(secrets.token_urlsafe(32))")
16+
```
17+
18+
3. Run HTTP server:
19+
```bash
20+
uv run late-mcp-http
21+
```
22+
23+
4. Test the server:
24+
```bash
25+
# Health check (no auth needed)
26+
curl http://localhost:8080/health
27+
28+
# Server info
29+
curl http://localhost:8080/
30+
31+
# SSE endpoint (with auth)
32+
curl -H "X-API-Key: your_key" http://localhost:8080/sse
33+
```
34+
35+
## Railway Deployment
36+
37+
### Option 1: Using Dockerfile (Recommended)
38+
39+
1. Push to GitHub
40+
2. Create new Railway project from repo
41+
3. Set environment variables in Railway:
42+
- `LATE_API_KEY`
43+
- `MCP_SERVER_API_KEY`
44+
4. Railway auto-detects Dockerfile and deploys
45+
46+
### Option 2: Using Railpack (Auto)
47+
48+
Railway will automatically:
49+
- Detect `pyproject.toml` and `uv.lock`
50+
- Install dependencies with `uv`
51+
- Run `late-mcp-http` command
52+
53+
## Connecting Clients
54+
55+
### Claude Code CLI
56+
```bash
57+
claude mcp add --transport http late https://your-app.railway.app/sse
58+
```
59+
60+
### Python Client
61+
```python
62+
from mcp.client.sse import sse_client
63+
64+
async with sse_client("https://your-app.railway.app/sse") as (read, write):
65+
# Use MCP client
66+
pass
67+
```
68+
69+
## Authentication
70+
71+
Add API key via:
72+
- Header: `Authorization: Bearer your_key`
73+
- Header: `X-API-Key: your_key`
74+
- Query: `?api_key=your_key`

‎main.py‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
"""Entry point for Railway deployment using Railpack."""
2+
3+
from late.mcp.http_server import main
4+
5+
if __name__ == "__main__":
6+
main()

‎pyproject.toml‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,8 @@ all = [
5454
]
5555
mcp = [
5656
"mcp>=1.0.0",
57+
"starlette>=0.42.0",
58+
"uvicorn[standard]>=0.32.0",
5759
]
5860
dev = [
5961
"pytest>=8.0.0",
@@ -66,7 +68,8 @@ dev = [
6668
]
6769

6870
[project.scripts]
69-
late-mcp = "late.mcp.server:mcp.run"
71+
late-mcp = "late.mcp.server:main"
72+
late-mcp-http = "late.mcp.http_server:main"
7073

7174
[project.urls]
7275
Homepage = "https://getlate.dev"

‎src/late/mcp/auth.py‎

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
"""Authentication module for Late MCP HTTP server."""
2+
3+
import os
4+
import secrets
5+
6+
from starlette.requests import Request
7+
from starlette.responses import JSONResponse
8+
9+
10+
def get_server_api_key() -> str:
11+
"""
12+
Get API key from environment variable.
13+
14+
Returns:
15+
The MCP server API key.
16+
17+
Raises:
18+
ValueError: If MCP_SERVER_API_KEY is not set.
19+
"""
20+
api_key = os.getenv("MCP_SERVER_API_KEY")
21+
if not api_key:
22+
raise ValueError(
23+
"MCP_SERVER_API_KEY environment variable not set. "
24+
"Please set it to secure your MCP server."
25+
)
26+
return api_key
27+
28+
29+
def extract_api_key(request: Request) -> str | None:
30+
"""
31+
Extract API key from request (header or query param).
32+
33+
Checks in order:
34+
1. Authorization header (Bearer token)
35+
2. X-API-Key header
36+
3. api_key query parameter
37+
38+
Args:
39+
request: The incoming Starlette request.
40+
41+
Returns:
42+
The extracted API key, or None if not found.
43+
"""
44+
# Try Authorization header first: "Bearer <key>"
45+
auth_header = request.headers.get("Authorization")
46+
if auth_header and auth_header.startswith("Bearer "):
47+
return auth_header[7:] # Remove "Bearer " prefix
48+
49+
# Try X-API-Key header
50+
api_key_header = request.headers.get("X-API-Key")
51+
if api_key_header:
52+
return api_key_header
53+
54+
# Try query parameter as fallback
55+
return request.query_params.get("api_key")
56+
57+
58+
def verify_api_key(request: Request) -> bool:
59+
"""
60+
Verify API key from request matches server key.
61+
62+
Uses secrets.compare_digest for timing-attack resistance.
63+
64+
Args:
65+
request: The incoming Starlette request.
66+
67+
Returns:
68+
True if API key is valid, False otherwise.
69+
"""
70+
try:
71+
expected_key = get_server_api_key()
72+
provided_key = extract_api_key(request)
73+
74+
if not provided_key:
75+
return False
76+
77+
# Use secrets.compare_digest for timing-attack resistance
78+
return secrets.compare_digest(expected_key, provided_key)
79+
except Exception:
80+
# If any error occurs (e.g., env var not set), deny access
81+
return False
82+
83+
84+
async def require_api_key(request: Request, call_next):
85+
"""
86+
Middleware to require API key on all requests except health check.
87+
88+
Args:
89+
request: The incoming Starlette request.
90+
call_next: The next middleware or route handler.
91+
92+
Returns:
93+
The response from the next handler, or 401 if unauthorized.
94+
"""
95+
# Allow health check without authentication
96+
if request.url.path == "/health":
97+
return await call_next(request)
98+
99+
# Verify API key for all other requests
100+
if not verify_api_key(request):
101+
return JSONResponse(
102+
{"error": "Invalid or missing API key"}, status_code=401
103+
)
104+
105+
return await call_next(request)

0 commit comments

Comments
 (0)