diff --git a/.env.example b/.env.example index 363bc4a..affc21b 100644 --- a/.env.example +++ b/.env.example @@ -1,13 +1,29 @@ -# ── Frappe Connection ──────────────────────────────────────────────────────── -# URL of your Frappe Docker instance (VPS IP or domain) -FRAPPE_URL=http://YOUR_VPS_IP:8000 +# ══════════════════════════════════════════════════════════════════════════════ +# DEPLOYMENT MODE +# ══════════════════════════════════════════════════════════════════════════════ +# +# SSE / VPS mode (hosted server — recommended): +# Frappe credentials are NOT stored here. Each user passes their own +# X-Frappe-URL, X-Frappe-API-Key, X-Frappe-API-Secret headers when connecting. +# Only set MCP_HOST, MCP_PORT, MCP_BEARER_TOKEN below. +# +# Local / stdio mode: +# Credentials are read from this file. Run: frappe-mcp (no --sse flag). +# Uncomment the Frappe Connection block below. +# +# ── SSE Server Settings (VPS hosting) ──────────────────────────────────────── +MCP_HOST=0.0.0.0 +MCP_PORT=8001 -# API credentials — generate in Frappe: Settings > My Profile > API Access -FRAPPE_API_KEY=your_api_key_here -FRAPPE_API_SECRET=your_api_secret_here +# Shared access token — all users must send this as: Authorization: Bearer +# Generate with: python -c "import secrets; print(secrets.token_hex(32))" +MCP_BEARER_TOKEN=change_this_to_a_strong_random_token -# For multi-site Docker setups — the site name e.g. "site1.localhost" -FRAPPE_SITE_NAME= +# ── Frappe Connection (local/stdio mode only) ───────────────────────────────── +# FRAPPE_URL=http://YOUR_VPS_IP:8000 +# FRAPPE_API_KEY=your_api_key_here +# FRAPPE_API_SECRET=your_api_secret_here +# FRAPPE_SITE_NAME= # ── Safety ─────────────────────────────────────────────────────────────────── # Set to true to block ALL write/delete operations (read-only audit mode) diff --git a/frappe_mcp/client/frappe_api.py b/frappe_mcp/client/frappe_api.py index 9048468..5e96086 100644 --- a/frappe_mcp/client/frappe_api.py +++ b/frappe_mcp/client/frappe_api.py @@ -7,6 +7,7 @@ import json import httpx from typing import Any from frappe_mcp.config import get_settings +from frappe_mcp.session import get_session class FrappeAPIError(Exception): @@ -18,21 +19,37 @@ class FrappeAPIError(Exception): class FrappeClient: def __init__(self): - self.settings = get_settings() - self.base_url = self.settings.frappe_url - self._auth_header = self._build_auth_header() + session = get_session() + if session: + # SSE mode: credentials supplied per-connection via request headers + self.base_url = session.frappe_url.rstrip("/") + self._api_key = session.api_key + self._api_secret = session.api_secret + self._site_name = session.site_name + self.request_timeout = session.request_timeout + else: + # Stdio/local mode: credentials from .env + s = get_settings() + self.base_url = s.frappe_url + self._api_key = s.frappe_api_key + self._api_secret = s.frappe_api_secret + self._site_name = s.frappe_site_name + self.request_timeout = s.request_timeout + + if not self._api_key or not self._api_secret: + raise FrappeAPIError( + "Frappe credentials not configured. " + "In SSE mode pass X-Frappe-URL, X-Frappe-API-Key, X-Frappe-API-Secret headers. " + "In local mode set FRAPPE_API_KEY and FRAPPE_API_SECRET in .env" + ) def _build_auth_header(self) -> dict[str, str]: - key = self.settings.frappe_api_key - secret = self.settings.frappe_api_secret - if not key or not secret: - raise FrappeAPIError("FRAPPE_API_KEY and FRAPPE_API_SECRET must be set in .env") - return {"Authorization": f"token {key}:{secret}"} + return {"Authorization": f"token {self._api_key}:{self._api_secret}"} def _headers(self, extra: dict | None = None) -> dict[str, str]: - h = {**self._auth_header, "Content-Type": "application/json", "Accept": "application/json"} - if self.settings.frappe_site_name: - h["X-Frappe-Site-Name"] = self.settings.frappe_site_name + h = {**self._build_auth_header(), "Content-Type": "application/json", "Accept": "application/json"} + if self._site_name: + h["X-Frappe-Site-Name"] = self._site_name if extra: h.update(extra) return h @@ -48,7 +65,7 @@ class FrappeClient: ) async def get(self, path: str, params: dict | None = None) -> Any: - async with httpx.AsyncClient(timeout=self.settings.request_timeout) as client: + async with httpx.AsyncClient(timeout=self.request_timeout) as client: resp = await client.get(self._url(path), headers=self._headers(), params=params) resp.raise_for_status() data = resp.json() @@ -87,7 +104,7 @@ class FrappeClient: resp.raise_for_status() async def post(self, path: str, payload: dict | None = None) -> Any: - async with httpx.AsyncClient(timeout=self.settings.request_timeout) as client: + async with httpx.AsyncClient(timeout=self.request_timeout) as client: resp = await client.post(self._url(path), headers=self._headers(), json=payload or {}) self._handle_error_response(resp) data = resp.json() @@ -95,7 +112,7 @@ class FrappeClient: return data.get("data", data) async def put(self, path: str, payload: dict | None = None) -> Any: - async with httpx.AsyncClient(timeout=self.settings.request_timeout) as client: + async with httpx.AsyncClient(timeout=self.request_timeout) as client: resp = await client.put(self._url(path), headers=self._headers(), json=payload or {}) self._handle_error_response(resp) data = resp.json() @@ -103,7 +120,7 @@ class FrappeClient: return data.get("data", data) async def delete(self, path: str) -> Any: - async with httpx.AsyncClient(timeout=self.settings.request_timeout) as client: + async with httpx.AsyncClient(timeout=self.request_timeout) as client: resp = await client.delete(self._url(path), headers=self._headers()) resp.raise_for_status() data = resp.json() diff --git a/frappe_mcp/server.py b/frappe_mcp/server.py index e133397..b79e204 100644 --- a/frappe_mcp/server.py +++ b/frappe_mcp/server.py @@ -82,6 +82,27 @@ def _run_sse(): auth = request.headers.get("Authorization", "") if auth != f"Bearer {bearer_token}": return Response("Unauthorized", status_code=401) + + # Extract per-user Frappe credentials from request headers + frappe_url = request.headers.get("X-Frappe-URL", "") + api_key = request.headers.get("X-Frappe-API-Key", "") + api_secret = request.headers.get("X-Frappe-API-Secret", "") + site_name = request.headers.get("X-Frappe-Site-Name", "") + + if not frappe_url or not api_key or not api_secret: + return Response( + "Missing required headers: X-Frappe-URL, X-Frappe-API-Key, X-Frappe-API-Secret", + status_code=400, + ) + + from frappe_mcp.session import SessionConfig, set_session + set_session(SessionConfig( + frappe_url=frappe_url, + api_key=api_key, + api_secret=api_secret, + site_name=site_name, + )) + async with sse.connect_sse( request.scope, request.receive, request._send ) as streams: diff --git a/frappe_mcp/session.py b/frappe_mcp/session.py new file mode 100644 index 0000000..767ed7a --- /dev/null +++ b/frappe_mcp/session.py @@ -0,0 +1,29 @@ +""" +Per-connection session config using Python contextvars. +Each SSE connection sets its own Frappe credentials, isolated from other users. +Falls back to .env values if no per-session config is set (local stdio mode). +""" + +from contextvars import ContextVar +from dataclasses import dataclass + + +@dataclass +class SessionConfig: + frappe_url: str + api_key: str + api_secret: str + site_name: str = "" + read_only_mode: bool = False + request_timeout: int = 30 + + +_session: ContextVar[SessionConfig | None] = ContextVar("frappe_session", default=None) + + +def set_session(config: SessionConfig) -> None: + _session.set(config) + + +def get_session() -> SessionConfig | None: + return _session.get()