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:
MOHAN 2026-07-01 13:43:34 +05:30
parent fc6d7c8ca6
commit 198b5f1b90
6 changed files with 487 additions and 44 deletions

View File

@ -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
View File

@ -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
View 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)

View File

@ -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}",
}, },
} }

View File

@ -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()

View File

@ -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()
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: if not api_key:
raise HTTPException( resp = _JSON(
{"error": "API key required. Pass ?api_key=<key> or Authorization: Bearer <key>"},
status_code=401, 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) user = get_user_by_api_key(api_key)
if not user: 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) conn_mgr = get_conn_manager(user.id)
confirm_mgr = get_confirm_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) async def _run_session(
tokens = set_user_context(user.id, user.email, conn_mgr, confirm_mgr) _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: try:
async with sse_transport.connect_sse( async with _t.connect() as (read_stream, write_stream):
request.scope, request.receive, request._send _ev.set()
) as (read_stream, write_stream): await mcp_app.run(
await mcp_app.run(read_stream, write_stream, mcp_app.create_initialization_options()) read_stream, write_stream,
mcp_app.create_initialization_options(),
)
except Exception:
log.exception("MCP session %s crashed", _sid[:12])
finally: finally:
reset_user_context(tokens) 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/") _mcp_endpoint = _MCPEndpoint()
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) 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__":