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
64 changes: 64 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
# Python cache
__pycache__/
*.py[cod]
*$py.class
*.so
.Python

# Virtual environments
.venv/
venv/
ENV/
env/

# Environment variables
.env
.env.local
.env.*.local

# Git
.git/
.gitignore
.gitattributes

# Documentation
*.md
docs/
examples/

# Tests
tests/
.pytest_cache/
.coverage
htmlcov/
.tox/

# IDE
.vscode/
.idea/
*.swp
*.swo
*~

# Build artifacts
build/
dist/
*.egg-info/
.eggs/

# Type checking
.mypy_cache/
.pytype/
.pyre/
.dmypy.json

# Linting
.ruff_cache/

# CI/CD
.github/
.gitlab-ci.yml

# Misc
*.log
.DS_Store
10 changes: 10 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -1,6 +1,16 @@
# Late API Configuration
LATE_API_KEY=your_api_key_here

# MCP Server Configuration (for HTTP/SSE deployment)
MCP_SERVER_API_KEY=your_secure_random_key_here

# Server Configuration (optional, has defaults)
HOST=0.0.0.0
PORT=8080

# AI Provider (optional - for content generation)
OPENAI_API_KEY=sk-your_openai_key_here
# ANTHROPIC_API_KEY=sk-ant-your_anthropic_key_here

# Generate a secure MCP API key with:
# python -c "import secrets; print(secrets.token_urlsafe(32))"
24 changes: 24 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
# Late MCP HTTP Server - Railway Deployment
# Uses uv for fast, reliable dependency management

FROM ghcr.io/astral-sh/uv:python3.12-slim

WORKDIR /app

# Copy dependency files
COPY pyproject.toml uv.lock README.md ./
COPY src ./src

# Install dependencies (no dev dependencies in production)
RUN --mount=type=cache,target=/root/.cache/uv \
uv sync --frozen --no-dev --extra mcp

# Expose port (Railway will set PORT env var)
EXPOSE 8080

# Health check for Railway monitoring
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD python -c "import httpx; httpx.get('http://localhost:8080/health', timeout=2.0)"

# Run HTTP server
CMD ["uv", "run", "late-mcp-http", "--host", "0.0.0.0", "--port", "8080"]
51 changes: 51 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,57 @@ Since Claude can't access local files, use the browser upload flow:

---

## 🌐 Remote Access (HTTP/SSE)

Deploy the MCP server to access it remotely from Claude Code CLI or custom clients.

### Quick Deploy to Railway

1. **Push to GitHub** and connect to Railway
2. **Set environment variables:**
- `LATE_API_KEY` - Your Late API key
- `MCP_SERVER_API_KEY` - Secure random key (generate with command below)

3. **Generate secure key:**
```bash
python -c "import secrets; print(secrets.token_urlsafe(32))"
```

4. **Railway auto-deploys** using the Dockerfile

### Connect from Claude Code CLI

```bash
# Add your deployed server
claude mcp add --transport http late https://your-app.railway.app/sse

# Authenticate
/mcp
```

### Local HTTP Server

```bash
# Set environment variables
export LATE_API_KEY=your_api_key
export MCP_SERVER_API_KEY=your_secure_key

# Install with HTTP support
uv sync --extra mcp

# Run HTTP server
uv run late-mcp-http
```

Server runs on `http://0.0.0.0:8080` with endpoints:
- `/health` - Health check (public)
- `/sse` - SSE connection (requires API key)
- `/messages/` - Message handler (requires API key)

📖 [Full HTTP deployment guide →](docs/HTTP_DEPLOYMENT.md)

---

## SDK Features

### Async Support
Expand Down
74 changes: 74 additions & 0 deletions docs/HTTP_DEPLOYMENT.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
# HTTP/SSE Deployment Guide

## Quick Start

### Local Testing

1. Install dependencies:
```bash
uv sync --extra mcp
```

2. Set environment variables:
```bash
export LATE_API_KEY=your_late_api_key
export MCP_SERVER_API_KEY=$(python -c "import secrets; print(secrets.token_urlsafe(32))")
```

3. Run HTTP server:
```bash
uv run late-mcp-http
```

4. Test the server:
```bash
# Health check (no auth needed)
curl http://localhost:8080/health

# Server info
curl http://localhost:8080/

# SSE endpoint (with auth)
curl -H "X-API-Key: your_key" http://localhost:8080/sse
```

## Railway Deployment

### Option 1: Using Dockerfile (Recommended)

1. Push to GitHub
2. Create new Railway project from repo
3. Set environment variables in Railway:
- `LATE_API_KEY`
- `MCP_SERVER_API_KEY`
4. Railway auto-detects Dockerfile and deploys

### Option 2: Using Railpack (Auto)

Railway will automatically:
- Detect `pyproject.toml` and `uv.lock`
- Install dependencies with `uv`
- Run `late-mcp-http` command

## Connecting Clients

### Claude Code CLI
```bash
claude mcp add --transport http late https://your-app.railway.app/sse
```

### Python Client
```python
from mcp.client.sse import sse_client

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

## Authentication

Add API key via:
- Header: `Authorization: Bearer your_key`
- Header: `X-API-Key: your_key`
- Query: `?api_key=your_key`
6 changes: 6 additions & 0 deletions main.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
"""Entry point for Railway deployment using Railpack."""

from late.mcp.http_server import main

if __name__ == "__main__":
main()
5 changes: 4 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,8 @@ all = [
]
mcp = [
"mcp>=1.0.0",
"starlette>=0.42.0",
"uvicorn[standard]>=0.32.0",
]
dev = [
"pytest>=8.0.0",
Expand All @@ -66,7 +68,8 @@ dev = [
]

[project.scripts]
late-mcp = "late.mcp.server:mcp.run"
late-mcp = "late.mcp.server:main"
late-mcp-http = "late.mcp.http_server:main"

[project.urls]
Homepage = "https://getlate.dev"
Expand Down
105 changes: 105 additions & 0 deletions src/late/mcp/auth.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
"""Authentication module for Late MCP HTTP server."""

import os
import secrets

from starlette.requests import Request
from starlette.responses import JSONResponse


def get_server_api_key() -> str:
"""
Get API key from environment variable.

Returns:
The MCP server API key.

Raises:
ValueError: If MCP_SERVER_API_KEY is not set.
"""
api_key = os.getenv("MCP_SERVER_API_KEY")
if not api_key:
raise ValueError(
"MCP_SERVER_API_KEY environment variable not set. "
"Please set it to secure your MCP server."
)
return api_key


def extract_api_key(request: Request) -> str | None:
"""
Extract API key from request (header or query param).

Checks in order:
1. Authorization header (Bearer token)
2. X-API-Key header
3. api_key query parameter

Args:
request: The incoming Starlette request.

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

# Try X-API-Key header
api_key_header = request.headers.get("X-API-Key")
if api_key_header:
return api_key_header

# Try query parameter as fallback
return request.query_params.get("api_key")


def verify_api_key(request: Request) -> bool:
"""
Verify API key from request matches server key.

Uses secrets.compare_digest for timing-attack resistance.

Args:
request: The incoming Starlette request.

Returns:
True if API key is valid, False otherwise.
"""
try:
expected_key = get_server_api_key()
provided_key = extract_api_key(request)

if not provided_key:
return False

# Use secrets.compare_digest for timing-attack resistance
return secrets.compare_digest(expected_key, provided_key)
except Exception:
# If any error occurs (e.g., env var not set), deny access
return False


async def require_api_key(request: Request, call_next):
"""
Middleware to require API key on all requests except health check.

Args:
request: The incoming Starlette request.
call_next: The next middleware or route handler.

Returns:
The response from the next handler, or 401 if unauthorized.
"""
# Allow health check without authentication
if request.url.path == "/health":
return await call_next(request)

# Verify API key for all other requests
if not verify_api_key(request):
return JSONResponse(
{"error": "Invalid or missing API key"}, status_code=401
)

return await call_next(request)
Loading