From 198b5f1b90e53cf8eb62d358b37a5f3d115654b1 Mon Sep 17 00:00:00 2001 From: MOHAN Date: Wed, 1 Jul 2026 13:43:34 +0530 Subject: [PATCH] Migrate MCP transport to Streamable HTTP, add session sweeper, CORS, public URL config MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .env.example | 8 +- .gitignore | 2 +- HANDOFF.md | 256 ++++++++++++++++++++++++++++++++++ src/mt_odoo_mcp/api/routes.py | 5 +- src/mt_odoo_mcp/config.py | 10 ++ src/mt_odoo_mcp/server.py | 250 +++++++++++++++++++++++++++------ 6 files changed, 487 insertions(+), 44 deletions(-) create mode 100644 HANDOFF.md diff --git a/.env.example b/.env.example index d1e6e27..607b15f 100644 --- a/.env.example +++ b/.env.example @@ -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. diff --git a/.gitignore b/.gitignore index 01cdae6..d3f4c18 100644 --- a/.gitignore +++ b/.gitignore @@ -5,7 +5,7 @@ __pycache__/ *.db *.db-shm *.db-wal -audit.jsonl +*.jsonl .venv/ venv/ dist/ diff --git a/HANDOFF.md b/HANDOFF.md new file mode 100644 index 0000000..42ab3b3 --- /dev/null +++ b/HANDOFF.md @@ -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://:/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= +``` + +--- + +### 🟡 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) diff --git a/src/mt_odoo_mcp/api/routes.py b/src/mt_odoo_mcp/api/routes.py index 7b1f5af..f77574f 100644 --- a/src/mt_odoo_mcp/api/routes.py +++ b/src/mt_odoo_mcp/api/routes.py @@ -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://:/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://:/mcp/sse", + "url": f"{settings.public_url}/mcp/sse", "header": f"Authorization: Bearer {new_key}", }, } diff --git a/src/mt_odoo_mcp/config.py b/src/mt_odoo_mcp/config.py index cf3e04e..3d17189 100644 --- a/src/mt_odoo_mcp/config.py +++ b/src/mt_odoo_mcp/config.py @@ -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() diff --git a/src/mt_odoo_mcp/server.py b/src/mt_odoo_mcp/server.py index fded644..a85279c 100644 --- a/src/mt_odoo_mcp/server.py +++ b/src/mt_odoo_mcp/server.py @@ -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= 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. - if not api_key: - raise HTTPException( - status_code=401, - detail="API key required. Pass ?api_key= or Authorization: Bearer ", - ) +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.""" - user = get_user_by_api_key(api_key) - if not user: - raise HTTPException(status_code=401, detail="Invalid API key.") + __slots__ = ("transport", "user", "last_activity") - 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 - conn_mgr = get_conn_manager(user.id) - 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) + def touch(self) -> None: + self.last_activity = time.monotonic() -@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) +# 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: + resp = _JSON( + {"error": "API key required. Pass ?api_key= or Authorization: Bearer "}, + 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=. " + "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 ────────────────────────────────────────────────────────────── @@ -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__":