Migrate MCP transport to Streamable HTTP, add session sweeper, CORS, public URL config
Replaces the old SseServerTransport (broken against current MCP clients — 405s on POST, session-id redirects, no protocol negotiation) with StreamableHTTPServerTransport. Adds an idle-session sweeper to prevent abandoned client connections from leaking memory indefinitely, CORS middleware for browser-based frontend access, and PUBLIC_URL/CORS_ORIGINS settings so signup responses show the correct connection URL instead of a placeholder. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
parent
fc6d7c8ca6
commit
198b5f1b90
@ -4,8 +4,14 @@ PORT=8000
|
||||
MCP_SERVER_NAME=mt-odoo-mcp
|
||||
MCP_LOG_LEVEL=INFO
|
||||
|
||||
# Base URL clients use to reach this server — shown in signup/regen-key API responses
|
||||
PUBLIC_URL=https://odoo-mcp.thedomainnest.com
|
||||
|
||||
# Comma-separated list of origins allowed to call the API from a browser (the frontend domain)
|
||||
CORS_ORIGINS=http://localhost:3000
|
||||
|
||||
# ─── Security (generate strong random values for production) ─────────────────
|
||||
# Used to sign API keys and session tokens — keep SECRET
|
||||
# Currently unused by the app (reserved for future signed tokens) — set anyway for forward compat
|
||||
SECRET_KEY=change-me-to-a-long-random-string-in-production
|
||||
|
||||
# Fernet encryption key for Odoo credentials stored in the DB.
|
||||
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@ -5,7 +5,7 @@ __pycache__/
|
||||
*.db
|
||||
*.db-shm
|
||||
*.db-wal
|
||||
audit.jsonl
|
||||
*.jsonl
|
||||
.venv/
|
||||
venv/
|
||||
dist/
|
||||
|
||||
256
HANDOFF.md
Normal file
256
HANDOFF.md
Normal file
@ -0,0 +1,256 @@
|
||||
# Multi-Tenant ODOO MCP — Project Handoff
|
||||
|
||||
## Live Server
|
||||
- **Backend URL:** `https://odoo-mcp.thedomainnest.com/`
|
||||
- **Stack:** Python 3.11, FastAPI, uvicorn, SQLite, MCP (SSE transport)
|
||||
- **Local repo path:** `e:\MCS_Repos\nodejs_web_dev\mcp_multitenant\Multi-Tenant-ODOO-MCP`
|
||||
|
||||
---
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
Multi-Tenant-ODOO-MCP/
|
||||
├── src/mt_odoo_mcp/
|
||||
│ ├── api/routes.py # REST API endpoints (signup, login, credentials)
|
||||
│ ├── auth/ # Auth service, models, crypto (bcrypt + Fernet)
|
||||
│ ├── client/ # Odoo XML-RPC connection + RPC helpers
|
||||
│ ├── tools/ # 60+ MCP tools (connection, discovery, CRUD, workflow, smart)
|
||||
│ ├── config.py # Settings loaded from .env
|
||||
│ ├── server.py # FastAPI app + MCP SSE transport
|
||||
│ ├── database.py # SQLite init
|
||||
│ └── registry.py # Per-user connection manager registry
|
||||
├── frontend/ # Next.js 14 frontend (see below)
|
||||
├── .env # Server environment variables
|
||||
├── pyproject.toml # Python dependencies
|
||||
└── HANDOFF.md # This file
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## What's Been Done
|
||||
|
||||
### Backend (Python/FastAPI)
|
||||
|
||||
| # | Fix / Feature | File |
|
||||
|---|--------------|------|
|
||||
| ✅ | Fixed bcrypt 5.0 compatibility — replaced passlib with direct bcrypt | `auth/service.py` |
|
||||
| ✅ | Fixed missing `email-validator` dependency | `pyproject.toml` |
|
||||
| ✅ | Fixed bad config import in auth service | `auth/service.py` |
|
||||
| ✅ | Added `CORSMiddleware` — allows browser requests from frontend | `server.py` |
|
||||
| ✅ | Added `public_url` and `cors_origins` config fields | `config.py` |
|
||||
| ✅ | Fixed hardcoded `http://<your-vps-host>:<port>/mcp/sse` in signup & regen-key API responses | `api/routes.py` |
|
||||
|
||||
### Frontend (Next.js 14)
|
||||
|
||||
**Location:** `frontend/`
|
||||
|
||||
| # | Page / File | Description |
|
||||
|---|-------------|-------------|
|
||||
| ✅ | Landing page | Hero, Features, How It Works, Pricing, Footer |
|
||||
| ✅ | Navbar | Responsive with mobile menu |
|
||||
| ✅ | Pricing section | 4 tiers — Starter $19, Pro $49, Agency $149, Enterprise Custom |
|
||||
| ✅ | Signup page | Form → API key reveal with copy-to-clipboard |
|
||||
| ✅ | Login page | Email + password form |
|
||||
| ✅ | Dashboard layout | Dark sidebar, all nav items, mobile responsive |
|
||||
| ✅ | Dashboard home | Stats cards, usage chart, quick actions, recent activity log |
|
||||
| ✅ | Odoo Connections page | List, add, and delete Odoo instances |
|
||||
| ✅ | MCP Endpoints page | URL + token copy buttons, Claude/Codex/Cursor setup JSON config |
|
||||
| ✅ | Billing page | Current plan, plan switcher, invoice history |
|
||||
| ✅ | Stripe Checkout API route | Creates Stripe Checkout session with 7-day trial |
|
||||
| ✅ | Stripe Webhook API route | Handles subscription created/updated/cancelled events |
|
||||
| ✅ | Typed API client | All calls wired to live backend | `frontend/src/lib/api.ts` |
|
||||
| ✅ | `.env.local` | Configured to point to `https://odoo-mcp.thedomainnest.com` |
|
||||
| ✅ | Production build | `next build` passes with zero errors |
|
||||
|
||||
---
|
||||
|
||||
## What Still Needs To Be Done
|
||||
|
||||
### 🔴 Critical — Must Do Before Frontend Works
|
||||
|
||||
#### 1. Deploy backend changes to the server
|
||||
|
||||
Three Python files were changed locally and must be pushed to the server:
|
||||
|
||||
- `src/mt_odoo_mcp/server.py`
|
||||
- `src/mt_odoo_mcp/config.py`
|
||||
- `src/mt_odoo_mcp/api/routes.py`
|
||||
|
||||
**Add these 2 lines to the server `.env` file:**
|
||||
|
||||
```env
|
||||
PUBLIC_URL=https://odoo-mcp.thedomainnest.com
|
||||
CORS_ORIGINS=http://localhost:3000,https://YOUR-FRONTEND-DOMAIN.com
|
||||
```
|
||||
|
||||
Then restart the service:
|
||||
|
||||
```bash
|
||||
# whichever process manager is used on the server:
|
||||
systemctl restart odoo-mcp
|
||||
# or
|
||||
pm2 restart odoo-mcp
|
||||
```
|
||||
|
||||
#### 2. Change SECRET_KEY on the server
|
||||
|
||||
The current `SECRET_KEY` in `.env` is still the default placeholder — this is a security risk.
|
||||
|
||||
```env
|
||||
SECRET_KEY=<replace with a long random string, e.g. output of: openssl rand -hex 32>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 🟡 Stripe Integration
|
||||
|
||||
#### 3. Create Stripe products and prices
|
||||
|
||||
In the Stripe Dashboard → Products, create 3 recurring monthly products:
|
||||
|
||||
| Product | Price |
|
||||
|---------|-------|
|
||||
| Starter | $19/month |
|
||||
| Pro | $49/month |
|
||||
| Agency | $149/month |
|
||||
|
||||
Copy the `price_xxx` IDs and fill in `frontend/.env.local`:
|
||||
|
||||
```env
|
||||
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_live_...
|
||||
STRIPE_SECRET_KEY=sk_live_...
|
||||
STRIPE_WEBHOOK_SECRET=whsec_...
|
||||
STRIPE_PRICE_STARTER=price_...
|
||||
STRIPE_PRICE_PRO=price_...
|
||||
STRIPE_PRICE_AGENCY=price_...
|
||||
```
|
||||
|
||||
#### 4. Add subscription fields to the backend users table
|
||||
|
||||
Add these columns to the SQLite `users` table:
|
||||
|
||||
```sql
|
||||
ALTER TABLE users ADD COLUMN plan TEXT DEFAULT 'free';
|
||||
ALTER TABLE users ADD COLUMN subscription_status TEXT DEFAULT 'inactive';
|
||||
ALTER TABLE users ADD COLUMN stripe_customer_id TEXT;
|
||||
```
|
||||
|
||||
Add a new API endpoint `PATCH /api/admin/subscription` (internal, called by the webhook) that updates these fields when Stripe confirms payment.
|
||||
|
||||
#### 5. Wire Stripe webhook to backend
|
||||
|
||||
The webhook handler at `frontend/src/app/api/stripe/webhook/route.ts` already parses Stripe events. Add a `fetch()` call inside the switch cases to hit the backend endpoint from step 4.
|
||||
|
||||
#### 6. Gate MCP access on subscription status
|
||||
|
||||
In `server.py`, inside the `/mcp/sse` endpoint, after the user is looked up, add a check:
|
||||
|
||||
```python
|
||||
if user.subscription_status not in ('active', 'trialing'):
|
||||
raise HTTPException(status_code=403, detail="Active subscription required.")
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 🟢 Remaining Dashboard Pages
|
||||
|
||||
| Page | Route | Description |
|
||||
|------|-------|-------------|
|
||||
| AI Setup Guides | `/dashboard/guides` | Step-by-step setup for Claude, Codex, Cursor, Windsurf |
|
||||
| Tools & Permissions | `/dashboard/tools` | Toggle which Odoo tools are enabled per module |
|
||||
| Usage | `/dashboard/usage` | Detailed usage graphs with date range filter |
|
||||
| Logs | `/dashboard/logs` | Activity log table with search and filters |
|
||||
| API Tokens | `/dashboard/tokens` | View key prefix, rotate API key button |
|
||||
| Team | `/dashboard/team` | Invite team members (needs backend multi-user support) |
|
||||
| Settings | `/dashboard/settings` | Change password, account preferences |
|
||||
|
||||
---
|
||||
|
||||
### 🟢 Deploy Frontend
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
npm install -g vercel
|
||||
vercel --prod
|
||||
```
|
||||
|
||||
- Set all env vars from `frontend/.env.local` inside Vercel project settings
|
||||
- After deploy, add the Vercel domain to `CORS_ORIGINS` in the server `.env` and restart
|
||||
|
||||
---
|
||||
|
||||
## How to Run Locally
|
||||
|
||||
### Backend (already running on server — skip if not developing backend)
|
||||
|
||||
```bash
|
||||
pip install -e .
|
||||
cp .env.example .env # fill in values
|
||||
python -m mt_odoo_mcp
|
||||
```
|
||||
|
||||
### Frontend
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
npm install
|
||||
npm run dev
|
||||
# → http://localhost:3000
|
||||
# API calls go to https://odoo-mcp.thedomainnest.com
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## API Reference (Backend)
|
||||
|
||||
| Method | Endpoint | Auth | Description |
|
||||
|--------|----------|------|-------------|
|
||||
| `GET` | `/health` | None | Health check |
|
||||
| `POST` | `/api/signup` | None | Create account, returns API key |
|
||||
| `POST` | `/api/login` | None | Verify credentials |
|
||||
| `GET` | `/api/me` | Bearer | Current user + Odoo instances |
|
||||
| `POST` | `/api/api-key/regenerate` | Bearer | Rotate API key |
|
||||
| `POST` | `/api/credentials` | Bearer | Add/update Odoo instance |
|
||||
| `GET` | `/api/credentials` | Bearer | List Odoo instances |
|
||||
| `DELETE` | `/api/credentials/{name}` | Bearer | Remove Odoo instance |
|
||||
| `GET` | `/mcp/sse` | Bearer | MCP SSE connection (used by AI clients) |
|
||||
|
||||
**Auth header format:** `Authorization: Bearer mtom_xxxxx...`
|
||||
|
||||
---
|
||||
|
||||
## MCP Client Config (Claude Code / Claude Desktop)
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"odoo-mcp": {
|
||||
"type": "sse",
|
||||
"url": "https://odoo-mcp.thedomainnest.com/mcp/sse",
|
||||
"headers": {
|
||||
"Authorization": "Bearer YOUR_API_KEY"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Or via CLI:
|
||||
|
||||
```bash
|
||||
claude mcp add --transport sse odoo-mcp https://odoo-mcp.thedomainnest.com/mcp/sse \
|
||||
-H "Authorization: Bearer YOUR_API_KEY"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Implementation Priority for Colleague
|
||||
|
||||
1. **Deploy backend changes + update `.env` on server → restart** ← unblocks everything
|
||||
2. **Test signup → dashboard → add Odoo connection** end to end
|
||||
3. **Set up Stripe**, fill in price IDs in `frontend/.env.local`
|
||||
4. **Add subscription columns** to SQLite + `/api/admin/subscription` endpoint
|
||||
5. **Wire webhook** to backend endpoint
|
||||
6. **Deploy frontend** to Vercel
|
||||
7. **Build remaining dashboard pages** (guides, tools, usage, logs, tokens, settings)
|
||||
@ -3,6 +3,7 @@ from __future__ import annotations
|
||||
from fastapi import APIRouter, HTTPException, Header
|
||||
from pydantic import BaseModel, EmailStr
|
||||
from typing import Optional
|
||||
from ..config import settings
|
||||
from ..auth.service import (
|
||||
AuthError, signup, login, get_user_by_api_key,
|
||||
upsert_odoo_credential, list_odoo_credentials,
|
||||
@ -88,7 +89,7 @@ def api_signup(body: SignupRequest):
|
||||
"warning": "Save your API key — it will not be shown again.",
|
||||
"mcp_connection": {
|
||||
"how_to_connect": "Set this in your MCP client config",
|
||||
"url": "http://<your-vps-host>:<port>/mcp/sse",
|
||||
"url": f"{settings.public_url}/mcp/sse",
|
||||
"header": f"Authorization: Bearer {full_api_key}",
|
||||
},
|
||||
}
|
||||
@ -137,7 +138,7 @@ def api_regen_key(authorization: Optional[str] = Header(None)):
|
||||
"api_key": new_key,
|
||||
"warning": "Your old API key is now invalid. Save this new key — it will not be shown again.",
|
||||
"mcp_connection": {
|
||||
"url": "http://<your-vps-host>:<port>/mcp/sse",
|
||||
"url": f"{settings.public_url}/mcp/sse",
|
||||
"header": f"Authorization: Bearer {new_key}",
|
||||
},
|
||||
}
|
||||
|
||||
@ -11,6 +11,12 @@ class Settings(BaseSettings):
|
||||
mcp_server_name: str = "mt-odoo-mcp"
|
||||
mcp_log_level: str = "INFO"
|
||||
|
||||
# Public URL (used in API responses so clients know where to connect)
|
||||
public_url: str = "http://localhost:8000"
|
||||
|
||||
# CORS — comma-separated list of allowed frontend origins
|
||||
cors_origins: str = "http://localhost:3000"
|
||||
|
||||
# Security
|
||||
secret_key: str = "change-me"
|
||||
encryption_key: str = "change-me"
|
||||
@ -25,5 +31,9 @@ class Settings(BaseSettings):
|
||||
audit_log_file: str = "./audit.jsonl"
|
||||
audit_log_enabled: bool = True
|
||||
|
||||
@property
|
||||
def cors_origins_list(self) -> list[str]:
|
||||
return [o.strip() for o in self.cors_origins.split(",") if o.strip()]
|
||||
|
||||
|
||||
settings = Settings()
|
||||
|
||||
@ -1,14 +1,19 @@
|
||||
from __future__ import annotations
|
||||
import asyncio
|
||||
import contextlib
|
||||
import json
|
||||
import logging
|
||||
import secrets as _secrets
|
||||
import sys
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
import uvicorn
|
||||
from fastapi import FastAPI, Request, HTTPException
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import JSONResponse
|
||||
from mcp.server import Server
|
||||
from mcp.server.sse import SseServerTransport
|
||||
from mcp.server.streamable_http import StreamableHTTPServerTransport
|
||||
from mcp.types import Tool, TextContent
|
||||
|
||||
from .config import settings
|
||||
@ -596,65 +601,225 @@ async def call_tool(name: str, arguments: dict) -> list[TextContent]:
|
||||
|
||||
# ─── FastAPI App ──────────────────────────────────────────────────────────────
|
||||
|
||||
@contextlib.asynccontextmanager
|
||||
async def _lifespan(_app: FastAPI):
|
||||
sweeper_task = asyncio.get_running_loop().create_task(_sweep_idle_sessions())
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
sweeper_task.cancel()
|
||||
|
||||
|
||||
app = FastAPI(
|
||||
title="Multi-Tenant Odoo MCP",
|
||||
description="Self-hosted MCP server for Odoo. Users register and manage their own Odoo instances.",
|
||||
version="1.0.0",
|
||||
lifespan=_lifespan,
|
||||
)
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=settings.cors_origins_list,
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
# Mount the REST API routes
|
||||
app.include_router(api_router)
|
||||
|
||||
# SSE transport — clients connect here
|
||||
sse_transport = SseServerTransport("/mcp/messages/")
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
async def health():
|
||||
return {"status": "ok", "server": settings.mcp_server_name}
|
||||
|
||||
|
||||
@app.get("/mcp/sse")
|
||||
async def mcp_sse_endpoint(request: Request):
|
||||
"""MCP SSE connection endpoint. Authenticate with: ?api_key=<your_key> or Authorization header."""
|
||||
# Extract API key from query param or Authorization header
|
||||
api_key = request.query_params.get("api_key")
|
||||
if not api_key:
|
||||
auth_header = request.headers.get("authorization", "")
|
||||
if auth_header.startswith("Bearer "):
|
||||
api_key = auth_header.removeprefix("Bearer ").strip()
|
||||
# ─── MCP Streamable HTTP Transport ───────────────────────────────────────────
|
||||
#
|
||||
# Each connecting client gets its own StreamableHTTPServerTransport instance,
|
||||
# keyed by the Mcp-Session-Id the server assigns at initialization.
|
||||
# The per-user context vars (connection managers) are set inside the background
|
||||
# task that runs mcp_app.run(), so they're available to all tool handlers.
|
||||
|
||||
class _MCPSession:
|
||||
"""Tracks a live MCP session plus when it was last touched, so idle
|
||||
sessions (client vanished without sending DELETE — network blips, app
|
||||
restarts, etc.) can be swept up instead of leaking forever."""
|
||||
|
||||
__slots__ = ("transport", "user", "last_activity")
|
||||
|
||||
def __init__(self, transport: StreamableHTTPServerTransport, user: Any):
|
||||
self.transport = transport
|
||||
self.user = user
|
||||
self.last_activity = time.monotonic()
|
||||
|
||||
def touch(self) -> None:
|
||||
self.last_activity = time.monotonic()
|
||||
|
||||
|
||||
# Global session registry: mcp_session_id → _MCPSession
|
||||
_mcp_sessions: dict[str, _MCPSession] = {}
|
||||
|
||||
# Sessions idle longer than this are forcibly terminated by the sweeper below.
|
||||
_SESSION_IDLE_TIMEOUT_SECONDS = 30 * 60
|
||||
_SESSION_SWEEP_INTERVAL_SECONDS = 60
|
||||
|
||||
|
||||
async def _sweep_idle_sessions() -> None:
|
||||
"""Background loop: terminate MCP sessions that have had no traffic for
|
||||
too long. Without this, an abandoned session (client disconnected
|
||||
without DELETE) runs its background task and holds its transport in
|
||||
memory forever."""
|
||||
while True:
|
||||
await asyncio.sleep(_SESSION_SWEEP_INTERVAL_SECONDS)
|
||||
now = time.monotonic()
|
||||
stale = [
|
||||
sid for sid, entry in list(_mcp_sessions.items())
|
||||
if now - entry.last_activity > _SESSION_IDLE_TIMEOUT_SECONDS
|
||||
]
|
||||
for sid in stale:
|
||||
entry = _mcp_sessions.get(sid)
|
||||
if entry is None:
|
||||
continue
|
||||
log.info("Sweeping idle MCP session %s (user=%s)", sid[:12], entry.user.email)
|
||||
try:
|
||||
await entry.transport.terminate()
|
||||
except Exception:
|
||||
log.exception("Error terminating idle session %s", sid[:12])
|
||||
# The background _run_session task's finally block pops _mcp_sessions
|
||||
# once terminate() unblocks mcp_app.run(); no need to pop here too.
|
||||
|
||||
|
||||
def _extract_api_key(scope: dict) -> str | None:
|
||||
"""Extract API key from ?api_key= query param or Authorization: Bearer header."""
|
||||
from urllib.parse import parse_qs
|
||||
qs = scope.get("query_string", b"").decode()
|
||||
params = parse_qs(qs)
|
||||
if "api_key" in params:
|
||||
return params["api_key"][0]
|
||||
for name, value in scope.get("headers", []):
|
||||
if name.lower() == b"authorization":
|
||||
val = value.decode()
|
||||
if val.startswith("Bearer "):
|
||||
return val[7:].strip()
|
||||
return None
|
||||
|
||||
|
||||
class _MCPEndpoint:
|
||||
"""Raw ASGI handler for MCP Streamable HTTP transport (MCP spec 2025-11-05)."""
|
||||
|
||||
async def __call__(self, scope, receive, send) -> None:
|
||||
if scope["type"] != "http":
|
||||
return
|
||||
|
||||
from starlette.requests import Request as _Req
|
||||
from starlette.responses import JSONResponse as _JSON
|
||||
|
||||
request = _Req(scope, receive)
|
||||
method = request.method
|
||||
session_id = request.headers.get("mcp-session-id")
|
||||
|
||||
# ── New session (initialize) ──────────────────────────────────────────
|
||||
if method == "POST" and not session_id:
|
||||
api_key = _extract_api_key(scope)
|
||||
if not api_key:
|
||||
raise HTTPException(
|
||||
resp = _JSON(
|
||||
{"error": "API key required. Pass ?api_key=<key> or Authorization: Bearer <key>"},
|
||||
status_code=401,
|
||||
detail="API key required. Pass ?api_key=<key> or Authorization: Bearer <key>",
|
||||
)
|
||||
await resp(scope, receive, send)
|
||||
return
|
||||
|
||||
user = get_user_by_api_key(api_key)
|
||||
if not user:
|
||||
raise HTTPException(status_code=401, detail="Invalid API key.")
|
||||
resp = _JSON({"error": "Invalid API key."}, status_code=401)
|
||||
await resp(scope, receive, send)
|
||||
return
|
||||
|
||||
log.info("MCP SSE connection: user=%s (id=%d)", user.email, user.id)
|
||||
# Session ID must be visible ASCII chars; hex satisfies that
|
||||
new_session_id = _secrets.token_hex(32)
|
||||
transport = StreamableHTTPServerTransport(
|
||||
mcp_session_id=new_session_id,
|
||||
is_json_response_enabled=False,
|
||||
)
|
||||
|
||||
# Build per-user managers
|
||||
conn_mgr = get_conn_manager(user.id)
|
||||
confirm_mgr = get_confirm_manager(user.id)
|
||||
ready = asyncio.Event()
|
||||
|
||||
# Set context vars for this async task (propagates to all child tasks)
|
||||
tokens = set_user_context(user.id, user.email, conn_mgr, confirm_mgr)
|
||||
async def _run_session(
|
||||
_t=transport, _u=user, _cm=conn_mgr, _fm=confirm_mgr,
|
||||
_ev=ready, _sid=new_session_id,
|
||||
):
|
||||
tokens = set_user_context(_u.id, _u.email, _cm, _fm)
|
||||
try:
|
||||
async with sse_transport.connect_sse(
|
||||
request.scope, request.receive, request._send
|
||||
) as (read_stream, write_stream):
|
||||
await mcp_app.run(read_stream, write_stream, mcp_app.create_initialization_options())
|
||||
async with _t.connect() as (read_stream, write_stream):
|
||||
_ev.set()
|
||||
await mcp_app.run(
|
||||
read_stream, write_stream,
|
||||
mcp_app.create_initialization_options(),
|
||||
)
|
||||
except Exception:
|
||||
log.exception("MCP session %s crashed", _sid[:12])
|
||||
finally:
|
||||
reset_user_context(tokens)
|
||||
_mcp_sessions.pop(_sid, None)
|
||||
log.info("MCP session %s closed (user=%s)", _sid[:12], _u.email)
|
||||
|
||||
asyncio.get_running_loop().create_task(_run_session())
|
||||
|
||||
try:
|
||||
await asyncio.wait_for(ready.wait(), timeout=10.0)
|
||||
except asyncio.TimeoutError:
|
||||
resp = _JSON({"error": "MCP session startup timed out."}, status_code=503)
|
||||
await resp(scope, receive, send)
|
||||
return
|
||||
|
||||
_mcp_sessions[new_session_id] = _MCPSession(transport=transport, user=user)
|
||||
log.info("MCP session created: %s for user=%s", new_session_id[:12], user.email)
|
||||
|
||||
await transport.handle_request(scope, receive, send)
|
||||
|
||||
# ── Existing session ──────────────────────────────────────────────────
|
||||
elif session_id:
|
||||
entry = _mcp_sessions.get(session_id)
|
||||
if not entry:
|
||||
resp = _JSON({"error": "Session not found or expired."}, status_code=404)
|
||||
await resp(scope, receive, send)
|
||||
return
|
||||
entry.touch()
|
||||
await entry.transport.handle_request(scope, receive, send)
|
||||
|
||||
# ── No session, not a new-session POST ────────────────────────────────
|
||||
else:
|
||||
resp = _JSON(
|
||||
{"error": "New sessions require POST with ?api_key=<key>. "
|
||||
"Existing sessions require Mcp-Session-Id header."},
|
||||
status_code=400,
|
||||
)
|
||||
await resp(scope, receive, send)
|
||||
|
||||
|
||||
@app.post("/mcp/messages/")
|
||||
async def mcp_messages_endpoint(request: Request):
|
||||
"""MCP message channel (used internally by the SSE transport)."""
|
||||
return await sse_transport.handle_post_message(request.scope, request.receive, request._send)
|
||||
_mcp_endpoint = _MCPEndpoint()
|
||||
|
||||
|
||||
class _MCPRouter:
|
||||
"""Thin ASGI wrapper that intercepts /mcp/sse before Starlette's routing.
|
||||
|
||||
Starlette's app.mount() causes a 307 redirect from /mcp/sse → /mcp/sse/,
|
||||
which can drop Mcp-Session-Id headers on some clients. By intercepting
|
||||
at the outermost ASGI layer we handle the exact paths directly and let
|
||||
everything else (REST routes, /.well-known/, etc.) fall through to FastAPI.
|
||||
"""
|
||||
|
||||
def __init__(self, inner):
|
||||
self.inner = inner
|
||||
|
||||
async def __call__(self, scope, receive, send):
|
||||
if scope.get("type") == "http" and scope.get("path", "").rstrip("/") == "/mcp/sse":
|
||||
await _mcp_endpoint(scope, receive, send)
|
||||
else:
|
||||
await self.inner(scope, receive, send)
|
||||
|
||||
|
||||
# ─── Entry Point ──────────────────────────────────────────────────────────────
|
||||
@ -662,7 +827,12 @@ async def mcp_messages_endpoint(request: Request):
|
||||
def main():
|
||||
init_db()
|
||||
log.info("Starting %s on %s:%d", settings.mcp_server_name, settings.host, settings.port)
|
||||
uvicorn.run(app, host=settings.host, port=settings.port, log_level=settings.mcp_log_level.lower())
|
||||
uvicorn.run(
|
||||
_MCPRouter(app),
|
||||
host=settings.host,
|
||||
port=settings.port,
|
||||
log_level=settings.mcp_log_level.lower(),
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user