The MCP config was using the internal container port (3000) instead of the external mapped port. Added EXTERNAL_PORT env var support so clients get the correct connection URL when running behind Docker port mapping.
200 lines
5.7 KiB
Python
200 lines
5.7 KiB
Python
"""Main entry point for the MCP server with SSE transport."""
|
|
|
|
import json
|
|
import os
|
|
import sys
|
|
from typing import Any
|
|
|
|
import uvicorn
|
|
from mcp.server.sse import SseServerTransport
|
|
|
|
from grist_mcp.server import create_server
|
|
from grist_mcp.config import Config, load_config
|
|
from grist_mcp.auth import Authenticator, AuthError
|
|
|
|
|
|
Scope = dict[str, Any]
|
|
Receive = Any
|
|
Send = Any
|
|
|
|
|
|
def _get_bearer_token(scope: Scope) -> str | None:
|
|
"""Extract Bearer token from Authorization header."""
|
|
headers = dict(scope.get("headers", []))
|
|
auth_header = headers.get(b"authorization", b"").decode()
|
|
if auth_header.startswith("Bearer "):
|
|
return auth_header[7:]
|
|
return None
|
|
|
|
|
|
async def send_error(send: Send, status: int, message: str) -> None:
|
|
"""Send an HTTP error response."""
|
|
body = json.dumps({"error": message}).encode()
|
|
await send({
|
|
"type": "http.response.start",
|
|
"status": status,
|
|
"headers": [[b"content-type", b"application/json"]],
|
|
})
|
|
await send({
|
|
"type": "http.response.body",
|
|
"body": body,
|
|
})
|
|
|
|
|
|
CONFIG_TEMPLATE = """\
|
|
# grist-mcp configuration
|
|
#
|
|
# Token Generation:
|
|
# python -c "import secrets; print(secrets.token_urlsafe(32))"
|
|
# openssl rand -base64 32
|
|
|
|
# Document definitions
|
|
documents:
|
|
my-document:
|
|
url: https://docs.getgrist.com
|
|
doc_id: YOUR_DOC_ID
|
|
api_key: ${GRIST_API_KEY}
|
|
|
|
# Agent tokens with access scopes
|
|
tokens:
|
|
- token: REPLACE_WITH_GENERATED_TOKEN
|
|
name: my-agent
|
|
scope:
|
|
- document: my-document
|
|
permissions: [read, write]
|
|
"""
|
|
|
|
|
|
def _ensure_config(config_path: str) -> bool:
|
|
"""Ensure config file exists. Creates template if missing.
|
|
|
|
Returns True if config is ready, False if template was created.
|
|
"""
|
|
path = os.path.abspath(config_path)
|
|
|
|
# Check if path is a directory (Docker creates this when mounting missing file)
|
|
if os.path.isdir(path):
|
|
os.rmdir(path)
|
|
|
|
if os.path.exists(path):
|
|
return True
|
|
|
|
# Create template config
|
|
with open(path, "w") as f:
|
|
f.write(CONFIG_TEMPLATE)
|
|
|
|
print(f"Created template configuration at: {path}")
|
|
print()
|
|
print("Please edit this file to configure your Grist documents and agent tokens,")
|
|
print("then restart the server.")
|
|
return False
|
|
|
|
|
|
def create_app(config: Config):
|
|
"""Create the ASGI application."""
|
|
auth = Authenticator(config)
|
|
|
|
sse = SseServerTransport("/messages")
|
|
|
|
async def handle_sse(scope: Scope, receive: Receive, send: Send) -> None:
|
|
# Extract and validate token from Authorization header
|
|
token = _get_bearer_token(scope)
|
|
if not token:
|
|
await send_error(send, 401, "Missing Authorization header")
|
|
return
|
|
|
|
try:
|
|
agent = auth.authenticate(token)
|
|
except AuthError as e:
|
|
await send_error(send, 401, str(e))
|
|
return
|
|
|
|
# Create a server instance for this authenticated connection
|
|
server = create_server(auth, agent)
|
|
|
|
async with sse.connect_sse(scope, receive, send) as streams:
|
|
await server.run(
|
|
streams[0], streams[1], server.create_initialization_options()
|
|
)
|
|
|
|
async def handle_messages(scope: Scope, receive: Receive, send: Send) -> None:
|
|
await sse.handle_post_message(scope, receive, send)
|
|
|
|
async def handle_health(scope: Scope, receive: Receive, send: Send) -> None:
|
|
await send({
|
|
"type": "http.response.start",
|
|
"status": 200,
|
|
"headers": [[b"content-type", b"application/json"]],
|
|
})
|
|
await send({
|
|
"type": "http.response.body",
|
|
"body": b'{"status":"ok"}',
|
|
})
|
|
|
|
async def handle_not_found(scope: Scope, receive: Receive, send: Send) -> None:
|
|
await send({
|
|
"type": "http.response.start",
|
|
"status": 404,
|
|
"headers": [[b"content-type", b"application/json"]],
|
|
})
|
|
await send({
|
|
"type": "http.response.body",
|
|
"body": b'{"error":"Not found"}',
|
|
})
|
|
|
|
async def app(scope: Scope, receive: Receive, send: Send) -> None:
|
|
if scope["type"] != "http":
|
|
return
|
|
|
|
path = scope["path"]
|
|
method = scope["method"]
|
|
|
|
if path == "/health" and method == "GET":
|
|
await handle_health(scope, receive, send)
|
|
elif path == "/sse" and method == "GET":
|
|
await handle_sse(scope, receive, send)
|
|
elif path == "/messages" and method == "POST":
|
|
await handle_messages(scope, receive, send)
|
|
else:
|
|
await handle_not_found(scope, receive, send)
|
|
|
|
return app
|
|
|
|
|
|
def _print_mcp_config(external_port: int, tokens: list) -> None:
|
|
"""Print Claude Code MCP configuration."""
|
|
print()
|
|
print("Claude Code MCP configuration (copy-paste to add):")
|
|
for t in tokens:
|
|
config = (
|
|
f'{{"type": "sse", "url": "http://localhost:{external_port}/sse", '
|
|
f'"headers": {{"Authorization": "Bearer {t.token}"}}}}'
|
|
)
|
|
print(f" claude mcp add-json grist-{t.name} '{config}'")
|
|
print()
|
|
|
|
|
|
def main():
|
|
"""Run the SSE server."""
|
|
port = int(os.environ.get("PORT", "3000"))
|
|
external_port = int(os.environ.get("EXTERNAL_PORT", str(port)))
|
|
config_path = os.environ.get("CONFIG_PATH", "/app/config.yaml")
|
|
|
|
if not _ensure_config(config_path):
|
|
return
|
|
|
|
config = load_config(config_path)
|
|
|
|
print(f"Starting grist-mcp SSE server on port {port}")
|
|
print(f" SSE endpoint: http://0.0.0.0:{port}/sse")
|
|
print(f" Messages endpoint: http://0.0.0.0:{port}/messages")
|
|
|
|
_print_mcp_config(external_port, config.tokens)
|
|
|
|
app = create_app(config)
|
|
uvicorn.run(app, host="0.0.0.0", port=port)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|