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_SERVER_NAME=mt-odoo-mcp
|
||||||
MCP_LOG_LEVEL=INFO
|
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) ─────────────────
|
# ─── 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
|
SECRET_KEY=change-me-to-a-long-random-string-in-production
|
||||||
|
|
||||||
# Fernet encryption key for Odoo credentials stored in the DB.
|
# Fernet encryption key for Odoo credentials stored in the DB.
|
||||||
|
|||||||
2
.gitignore
vendored
2
.gitignore
vendored
@ -5,7 +5,7 @@ __pycache__/
|
|||||||
*.db
|
*.db
|
||||||
*.db-shm
|
*.db-shm
|
||||||
*.db-wal
|
*.db-wal
|
||||||
audit.jsonl
|
*.jsonl
|
||||||
.venv/
|
.venv/
|
||||||
venv/
|
venv/
|
||||||
dist/
|
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 fastapi import APIRouter, HTTPException, Header
|
||||||
from pydantic import BaseModel, EmailStr
|
from pydantic import BaseModel, EmailStr
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
from ..config import settings
|
||||||
from ..auth.service import (
|
from ..auth.service import (
|
||||||
AuthError, signup, login, get_user_by_api_key,
|
AuthError, signup, login, get_user_by_api_key,
|
||||||
upsert_odoo_credential, list_odoo_credentials,
|
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.",
|
"warning": "Save your API key — it will not be shown again.",
|
||||||
"mcp_connection": {
|
"mcp_connection": {
|
||||||
"how_to_connect": "Set this in your MCP client config",
|
"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}",
|
"header": f"Authorization: Bearer {full_api_key}",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@ -137,7 +138,7 @@ def api_regen_key(authorization: Optional[str] = Header(None)):
|
|||||||
"api_key": new_key,
|
"api_key": new_key,
|
||||||
"warning": "Your old API key is now invalid. Save this new key — it will not be shown again.",
|
"warning": "Your old API key is now invalid. Save this new key — it will not be shown again.",
|
||||||
"mcp_connection": {
|
"mcp_connection": {
|
||||||
"url": "http://<your-vps-host>:<port>/mcp/sse",
|
"url": f"{settings.public_url}/mcp/sse",
|
||||||
"header": f"Authorization: Bearer {new_key}",
|
"header": f"Authorization: Bearer {new_key}",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@ -11,6 +11,12 @@ class Settings(BaseSettings):
|
|||||||
mcp_server_name: str = "mt-odoo-mcp"
|
mcp_server_name: str = "mt-odoo-mcp"
|
||||||
mcp_log_level: str = "INFO"
|
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
|
# Security
|
||||||
secret_key: str = "change-me"
|
secret_key: str = "change-me"
|
||||||
encryption_key: str = "change-me"
|
encryption_key: str = "change-me"
|
||||||
@ -25,5 +31,9 @@ class Settings(BaseSettings):
|
|||||||
audit_log_file: str = "./audit.jsonl"
|
audit_log_file: str = "./audit.jsonl"
|
||||||
audit_log_enabled: bool = True
|
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()
|
settings = Settings()
|
||||||
|
|||||||
@ -1,14 +1,19 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
import asyncio
|
||||||
|
import contextlib
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
|
import secrets as _secrets
|
||||||
import sys
|
import sys
|
||||||
|
import time
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
import uvicorn
|
import uvicorn
|
||||||
from fastapi import FastAPI, Request, HTTPException
|
from fastapi import FastAPI, Request, HTTPException
|
||||||
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
from fastapi.responses import JSONResponse
|
from fastapi.responses import JSONResponse
|
||||||
from mcp.server import Server
|
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 mcp.types import Tool, TextContent
|
||||||
|
|
||||||
from .config import settings
|
from .config import settings
|
||||||
@ -596,65 +601,225 @@ async def call_tool(name: str, arguments: dict) -> list[TextContent]:
|
|||||||
|
|
||||||
# ─── FastAPI App ──────────────────────────────────────────────────────────────
|
# ─── 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(
|
app = FastAPI(
|
||||||
title="Multi-Tenant Odoo MCP",
|
title="Multi-Tenant Odoo MCP",
|
||||||
description="Self-hosted MCP server for Odoo. Users register and manage their own Odoo instances.",
|
description="Self-hosted MCP server for Odoo. Users register and manage their own Odoo instances.",
|
||||||
version="1.0.0",
|
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
|
# Mount the REST API routes
|
||||||
app.include_router(api_router)
|
app.include_router(api_router)
|
||||||
|
|
||||||
# SSE transport — clients connect here
|
|
||||||
sse_transport = SseServerTransport("/mcp/messages/")
|
|
||||||
|
|
||||||
|
|
||||||
@app.get("/health")
|
@app.get("/health")
|
||||||
async def health():
|
async def health():
|
||||||
return {"status": "ok", "server": settings.mcp_server_name}
|
return {"status": "ok", "server": settings.mcp_server_name}
|
||||||
|
|
||||||
|
|
||||||
@app.get("/mcp/sse")
|
# ─── MCP Streamable HTTP Transport ───────────────────────────────────────────
|
||||||
async def mcp_sse_endpoint(request: Request):
|
#
|
||||||
"""MCP SSE connection endpoint. Authenticate with: ?api_key=<your_key> or Authorization header."""
|
# Each connecting client gets its own StreamableHTTPServerTransport instance,
|
||||||
# Extract API key from query param or Authorization header
|
# keyed by the Mcp-Session-Id the server assigns at initialization.
|
||||||
api_key = request.query_params.get("api_key")
|
# The per-user context vars (connection managers) are set inside the background
|
||||||
if not api_key:
|
# task that runs mcp_app.run(), so they're available to all tool handlers.
|
||||||
auth_header = request.headers.get("authorization", "")
|
|
||||||
if auth_header.startswith("Bearer "):
|
|
||||||
api_key = auth_header.removeprefix("Bearer ").strip()
|
|
||||||
|
|
||||||
if not api_key:
|
class _MCPSession:
|
||||||
raise HTTPException(
|
"""Tracks a live MCP session plus when it was last touched, so idle
|
||||||
status_code=401,
|
sessions (client vanished without sending DELETE — network blips, app
|
||||||
detail="API key required. Pass ?api_key=<key> or Authorization: Bearer <key>",
|
restarts, etc.) can be swept up instead of leaking forever."""
|
||||||
)
|
|
||||||
|
|
||||||
user = get_user_by_api_key(api_key)
|
__slots__ = ("transport", "user", "last_activity")
|
||||||
if not user:
|
|
||||||
raise HTTPException(status_code=401, detail="Invalid API key.")
|
|
||||||
|
|
||||||
log.info("MCP SSE connection: user=%s (id=%d)", user.email, user.id)
|
def __init__(self, transport: StreamableHTTPServerTransport, user: Any):
|
||||||
|
self.transport = transport
|
||||||
|
self.user = user
|
||||||
|
self.last_activity = time.monotonic()
|
||||||
|
|
||||||
# Build per-user managers
|
def touch(self) -> None:
|
||||||
conn_mgr = get_conn_manager(user.id)
|
self.last_activity = time.monotonic()
|
||||||
confirm_mgr = get_confirm_manager(user.id)
|
|
||||||
|
|
||||||
# Set context vars for this async task (propagates to all child tasks)
|
|
||||||
tokens = set_user_context(user.id, user.email, conn_mgr, confirm_mgr)
|
|
||||||
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())
|
|
||||||
finally:
|
|
||||||
reset_user_context(tokens)
|
|
||||||
|
|
||||||
|
|
||||||
@app.post("/mcp/messages/")
|
# Global session registry: mcp_session_id → _MCPSession
|
||||||
async def mcp_messages_endpoint(request: Request):
|
_mcp_sessions: dict[str, _MCPSession] = {}
|
||||||
"""MCP message channel (used internally by the SSE transport)."""
|
|
||||||
return await sse_transport.handle_post_message(request.scope, request.receive, request._send)
|
# 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:
|
||||||
|
resp = _JSON(
|
||||||
|
{"error": "API key required. Pass ?api_key=<key> or Authorization: Bearer <key>"},
|
||||||
|
status_code=401,
|
||||||
|
)
|
||||||
|
await resp(scope, receive, send)
|
||||||
|
return
|
||||||
|
|
||||||
|
user = get_user_by_api_key(api_key)
|
||||||
|
if not user:
|
||||||
|
resp = _JSON({"error": "Invalid API key."}, status_code=401)
|
||||||
|
await resp(scope, receive, send)
|
||||||
|
return
|
||||||
|
|
||||||
|
# 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,
|
||||||
|
)
|
||||||
|
|
||||||
|
conn_mgr = get_conn_manager(user.id)
|
||||||
|
confirm_mgr = get_confirm_manager(user.id)
|
||||||
|
ready = asyncio.Event()
|
||||||
|
|
||||||
|
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 _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)
|
||||||
|
|
||||||
|
|
||||||
|
_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 ──────────────────────────────────────────────────────────────
|
# ─── Entry Point ──────────────────────────────────────────────────────────────
|
||||||
@ -662,7 +827,12 @@ async def mcp_messages_endpoint(request: Request):
|
|||||||
def main():
|
def main():
|
||||||
init_db()
|
init_db()
|
||||||
log.info("Starting %s on %s:%d", settings.mcp_server_name, settings.host, settings.port)
|
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__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user