Skip to content

Commit 923d72f

Browse files
committed
Add MCP HTTP server for Claude.ai browser integration
- Add mcp_http.py: Streamable HTTP transport for browser MCP - Add Dockerfile.mcp: Docker config for Railway deployment - Add railway.mcp.json: Railway deployment configuration - Configure CORS for claude.ai and rosetta.study domains - Implements JSON-RPC over HTTP for MCP protocol The MCP HTTP server exposes the same 5 tools as the stdio version: - translate_excel - get_excel_sheets - count_translatable_cells - preview_cells - estimate_translation_cost Deploy to: mcp.rosetta.study
1 parent 05c902d commit 923d72f

3 files changed

Lines changed: 376 additions & 0 deletions

File tree

Dockerfile.mcp

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
# Dockerfile for Rosetta MCP HTTP Server
2+
FROM python:3.12-slim
3+
4+
WORKDIR /app
5+
6+
# Install system dependencies
7+
RUN apt-get update && apt-get install -y --no-install-recommends \
8+
curl \
9+
&& rm -rf /var/lib/apt/lists/*
10+
11+
# Install uv for faster dependency management
12+
RUN pip install uv
13+
14+
# Copy project files
15+
COPY pyproject.toml uv.lock ./
16+
COPY src/ ./src/
17+
18+
# Install dependencies
19+
RUN uv sync --no-dev
20+
21+
# Set environment variables
22+
ENV ENVIRONMENT=production
23+
24+
# The PORT is set by Railway
25+
ENV PORT=8080
26+
27+
# Expose port (Railway sets this dynamically)
28+
EXPOSE 8080
29+
30+
# Run the MCP HTTP server
31+
CMD uv run uvicorn rosetta.api.mcp_http:app --host 0.0.0.0 --port ${PORT:-8080}

railway.mcp.json

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
{
2+
"$schema": "https://railway.app/railway.schema.json",
3+
"build": {
4+
"builder": "DOCKERFILE",
5+
"dockerfilePath": "Dockerfile.mcp"
6+
},
7+
"deploy": {
8+
"startCommand": "uv run uvicorn rosetta.api.mcp_http:app --host 0.0.0.0 --port $PORT",
9+
"healthcheckPath": "/health",
10+
"healthcheckTimeout": 10,
11+
"restartPolicyType": "ON_FAILURE",
12+
"restartPolicyMaxRetries": 3
13+
}
14+
}

src/rosetta/api/mcp_http.py

Lines changed: 331 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,331 @@
1+
"""MCP HTTP Server for Rosetta - Streamable HTTP Transport.
2+
3+
This implements the MCP Streamable HTTP transport for browser-based Claude.ai integration.
4+
Specification: https://modelcontextprotocol.io/docs/concepts/transports
5+
6+
The server provides a single /mcp endpoint that handles JSON-RPC messages via HTTP POST.
7+
"""
8+
9+
import json
10+
import uuid
11+
import logging
12+
import os
13+
from typing import Any
14+
15+
from fastapi import FastAPI, Request, Response
16+
from fastapi.middleware.cors import CORSMiddleware
17+
from fastapi.responses import JSONResponse, StreamingResponse
18+
19+
# Import tool implementations from the main MCP module
20+
from rosetta.api.mcp import (
21+
TOOLS,
22+
tool_translate_excel,
23+
tool_get_sheets,
24+
tool_count_cells,
25+
tool_preview_cells,
26+
tool_estimate_cost,
27+
validate_sheets,
28+
validate_language,
29+
validate_context,
30+
)
31+
32+
# Configure logging
33+
logging.basicConfig(level=logging.INFO)
34+
logger = logging.getLogger(__name__)
35+
36+
# MCP Protocol Version
37+
MCP_PROTOCOL_VERSION = "2025-06-18"
38+
39+
# Create FastAPI app
40+
app = FastAPI(
41+
title="Rosetta MCP Server",
42+
description="AI-powered Excel translation MCP server",
43+
version="0.1.1",
44+
)
45+
46+
# CORS configuration for browser access
47+
# Allow Claude.ai and rosetta.study origins
48+
ALLOWED_ORIGINS = [
49+
"https://claude.ai",
50+
"https://www.claude.ai",
51+
"https://rosetta.study",
52+
"https://www.rosetta.study",
53+
"https://mcp.rosetta.study",
54+
]
55+
56+
# In development, allow localhost
57+
if os.getenv("ENVIRONMENT", "development") == "development":
58+
ALLOWED_ORIGINS.extend([
59+
"http://localhost:3000",
60+
"http://localhost:5173",
61+
"http://127.0.0.1:3000",
62+
"http://127.0.0.1:5173",
63+
])
64+
65+
app.add_middleware(
66+
CORSMiddleware,
67+
allow_origins=ALLOWED_ORIGINS,
68+
allow_credentials=True,
69+
allow_methods=["GET", "POST", "OPTIONS"],
70+
allow_headers=["*"],
71+
expose_headers=["Mcp-Session-Id"],
72+
)
73+
74+
# Session storage (in-memory for now)
75+
sessions: dict[str, dict] = {}
76+
77+
78+
def create_response(id: int | str | None, result: Any = None, error: dict | None = None) -> dict:
79+
"""Create a JSON-RPC response."""
80+
response = {"jsonrpc": "2.0", "id": id}
81+
if error:
82+
response["error"] = error
83+
else:
84+
response["result"] = result
85+
return response
86+
87+
88+
def create_error(id: int | str | None, code: int, message: str, data: Any = None) -> dict:
89+
"""Create a JSON-RPC error response."""
90+
error = {"code": code, "message": message}
91+
if data:
92+
error["data"] = data
93+
return create_response(id, error=error)
94+
95+
96+
# Tool handlers mapping
97+
TOOL_HANDLERS = {
98+
"translate_excel": tool_translate_excel,
99+
"get_excel_sheets": tool_get_sheets,
100+
"count_translatable_cells": tool_count_cells,
101+
"preview_cells": tool_preview_cells,
102+
"estimate_translation_cost": tool_estimate_cost,
103+
}
104+
105+
106+
async def handle_initialize(params: dict | None) -> dict:
107+
"""Handle initialize request."""
108+
return {
109+
"protocolVersion": MCP_PROTOCOL_VERSION,
110+
"capabilities": {
111+
"tools": {"listChanged": False},
112+
},
113+
"serverInfo": {
114+
"name": "rosetta-mcp",
115+
"version": "0.1.1",
116+
},
117+
}
118+
119+
120+
async def handle_tools_list(params: dict | None) -> dict:
121+
"""Handle tools/list request."""
122+
tools = []
123+
for tool in TOOLS:
124+
tools.append({
125+
"name": tool.name,
126+
"description": tool.description,
127+
"inputSchema": tool.inputSchema.model_dump(),
128+
})
129+
return {"tools": tools}
130+
131+
132+
async def handle_tools_call(params: dict | None) -> dict:
133+
"""Handle tools/call request."""
134+
if not params:
135+
raise ValueError("Missing params")
136+
137+
name = params.get("name")
138+
arguments = params.get("arguments", {})
139+
140+
if not name:
141+
raise ValueError("Missing tool name")
142+
143+
if name not in TOOL_HANDLERS:
144+
raise ValueError(f"Unknown tool: {name}")
145+
146+
try:
147+
# Call the handler (they handle their own validation)
148+
handler = TOOL_HANDLERS[name]
149+
result = handler(arguments)
150+
151+
# Extract text content from the result
152+
if hasattr(result, 'content') and result.content:
153+
text_content = result.content[0].text if result.content else ""
154+
is_error = getattr(result, 'isError', False)
155+
else:
156+
text_content = str(result)
157+
is_error = False
158+
159+
return {
160+
"content": [{"type": "text", "text": text_content}],
161+
"isError": is_error,
162+
}
163+
164+
except ValueError as e:
165+
return {
166+
"content": [{"type": "text", "text": f"Validation error: {str(e)}"}],
167+
"isError": True,
168+
}
169+
except Exception as e:
170+
logger.error(f"Error in tool {name}: {e}")
171+
return {
172+
"content": [{"type": "text", "text": f"Error processing request: {str(e)}"}],
173+
"isError": True,
174+
}
175+
176+
177+
async def handle_request(method: str, params: dict | None, request_id: int | str | None) -> dict:
178+
"""Route and handle a JSON-RPC request."""
179+
try:
180+
if method == "initialize":
181+
result = await handle_initialize(params)
182+
elif method == "initialized":
183+
result = {} # Acknowledgment, no response needed
184+
elif method == "tools/list":
185+
result = await handle_tools_list(params)
186+
elif method == "tools/call":
187+
result = await handle_tools_call(params)
188+
elif method == "ping":
189+
result = {}
190+
else:
191+
return create_error(request_id, -32601, f"Method not found: {method}")
192+
193+
return create_response(request_id, result)
194+
195+
except ValueError as e:
196+
return create_error(request_id, -32602, str(e))
197+
except Exception as e:
198+
logger.error(f"Error handling {method}: {e}")
199+
return create_error(request_id, -32603, "Internal error")
200+
201+
202+
@app.get("/health")
203+
async def health():
204+
"""Health check endpoint."""
205+
return {"status": "healthy", "server": "rosetta-mcp", "version": "0.1.1"}
206+
207+
208+
@app.get("/")
209+
async def root():
210+
"""Root endpoint with server info."""
211+
return {
212+
"name": "rosetta-mcp",
213+
"version": "0.1.1",
214+
"description": "AI-powered Excel translation MCP server",
215+
"mcp_endpoint": "/mcp",
216+
"health": "/health",
217+
}
218+
219+
220+
@app.post("/mcp")
221+
async def mcp_post(request: Request):
222+
"""
223+
MCP Streamable HTTP endpoint - POST method.
224+
225+
Handles JSON-RPC requests from MCP clients.
226+
"""
227+
# Validate protocol version header (optional but recommended)
228+
protocol_version = request.headers.get("mcp-protocol-version")
229+
if protocol_version:
230+
logger.info(f"MCP Protocol Version: {protocol_version}")
231+
232+
# Get or create session
233+
session_id = request.headers.get("mcp-session-id")
234+
if not session_id:
235+
session_id = str(uuid.uuid4())
236+
sessions[session_id] = {"created": True}
237+
238+
# Parse request body
239+
try:
240+
body = await request.json()
241+
except json.JSONDecodeError:
242+
return JSONResponse(
243+
status_code=400,
244+
content=create_error(None, -32700, "Parse error"),
245+
)
246+
247+
# Handle single request or batch
248+
if isinstance(body, list):
249+
# Batch request
250+
responses = []
251+
for item in body:
252+
try:
253+
method = item.get("method", "")
254+
params = item.get("params")
255+
request_id = item.get("id")
256+
resp = await handle_request(method, params, request_id)
257+
if request_id is not None: # Don't include responses for notifications
258+
responses.append(resp)
259+
except Exception as e:
260+
logger.error(f"Error processing batch item: {e}")
261+
responses.append(create_error(item.get("id"), -32600, "Invalid Request"))
262+
263+
return JSONResponse(
264+
content=responses,
265+
headers={"Mcp-Session-Id": session_id},
266+
)
267+
else:
268+
# Single request
269+
try:
270+
method = body.get("method", "")
271+
params = body.get("params")
272+
request_id = body.get("id")
273+
resp = await handle_request(method, params, request_id)
274+
275+
return JSONResponse(
276+
content=resp,
277+
headers={"Mcp-Session-Id": session_id},
278+
)
279+
except Exception as e:
280+
logger.error(f"Error processing request: {e}")
281+
return JSONResponse(
282+
status_code=400,
283+
content=create_error(body.get("id"), -32600, "Invalid Request"),
284+
)
285+
286+
287+
@app.get("/mcp")
288+
async def mcp_get(request: Request):
289+
"""
290+
MCP Streamable HTTP endpoint - GET method.
291+
292+
Opens an SSE stream for server-initiated messages.
293+
For Rosetta, we don't have server-initiated messages, so this just keeps the connection alive.
294+
"""
295+
async def event_stream():
296+
import asyncio
297+
# Send initial comment to keep connection alive
298+
yield ": connected\n\n"
299+
300+
# Keep connection open (clients can close when done)
301+
while True:
302+
await asyncio.sleep(30)
303+
yield ": ping\n\n"
304+
305+
return StreamingResponse(
306+
event_stream(),
307+
media_type="text/event-stream",
308+
headers={
309+
"Cache-Control": "no-cache",
310+
"Connection": "keep-alive",
311+
},
312+
)
313+
314+
315+
@app.options("/mcp")
316+
async def mcp_options():
317+
"""Handle CORS preflight requests."""
318+
return Response(
319+
status_code=200,
320+
headers={
321+
"Access-Control-Allow-Origin": "*",
322+
"Access-Control-Allow-Methods": "GET, POST, OPTIONS",
323+
"Access-Control-Allow-Headers": "Content-Type, MCP-Protocol-Version, Mcp-Session-Id, Accept",
324+
},
325+
)
326+
327+
328+
if __name__ == "__main__":
329+
import uvicorn
330+
port = int(os.getenv("PORT", 8001))
331+
uvicorn.run(app, host="0.0.0.0", port=port)

0 commit comments

Comments
 (0)