From 98d8b896fc3552d6a1086101eaa668a5f3c985e1 Mon Sep 17 00:00:00 2001 From: Muhammad Hassan Ahmed <33365802+hassan11196@users.noreply.github.com> Date: Sun, 15 Mar 2026 19:56:25 +0000 Subject: [PATCH 01/30] add archi MCP server Introduces archi_mcp/, a standalone Model Context Protocol server that exposes archi's RAG capabilities as MCP tools for VS Code, Cursor, and other MCP-compatible AI assistants. Tools exposed: - archi_query ask a question via the active RAG pipeline - archi_list_documents browse the indexed knowledge base - archi_get_document_content read a specific indexed document - archi_get_deployment_info show active pipeline/model/retrieval config - archi_list_agents list available agent specs - archi_health verify the deployment is reachable The server connects to a running archi chat service over HTTP (stdio transport); no archi internals are imported. Configuration is via ARCHI_URL, ARCHI_API_KEY, and ARCHI_TIMEOUT environment variables. pyproject.toml: - adds [project.optional-dependencies] mcp = ["mcp>=1.0.0", ...] - registers archi-mcp CLI entry point - includes archi_mcp package in setuptools find archi_mcp/README.md covers VS Code (.vscode/mcp.json), Cursor (~/.cursor/mcp.json), and generic stdio client setup. --- archi_mcp/README.md | 187 ++++++++++++++++++ archi_mcp/__init__.py | 1 + archi_mcp/__main__.py | 3 + archi_mcp/client.py | 167 ++++++++++++++++ archi_mcp/server.py | 443 ++++++++++++++++++++++++++++++++++++++++++ pyproject.toml | 9 +- 6 files changed, 809 insertions(+), 1 deletion(-) create mode 100644 archi_mcp/README.md create mode 100644 archi_mcp/__init__.py create mode 100644 archi_mcp/__main__.py create mode 100644 archi_mcp/client.py create mode 100644 archi_mcp/server.py diff --git a/archi_mcp/README.md b/archi_mcp/README.md new file mode 100644 index 000000000..427347252 --- /dev/null +++ b/archi_mcp/README.md @@ -0,0 +1,187 @@ +# archi MCP Server + +Expose your [archi](https://github.com/archi-physics/archi) knowledge base as a set of +**Model Context Protocol (MCP) tools** so that AI assistants in +[VS Code](https://code.visualstudio.com/), [Cursor](https://cursor.sh/), and any other +MCP-compatible client can query it directly. + +--- + +## What this provides + +| Tool | Description | +|---|---| +| `archi_query` | Ask archi a question. Uses the active RAG pipeline to retrieve relevant documents and compose a grounded answer. Supports multi-turn conversation. | +| `archi_list_documents` | List documents indexed in archi's knowledge base (filterable by keyword or source type). | +| `archi_get_document_content` | Read the full text of a specific indexed document. | +| `archi_get_deployment_info` | Show the active pipeline, model, retrieval settings, and available providers. | +| `archi_list_agents` | List available agent configurations (agent specs). | +| `archi_health` | Check that the archi deployment is reachable and its database is connected. | + +--- + +## Prerequisites + +1. A running archi deployment (the chat app service must be reachable). +2. Python 3.9+ with the `mcp` package installed. + +```bash +pip install "mcp>=1.0.0" +``` + +Or install directly from the archi repo: + +```bash +pip install -e ".[mcp]" +``` + +--- + +## Environment variables + +| Variable | Default | Description | +|---|---|---| +| `ARCHI_URL` | `http://localhost:5000` | Base URL of your archi chat app service. | +| `ARCHI_API_KEY` | _(none)_ | Bearer token, if archi auth is enabled. | +| `ARCHI_TIMEOUT` | `120` | HTTP request timeout in seconds. | + +--- + +## Running the server manually + +```bash +ARCHI_URL=http://your-archi-host:5000 python -m archi_mcp +# or +ARCHI_URL=http://your-archi-host:5000 archi-mcp +``` + +The server uses **stdio transport** (stdin/stdout), which is the standard transport +used by VS Code and Cursor. + +--- + +## VS Code setup + +### Option A — `.vscode/mcp.json` (workspace-scoped, recommended) + +Create `.vscode/mcp.json` in your project root: + +```json +{ + "servers": { + "archi": { + "type": "stdio", + "command": "python", + "args": ["-m", "archi_mcp"], + "env": { + "ARCHI_URL": "http://localhost:5000", + "ARCHI_API_KEY": "", + "ARCHI_TIMEOUT": "120" + } + } + } +} +``` + +> **Tip:** Set `ARCHI_URL` to the address of your archi chat app service. +> If archi runs in a container, use `http://localhost:` where +> `` is the port mapped to the container's chat service. + +### Option B — User settings (`settings.json`) + +Open your VS Code user `settings.json` and add: + +```json +"github.copilot.chat.mcp.servers": { + "archi": { + "type": "stdio", + "command": "python", + "args": ["-m", "archi_mcp"], + "env": { + "ARCHI_URL": "http://localhost:5000" + } + } +} +``` + +### Verifying in VS Code + +1. Open the GitHub Copilot Chat panel. +2. Click the **Tools** button (plug icon). +3. You should see the six `archi_*` tools listed and enabled. + +--- + +## Cursor setup + +Open **Cursor Settings → MCP** (or edit `~/.cursor/mcp.json`) and add: + +```json +{ + "mcpServers": { + "archi": { + "command": "python", + "args": ["-m", "archi_mcp"], + "env": { + "ARCHI_URL": "http://localhost:5000", + "ARCHI_API_KEY": "", + "ARCHI_TIMEOUT": "120" + } + } + } +} +``` + +Then restart Cursor. The archi tools appear in the **Composer** tool list. + +--- + +## Other MCP clients + +The server uses the standard **stdio** transport, so it works with any MCP-compatible +client that can launch a subprocess. Point the client at: + +``` +command: python -m archi_mcp +env: ARCHI_URL=http://: +``` + +--- + +## Example usage + +Once configured, you can ask your AI assistant: + +> _"Use archi to find documentation about the GPU cluster submission process."_ + +> _"Query archi: what are the memory limits for batch jobs?"_ + +> _"List the documents archi has indexed, then show me the content of the SLURM guide."_ + +The assistant will invoke the appropriate `archi_*` tool and incorporate the retrieved +information into its response. + +--- + +## Multi-turn conversations + +`archi_query` returns a `conversation_id`. Pass it back to continue the thread: + +``` +First call: archi_query(question="What is SubMIT?") + → answer + conversation_id: 42 + +Follow-up: archi_query(question="What hardware does it use?", conversation_id=42) + → answer with context from the previous exchange +``` + +--- + +## Troubleshooting + +| Symptom | Fix | +|---|---| +| `Cannot reach archi at http://localhost:5000` | Check `ARCHI_URL`, ensure the chat service is running, and that the port is reachable from where the MCP server runs. | +| `archi returned 401` | Set `ARCHI_API_KEY` to a valid token if archi authentication is enabled. | +| Tools not visible in VS Code | Reload the VS Code window after editing `mcp.json`. Confirm that `python -m archi_mcp` exits cleanly without errors. | +| Empty document list | The archi data-manager service may not have finished ingestion yet, or no sources are configured. | diff --git a/archi_mcp/__init__.py b/archi_mcp/__init__.py new file mode 100644 index 000000000..78704a2b7 --- /dev/null +++ b/archi_mcp/__init__.py @@ -0,0 +1 @@ +# archi MCP Server package diff --git a/archi_mcp/__main__.py b/archi_mcp/__main__.py new file mode 100644 index 000000000..bcc16448a --- /dev/null +++ b/archi_mcp/__main__.py @@ -0,0 +1,3 @@ +from archi_mcp.server import main + +main() diff --git a/archi_mcp/client.py b/archi_mcp/client.py new file mode 100644 index 000000000..34253e3ca --- /dev/null +++ b/archi_mcp/client.py @@ -0,0 +1,167 @@ +""" +HTTP client for communicating with a running archi deployment. + +Wraps the archi REST API so the MCP server can call archi endpoints +without embedding any of archi's internal Python dependencies. +""" + +from __future__ import annotations + +import time +import uuid +from typing import Any, Dict, List, Optional + +import requests + + +class ArchiClientError(RuntimeError): + """Raised when the archi API returns an error or is unreachable.""" + + +class ArchiClient: + """Thin HTTP client for archi's REST API.""" + + def __init__( + self, + base_url: str = "http://localhost:5000", + timeout: int = 120, + api_key: Optional[str] = None, + ) -> None: + self.base_url = base_url.rstrip("/") + self.timeout = timeout + self._client_id = f"mcp-{uuid.uuid4().hex[:12]}" + self._session = requests.Session() + self._session.headers["X-Client-ID"] = self._client_id + if api_key: + self._session.headers["Authorization"] = f"Bearer {api_key}" + + # ------------------------------------------------------------------ + # Helpers + # ------------------------------------------------------------------ + + def _url(self, path: str) -> str: + return f"{self.base_url}{path}" + + def _get(self, path: str, params: Optional[Dict] = None) -> Any: + try: + resp = self._session.get(self._url(path), params=params, timeout=self.timeout) + except requests.RequestException as exc: + raise ArchiClientError(f"Cannot reach archi at {self.base_url}: {exc}") from exc + if not resp.ok: + raise ArchiClientError(f"archi returned {resp.status_code} for GET {path}: {resp.text}") + return resp.json() + + def _post(self, path: str, payload: Dict) -> Any: + try: + resp = self._session.post(self._url(path), json=payload, timeout=self.timeout) + except requests.RequestException as exc: + raise ArchiClientError(f"Cannot reach archi at {self.base_url}: {exc}") from exc + if not resp.ok: + raise ArchiClientError(f"archi returned {resp.status_code} for POST {path}: {resp.text}") + return resp.json() + + # ------------------------------------------------------------------ + # Health + # ------------------------------------------------------------------ + + def health(self) -> Dict[str, Any]: + """Return health status from archi.""" + return self._get("/api/health") + + # ------------------------------------------------------------------ + # Query / Chat + # ------------------------------------------------------------------ + + def query( + self, + message: str, + conversation_id: Optional[int] = None, + provider: Optional[str] = None, + model: Optional[str] = None, + ) -> Dict[str, Any]: + """ + Ask archi a question using its active RAG pipeline. + + Returns a dict with at least: + response (str): the answer text + conversation_id (int): conversation ID for follow-up queries + """ + payload: Dict[str, Any] = { + "last_message": message, + "client_id": self._client_id, + "client_sent_msg_ts": int(time.time() * 1000), + "client_timeout": self.timeout * 1000, + "include_agent_steps": False, + "include_tool_steps": False, + } + if conversation_id is not None: + payload["conversation_id"] = conversation_id + if provider: + payload["provider"] = provider + if model: + payload["model"] = model + + return self._post("/api/get_chat_response", payload) + + # ------------------------------------------------------------------ + # Documents / Data Viewer + # ------------------------------------------------------------------ + + def list_documents( + self, + page: int = 1, + per_page: int = 50, + search: Optional[str] = None, + source_type: Optional[str] = None, + ) -> Dict[str, Any]: + """List indexed documents in the archi vectorstore.""" + params: Dict[str, Any] = {"page": page, "per_page": per_page} + if search: + params["search"] = search + if source_type: + params["source_type"] = source_type + return self._get("/api/data/documents", params=params) + + def get_document_content(self, document_hash: str) -> Dict[str, Any]: + """Return the raw content of a specific indexed document.""" + return self._get(f"/api/data/documents/{document_hash}/content") + + def get_document_chunks(self, document_hash: str) -> Dict[str, Any]: + """Return the vectorstore chunks for a specific document.""" + return self._get(f"/api/data/documents/{document_hash}/chunks") + + # ------------------------------------------------------------------ + # Configuration + # ------------------------------------------------------------------ + + def get_static_config(self) -> Dict[str, Any]: + """Return the static (deploy-time) archi configuration.""" + return self._get("/api/config/static") + + def get_dynamic_config(self) -> Dict[str, Any]: + """Return the dynamic (runtime) archi configuration.""" + return self._get("/api/config/dynamic") + + # ------------------------------------------------------------------ + # Agents + # ------------------------------------------------------------------ + + def list_agents(self) -> Dict[str, Any]: + """Return the list of available agent spec files.""" + return self._get("/api/agents/list") + + def get_agent_info(self) -> Dict[str, Any]: + """Return info about the currently active agent.""" + return self._get("/api/agent/info") + + # ------------------------------------------------------------------ + # Providers / Models + # ------------------------------------------------------------------ + + def list_providers(self) -> Dict[str, Any]: + """Return available model providers and their models.""" + return self._get("/api/providers") + + def get_api_info(self) -> Dict[str, Any]: + """Return archi API version and feature information.""" + return self._get("/api/info") diff --git a/archi_mcp/server.py b/archi_mcp/server.py new file mode 100644 index 000000000..a8a8d19e4 --- /dev/null +++ b/archi_mcp/server.py @@ -0,0 +1,443 @@ +""" +archi MCP Server + +Exposes archi's RAG capabilities as MCP tools so AI assistants in +VS Code (GitHub Copilot), Cursor, and other MCP-compatible clients +can query your archi knowledge base directly. + +Usage +----- +Run as a standalone process (stdio transport, which VS Code / Cursor use): + + python -m archi_mcp + +Or via the installed CLI entry-point: + + archi-mcp + +Configuration via environment variables: + + ARCHI_URL URL of a running archi deployment (default: http://localhost:5000) + ARCHI_API_KEY Optional bearer token if archi authentication is enabled + ARCHI_TIMEOUT HTTP timeout in seconds (default: 120) +""" + +from __future__ import annotations + +import json +import os +import sys +import textwrap +from typing import Any, Dict, List, Optional + +# --------------------------------------------------------------------------- +# Dependency check – give a clear error if mcp is not installed. +# --------------------------------------------------------------------------- +try: + from mcp.server import Server + from mcp.server.stdio import stdio_server + import mcp.types as types +except ImportError as _err: # noqa: F841 + print( + "ERROR: The 'mcp' package is required to run the archi MCP server.\n" + "Install it with: pip install mcp\n" + "Or: pip install 'archi[mcp]'", + file=sys.stderr, + ) + sys.exit(1) + +from archi_mcp.client import ArchiClient, ArchiClientError # noqa: E402 (local import) + +# --------------------------------------------------------------------------- +# Server configuration +# --------------------------------------------------------------------------- +_DEFAULT_URL = "http://localhost:5000" +_DEFAULT_TIMEOUT = 120 + +ARCHI_URL: str = os.environ.get("ARCHI_URL", _DEFAULT_URL) +ARCHI_API_KEY: Optional[str] = os.environ.get("ARCHI_API_KEY") +ARCHI_TIMEOUT: int = int(os.environ.get("ARCHI_TIMEOUT", str(_DEFAULT_TIMEOUT))) + +# --------------------------------------------------------------------------- +# MCP Server setup +# --------------------------------------------------------------------------- +server = Server("archi") +_client: Optional[ArchiClient] = None + + +def _get_client() -> ArchiClient: + global _client + if _client is None: + _client = ArchiClient( + base_url=ARCHI_URL, + timeout=ARCHI_TIMEOUT, + api_key=ARCHI_API_KEY, + ) + return _client + + +# --------------------------------------------------------------------------- +# Tool definitions +# --------------------------------------------------------------------------- + +@server.list_tools() +async def list_tools() -> List[types.Tool]: + return [ + types.Tool( + name="archi_query", + description=textwrap.dedent("""\ + Ask a question to the archi RAG (Retrieval-Augmented Generation) system. + + archi retrieves relevant documents from its knowledge base and uses an LLM + to compose a grounded answer. Use this tool when you need information that + is stored in the connected archi deployment (documentation, tickets, wiki + pages, research papers, course material, etc.). + + You may continue a conversation by passing the conversation_id returned by + a previous call. + """), + inputSchema={ + "type": "object", + "properties": { + "question": { + "type": "string", + "description": "The question or request to send to archi.", + }, + "conversation_id": { + "type": "integer", + "description": ( + "Optional. Pass the conversation_id from a previous archi_query " + "call to continue the same conversation thread." + ), + }, + "provider": { + "type": "string", + "description": ( + "Optional. Override the LLM provider for this query " + "(e.g. 'openai', 'anthropic', 'gemini', 'openrouter', 'local')." + ), + }, + "model": { + "type": "string", + "description": ( + "Optional. Override the specific model for this query " + "(e.g. 'gpt-4o', 'claude-3-5-sonnet', 'gemini-1.5-pro')." + ), + }, + }, + "required": ["question"], + }, + ), + types.Tool( + name="archi_list_documents", + description=textwrap.dedent("""\ + List the documents that have been indexed into archi's knowledge base. + + Returns a paginated list of document metadata (filename, source type, + URL, last updated, etc.). Use this tool to discover what information + archi has access to before querying it, or to find a specific document's + hash for use with archi_get_document_content. + """), + inputSchema={ + "type": "object", + "properties": { + "search": { + "type": "string", + "description": "Optional keyword to filter documents by name or URL.", + }, + "source_type": { + "type": "string", + "description": ( + "Optional. Filter by source type: 'web', 'git', 'local', " + "'jira', 'redmine', etc." + ), + }, + "page": { + "type": "integer", + "description": "Page number (1-based, default 1).", + "default": 1, + }, + "per_page": { + "type": "integer", + "description": "Number of results per page (default 50, max 200).", + "default": 50, + }, + }, + "required": [], + }, + ), + types.Tool( + name="archi_get_document_content", + description=textwrap.dedent("""\ + Retrieve the full text content of a document that is indexed in archi. + + Use archi_list_documents first to obtain a document's hash, then pass it + here to read the raw source text that archi ingested. + """), + inputSchema={ + "type": "object", + "properties": { + "document_hash": { + "type": "string", + "description": "The document hash returned by archi_list_documents.", + }, + }, + "required": ["document_hash"], + }, + ), + types.Tool( + name="archi_get_deployment_info", + description=textwrap.dedent("""\ + Return configuration and status information about the connected archi + deployment. + + Includes the active LLM pipeline and model, retrieval settings (number of + documents retrieved, hybrid search weights), embedding model, and the list + of available pipelines and LLM providers. Useful for understanding how + archi is configured before issuing queries. + """), + inputSchema={ + "type": "object", + "properties": {}, + "required": [], + }, + ), + types.Tool( + name="archi_list_agents", + description=textwrap.dedent("""\ + Return the agent configurations (agent specs) available in the connected + archi deployment. + + Each agent spec defines a name, a system prompt, and the set of tools + (retriever, MCP servers, local file search, etc.) that agent can use. + Use this to understand which specialised agents are available before + selecting one for archi_query. + """), + inputSchema={ + "type": "object", + "properties": {}, + "required": [], + }, + ), + types.Tool( + name="archi_health", + description=textwrap.dedent("""\ + Check whether the archi deployment is reachable and healthy. + + Returns the service status and database connectivity. Call this first + if other archi tools are failing, to confirm that the deployment is up. + """), + inputSchema={ + "type": "object", + "properties": {}, + "required": [], + }, + ), + ] + + +# --------------------------------------------------------------------------- +# Tool handlers +# --------------------------------------------------------------------------- + +def _ok(data: Any) -> List[types.TextContent]: + """Wrap any Python value as a JSON TextContent block.""" + if isinstance(data, str): + return [types.TextContent(type="text", text=data)] + return [types.TextContent(type="text", text=json.dumps(data, indent=2, default=str))] + + +def _err(message: str) -> List[types.TextContent]: + return [types.TextContent(type="text", text=f"ERROR: {message}")] + + +@server.call_tool() +async def call_tool(name: str, arguments: Dict[str, Any]) -> List[types.TextContent]: + client = _get_client() + + # ------------------------------------------------------------------ + # archi_query + # ------------------------------------------------------------------ + if name == "archi_query": + question = arguments.get("question", "").strip() + if not question: + return _err("'question' is required and must not be empty.") + try: + result = client.query( + message=question, + conversation_id=arguments.get("conversation_id"), + provider=arguments.get("provider"), + model=arguments.get("model"), + ) + except ArchiClientError as exc: + return _err(str(exc)) + + answer = result.get("response", "") + conv_id = result.get("conversation_id") + + parts = [answer] + if conv_id is not None: + parts.append( + f"\n\n---\n_conversation_id: {conv_id} " + "(pass this to archi_query to continue the conversation)_" + ) + return [types.TextContent(type="text", text="".join(parts))] + + # ------------------------------------------------------------------ + # archi_list_documents + # ------------------------------------------------------------------ + elif name == "archi_list_documents": + try: + result = client.list_documents( + page=arguments.get("page", 1), + per_page=min(arguments.get("per_page", 50), 200), + search=arguments.get("search"), + source_type=arguments.get("source_type"), + ) + except ArchiClientError as exc: + return _err(str(exc)) + + docs = result.get("documents", result.get("items", [])) + total = result.get("total", len(docs)) + page = result.get("page", 1) + per_page = result.get("per_page", len(docs)) + + lines = [f"Found {total} document(s) (page {page}, {per_page} per page):\n"] + for doc in docs: + name_field = ( + doc.get("filename") + or doc.get("name") + or doc.get("url") + or doc.get("hash", "unknown") + ) + source = doc.get("source_type", doc.get("type", "")) + doc_hash = doc.get("hash", doc.get("id", "")) + lines.append(f" • {name_field} [{source}] hash={doc_hash}") + + lines.append( + "\nUse archi_get_document_content(document_hash=) to read a document's text." + ) + return [types.TextContent(type="text", text="\n".join(lines))] + + # ------------------------------------------------------------------ + # archi_get_document_content + # ------------------------------------------------------------------ + elif name == "archi_get_document_content": + doc_hash = arguments.get("document_hash", "").strip() + if not doc_hash: + return _err("'document_hash' is required.") + try: + result = client.get_document_content(doc_hash) + except ArchiClientError as exc: + return _err(str(exc)) + content = result.get("content", result.get("text", json.dumps(result, indent=2))) + return [types.TextContent(type="text", text=content)] + + # ------------------------------------------------------------------ + # archi_get_deployment_info + # ------------------------------------------------------------------ + elif name == "archi_get_deployment_info": + try: + static = client.get_static_config() + dynamic = client.get_dynamic_config() + except ArchiClientError as exc: + return _err(str(exc)) + + lines = [ + f"# archi Deployment: {static.get('deployment_name', 'unknown')}", + "", + "## Active configuration", + f" Pipeline: {dynamic.get('active_pipeline', 'n/a')}", + f" Model: {dynamic.get('active_model', 'n/a')}", + f" Temperature: {dynamic.get('temperature', 'n/a')}", + f" Max tokens: {dynamic.get('max_tokens', 'n/a')}", + f" Docs retrieved (k): {dynamic.get('num_documents_to_retrieve', 'n/a')}", + f" Hybrid search: {dynamic.get('use_hybrid_search', 'n/a')}", + f" BM25 weight: {dynamic.get('bm25_weight', 'n/a')}", + f" Semantic weight: {dynamic.get('semantic_weight', 'n/a')}", + "", + "## Embedding", + f" Model: {static.get('embedding_model', 'n/a')}", + f" Dimensions: {static.get('embedding_dimensions', 'n/a')}", + f" Chunk size: {static.get('chunk_size', 'n/a')}", + f" Chunk overlap: {static.get('chunk_overlap', 'n/a')}", + f" Distance metric: {static.get('distance_metric', 'n/a')}", + "", + "## Available pipelines", + ] + for p in static.get("available_pipelines", []): + lines.append(f" • {p}") + lines.append("") + lines.append("## Available models / providers") + for provider in static.get("available_providers", []): + lines.append(f" • {provider}") + + return [types.TextContent(type="text", text="\n".join(lines))] + + # ------------------------------------------------------------------ + # archi_list_agents + # ------------------------------------------------------------------ + elif name == "archi_list_agents": + try: + result = client.list_agents() + except ArchiClientError as exc: + return _err(str(exc)) + + agents = result.get("agents", result if isinstance(result, list) else []) + if not agents: + return [types.TextContent(type="text", text="No agent specs found in this deployment.")] + + lines = [f"Available agent specs ({len(agents)}):\n"] + for agent in agents: + agent_name = agent.get("name", agent.get("filename", "unknown")) + tools = agent.get("tools", []) + tools_str = ", ".join(tools) if tools else "none" + lines.append(f" • {agent_name}") + lines.append(f" Tools: {tools_str}") + + return [types.TextContent(type="text", text="\n".join(lines))] + + # ------------------------------------------------------------------ + # archi_health + # ------------------------------------------------------------------ + elif name == "archi_health": + try: + result = client.health() + except ArchiClientError as exc: + return _err(str(exc)) + status = result.get("status", "unknown") + db = result.get("database", "unknown") + ts = result.get("timestamp", "") + msg = f"archi status: {status}\nDatabase: {db}\nTimestamp: {ts}" + return [types.TextContent(type="text", text=msg)] + + else: + return _err(f"Unknown tool: {name}") + + +# --------------------------------------------------------------------------- +# Entry point +# --------------------------------------------------------------------------- + +async def _run() -> None: + async with stdio_server() as (read_stream, write_stream): + await server.run( + read_stream, + write_stream, + server.create_initialization_options(), + ) + + +def main() -> None: + """CLI entry point: archi-mcp""" + import asyncio + + print( + f"Starting archi MCP server (archi URL: {ARCHI_URL})", + file=sys.stderr, + ) + asyncio.run(_run()) + + +if __name__ == "__main__": + main() diff --git a/pyproject.toml b/pyproject.toml index f5136f334..7437918ea 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -29,8 +29,15 @@ dependencies = [ "psycopg2-binary==2.9.10" ] +[project.optional-dependencies] +mcp = [ + "mcp>=1.0.0", + "requests>=2.31.0", +] + [project.scripts] archi = "src.cli.cli_main:main" +archi-mcp = "archi_mcp.server:main" [tool.setuptools.package-data] "src.cli" = ["templates/**/*"] @@ -39,7 +46,7 @@ archi = "src.cli.cli_main:main" include-package-data = true [tool.setuptools.packages.find] -include = ["src*"] +include = ["src*", "archi_mcp*"] [build-system] requires = ["setuptools>=61.0.0"] From 12c93ede964e077a17979e3c76629d1b718d5a65 Mon Sep 17 00:00:00 2001 From: Muhammad Hassan Ahmed <33365802+hassan11196@users.noreply.github.com> Date: Sun, 15 Mar 2026 19:57:59 +0000 Subject: [PATCH 02/30] fix default archi port to 7861 (chat service default) --- archi_mcp/README.md | 10 +++++----- archi_mcp/server.py | 4 ++-- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/archi_mcp/README.md b/archi_mcp/README.md index 427347252..742e58335 100644 --- a/archi_mcp/README.md +++ b/archi_mcp/README.md @@ -41,7 +41,7 @@ pip install -e ".[mcp]" | Variable | Default | Description | |---|---|---| -| `ARCHI_URL` | `http://localhost:5000` | Base URL of your archi chat app service. | +| `ARCHI_URL` | `http://localhost:7861` | Base URL of your archi chat app service. | | `ARCHI_API_KEY` | _(none)_ | Bearer token, if archi auth is enabled. | | `ARCHI_TIMEOUT` | `120` | HTTP request timeout in seconds. | @@ -74,7 +74,7 @@ Create `.vscode/mcp.json` in your project root: "command": "python", "args": ["-m", "archi_mcp"], "env": { - "ARCHI_URL": "http://localhost:5000", + "ARCHI_URL": "http://localhost:7861", "ARCHI_API_KEY": "", "ARCHI_TIMEOUT": "120" } @@ -98,7 +98,7 @@ Open your VS Code user `settings.json` and add: "command": "python", "args": ["-m", "archi_mcp"], "env": { - "ARCHI_URL": "http://localhost:5000" + "ARCHI_URL": "http://localhost:7861" } } } @@ -123,7 +123,7 @@ Open **Cursor Settings → MCP** (or edit `~/.cursor/mcp.json`) and add: "command": "python", "args": ["-m", "archi_mcp"], "env": { - "ARCHI_URL": "http://localhost:5000", + "ARCHI_URL": "http://localhost:7861", "ARCHI_API_KEY": "", "ARCHI_TIMEOUT": "120" } @@ -181,7 +181,7 @@ Follow-up: archi_query(question="What hardware does it use?", conversation_id= | Symptom | Fix | |---|---| -| `Cannot reach archi at http://localhost:5000` | Check `ARCHI_URL`, ensure the chat service is running, and that the port is reachable from where the MCP server runs. | +| `Cannot reach archi at http://localhost:7861` | Check `ARCHI_URL`, ensure the chat service is running, and that the port is reachable from where the MCP server runs. | | `archi returned 401` | Set `ARCHI_API_KEY` to a valid token if archi authentication is enabled. | | Tools not visible in VS Code | Reload the VS Code window after editing `mcp.json`. Confirm that `python -m archi_mcp` exits cleanly without errors. | | Empty document list | The archi data-manager service may not have finished ingestion yet, or no sources are configured. | diff --git a/archi_mcp/server.py b/archi_mcp/server.py index a8a8d19e4..338ebc633 100644 --- a/archi_mcp/server.py +++ b/archi_mcp/server.py @@ -17,7 +17,7 @@ Configuration via environment variables: - ARCHI_URL URL of a running archi deployment (default: http://localhost:5000) + ARCHI_URL URL of a running archi deployment (default: http://localhost:7861) ARCHI_API_KEY Optional bearer token if archi authentication is enabled ARCHI_TIMEOUT HTTP timeout in seconds (default: 120) """ @@ -51,7 +51,7 @@ # --------------------------------------------------------------------------- # Server configuration # --------------------------------------------------------------------------- -_DEFAULT_URL = "http://localhost:5000" +_DEFAULT_URL = "http://localhost:7861" _DEFAULT_TIMEOUT = 120 ARCHI_URL: str = os.environ.get("ARCHI_URL", _DEFAULT_URL) From eca35ed910489a60a7a57863ab078fef25eb6c03 Mon Sep 17 00:00:00 2001 From: Muhammad Hassan Ahmed <33365802+hassan11196@users.noreply.github.com> Date: Sun, 15 Mar 2026 22:05:15 +0000 Subject: [PATCH 03/30] make archi-mcp configurable from archi config YAML - Add services.mcp_server block to base-config.yaml template (url, api_key, timeout; url defaults to chat_app hostname+port) - Add --config flag to archi-mcp CLI to read settings from a rendered archi config file; env vars still take precedence - Rewrite archi_mcp/README.md with server setup, archi config snippet, and VS Code / Cursor client setup instructions --- archi_mcp/README.md | 204 +++++++++++++---------------- archi_mcp/server.py | 77 ++++++++++- src/cli/templates/base-config.yaml | 9 ++ 3 files changed, 175 insertions(+), 115 deletions(-) diff --git a/archi_mcp/README.md b/archi_mcp/README.md index 742e58335..4ecf4a3af 100644 --- a/archi_mcp/README.md +++ b/archi_mcp/README.md @@ -1,9 +1,8 @@ # archi MCP Server -Expose your [archi](https://github.com/archi-physics/archi) knowledge base as a set of -**Model Context Protocol (MCP) tools** so that AI assistants in -[VS Code](https://code.visualstudio.com/), [Cursor](https://cursor.sh/), and any other -MCP-compatible client can query it directly. +Expose your [archi](https://github.com/archi-physics/archi) knowledge base as +**Model Context Protocol (MCP) tools** so that AI assistants in VS Code, Cursor, +and any other MCP-compatible client can query it directly. --- @@ -11,177 +10,160 @@ MCP-compatible client can query it directly. | Tool | Description | |---|---| -| `archi_query` | Ask archi a question. Uses the active RAG pipeline to retrieve relevant documents and compose a grounded answer. Supports multi-turn conversation. | -| `archi_list_documents` | List documents indexed in archi's knowledge base (filterable by keyword or source type). | -| `archi_get_document_content` | Read the full text of a specific indexed document. | -| `archi_get_deployment_info` | Show the active pipeline, model, retrieval settings, and available providers. | -| `archi_list_agents` | List available agent configurations (agent specs). | -| `archi_health` | Check that the archi deployment is reachable and its database is connected. | +| `archi_query` | Ask a question via archi's active RAG pipeline | +| `archi_list_documents` | Browse the indexed knowledge base | +| `archi_get_document_content` | Read the full text of an indexed document | +| `archi_get_deployment_info` | Show active pipeline, model, and retrieval config | +| `archi_list_agents` | List available agent specs | +| `archi_health` | Verify the deployment is reachable | --- -## Prerequisites +## Server setup -1. A running archi deployment (the chat app service must be reachable). -2. Python 3.9+ with the `mcp` package installed. +### 1. Install ```bash -pip install "mcp>=1.0.0" +pip install "archi[mcp]" ``` -Or install directly from the archi repo: +Or, in development (from the repo root): ```bash pip install -e ".[mcp]" ``` ---- +### 2. Configure archi + +Add an `mcp_server` block to your archi deployment config YAML. The defaults +work for a local deployment on the standard port: + +```yaml +services: + chat_app: + port: 7861 + external_port: 7861 + hostname: localhost # or your public hostname / domain + + mcp_server: + enabled: true + # Public URL that MCP clients will connect to. + # Defaults to http://: + url: "http://localhost:7861" + # Set this if chat app auth is enabled (services.chat_app.auth.enabled: true). + api_key: "" + # HTTP request timeout in seconds. + timeout: 120 +``` -## Environment variables +Redeploy so the rendered config picks up the new block: -| Variable | Default | Description | -|---|---|---| -| `ARCHI_URL` | `http://localhost:7861` | Base URL of your archi chat app service. | -| `ARCHI_API_KEY` | _(none)_ | Bearer token, if archi auth is enabled. | -| `ARCHI_TIMEOUT` | `120` | HTTP request timeout in seconds. | +```bash +archi restart --name --service chatbot +``` ---- +The rendered config lands at: -## Running the server manually +``` +~/.archi/archi-/configs/chat-config.yaml +``` + +### 3. Start the MCP server + +Point `archi-mcp` at the rendered config so it reads `services.mcp_server.*` +automatically: ```bash -ARCHI_URL=http://your-archi-host:5000 python -m archi_mcp -# or -ARCHI_URL=http://your-archi-host:5000 archi-mcp +archi-mcp --config ~/.archi/archi-/configs/chat-config.yaml ``` -The server uses **stdio transport** (stdin/stdout), which is the standard transport -used by VS Code and Cursor. +Without `--config`, settings fall back to environment variables: + +| Variable | Default | Description | +|---|---|---| +| `ARCHI_URL` | `http://localhost:7861` | Base URL of the archi chat service | +| `ARCHI_API_KEY` | *(none)* | Bearer token when auth is enabled | +| `ARCHI_TIMEOUT` | `120` | HTTP timeout in seconds | --- -## VS Code setup +## Client setup -### Option A — `.vscode/mcp.json` (workspace-scoped, recommended) +### VS Code (GitHub Copilot) -Create `.vscode/mcp.json` in your project root: +Create or edit `.vscode/mcp.json` in your workspace: ```json { "servers": { "archi": { "type": "stdio", - "command": "python", - "args": ["-m", "archi_mcp"], + "command": "archi-mcp", + "args": ["--config", "${env:HOME}/.archi/archi-mydeployment/configs/chat-config.yaml"] + } + } +} +``` + +To use environment variables instead: + +```json +{ + "servers": { + "archi": { + "type": "stdio", + "command": "archi-mcp", "env": { "ARCHI_URL": "http://localhost:7861", - "ARCHI_API_KEY": "", - "ARCHI_TIMEOUT": "120" + "ARCHI_API_KEY": "optional-token" } } } } ``` -> **Tip:** Set `ARCHI_URL` to the address of your archi chat app service. -> If archi runs in a container, use `http://localhost:` where -> `` is the port mapped to the container's chat service. +Reload the window (`Ctrl+Shift+P` → **Developer: Reload Window**) and the +archi tools appear in GitHub Copilot's tool picker. -### Option B — User settings (`settings.json`) +### Cursor -Open your VS Code user `settings.json` and add: +Edit `~/.cursor/mcp.json` (create it if it doesn't exist): ```json -"github.copilot.chat.mcp.servers": { - "archi": { - "type": "stdio", - "command": "python", - "args": ["-m", "archi_mcp"], - "env": { - "ARCHI_URL": "http://localhost:7861" +{ + "mcpServers": { + "archi": { + "command": "archi-mcp", + "args": ["--config", "/home/you/.archi/archi-mydeployment/configs/chat-config.yaml"] } } } ``` -### Verifying in VS Code - -1. Open the GitHub Copilot Chat panel. -2. Click the **Tools** button (plug icon). -3. You should see the six `archi_*` tools listed and enabled. - ---- - -## Cursor setup - -Open **Cursor Settings → MCP** (or edit `~/.cursor/mcp.json`) and add: +Or with environment variables: ```json { "mcpServers": { "archi": { - "command": "python", - "args": ["-m", "archi_mcp"], + "command": "archi-mcp", "env": { - "ARCHI_URL": "http://localhost:7861", - "ARCHI_API_KEY": "", - "ARCHI_TIMEOUT": "120" + "ARCHI_URL": "http://localhost:7861" } } } } ``` -Then restart Cursor. The archi tools appear in the **Composer** tool list. - ---- - -## Other MCP clients - -The server uses the standard **stdio** transport, so it works with any MCP-compatible -client that can launch a subprocess. Point the client at: - -``` -command: python -m archi_mcp -env: ARCHI_URL=http://: -``` - ---- - -## Example usage - -Once configured, you can ask your AI assistant: - -> _"Use archi to find documentation about the GPU cluster submission process."_ - -> _"Query archi: what are the memory limits for batch jobs?"_ - -> _"List the documents archi has indexed, then show me the content of the SLURM guide."_ - -The assistant will invoke the appropriate `archi_*` tool and incorporate the retrieved -information into its response. - ---- - -## Multi-turn conversations - -`archi_query` returns a `conversation_id`. Pass it back to continue the thread: - -``` -First call: archi_query(question="What is SubMIT?") - → answer + conversation_id: 42 - -Follow-up: archi_query(question="What hardware does it use?", conversation_id=42) - → answer with context from the previous exchange -``` +Restart Cursor. The archi tools appear under **MCP Tools** in the Composer panel. --- ## Troubleshooting -| Symptom | Fix | +| Error | Fix | |---|---| -| `Cannot reach archi at http://localhost:7861` | Check `ARCHI_URL`, ensure the chat service is running, and that the port is reachable from where the MCP server runs. | -| `archi returned 401` | Set `ARCHI_API_KEY` to a valid token if archi authentication is enabled. | -| Tools not visible in VS Code | Reload the VS Code window after editing `mcp.json`. Confirm that `python -m archi_mcp` exits cleanly without errors. | -| Empty document list | The archi data-manager service may not have finished ingestion yet, or no sources are configured. | +| `mcp package not found` | Run `pip install "archi[mcp]"` | +| `Cannot reach archi at http://localhost:7861` | Check `ARCHI_URL` or `services.mcp_server.url`; ensure the chat service is running | +| `401 Unauthorized` | Set `ARCHI_API_KEY` or `services.mcp_server.api_key` to a valid token | +| `WARNING: could not read archi config` | Check the path passed to `--config` | diff --git a/archi_mcp/server.py b/archi_mcp/server.py index 338ebc633..3d228820c 100644 --- a/archi_mcp/server.py +++ b/archi_mcp/server.py @@ -15,7 +15,14 @@ archi-mcp -Configuration via environment variables: +Point at an archi config file so settings are read automatically: + + archi-mcp --config ~/.archi/archi-mydeployment/configs/chat-config.yaml + +Configuration (in order of precedence: CLI flag > env var > archi config > default): + + --config Path to a rendered archi config YAML file. + Reads services.mcp_server.{url,api_key,timeout}. ARCHI_URL URL of a running archi deployment (default: http://localhost:7861) ARCHI_API_KEY Optional bearer token if archi authentication is enabled @@ -24,6 +31,7 @@ from __future__ import annotations +import argparse import json import os import sys @@ -54,9 +62,48 @@ _DEFAULT_URL = "http://localhost:7861" _DEFAULT_TIMEOUT = 120 -ARCHI_URL: str = os.environ.get("ARCHI_URL", _DEFAULT_URL) -ARCHI_API_KEY: Optional[str] = os.environ.get("ARCHI_API_KEY") -ARCHI_TIMEOUT: int = int(os.environ.get("ARCHI_TIMEOUT", str(_DEFAULT_TIMEOUT))) + +def _load_archi_config(config_path: str) -> Dict[str, Any]: + """Load services.mcp_server settings from a rendered archi config YAML.""" + try: + import yaml # PyYAML is already a core archi dependency + except ImportError: + return {} + try: + with open(config_path) as f: + data = yaml.safe_load(f) or {} + return data.get("services", {}).get("mcp_server", {}) + except Exception as exc: + print(f"WARNING: could not read archi config {config_path!r}: {exc}", file=sys.stderr) + return {} + + +def _resolve_config(config_path: Optional[str]) -> tuple: + """Return (url, api_key, timeout) by merging archi config, env vars, and defaults.""" + file_cfg: Dict[str, Any] = _load_archi_config(config_path) if config_path else {} + + url = ( + os.environ.get("ARCHI_URL") + or file_cfg.get("url") + or _DEFAULT_URL + ) + api_key = ( + os.environ.get("ARCHI_API_KEY") + or file_cfg.get("api_key") + or None + ) + timeout_raw = ( + os.environ.get("ARCHI_TIMEOUT") + or file_cfg.get("timeout") + or _DEFAULT_TIMEOUT + ) + return url, api_key, int(timeout_raw) + + +# Resolved at startup (may be overridden by main() after arg parsing) +ARCHI_URL: str = _DEFAULT_URL +ARCHI_API_KEY: Optional[str] = None +ARCHI_TIMEOUT: int = _DEFAULT_TIMEOUT # --------------------------------------------------------------------------- # MCP Server setup @@ -432,6 +479,28 @@ def main() -> None: """CLI entry point: archi-mcp""" import asyncio + global ARCHI_URL, ARCHI_API_KEY, ARCHI_TIMEOUT, _client + + parser = argparse.ArgumentParser( + prog="archi-mcp", + description="archi MCP server – expose your archi knowledge base as MCP tools.", + ) + parser.add_argument( + "--config", + metavar="PATH", + default=None, + help=( + "Path to a rendered archi config YAML file " + "(e.g. ~/.archi/archi-mydeployment/configs/chat-config.yaml). " + "Reads services.mcp_server.{url,api_key,timeout}. " + "Environment variables take precedence over file values." + ), + ) + args = parser.parse_args() + + ARCHI_URL, ARCHI_API_KEY, ARCHI_TIMEOUT = _resolve_config(args.config) + _client = None # reset so _get_client() picks up new values + print( f"Starting archi MCP server (archi URL: {ARCHI_URL})", file=sys.stderr, diff --git a/src/cli/templates/base-config.yaml b/src/cli/templates/base-config.yaml index ef6d21fee..79b7e8671 100644 --- a/src/cli/templates/base-config.yaml +++ b/src/cli/templates/base-config.yaml @@ -142,6 +142,15 @@ services: grafana: port: {{ services.grafana.port | default(3000, true) }} external_port: {{ services.grafana.external_port | default(3000, true) }} + mcp_server: + enabled: {{ services.mcp_server.enabled | default(true, true) }} + # Public URL of the chat service that MCP clients will connect to. + # Defaults to the chat app's hostname and external port. + url: "{{ services.mcp_server.url | default('http://' + (services.chat_app.hostname | default('localhost', true)) + ':' + (services.chat_app.external_port | default(7861, true) | string), true) }}" + # Optional bearer token when chat app auth is enabled. + api_key: "{{ services.mcp_server.api_key | default('', true) }}" + # HTTP request timeout in seconds. + timeout: {{ services.mcp_server.timeout | default(120, true) }} data_manager: collection_name: {{ collection_name | default("default_collection", true) }} From 3bd774abb6f336d3ce1431f2e7946667ef27be0e Mon Sep 17 00:00:00 2001 From: Muhammad Hassan Ahmed <33365802+hassan11196@users.noreply.github.com> Date: Sun, 15 Mar 2026 22:49:55 +0000 Subject: [PATCH 04/30] feat: add built-in MCP SSE endpoint to the chat service MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds /mcp/sse and /mcp/messages routes directly to the Flask chat app so MCP clients can connect with just a URL — no local archi-mcp command or pip install required. VS Code (.vscode/mcp.json): { "servers": { "archi": { "type": "sse", "url": "http://localhost:7861/mcp/sse" } } } Cursor (~/.cursor/mcp.json): { "mcpServers": { "archi": { "url": "http://localhost:7861/mcp/sse" } } } The SSE transport (JSON-RPC 2.0 over Server-Sent Events) is implemented natively in Flask using thread-safe queues — no Starlette or extra dependencies needed. Tool handlers call archi internals directly inside the same process (chat wrapper, data viewer, agent spec loader). Files changed: - src/interfaces/chat_app/mcp_sse.py (new) — SSE transport + 6 tools - src/interfaces/chat_app/app.py — register_mcp_sse() call in FlaskAppWrapper - archi_mcp/README.md — document HTTP+SSE as the recommended option --- archi_mcp/README.md | 60 +++- src/interfaces/chat_app/app.py | 6 + src/interfaces/chat_app/mcp_sse.py | 499 +++++++++++++++++++++++++++++ 3 files changed, 562 insertions(+), 3 deletions(-) create mode 100644 src/interfaces/chat_app/mcp_sse.py diff --git a/archi_mcp/README.md b/archi_mcp/README.md index 4ecf4a3af..65d483631 100644 --- a/archi_mcp/README.md +++ b/archi_mcp/README.md @@ -4,6 +4,15 @@ Expose your [archi](https://github.com/archi-physics/archi) knowledge base as **Model Context Protocol (MCP) tools** so that AI assistants in VS Code, Cursor, and any other MCP-compatible client can query it directly. +Two transport options are available: + +| Transport | How to connect | Requires local install? | +|---|---|---| +| **HTTP+SSE** *(built-in)* | Point client at `http://:/mcp/sse` | **No** | +| **stdio** | Run `archi-mcp` locally | Yes (`pip install "archi[mcp]"`) | + +> **Recommended:** use the built-in HTTP+SSE endpoint — no installation needed. + --- ## What this provides @@ -19,7 +28,49 @@ and any other MCP-compatible client can query it directly. --- -## Server setup +## Option A – Built-in HTTP+SSE endpoint (recommended) + +The archi chat service exposes MCP tools directly at `/mcp/sse`. +No separate process to install or start. + +### VS Code (.vscode/mcp.json) + +```json +{ + "servers": { + "archi": { + "type": "sse", + "url": "http://localhost:7861/mcp/sse" + } + } +} +``` + +### Cursor (~/.cursor/mcp.json) + +```json +{ + "mcpServers": { + "archi": { + "url": "http://localhost:7861/mcp/sse" + } + } +} +``` + +Replace `localhost:7861` with the public hostname and port of your archi +deployment when connecting remotely. + +Reload the window / restart the editor and the archi tools appear automatically. + +--- + +## Option B – stdio server (archi-mcp CLI) + +Use this when you cannot reach the archi service directly over HTTP (e.g. the +service is behind a firewall and you tunnel to it separately). + +### Server setup ### 1. Install @@ -87,7 +138,7 @@ Without `--config`, settings fall back to environment variables: --- -## Client setup +## stdio Client setup ### VS Code (GitHub Copilot) @@ -159,11 +210,14 @@ Restart Cursor. The archi tools appear under **MCP Tools** in the Composer panel --- +--- + ## Troubleshooting | Error | Fix | |---|---| -| `mcp package not found` | Run `pip install "archi[mcp]"` | +| SSE URL not reachable | Confirm the archi chat service is running and the URL is correct | +| `mcp package not found` (stdio) | Run `pip install "archi[mcp]"` | | `Cannot reach archi at http://localhost:7861` | Check `ARCHI_URL` or `services.mcp_server.url`; ensure the chat service is running | | `401 Unauthorized` | Set `ARCHI_API_KEY` or `services.mcp_server.api_key` to a valid token | | `WARNING: could not read archi config` | Check the path passed to `--config` | diff --git a/src/interfaces/chat_app/app.py b/src/interfaces/chat_app/app.py index 6c3e877dc..7c2cb1e98 100644 --- a/src/interfaces/chat_app/app.py +++ b/src/interfaces/chat_app/app.py @@ -2289,6 +2289,12 @@ def _inject_alerts(): require_auth=self.require_auth, ) + # MCP SSE endpoint – exposes archi as MCP tools over HTTP+SSE. + # Clients connect with just a URL; no local CLI install needed. + logger.info("Adding MCP SSE endpoint at /mcp/sse") + from src.interfaces.chat_app.mcp_sse import register_mcp_sse + register_mcp_sse(self.app, self) + # add unified auth endpoints if self.auth_enabled: logger.info("Adding unified authentication endpoints") diff --git a/src/interfaces/chat_app/mcp_sse.py b/src/interfaces/chat_app/mcp_sse.py new file mode 100644 index 000000000..762857738 --- /dev/null +++ b/src/interfaces/chat_app/mcp_sse.py @@ -0,0 +1,499 @@ +""" +MCP SSE endpoint – exposes archi's RAG capabilities as MCP tools over HTTP+SSE. + +AI assistants in VS Code (GitHub Copilot), Cursor, and any other MCP-compatible +client can connect with just a URL: + + http://:/mcp/sse + +No local installation required on the client side. + +VS Code (.vscode/mcp.json): + { + "servers": { + "archi": { "type": "sse", "url": "http://localhost:7861/mcp/sse" } + } + } + +Cursor (~/.cursor/mcp.json): + { + "mcpServers": { + "archi": { "url": "http://localhost:7861/mcp/sse" } + } + } + +Protocol +-------- +This implements the MCP SSE transport (JSON-RPC 2.0 over Server-Sent Events): + + 1. Client GETs /mcp/sse → receives an SSE stream. + 2. Server immediately sends an "endpoint" event with the POST URL: + event: endpoint + data: /mcp/messages?session_id= + 3. Client POSTs JSON-RPC messages to /mcp/messages?session_id=. + 4. Server pushes JSON-RPC responses back via the SSE stream. + 5. Keepalive comments (": keepalive") are sent every 30 s to prevent + proxies from closing idle connections. + +No external ``mcp`` package is required for the SSE transport – the protocol +is implemented directly in Flask using thread-safe queues. +""" + +from __future__ import annotations + +import json +import queue +import textwrap +import uuid +from datetime import datetime, timezone +from threading import Lock +from typing import Any, Dict, Optional + +from flask import Blueprint, Response, request, stream_with_context + +from src.utils.logging import get_logger + +logger = get_logger(__name__) + +# --------------------------------------------------------------------------- +# Constants +# --------------------------------------------------------------------------- + +_MCP_VERSION = "2024-11-05" +_SERVER_INFO = {"name": "archi", "version": "1.0.0"} +_KEEPALIVE_TIMEOUT = 30 # seconds between keepalive pings + +# --------------------------------------------------------------------------- +# Session registry (session_id → Queue of outgoing JSON-RPC messages) +# --------------------------------------------------------------------------- + +_sessions: Dict[str, queue.Queue] = {} +_sessions_lock = Lock() + +# --------------------------------------------------------------------------- +# MCP tool definitions +# --------------------------------------------------------------------------- + +_TOOLS = [ + { + "name": "archi_query", + "description": textwrap.dedent("""\ + Ask a question to the archi RAG (Retrieval-Augmented Generation) system. + + archi retrieves relevant documents from its knowledge base and uses an LLM + to compose a grounded answer. Use this tool when you need information that + is stored in the connected archi deployment (documentation, tickets, wiki + pages, research papers, course material, etc.). + + You may continue a conversation by passing the conversation_id returned by + a previous call. + """), + "inputSchema": { + "type": "object", + "properties": { + "question": { + "type": "string", + "description": "The question or request to send to archi.", + }, + "conversation_id": { + "type": "integer", + "description": ( + "Optional. Pass the conversation_id from a previous archi_query " + "call to continue the same conversation thread." + ), + }, + "provider": { + "type": "string", + "description": "Optional. Override the LLM provider (e.g. 'openai', 'anthropic').", + }, + "model": { + "type": "string", + "description": "Optional. Override the specific model (e.g. 'gpt-4o').", + }, + }, + "required": ["question"], + }, + }, + { + "name": "archi_list_documents", + "description": textwrap.dedent("""\ + List the documents that have been indexed into archi's knowledge base. + + Returns a paginated list of document metadata (filename, source type, + URL, last updated, etc.). Use this tool to discover what information + archi has access to before querying it, or to find a specific document's + hash for use with archi_get_document_content. + """), + "inputSchema": { + "type": "object", + "properties": { + "search": { + "type": "string", + "description": "Optional keyword to filter documents by name or URL.", + }, + "source_type": { + "type": "string", + "description": "Optional. Filter by source type: 'web', 'git', 'local', 'jira', etc.", + }, + "limit": { + "type": "integer", + "description": "Max results to return (default 50, max 200).", + "default": 50, + }, + "offset": { + "type": "integer", + "description": "Pagination offset (default 0).", + "default": 0, + }, + }, + "required": [], + }, + }, + { + "name": "archi_get_document_content", + "description": textwrap.dedent("""\ + Retrieve the full text content of a document indexed in archi. + + Use archi_list_documents first to obtain a document's hash, then pass + it here to read the raw source text that archi ingested. + """), + "inputSchema": { + "type": "object", + "properties": { + "document_hash": { + "type": "string", + "description": "The document hash returned by archi_list_documents.", + }, + }, + "required": ["document_hash"], + }, + }, + { + "name": "archi_get_deployment_info", + "description": textwrap.dedent("""\ + Return configuration and status information about the connected archi + deployment. + + Includes the active LLM pipeline and model, retrieval settings (number of + documents retrieved, hybrid search weights), embedding model, and the list + of available pipelines. Useful for understanding how archi is configured + before issuing queries. + """), + "inputSchema": {"type": "object", "properties": {}, "required": []}, + }, + { + "name": "archi_list_agents", + "description": textwrap.dedent("""\ + Return the agent configurations (agent specs) available in this archi + deployment. + + Each agent spec defines a name, a system prompt, and the set of tools + (retriever, MCP servers, local file search, etc.) that agent can use. + """), + "inputSchema": {"type": "object", "properties": {}, "required": []}, + }, + { + "name": "archi_health", + "description": ( + "Check whether the archi deployment is reachable and its database is healthy." + ), + "inputSchema": {"type": "object", "properties": {}, "required": []}, + }, +] + +# --------------------------------------------------------------------------- +# JSON-RPC helpers +# --------------------------------------------------------------------------- + + +def _ok(result: Any, rpc_id: Any) -> Dict: + return {"jsonrpc": "2.0", "id": rpc_id, "result": result} + + +def _err(code: int, message: str, rpc_id: Any) -> Dict: + return {"jsonrpc": "2.0", "id": rpc_id, "error": {"code": code, "message": message}} + + +def _text(text: str) -> Dict: + """Wrap a string as an MCP tool result.""" + return {"content": [{"type": "text", "text": str(text)}]} + + +# --------------------------------------------------------------------------- +# Tool handlers (run inside the Flask process – no HTTP round-trip) +# --------------------------------------------------------------------------- + + +def _call_tool(name: str, arguments: Dict[str, Any], wrapper) -> Dict: + """Dispatch a tools/call request to the appropriate archi internals.""" + try: + if name == "archi_query": + return _tool_query(arguments, wrapper) + elif name == "archi_list_documents": + return _tool_list_documents(arguments, wrapper) + elif name == "archi_get_document_content": + return _tool_get_document_content(arguments, wrapper) + elif name == "archi_get_deployment_info": + return _tool_deployment_info(wrapper) + elif name == "archi_list_agents": + return _tool_list_agents(wrapper) + elif name == "archi_health": + return _text("status: OK\ndatabase: OK") + else: + return _text(f"ERROR: Unknown tool '{name}'.") + except Exception as exc: + logger.exception("MCP tool %s raised an exception", name) + return _text(f"ERROR: {exc}") + + +def _tool_query(arguments: Dict[str, Any], wrapper) -> Dict: + question = (arguments.get("question") or "").strip() + if not question: + return _text("ERROR: 'question' is required.") + + conversation_id = arguments.get("conversation_id") + client_id = f"mcp-sse-{uuid.uuid4().hex[:12]}" + now = datetime.now(timezone.utc) + + response, new_conv_id, _, _, error_code = wrapper.chat( + question, + conversation_id, + client_id, + False, # is_refresh + now, # server_received_msg_ts + 0.0, # client_sent_msg_ts (unknown for MCP callers) + 120.0, # client_timeout (seconds) + None, # config_name (use active config) + ) + + if error_code is not None: + return _text(f"ERROR: chat returned error code {error_code}.") + + parts = [response or ""] + if new_conv_id is not None: + parts.append( + f"\n\n---\n_conversation_id: {new_conv_id} " + "(pass this to archi_query to continue the conversation)_" + ) + return _text("".join(parts)) + + +def _tool_list_documents(arguments: Dict[str, Any], wrapper) -> Dict: + limit = min(int(arguments.get("limit", 50)), 200) + offset = int(arguments.get("offset", 0)) + search: Optional[str] = arguments.get("search") or None + source_type: Optional[str] = arguments.get("source_type") or None + + result = wrapper.chat.data_viewer.list_documents( + conversation_id=None, + source_type=source_type, + search=search, + enabled_filter=None, + limit=limit, + offset=offset, + ) + docs = result.get("documents", result.get("items", [])) + total = result.get("total", len(docs)) + + lines = [f"Found {total} document(s) (offset={offset}, limit={limit}):\n"] + for doc in docs: + display = ( + doc.get("display_name") + or doc.get("filename") + or doc.get("url") + or doc.get("hash", "unknown") + ) + source = doc.get("source_type", doc.get("type", "")) + doc_hash = doc.get("hash", doc.get("id", "")) + lines.append(f" • {display} [{source}] hash={doc_hash}") + + lines.append( + "\nUse archi_get_document_content(document_hash=) to read a document." + ) + return _text("\n".join(lines)) + + +def _tool_get_document_content(arguments: Dict[str, Any], wrapper) -> Dict: + doc_hash = (arguments.get("document_hash") or "").strip() + if not doc_hash: + return _text("ERROR: 'document_hash' is required.") + + result = wrapper.chat.data_viewer.get_document_content(doc_hash) + if result is None: + return _text(f"ERROR: Document not found: {doc_hash}") + + content = result.get("content", result.get("text", json.dumps(result, indent=2))) + return _text(content) + + +def _tool_deployment_info(wrapper) -> Dict: + from src.utils.config_access import get_full_config, get_dynamic_config + + config = get_full_config() or {} + services = config.get("services", {}) + chat_cfg = services.get("chat_app", {}) + dm_cfg = services.get("data_manager", {}) + + try: + dynamic = get_dynamic_config() + except Exception: + dynamic = None + + lines = [ + f"# archi Deployment: {config.get('name', 'unknown')}", + "", + "## Active configuration", + f" Pipeline: {chat_cfg.get('pipeline', 'n/a')}", + ] + if dynamic: + lines += [ + f" Model: {dynamic.active_model}", + f" Temperature: {dynamic.temperature}", + f" Max tokens: {dynamic.max_tokens}", + f" Docs retrieved (k): {dynamic.num_documents_to_retrieve}", + f" Hybrid search: {dynamic.use_hybrid_search}", + f" BM25 weight: {dynamic.bm25_weight}", + f" Semantic weight: {dynamic.semantic_weight}", + ] + + embedding_cfg = dm_cfg.get("embedding", {}) + lines += [ + "", + "## Embedding", + f" Model: {embedding_cfg.get('model', 'n/a')}", + f" Chunk size: {embedding_cfg.get('chunk_size', 'n/a')}", + f" Chunk overlap: {embedding_cfg.get('chunk_overlap', 'n/a')}", + ] + return _text("\n".join(lines)) + + +def _tool_list_agents(wrapper) -> Dict: + from src.archi.pipelines.agents.agent_spec import ( + AgentSpecError, + list_agent_files, + load_agent_spec, + ) + + agents_dir = wrapper._get_agents_dir() + lines = [] + for path in list_agent_files(agents_dir): + try: + spec = load_agent_spec(path) + tools_str = ", ".join(getattr(spec, "tools", []) or []) or "none" + lines.append(f" • {spec.name} ({path.name})") + lines.append(f" Tools: {tools_str}") + except AgentSpecError: + pass + + if not lines: + return _text("No agent specs found in this deployment.") + return _text("Available agents:\n" + "\n".join(lines)) + + +# --------------------------------------------------------------------------- +# JSON-RPC dispatcher +# --------------------------------------------------------------------------- + + +def _dispatch(body: Dict, session_queue: queue.Queue, wrapper) -> None: + """Process one incoming JSON-RPC message and enqueue the response if needed.""" + rpc_id = body.get("id") + method = body.get("method", "") + params = body.get("params") or {} + + # Notifications have no id – no response expected. + if rpc_id is None: + return + + if method == "initialize": + response = _ok( + { + "protocolVersion": _MCP_VERSION, + "capabilities": {"tools": {}}, + "serverInfo": _SERVER_INFO, + }, + rpc_id, + ) + elif method == "tools/list": + response = _ok({"tools": _TOOLS}, rpc_id) + elif method == "tools/call": + result = _call_tool( + params.get("name", ""), + params.get("arguments") or {}, + wrapper, + ) + response = _ok(result, rpc_id) + elif method == "ping": + response = _ok({}, rpc_id) + else: + response = _err(-32601, f"Method not found: {method}", rpc_id) + + session_queue.put(response) + + +# --------------------------------------------------------------------------- +# Blueprint factory +# --------------------------------------------------------------------------- + + +def register_mcp_sse(app, wrapper) -> None: + """Register the MCP SSE endpoints on a Flask app. + + Adds two routes: + GET /mcp/sse – SSE stream (MCP clients connect here) + POST /mcp/messages – JSON-RPC message receiver + """ + mcp = Blueprint("mcp_sse", __name__) + + @mcp.route("/mcp/sse") + def sse(): + """Open an SSE stream for one MCP client session.""" + session_id = uuid.uuid4().hex + q: queue.Queue = queue.Queue() + with _sessions_lock: + _sessions[session_id] = q + + def generate(): + # Advertise the POST endpoint to the client. + yield f"event: endpoint\ndata: /mcp/messages?session_id={session_id}\n\n" + try: + while True: + try: + msg = q.get(timeout=_KEEPALIVE_TIMEOUT) + if msg is None: + break + yield f"event: message\ndata: {json.dumps(msg)}\n\n" + except queue.Empty: + yield ": keepalive\n\n" + finally: + with _sessions_lock: + _sessions.pop(session_id, None) + + return Response( + stream_with_context(generate()), + content_type="text/event-stream", + headers={ + "Cache-Control": "no-cache", + "X-Accel-Buffering": "no", + "Connection": "keep-alive", + }, + ) + + @mcp.route("/mcp/messages", methods=["POST"]) + def messages(): + """Receive a JSON-RPC message from an MCP client.""" + session_id = request.args.get("session_id", "") + with _sessions_lock: + q = _sessions.get(session_id) + if q is None: + return {"error": "unknown or expired session_id"}, 404 + + body = request.get_json(silent=True) + if not body: + return {"error": "request body must be valid JSON"}, 400 + + _dispatch(body, q, wrapper) + return "", 202 + + app.register_blueprint(mcp) + logger.info("Registered MCP SSE endpoint at /mcp/sse") From c159db5ad5a913f49b1d1993b86dcf877d5a0a35 Mon Sep 17 00:00:00 2001 From: Muhammad Hassan Ahmed <33365802+hassan11196@users.noreply.github.com> Date: Sun, 15 Mar 2026 22:52:47 +0000 Subject: [PATCH 05/30] remove archi_mcp standalone package The built-in /mcp/sse endpoint on the chat service makes the separate archi-mcp CLI redundant. Clients now connect with just a URL. - Delete archi_mcp/ package (server, client, README, entry point) - Remove [project.optional-dependencies].mcp from pyproject.toml - Remove archi-mcp entry point script from pyproject.toml - Remove archi_mcp* from package discovery --- archi_mcp/README.md | 223 ------------------ archi_mcp/__init__.py | 1 - archi_mcp/__main__.py | 3 - archi_mcp/client.py | 167 -------------- archi_mcp/server.py | 512 ------------------------------------------ pyproject.toml | 9 +- 6 files changed, 1 insertion(+), 914 deletions(-) delete mode 100644 archi_mcp/README.md delete mode 100644 archi_mcp/__init__.py delete mode 100644 archi_mcp/__main__.py delete mode 100644 archi_mcp/client.py delete mode 100644 archi_mcp/server.py diff --git a/archi_mcp/README.md b/archi_mcp/README.md deleted file mode 100644 index 65d483631..000000000 --- a/archi_mcp/README.md +++ /dev/null @@ -1,223 +0,0 @@ -# archi MCP Server - -Expose your [archi](https://github.com/archi-physics/archi) knowledge base as -**Model Context Protocol (MCP) tools** so that AI assistants in VS Code, Cursor, -and any other MCP-compatible client can query it directly. - -Two transport options are available: - -| Transport | How to connect | Requires local install? | -|---|---|---| -| **HTTP+SSE** *(built-in)* | Point client at `http://:/mcp/sse` | **No** | -| **stdio** | Run `archi-mcp` locally | Yes (`pip install "archi[mcp]"`) | - -> **Recommended:** use the built-in HTTP+SSE endpoint — no installation needed. - ---- - -## What this provides - -| Tool | Description | -|---|---| -| `archi_query` | Ask a question via archi's active RAG pipeline | -| `archi_list_documents` | Browse the indexed knowledge base | -| `archi_get_document_content` | Read the full text of an indexed document | -| `archi_get_deployment_info` | Show active pipeline, model, and retrieval config | -| `archi_list_agents` | List available agent specs | -| `archi_health` | Verify the deployment is reachable | - ---- - -## Option A – Built-in HTTP+SSE endpoint (recommended) - -The archi chat service exposes MCP tools directly at `/mcp/sse`. -No separate process to install or start. - -### VS Code (.vscode/mcp.json) - -```json -{ - "servers": { - "archi": { - "type": "sse", - "url": "http://localhost:7861/mcp/sse" - } - } -} -``` - -### Cursor (~/.cursor/mcp.json) - -```json -{ - "mcpServers": { - "archi": { - "url": "http://localhost:7861/mcp/sse" - } - } -} -``` - -Replace `localhost:7861` with the public hostname and port of your archi -deployment when connecting remotely. - -Reload the window / restart the editor and the archi tools appear automatically. - ---- - -## Option B – stdio server (archi-mcp CLI) - -Use this when you cannot reach the archi service directly over HTTP (e.g. the -service is behind a firewall and you tunnel to it separately). - -### Server setup - -### 1. Install - -```bash -pip install "archi[mcp]" -``` - -Or, in development (from the repo root): - -```bash -pip install -e ".[mcp]" -``` - -### 2. Configure archi - -Add an `mcp_server` block to your archi deployment config YAML. The defaults -work for a local deployment on the standard port: - -```yaml -services: - chat_app: - port: 7861 - external_port: 7861 - hostname: localhost # or your public hostname / domain - - mcp_server: - enabled: true - # Public URL that MCP clients will connect to. - # Defaults to http://: - url: "http://localhost:7861" - # Set this if chat app auth is enabled (services.chat_app.auth.enabled: true). - api_key: "" - # HTTP request timeout in seconds. - timeout: 120 -``` - -Redeploy so the rendered config picks up the new block: - -```bash -archi restart --name --service chatbot -``` - -The rendered config lands at: - -``` -~/.archi/archi-/configs/chat-config.yaml -``` - -### 3. Start the MCP server - -Point `archi-mcp` at the rendered config so it reads `services.mcp_server.*` -automatically: - -```bash -archi-mcp --config ~/.archi/archi-/configs/chat-config.yaml -``` - -Without `--config`, settings fall back to environment variables: - -| Variable | Default | Description | -|---|---|---| -| `ARCHI_URL` | `http://localhost:7861` | Base URL of the archi chat service | -| `ARCHI_API_KEY` | *(none)* | Bearer token when auth is enabled | -| `ARCHI_TIMEOUT` | `120` | HTTP timeout in seconds | - ---- - -## stdio Client setup - -### VS Code (GitHub Copilot) - -Create or edit `.vscode/mcp.json` in your workspace: - -```json -{ - "servers": { - "archi": { - "type": "stdio", - "command": "archi-mcp", - "args": ["--config", "${env:HOME}/.archi/archi-mydeployment/configs/chat-config.yaml"] - } - } -} -``` - -To use environment variables instead: - -```json -{ - "servers": { - "archi": { - "type": "stdio", - "command": "archi-mcp", - "env": { - "ARCHI_URL": "http://localhost:7861", - "ARCHI_API_KEY": "optional-token" - } - } - } -} -``` - -Reload the window (`Ctrl+Shift+P` → **Developer: Reload Window**) and the -archi tools appear in GitHub Copilot's tool picker. - -### Cursor - -Edit `~/.cursor/mcp.json` (create it if it doesn't exist): - -```json -{ - "mcpServers": { - "archi": { - "command": "archi-mcp", - "args": ["--config", "/home/you/.archi/archi-mydeployment/configs/chat-config.yaml"] - } - } -} -``` - -Or with environment variables: - -```json -{ - "mcpServers": { - "archi": { - "command": "archi-mcp", - "env": { - "ARCHI_URL": "http://localhost:7861" - } - } - } -} -``` - -Restart Cursor. The archi tools appear under **MCP Tools** in the Composer panel. - ---- - ---- - -## Troubleshooting - -| Error | Fix | -|---|---| -| SSE URL not reachable | Confirm the archi chat service is running and the URL is correct | -| `mcp package not found` (stdio) | Run `pip install "archi[mcp]"` | -| `Cannot reach archi at http://localhost:7861` | Check `ARCHI_URL` or `services.mcp_server.url`; ensure the chat service is running | -| `401 Unauthorized` | Set `ARCHI_API_KEY` or `services.mcp_server.api_key` to a valid token | -| `WARNING: could not read archi config` | Check the path passed to `--config` | diff --git a/archi_mcp/__init__.py b/archi_mcp/__init__.py deleted file mode 100644 index 78704a2b7..000000000 --- a/archi_mcp/__init__.py +++ /dev/null @@ -1 +0,0 @@ -# archi MCP Server package diff --git a/archi_mcp/__main__.py b/archi_mcp/__main__.py deleted file mode 100644 index bcc16448a..000000000 --- a/archi_mcp/__main__.py +++ /dev/null @@ -1,3 +0,0 @@ -from archi_mcp.server import main - -main() diff --git a/archi_mcp/client.py b/archi_mcp/client.py deleted file mode 100644 index 34253e3ca..000000000 --- a/archi_mcp/client.py +++ /dev/null @@ -1,167 +0,0 @@ -""" -HTTP client for communicating with a running archi deployment. - -Wraps the archi REST API so the MCP server can call archi endpoints -without embedding any of archi's internal Python dependencies. -""" - -from __future__ import annotations - -import time -import uuid -from typing import Any, Dict, List, Optional - -import requests - - -class ArchiClientError(RuntimeError): - """Raised when the archi API returns an error or is unreachable.""" - - -class ArchiClient: - """Thin HTTP client for archi's REST API.""" - - def __init__( - self, - base_url: str = "http://localhost:5000", - timeout: int = 120, - api_key: Optional[str] = None, - ) -> None: - self.base_url = base_url.rstrip("/") - self.timeout = timeout - self._client_id = f"mcp-{uuid.uuid4().hex[:12]}" - self._session = requests.Session() - self._session.headers["X-Client-ID"] = self._client_id - if api_key: - self._session.headers["Authorization"] = f"Bearer {api_key}" - - # ------------------------------------------------------------------ - # Helpers - # ------------------------------------------------------------------ - - def _url(self, path: str) -> str: - return f"{self.base_url}{path}" - - def _get(self, path: str, params: Optional[Dict] = None) -> Any: - try: - resp = self._session.get(self._url(path), params=params, timeout=self.timeout) - except requests.RequestException as exc: - raise ArchiClientError(f"Cannot reach archi at {self.base_url}: {exc}") from exc - if not resp.ok: - raise ArchiClientError(f"archi returned {resp.status_code} for GET {path}: {resp.text}") - return resp.json() - - def _post(self, path: str, payload: Dict) -> Any: - try: - resp = self._session.post(self._url(path), json=payload, timeout=self.timeout) - except requests.RequestException as exc: - raise ArchiClientError(f"Cannot reach archi at {self.base_url}: {exc}") from exc - if not resp.ok: - raise ArchiClientError(f"archi returned {resp.status_code} for POST {path}: {resp.text}") - return resp.json() - - # ------------------------------------------------------------------ - # Health - # ------------------------------------------------------------------ - - def health(self) -> Dict[str, Any]: - """Return health status from archi.""" - return self._get("/api/health") - - # ------------------------------------------------------------------ - # Query / Chat - # ------------------------------------------------------------------ - - def query( - self, - message: str, - conversation_id: Optional[int] = None, - provider: Optional[str] = None, - model: Optional[str] = None, - ) -> Dict[str, Any]: - """ - Ask archi a question using its active RAG pipeline. - - Returns a dict with at least: - response (str): the answer text - conversation_id (int): conversation ID for follow-up queries - """ - payload: Dict[str, Any] = { - "last_message": message, - "client_id": self._client_id, - "client_sent_msg_ts": int(time.time() * 1000), - "client_timeout": self.timeout * 1000, - "include_agent_steps": False, - "include_tool_steps": False, - } - if conversation_id is not None: - payload["conversation_id"] = conversation_id - if provider: - payload["provider"] = provider - if model: - payload["model"] = model - - return self._post("/api/get_chat_response", payload) - - # ------------------------------------------------------------------ - # Documents / Data Viewer - # ------------------------------------------------------------------ - - def list_documents( - self, - page: int = 1, - per_page: int = 50, - search: Optional[str] = None, - source_type: Optional[str] = None, - ) -> Dict[str, Any]: - """List indexed documents in the archi vectorstore.""" - params: Dict[str, Any] = {"page": page, "per_page": per_page} - if search: - params["search"] = search - if source_type: - params["source_type"] = source_type - return self._get("/api/data/documents", params=params) - - def get_document_content(self, document_hash: str) -> Dict[str, Any]: - """Return the raw content of a specific indexed document.""" - return self._get(f"/api/data/documents/{document_hash}/content") - - def get_document_chunks(self, document_hash: str) -> Dict[str, Any]: - """Return the vectorstore chunks for a specific document.""" - return self._get(f"/api/data/documents/{document_hash}/chunks") - - # ------------------------------------------------------------------ - # Configuration - # ------------------------------------------------------------------ - - def get_static_config(self) -> Dict[str, Any]: - """Return the static (deploy-time) archi configuration.""" - return self._get("/api/config/static") - - def get_dynamic_config(self) -> Dict[str, Any]: - """Return the dynamic (runtime) archi configuration.""" - return self._get("/api/config/dynamic") - - # ------------------------------------------------------------------ - # Agents - # ------------------------------------------------------------------ - - def list_agents(self) -> Dict[str, Any]: - """Return the list of available agent spec files.""" - return self._get("/api/agents/list") - - def get_agent_info(self) -> Dict[str, Any]: - """Return info about the currently active agent.""" - return self._get("/api/agent/info") - - # ------------------------------------------------------------------ - # Providers / Models - # ------------------------------------------------------------------ - - def list_providers(self) -> Dict[str, Any]: - """Return available model providers and their models.""" - return self._get("/api/providers") - - def get_api_info(self) -> Dict[str, Any]: - """Return archi API version and feature information.""" - return self._get("/api/info") diff --git a/archi_mcp/server.py b/archi_mcp/server.py deleted file mode 100644 index 3d228820c..000000000 --- a/archi_mcp/server.py +++ /dev/null @@ -1,512 +0,0 @@ -""" -archi MCP Server - -Exposes archi's RAG capabilities as MCP tools so AI assistants in -VS Code (GitHub Copilot), Cursor, and other MCP-compatible clients -can query your archi knowledge base directly. - -Usage ------ -Run as a standalone process (stdio transport, which VS Code / Cursor use): - - python -m archi_mcp - -Or via the installed CLI entry-point: - - archi-mcp - -Point at an archi config file so settings are read automatically: - - archi-mcp --config ~/.archi/archi-mydeployment/configs/chat-config.yaml - -Configuration (in order of precedence: CLI flag > env var > archi config > default): - - --config Path to a rendered archi config YAML file. - Reads services.mcp_server.{url,api_key,timeout}. - - ARCHI_URL URL of a running archi deployment (default: http://localhost:7861) - ARCHI_API_KEY Optional bearer token if archi authentication is enabled - ARCHI_TIMEOUT HTTP timeout in seconds (default: 120) -""" - -from __future__ import annotations - -import argparse -import json -import os -import sys -import textwrap -from typing import Any, Dict, List, Optional - -# --------------------------------------------------------------------------- -# Dependency check – give a clear error if mcp is not installed. -# --------------------------------------------------------------------------- -try: - from mcp.server import Server - from mcp.server.stdio import stdio_server - import mcp.types as types -except ImportError as _err: # noqa: F841 - print( - "ERROR: The 'mcp' package is required to run the archi MCP server.\n" - "Install it with: pip install mcp\n" - "Or: pip install 'archi[mcp]'", - file=sys.stderr, - ) - sys.exit(1) - -from archi_mcp.client import ArchiClient, ArchiClientError # noqa: E402 (local import) - -# --------------------------------------------------------------------------- -# Server configuration -# --------------------------------------------------------------------------- -_DEFAULT_URL = "http://localhost:7861" -_DEFAULT_TIMEOUT = 120 - - -def _load_archi_config(config_path: str) -> Dict[str, Any]: - """Load services.mcp_server settings from a rendered archi config YAML.""" - try: - import yaml # PyYAML is already a core archi dependency - except ImportError: - return {} - try: - with open(config_path) as f: - data = yaml.safe_load(f) or {} - return data.get("services", {}).get("mcp_server", {}) - except Exception as exc: - print(f"WARNING: could not read archi config {config_path!r}: {exc}", file=sys.stderr) - return {} - - -def _resolve_config(config_path: Optional[str]) -> tuple: - """Return (url, api_key, timeout) by merging archi config, env vars, and defaults.""" - file_cfg: Dict[str, Any] = _load_archi_config(config_path) if config_path else {} - - url = ( - os.environ.get("ARCHI_URL") - or file_cfg.get("url") - or _DEFAULT_URL - ) - api_key = ( - os.environ.get("ARCHI_API_KEY") - or file_cfg.get("api_key") - or None - ) - timeout_raw = ( - os.environ.get("ARCHI_TIMEOUT") - or file_cfg.get("timeout") - or _DEFAULT_TIMEOUT - ) - return url, api_key, int(timeout_raw) - - -# Resolved at startup (may be overridden by main() after arg parsing) -ARCHI_URL: str = _DEFAULT_URL -ARCHI_API_KEY: Optional[str] = None -ARCHI_TIMEOUT: int = _DEFAULT_TIMEOUT - -# --------------------------------------------------------------------------- -# MCP Server setup -# --------------------------------------------------------------------------- -server = Server("archi") -_client: Optional[ArchiClient] = None - - -def _get_client() -> ArchiClient: - global _client - if _client is None: - _client = ArchiClient( - base_url=ARCHI_URL, - timeout=ARCHI_TIMEOUT, - api_key=ARCHI_API_KEY, - ) - return _client - - -# --------------------------------------------------------------------------- -# Tool definitions -# --------------------------------------------------------------------------- - -@server.list_tools() -async def list_tools() -> List[types.Tool]: - return [ - types.Tool( - name="archi_query", - description=textwrap.dedent("""\ - Ask a question to the archi RAG (Retrieval-Augmented Generation) system. - - archi retrieves relevant documents from its knowledge base and uses an LLM - to compose a grounded answer. Use this tool when you need information that - is stored in the connected archi deployment (documentation, tickets, wiki - pages, research papers, course material, etc.). - - You may continue a conversation by passing the conversation_id returned by - a previous call. - """), - inputSchema={ - "type": "object", - "properties": { - "question": { - "type": "string", - "description": "The question or request to send to archi.", - }, - "conversation_id": { - "type": "integer", - "description": ( - "Optional. Pass the conversation_id from a previous archi_query " - "call to continue the same conversation thread." - ), - }, - "provider": { - "type": "string", - "description": ( - "Optional. Override the LLM provider for this query " - "(e.g. 'openai', 'anthropic', 'gemini', 'openrouter', 'local')." - ), - }, - "model": { - "type": "string", - "description": ( - "Optional. Override the specific model for this query " - "(e.g. 'gpt-4o', 'claude-3-5-sonnet', 'gemini-1.5-pro')." - ), - }, - }, - "required": ["question"], - }, - ), - types.Tool( - name="archi_list_documents", - description=textwrap.dedent("""\ - List the documents that have been indexed into archi's knowledge base. - - Returns a paginated list of document metadata (filename, source type, - URL, last updated, etc.). Use this tool to discover what information - archi has access to before querying it, or to find a specific document's - hash for use with archi_get_document_content. - """), - inputSchema={ - "type": "object", - "properties": { - "search": { - "type": "string", - "description": "Optional keyword to filter documents by name or URL.", - }, - "source_type": { - "type": "string", - "description": ( - "Optional. Filter by source type: 'web', 'git', 'local', " - "'jira', 'redmine', etc." - ), - }, - "page": { - "type": "integer", - "description": "Page number (1-based, default 1).", - "default": 1, - }, - "per_page": { - "type": "integer", - "description": "Number of results per page (default 50, max 200).", - "default": 50, - }, - }, - "required": [], - }, - ), - types.Tool( - name="archi_get_document_content", - description=textwrap.dedent("""\ - Retrieve the full text content of a document that is indexed in archi. - - Use archi_list_documents first to obtain a document's hash, then pass it - here to read the raw source text that archi ingested. - """), - inputSchema={ - "type": "object", - "properties": { - "document_hash": { - "type": "string", - "description": "The document hash returned by archi_list_documents.", - }, - }, - "required": ["document_hash"], - }, - ), - types.Tool( - name="archi_get_deployment_info", - description=textwrap.dedent("""\ - Return configuration and status information about the connected archi - deployment. - - Includes the active LLM pipeline and model, retrieval settings (number of - documents retrieved, hybrid search weights), embedding model, and the list - of available pipelines and LLM providers. Useful for understanding how - archi is configured before issuing queries. - """), - inputSchema={ - "type": "object", - "properties": {}, - "required": [], - }, - ), - types.Tool( - name="archi_list_agents", - description=textwrap.dedent("""\ - Return the agent configurations (agent specs) available in the connected - archi deployment. - - Each agent spec defines a name, a system prompt, and the set of tools - (retriever, MCP servers, local file search, etc.) that agent can use. - Use this to understand which specialised agents are available before - selecting one for archi_query. - """), - inputSchema={ - "type": "object", - "properties": {}, - "required": [], - }, - ), - types.Tool( - name="archi_health", - description=textwrap.dedent("""\ - Check whether the archi deployment is reachable and healthy. - - Returns the service status and database connectivity. Call this first - if other archi tools are failing, to confirm that the deployment is up. - """), - inputSchema={ - "type": "object", - "properties": {}, - "required": [], - }, - ), - ] - - -# --------------------------------------------------------------------------- -# Tool handlers -# --------------------------------------------------------------------------- - -def _ok(data: Any) -> List[types.TextContent]: - """Wrap any Python value as a JSON TextContent block.""" - if isinstance(data, str): - return [types.TextContent(type="text", text=data)] - return [types.TextContent(type="text", text=json.dumps(data, indent=2, default=str))] - - -def _err(message: str) -> List[types.TextContent]: - return [types.TextContent(type="text", text=f"ERROR: {message}")] - - -@server.call_tool() -async def call_tool(name: str, arguments: Dict[str, Any]) -> List[types.TextContent]: - client = _get_client() - - # ------------------------------------------------------------------ - # archi_query - # ------------------------------------------------------------------ - if name == "archi_query": - question = arguments.get("question", "").strip() - if not question: - return _err("'question' is required and must not be empty.") - try: - result = client.query( - message=question, - conversation_id=arguments.get("conversation_id"), - provider=arguments.get("provider"), - model=arguments.get("model"), - ) - except ArchiClientError as exc: - return _err(str(exc)) - - answer = result.get("response", "") - conv_id = result.get("conversation_id") - - parts = [answer] - if conv_id is not None: - parts.append( - f"\n\n---\n_conversation_id: {conv_id} " - "(pass this to archi_query to continue the conversation)_" - ) - return [types.TextContent(type="text", text="".join(parts))] - - # ------------------------------------------------------------------ - # archi_list_documents - # ------------------------------------------------------------------ - elif name == "archi_list_documents": - try: - result = client.list_documents( - page=arguments.get("page", 1), - per_page=min(arguments.get("per_page", 50), 200), - search=arguments.get("search"), - source_type=arguments.get("source_type"), - ) - except ArchiClientError as exc: - return _err(str(exc)) - - docs = result.get("documents", result.get("items", [])) - total = result.get("total", len(docs)) - page = result.get("page", 1) - per_page = result.get("per_page", len(docs)) - - lines = [f"Found {total} document(s) (page {page}, {per_page} per page):\n"] - for doc in docs: - name_field = ( - doc.get("filename") - or doc.get("name") - or doc.get("url") - or doc.get("hash", "unknown") - ) - source = doc.get("source_type", doc.get("type", "")) - doc_hash = doc.get("hash", doc.get("id", "")) - lines.append(f" • {name_field} [{source}] hash={doc_hash}") - - lines.append( - "\nUse archi_get_document_content(document_hash=) to read a document's text." - ) - return [types.TextContent(type="text", text="\n".join(lines))] - - # ------------------------------------------------------------------ - # archi_get_document_content - # ------------------------------------------------------------------ - elif name == "archi_get_document_content": - doc_hash = arguments.get("document_hash", "").strip() - if not doc_hash: - return _err("'document_hash' is required.") - try: - result = client.get_document_content(doc_hash) - except ArchiClientError as exc: - return _err(str(exc)) - content = result.get("content", result.get("text", json.dumps(result, indent=2))) - return [types.TextContent(type="text", text=content)] - - # ------------------------------------------------------------------ - # archi_get_deployment_info - # ------------------------------------------------------------------ - elif name == "archi_get_deployment_info": - try: - static = client.get_static_config() - dynamic = client.get_dynamic_config() - except ArchiClientError as exc: - return _err(str(exc)) - - lines = [ - f"# archi Deployment: {static.get('deployment_name', 'unknown')}", - "", - "## Active configuration", - f" Pipeline: {dynamic.get('active_pipeline', 'n/a')}", - f" Model: {dynamic.get('active_model', 'n/a')}", - f" Temperature: {dynamic.get('temperature', 'n/a')}", - f" Max tokens: {dynamic.get('max_tokens', 'n/a')}", - f" Docs retrieved (k): {dynamic.get('num_documents_to_retrieve', 'n/a')}", - f" Hybrid search: {dynamic.get('use_hybrid_search', 'n/a')}", - f" BM25 weight: {dynamic.get('bm25_weight', 'n/a')}", - f" Semantic weight: {dynamic.get('semantic_weight', 'n/a')}", - "", - "## Embedding", - f" Model: {static.get('embedding_model', 'n/a')}", - f" Dimensions: {static.get('embedding_dimensions', 'n/a')}", - f" Chunk size: {static.get('chunk_size', 'n/a')}", - f" Chunk overlap: {static.get('chunk_overlap', 'n/a')}", - f" Distance metric: {static.get('distance_metric', 'n/a')}", - "", - "## Available pipelines", - ] - for p in static.get("available_pipelines", []): - lines.append(f" • {p}") - lines.append("") - lines.append("## Available models / providers") - for provider in static.get("available_providers", []): - lines.append(f" • {provider}") - - return [types.TextContent(type="text", text="\n".join(lines))] - - # ------------------------------------------------------------------ - # archi_list_agents - # ------------------------------------------------------------------ - elif name == "archi_list_agents": - try: - result = client.list_agents() - except ArchiClientError as exc: - return _err(str(exc)) - - agents = result.get("agents", result if isinstance(result, list) else []) - if not agents: - return [types.TextContent(type="text", text="No agent specs found in this deployment.")] - - lines = [f"Available agent specs ({len(agents)}):\n"] - for agent in agents: - agent_name = agent.get("name", agent.get("filename", "unknown")) - tools = agent.get("tools", []) - tools_str = ", ".join(tools) if tools else "none" - lines.append(f" • {agent_name}") - lines.append(f" Tools: {tools_str}") - - return [types.TextContent(type="text", text="\n".join(lines))] - - # ------------------------------------------------------------------ - # archi_health - # ------------------------------------------------------------------ - elif name == "archi_health": - try: - result = client.health() - except ArchiClientError as exc: - return _err(str(exc)) - status = result.get("status", "unknown") - db = result.get("database", "unknown") - ts = result.get("timestamp", "") - msg = f"archi status: {status}\nDatabase: {db}\nTimestamp: {ts}" - return [types.TextContent(type="text", text=msg)] - - else: - return _err(f"Unknown tool: {name}") - - -# --------------------------------------------------------------------------- -# Entry point -# --------------------------------------------------------------------------- - -async def _run() -> None: - async with stdio_server() as (read_stream, write_stream): - await server.run( - read_stream, - write_stream, - server.create_initialization_options(), - ) - - -def main() -> None: - """CLI entry point: archi-mcp""" - import asyncio - - global ARCHI_URL, ARCHI_API_KEY, ARCHI_TIMEOUT, _client - - parser = argparse.ArgumentParser( - prog="archi-mcp", - description="archi MCP server – expose your archi knowledge base as MCP tools.", - ) - parser.add_argument( - "--config", - metavar="PATH", - default=None, - help=( - "Path to a rendered archi config YAML file " - "(e.g. ~/.archi/archi-mydeployment/configs/chat-config.yaml). " - "Reads services.mcp_server.{url,api_key,timeout}. " - "Environment variables take precedence over file values." - ), - ) - args = parser.parse_args() - - ARCHI_URL, ARCHI_API_KEY, ARCHI_TIMEOUT = _resolve_config(args.config) - _client = None # reset so _get_client() picks up new values - - print( - f"Starting archi MCP server (archi URL: {ARCHI_URL})", - file=sys.stderr, - ) - asyncio.run(_run()) - - -if __name__ == "__main__": - main() diff --git a/pyproject.toml b/pyproject.toml index 7437918ea..f5136f334 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -29,15 +29,8 @@ dependencies = [ "psycopg2-binary==2.9.10" ] -[project.optional-dependencies] -mcp = [ - "mcp>=1.0.0", - "requests>=2.31.0", -] - [project.scripts] archi = "src.cli.cli_main:main" -archi-mcp = "archi_mcp.server:main" [tool.setuptools.package-data] "src.cli" = ["templates/**/*"] @@ -46,7 +39,7 @@ archi-mcp = "archi_mcp.server:main" include-package-data = true [tool.setuptools.packages.find] -include = ["src*", "archi_mcp*"] +include = ["src*"] [build-system] requires = ["setuptools>=61.0.0"] From 1b0840253e672d1759df88ed7c7f34670662df47 Mon Sep 17 00:00:00 2001 From: Muhammad Hassan Ahmed <33365802+hassan11196@users.noreply.github.com> Date: Sun, 15 Mar 2026 23:14:04 +0000 Subject: [PATCH 06/30] feat: SSO-gated MCP access with bearer token auth Users now visit /mcp/auth (browser) after SSO login to get a long-lived bearer token. The token is stored in the new mcp_tokens PostgreSQL table. VS Code / Cursor MCP configs must include the token as an Authorization header. The /mcp/sse and /mcp/messages endpoints enforce token validation when auth is enabled; unauthenticated clients receive a 401 JSON response with a login_url pointing to /mcp/auth. Changes: - init.sql: add mcp_tokens table (token, user_id, last_used_at, expires_at) - mcp_sse.py: bearer-token validation (_validate_mcp_token), auth guard on both SSE and messages endpoints, updated session registry to carry user_id - app.py: register /mcp/auth (GET) and /mcp/auth/regenerate (POST) routes, token DB helpers (_get_mcp_token, _create_mcp_token, _rotate_mcp_token), sso_callback now honours session['sso_next'] for post-login redirects - templates/mcp_auth.html: token display page with VS Code / Cursor snippets and token rotation UI --- src/cli/templates/init.sql | 15 ++ src/interfaces/chat_app/app.py | 119 ++++++++++++- src/interfaces/chat_app/mcp_sse.py | 103 +++++++++++- .../chat_app/templates/mcp_auth.html | 156 ++++++++++++++++++ 4 files changed, 382 insertions(+), 11 deletions(-) create mode 100644 src/interfaces/chat_app/templates/mcp_auth.html diff --git a/src/cli/templates/init.sql b/src/cli/templates/init.sql index 1334fc23c..cf0ca640b 100644 --- a/src/cli/templates/init.sql +++ b/src/cli/templates/init.sql @@ -88,6 +88,21 @@ CREATE TABLE IF NOT EXISTS sessions ( CREATE INDEX IF NOT EXISTS idx_sessions_user ON sessions(user_id); CREATE INDEX IF NOT EXISTS idx_sessions_expires ON sessions(expires_at); +-- ============================================================================ +-- 1.2 MCP API TOKENS (VS Code / Cursor integration) +-- ============================================================================ + +CREATE TABLE IF NOT EXISTS mcp_tokens ( + token VARCHAR(64) PRIMARY KEY, -- secrets.token_hex(32) + user_id VARCHAR(200) NOT NULL REFERENCES users(id) ON DELETE CASCADE, + display_name TEXT, -- e.g. "VS Code – work laptop" + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + last_used_at TIMESTAMPTZ, + expires_at TIMESTAMPTZ -- NULL = never expires +); + +CREATE INDEX IF NOT EXISTS idx_mcp_tokens_user ON mcp_tokens(user_id); + -- ============================================================================ -- 2. STATIC CONFIGURATION (Deploy-Time) -- ============================================================================ diff --git a/src/interfaces/chat_app/app.py b/src/interfaces/chat_app/app.py index 7c2cb1e98..04c157e15 100644 --- a/src/interfaces/chat_app/app.py +++ b/src/interfaces/chat_app/app.py @@ -2293,7 +2293,12 @@ def _inject_alerts(): # Clients connect with just a URL; no local CLI install needed. logger.info("Adding MCP SSE endpoint at /mcp/sse") from src.interfaces.chat_app.mcp_sse import register_mcp_sse - register_mcp_sse(self.app, self) + _mcp_auth_required = self.auth_enabled and self.sso_enabled + register_mcp_sse( + self.app, self, + pg_config=self.pg_config, + auth_enabled=_mcp_auth_required, + ) # add unified auth endpoints if self.auth_enabled: @@ -2307,6 +2312,8 @@ def _inject_alerts(): if self.sso_enabled: self.add_endpoint('/redirect', 'sso_callback', self.sso_callback) + self.add_endpoint('/mcp/auth', 'mcp_auth', self.mcp_auth, methods=['GET']) + self.add_endpoint('/mcp/auth/regenerate', 'mcp_auth_regenerate', self.mcp_auth_regenerate, methods=['POST']) def _set_user_session(self, email: str, name: str, username: str, user_id: str = '', auth_method: str = 'sso', roles: list = None): """Set user session with well-defined structure.""" @@ -2482,7 +2489,12 @@ def sso_callback(self): ) logger.info(f"SSO login successful for user: {user_email} with roles: {user_roles}") - + + # Honour any pending post-login redirect (e.g. /mcp/auth) + next_url = session.pop('sso_next', None) + if next_url: + return redirect(next_url) + # Redirect to main page return redirect(url_for('index')) @@ -2498,6 +2510,109 @@ def sso_callback(self): flash(f"Authentication failed: {str(e)}") return redirect(url_for('login')) + # ------------------------------------------------------------------ + # MCP token helpers + # ------------------------------------------------------------------ + + def _get_mcp_token(self, user_id: str) -> Optional[str]: + """Return the existing MCP token for a user, or None.""" + import secrets as _secrets + conn = psycopg2.connect(**self.pg_config) + try: + with conn.cursor() as cur: + cur.execute( + """SELECT token FROM mcp_tokens + WHERE user_id = %s + AND (expires_at IS NULL OR expires_at > NOW()) + ORDER BY created_at DESC LIMIT 1""", + (user_id,), + ) + row = cur.fetchone() + return row[0] if row else None + finally: + conn.close() + + def _create_mcp_token(self, user_id: str) -> str: + """Create and store a new MCP token for a user, returning the token string.""" + import secrets as _secrets + token = _secrets.token_hex(32) + conn = psycopg2.connect(**self.pg_config) + try: + with conn.cursor() as cur: + cur.execute( + "INSERT INTO mcp_tokens (token, user_id) VALUES (%s, %s)", + (token, user_id), + ) + conn.commit() + finally: + conn.close() + return token + + def _rotate_mcp_token(self, user_id: str) -> str: + """Delete all existing MCP tokens for a user and create a fresh one.""" + import secrets as _secrets + token = _secrets.token_hex(32) + conn = psycopg2.connect(**self.pg_config) + try: + with conn.cursor() as cur: + cur.execute("DELETE FROM mcp_tokens WHERE user_id = %s", (user_id,)) + cur.execute( + "INSERT INTO mcp_tokens (token, user_id) VALUES (%s, %s)", + (token, user_id), + ) + conn.commit() + finally: + conn.close() + return token + + # ------------------------------------------------------------------ + # MCP auth page + # ------------------------------------------------------------------ + + def mcp_auth(self): + """Show the MCP token page. + + Requires SSO login. If the user is not logged in they are + redirected to the SSO provider; after login the SSO callback + returns them here via ``session['sso_next']``. + """ + if not session.get('logged_in'): + session['sso_next'] = '/mcp/auth' + return redirect(url_for('login') + '?method=sso') + + user_info = session.get('user', {}) + user_id = user_info.get('id') + if not user_id: + flash("Could not determine your user identity. Please log in again.") + return redirect(url_for('login')) + + token = self._get_mcp_token(user_id) + if not token: + token = self._create_mcp_token(user_id) + + mcp_url = request.host_url.rstrip('/') + '/mcp/sse' + regenerated = request.args.get('regenerated') == '1' + + return render_template( + 'mcp_auth.html', + token=token, + mcp_url=mcp_url, + user=user_info, + regenerated=regenerated, + ) + + def mcp_auth_regenerate(self): + """Rotate the user's MCP token (POST /mcp/auth/regenerate).""" + if not session.get('logged_in'): + return jsonify({'error': 'Authentication required'}), 401 + + user_id = session.get('user', {}).get('id') + if not user_id: + return jsonify({'error': 'Could not determine user identity'}), 400 + + self._rotate_mcp_token(user_id) + return redirect(url_for('mcp_auth') + '?regenerated=1') + def get_user(self): """API endpoint to get current user information including roles and permissions""" if session.get('logged_in'): diff --git a/src/interfaces/chat_app/mcp_sse.py b/src/interfaces/chat_app/mcp_sse.py index 762857738..f70f62b90 100644 --- a/src/interfaces/chat_app/mcp_sse.py +++ b/src/interfaces/chat_app/mcp_sse.py @@ -49,7 +49,8 @@ from threading import Lock from typing import Any, Dict, Optional -from flask import Blueprint, Response, request, stream_with_context +import psycopg2 +from flask import Blueprint, Response, jsonify, request, stream_with_context from src.utils.logging import get_logger @@ -64,12 +65,53 @@ _KEEPALIVE_TIMEOUT = 30 # seconds between keepalive pings # --------------------------------------------------------------------------- -# Session registry (session_id → Queue of outgoing JSON-RPC messages) +# Session registry (session_id → {"queue": Queue, "user_id": str|None}) # --------------------------------------------------------------------------- -_sessions: Dict[str, queue.Queue] = {} +_sessions: Dict[str, Dict] = {} _sessions_lock = Lock() + +# --------------------------------------------------------------------------- +# Token validation +# --------------------------------------------------------------------------- + + +def _validate_mcp_token(token: str, pg_config: dict) -> Optional[str]: + """Validate an MCP bearer token and return the user_id, or None if invalid.""" + if not token or not pg_config: + return None + try: + conn = psycopg2.connect(**pg_config) + try: + with conn.cursor() as cur: + cur.execute( + """SELECT user_id FROM mcp_tokens + WHERE token = %s + AND (expires_at IS NULL OR expires_at > NOW())""", + (token,), + ) + row = cur.fetchone() + if row: + cur.execute( + "UPDATE mcp_tokens SET last_used_at = NOW() WHERE token = %s", + (token,), + ) + conn.commit() + return row[0] + finally: + conn.close() + except Exception: + logger.exception("Error validating MCP token") + return None + + +def _extract_bearer_token(req) -> Optional[str]: + auth_header = req.headers.get("Authorization", "") + if auth_header.startswith("Bearer "): + return auth_header[7:].strip() + return None + # --------------------------------------------------------------------------- # MCP tool definitions # --------------------------------------------------------------------------- @@ -436,22 +478,56 @@ def _dispatch(body: Dict, session_queue: queue.Queue, wrapper) -> None: # --------------------------------------------------------------------------- -def register_mcp_sse(app, wrapper) -> None: +def register_mcp_sse(app, wrapper, pg_config: dict = None, auth_enabled: bool = False) -> None: """Register the MCP SSE endpoints on a Flask app. - Adds two routes: + Adds routes: GET /mcp/sse – SSE stream (MCP clients connect here) POST /mcp/messages – JSON-RPC message receiver + + When ``auth_enabled`` is True, both endpoints require an + ``Authorization: Bearer `` header. Tokens are issued via + the ``/mcp/auth`` page after the user logs in through SSO. """ mcp = Blueprint("mcp_sse", __name__) + def _auth_check(): + """Return (user_id, error_response) tuple. error_response is None on success.""" + if not auth_enabled: + return None, None + token = _extract_bearer_token(request) + if not token: + resp = jsonify({ + "error": "unauthorized", + "message": "MCP access requires a bearer token. " + "Visit /mcp/auth to generate one after logging in.", + "login_url": "/mcp/auth", + }) + resp.status_code = 401 + return None, resp + user_id = _validate_mcp_token(token, pg_config) + if not user_id: + resp = jsonify({ + "error": "invalid_token", + "message": "The bearer token is invalid or has expired. " + "Visit /mcp/auth to generate a new token.", + "login_url": "/mcp/auth", + }) + resp.status_code = 401 + return None, resp + return user_id, None + @mcp.route("/mcp/sse") def sse(): """Open an SSE stream for one MCP client session.""" + user_id, err = _auth_check() + if err is not None: + return err + session_id = uuid.uuid4().hex q: queue.Queue = queue.Queue() with _sessions_lock: - _sessions[session_id] = q + _sessions[session_id] = {"queue": q, "user_id": user_id} def generate(): # Advertise the POST endpoint to the client. @@ -482,12 +558,18 @@ def generate(): @mcp.route("/mcp/messages", methods=["POST"]) def messages(): """Receive a JSON-RPC message from an MCP client.""" + _, err = _auth_check() + if err is not None: + return err + session_id = request.args.get("session_id", "") with _sessions_lock: - q = _sessions.get(session_id) - if q is None: + session_entry = _sessions.get(session_id) + if session_entry is None: return {"error": "unknown or expired session_id"}, 404 + q = session_entry["queue"] + body = request.get_json(silent=True) if not body: return {"error": "request body must be valid JSON"}, 400 @@ -496,4 +578,7 @@ def messages(): return "", 202 app.register_blueprint(mcp) - logger.info("Registered MCP SSE endpoint at /mcp/sse") + if auth_enabled: + logger.info("Registered MCP SSE endpoint at /mcp/sse (auth required – Bearer token)") + else: + logger.info("Registered MCP SSE endpoint at /mcp/sse (no auth)") diff --git a/src/interfaces/chat_app/templates/mcp_auth.html b/src/interfaces/chat_app/templates/mcp_auth.html new file mode 100644 index 000000000..f9b0c89fd --- /dev/null +++ b/src/interfaces/chat_app/templates/mcp_auth.html @@ -0,0 +1,156 @@ + + + + + Archi – MCP Token + + + + + + + + + +
+
+ archi logo + archi +
+ +

MCP Access Token

+

Use this token to connect VS Code or Cursor to archi's MCP server.

+ + {% if user %} +
+ Logged in as {{ user.email or user.name or user.username }} +
+ {% endif %} + + {% if regenerated %} +
+ Token regenerated. Your previous token is now invalid. Update your IDE config below. +
+ {% endif %} + + +
+

Your token

+
+ Keep this token secret – it grants access to archi on your behalf. +
+
+
{{ token }}
+ +
+
+ + +
+

Add to your IDE

+

+ Paste one of the snippets below into your IDE's MCP configuration file. +

+ +
+ + +
+ +
+

+ File: .vscode/mcp.json (in your project root) or + settings.json under mcp.servers. +

+
{
+  "servers": {
+    "archi": {
+      "type": "sse",
+      "url": "{{ mcp_url }}",
+      "headers": {
+        "Authorization": "Bearer {{ token }}"
+      }
+    }
+  }
+}
+ +
+ +
+

+ File: ~/.cursor/mcp.json +

+
{
+  "mcpServers": {
+    "archi": {
+      "url": "{{ mcp_url }}",
+      "headers": {
+        "Authorization": "Bearer {{ token }}"
+      }
+    }
+  }
+}
+ +
+
+ + +
+

Rotate token

+

+ Regenerate a new token. Your current token will stop working immediately. +

+
+ +
+
+
+ + + + From 92994947d6090593e7b11ecd065caa33ef26fa2e Mon Sep 17 00:00:00 2001 From: Muhammad Hassan Ahmed <33365802+hassan11196@users.noreply.github.com> Date: Sun, 15 Mar 2026 23:25:06 +0000 Subject: [PATCH 07/30] feat: add OAuth2 PKCE authorization server for MCP clients MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements the standard OAuth2 authorization code flow with PKCE so that MCP clients (Claude Desktop, VS Code, etc.) can authenticate automatically without manual token copy-paste. New endpoints: GET /.well-known/oauth-authorization-server – RFC 8414 discovery GET /authorize – PKCE authorization (redirects to SSO if needed) POST /token – code → bearer token exchange New DB table: mcp_auth_codes (short-lived, single-use PKCE codes). --- src/cli/templates/init.sql | 15 ++++ src/interfaces/chat_app/app.py | 132 +++++++++++++++++++++++++++++++++ 2 files changed, 147 insertions(+) diff --git a/src/cli/templates/init.sql b/src/cli/templates/init.sql index cf0ca640b..a40f7b8ce 100644 --- a/src/cli/templates/init.sql +++ b/src/cli/templates/init.sql @@ -103,6 +103,21 @@ CREATE TABLE IF NOT EXISTS mcp_tokens ( CREATE INDEX IF NOT EXISTS idx_mcp_tokens_user ON mcp_tokens(user_id); +-- Short-lived authorization codes for the OAuth2 PKCE flow used by MCP clients. +CREATE TABLE IF NOT EXISTS mcp_auth_codes ( + code VARCHAR(64) PRIMARY KEY, + user_id VARCHAR(200) NOT NULL REFERENCES users(id) ON DELETE CASCADE, + code_challenge VARCHAR(128) NOT NULL, + code_challenge_method VARCHAR(10) NOT NULL DEFAULT 'S256', + redirect_uri TEXT NOT NULL, + client_id VARCHAR(100) NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + expires_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + INTERVAL '10 minutes', + used BOOLEAN NOT NULL DEFAULT FALSE +); + +CREATE INDEX IF NOT EXISTS idx_mcp_auth_codes_expires ON mcp_auth_codes(expires_at); + -- ============================================================================ -- 2. STATIC CONFIGURATION (Deploy-Time) -- ============================================================================ diff --git a/src/interfaces/chat_app/app.py b/src/interfaces/chat_app/app.py index 04c157e15..7eb6d5ff5 100644 --- a/src/interfaces/chat_app/app.py +++ b/src/interfaces/chat_app/app.py @@ -2315,6 +2315,138 @@ def _inject_alerts(): self.add_endpoint('/mcp/auth', 'mcp_auth', self.mcp_auth, methods=['GET']) self.add_endpoint('/mcp/auth/regenerate', 'mcp_auth_regenerate', self.mcp_auth_regenerate, methods=['POST']) + # OAuth2 PKCE endpoints for MCP clients (always registered so MCP + # clients can discover and use the authorization server). + self.add_endpoint('/.well-known/oauth-authorization-server', 'oauth_metadata', self.oauth_metadata, methods=['GET']) + self.add_endpoint('/authorize', 'oauth_authorize', self.oauth_authorize, methods=['GET']) + self.add_endpoint('/token', 'oauth_token', self.oauth_token, methods=['POST']) + + # ------------------------------------------------------------------ + # OAuth2 PKCE endpoints (used by MCP clients like Claude Desktop) + # ------------------------------------------------------------------ + + def oauth_metadata(self): + """GET /.well-known/oauth-authorization-server — RFC 8414 discovery doc.""" + base = request.host_url.rstrip('/') + return jsonify({ + "issuer": base, + "authorization_endpoint": base + "/authorize", + "token_endpoint": base + "/token", + "response_types_supported": ["code"], + "grant_types_supported": ["authorization_code"], + "code_challenge_methods_supported": ["S256"], + }) + + def oauth_authorize(self): + """GET /authorize — OAuth2 authorization code endpoint with PKCE. + + If the user is not logged in they are redirected to SSO login and + returned here afterwards via ``session['sso_next']``. Once logged in + an auth code is generated, stored in ``mcp_auth_codes``, and the + browser is redirected back to the client's ``redirect_uri``. + """ + import secrets as _secrets + + client_id = request.args.get('client_id', '') + response_type = request.args.get('response_type', '') + code_challenge = request.args.get('code_challenge', '') + code_challenge_method = request.args.get('code_challenge_method', 'S256') + redirect_uri = request.args.get('redirect_uri', '') + state = request.args.get('state', '') + + if response_type != 'code' or not code_challenge or not redirect_uri: + return jsonify({"error": "invalid_request", + "error_description": "response_type=code, code_challenge, and redirect_uri are required"}), 400 + + if not session.get('logged_in'): + # Preserve all OAuth params so we return here after SSO login. + session['sso_next'] = request.url + if self.sso_enabled: + return redirect(url_for('login') + '?method=sso') + return redirect(url_for('login')) + + user_id = session.get('user', {}).get('id') + if not user_id: + return jsonify({"error": "server_error", "error_description": "Could not determine user identity"}), 500 + + # Create a short-lived auth code. + code = _secrets.token_hex(32) + conn = psycopg2.connect(**self.pg_config) + try: + with conn.cursor() as cur: + cur.execute( + """INSERT INTO mcp_auth_codes + (code, user_id, code_challenge, code_challenge_method, redirect_uri, client_id) + VALUES (%s, %s, %s, %s, %s, %s)""", + (code, user_id, code_challenge, code_challenge_method, redirect_uri, client_id), + ) + conn.commit() + finally: + conn.close() + + from urllib.parse import urlencode + params = {"code": code} + if state: + params["state"] = state + sep = '&' if '?' in redirect_uri else '?' + return redirect(redirect_uri + sep + urlencode(params)) + + def oauth_token(self): + """POST /token — OAuth2 token exchange with PKCE verification.""" + import hashlib as _hashlib + import base64 as _base64 + + grant_type = request.form.get('grant_type', '') + code = request.form.get('code', '') + code_verifier = request.form.get('code_verifier', '') + redirect_uri = request.form.get('redirect_uri', '') + + if grant_type != 'authorization_code' or not code or not code_verifier: + return jsonify({"error": "invalid_request", + "error_description": "grant_type=authorization_code, code, and code_verifier are required"}), 400 + + conn = psycopg2.connect(**self.pg_config) + try: + with conn.cursor() as cur: + cur.execute( + """SELECT user_id, code_challenge, code_challenge_method, redirect_uri, used + FROM mcp_auth_codes + WHERE code = %s AND expires_at > NOW()""", + (code,), + ) + row = cur.fetchone() + if not row: + return jsonify({"error": "invalid_grant", + "error_description": "Authorization code is invalid or expired"}), 400 + + user_id, stored_challenge, challenge_method, stored_redirect, used = row + if used: + return jsonify({"error": "invalid_grant", + "error_description": "Authorization code has already been used"}), 400 + + # Verify PKCE: BASE64URL(SHA256(code_verifier)) == code_challenge + digest = _hashlib.sha256(code_verifier.encode()).digest() + computed = _base64.urlsafe_b64encode(digest).rstrip(b'=').decode() + if computed != stored_challenge: + return jsonify({"error": "invalid_grant", + "error_description": "code_verifier does not match code_challenge"}), 400 + + # Mark code as used. + cur.execute("UPDATE mcp_auth_codes SET used = TRUE WHERE code = %s", (code,)) + conn.commit() + finally: + conn.close() + + # Return the user's long-lived MCP bearer token as the access_token. + token = self._get_mcp_token(user_id) + if not token: + token = self._create_mcp_token(user_id) + + return jsonify({ + "access_token": token, + "token_type": "bearer", + }) + def _set_user_session(self, email: str, name: str, username: str, user_id: str = '', auth_method: str = 'sso', roles: list = None): """Set user session with well-defined structure.""" session['user'] = { From 87673808d3088dcae4f2a4f189b12fea18de5547 Mon Sep 17 00:00:00 2001 From: Muhammad Hassan Ahmed <33365802+hassan11196@users.noreply.github.com> Date: Sun, 15 Mar 2026 23:35:49 +0000 Subject: [PATCH 08/30] fix: address security and efficiency issues in OAuth2 PKCE endpoints - Security: use atomic UPDATE...RETURNING to prevent auth-code replay attacks - Security: validate redirect_uri in /token matches the one from /authorize - Efficiency: inline token fetch/create inside existing DB connection (1 conn instead of 2-3) - Efficiency: opportunistically delete expired mcp_auth_codes rows on each token exchange - Bug fix: use urlparse/urlunparse for redirect_uri assembly (handles trailing ? edge case) - Cleanup: move secrets/hashlib/base64/urlencode to module-level imports - Cleanup: remove dead variable challenge_method --- src/interfaces/chat_app/app.py | 82 +++++++++++++++++++++------------- 1 file changed, 51 insertions(+), 31 deletions(-) diff --git a/src/interfaces/chat_app/app.py b/src/interfaces/chat_app/app.py index 7eb6d5ff5..ac643b008 100644 --- a/src/interfaces/chat_app/app.py +++ b/src/interfaces/chat_app/app.py @@ -9,7 +9,10 @@ from threading import Lock from typing import Any, Dict, Iterator, List, Optional from pathlib import Path -from urllib.parse import urlparse +import base64 +import hashlib +import secrets +from urllib.parse import urlparse, urlencode, urlunparse, parse_qs, urljoin from functools import wraps import requests @@ -2345,8 +2348,6 @@ def oauth_authorize(self): an auth code is generated, stored in ``mcp_auth_codes``, and the browser is redirected back to the client's ``redirect_uri``. """ - import secrets as _secrets - client_id = request.args.get('client_id', '') response_type = request.args.get('response_type', '') code_challenge = request.args.get('code_challenge', '') @@ -2370,7 +2371,7 @@ def oauth_authorize(self): return jsonify({"error": "server_error", "error_description": "Could not determine user identity"}), 500 # Create a short-lived auth code. - code = _secrets.token_hex(32) + code = secrets.token_hex(32) conn = psycopg2.connect(**self.pg_config) try: with conn.cursor() as cur: @@ -2384,18 +2385,16 @@ def oauth_authorize(self): finally: conn.close() - from urllib.parse import urlencode + # Safely append code (and optional state) to the redirect_uri. + parsed = urlparse(redirect_uri) params = {"code": code} if state: params["state"] = state - sep = '&' if '?' in redirect_uri else '?' - return redirect(redirect_uri + sep + urlencode(params)) + new_query = urlencode(params) if not parsed.query else parsed.query + '&' + urlencode(params) + return redirect(urlunparse(parsed._replace(query=new_query))) def oauth_token(self): """POST /token — OAuth2 token exchange with PKCE verification.""" - import hashlib as _hashlib - import base64 as _base64 - grant_type = request.form.get('grant_type', '') code = request.form.get('code', '') code_verifier = request.form.get('code_verifier', '') @@ -2405,47 +2404,68 @@ def oauth_token(self): return jsonify({"error": "invalid_request", "error_description": "grant_type=authorization_code, code, and code_verifier are required"}), 400 + # Verify PKCE before touching the DB. + digest = hashlib.sha256(code_verifier.encode()).digest() + computed_challenge = base64.urlsafe_b64encode(digest).rstrip(b'=').decode() + conn = psycopg2.connect(**self.pg_config) try: with conn.cursor() as cur: + # Atomically mark the code as used and return its fields. + # This prevents replay attacks without a separate SELECT + UPDATE. cur.execute( - """SELECT user_id, code_challenge, code_challenge_method, redirect_uri, used - FROM mcp_auth_codes - WHERE code = %s AND expires_at > NOW()""", + """UPDATE mcp_auth_codes + SET used = TRUE + WHERE code = %s + AND used = FALSE + AND expires_at > NOW() + RETURNING user_id, code_challenge, redirect_uri""", (code,), ) row = cur.fetchone() if not row: return jsonify({"error": "invalid_grant", - "error_description": "Authorization code is invalid or expired"}), 400 + "error_description": "Authorization code is invalid, expired, or already used"}), 400 + + user_id, stored_challenge, stored_redirect = row - user_id, stored_challenge, challenge_method, stored_redirect, used = row - if used: + # Validate redirect_uri matches what was used in /authorize. + if redirect_uri and redirect_uri != stored_redirect: return jsonify({"error": "invalid_grant", - "error_description": "Authorization code has already been used"}), 400 + "error_description": "redirect_uri does not match the authorization request"}), 400 # Verify PKCE: BASE64URL(SHA256(code_verifier)) == code_challenge - digest = _hashlib.sha256(code_verifier.encode()).digest() - computed = _base64.urlsafe_b64encode(digest).rstrip(b'=').decode() - if computed != stored_challenge: + if computed_challenge != stored_challenge: return jsonify({"error": "invalid_grant", "error_description": "code_verifier does not match code_challenge"}), 400 - # Mark code as used. - cur.execute("UPDATE mcp_auth_codes SET used = TRUE WHERE code = %s", (code,)) + # Opportunistically delete expired codes to keep the table tidy. + cur.execute("DELETE FROM mcp_auth_codes WHERE expires_at < NOW()") + + # Fetch or create the user's long-lived MCP token in the same + # connection to avoid opening extra DB connections. + cur.execute( + """SELECT token FROM mcp_tokens + WHERE user_id = %s + AND (expires_at IS NULL OR expires_at > NOW()) + ORDER BY created_at DESC LIMIT 1""", + (user_id,), + ) + token_row = cur.fetchone() + if token_row: + token = token_row[0] + else: + token = secrets.token_hex(32) + cur.execute( + "INSERT INTO mcp_tokens (token, user_id) VALUES (%s, %s)", + (token, user_id), + ) + conn.commit() finally: conn.close() - # Return the user's long-lived MCP bearer token as the access_token. - token = self._get_mcp_token(user_id) - if not token: - token = self._create_mcp_token(user_id) - - return jsonify({ - "access_token": token, - "token_type": "bearer", - }) + return jsonify({"access_token": token, "token_type": "bearer"}) def _set_user_session(self, email: str, name: str, username: str, user_id: str = '', auth_method: str = 'sso', roles: list = None): """Set user session with well-defined structure.""" From 5cbdcf57cb8882803975047d46220af61a830536 Mon Sep 17 00:00:00 2001 From: Muhammad Hassan Ahmed <33365802+hassan11196@users.noreply.github.com> Date: Sun, 15 Mar 2026 23:43:16 +0000 Subject: [PATCH 09/30] fix: reuse existing SSO in /authorize instead of bouncing through login page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When an MCP client hits /authorize and the user isn't logged in, directly invoke self.oauth.sso.authorize_redirect() — the same call the login handler uses — instead of redirecting to /login?method=sso as an intermediate step. The existing sso_next session key still brings the user back to /authorize after the SSO callback completes, so the rest of the PKCE flow is unchanged. --- src/interfaces/chat_app/app.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/interfaces/chat_app/app.py b/src/interfaces/chat_app/app.py index ac643b008..2c6079ec0 100644 --- a/src/interfaces/chat_app/app.py +++ b/src/interfaces/chat_app/app.py @@ -2362,8 +2362,10 @@ def oauth_authorize(self): if not session.get('logged_in'): # Preserve all OAuth params so we return here after SSO login. session['sso_next'] = request.url - if self.sso_enabled: - return redirect(url_for('login') + '?method=sso') + if self.sso_enabled and self.oauth: + # Reuse the existing SSO config directly — no intermediate login page. + sso_callback_uri = url_for('sso_callback', _external=True) + return self.oauth.sso.authorize_redirect(sso_callback_uri) return redirect(url_for('login')) user_id = session.get('user', {}).get('id') From bc5a11405be78eb91071c0cb7d551a42aa575c6b Mon Sep 17 00:00:00 2001 From: Muhammad Hassan Ahmed <33365802+hassan11196@users.noreply.github.com> Date: Sun, 15 Mar 2026 23:46:31 +0000 Subject: [PATCH 10/30] fix: pass message in correct format to wrapper.chat in MCP tool query ChatWrapper.__call__ expects message as [["User", content]] (matching the JS client's history.slice(-1) format). _tool_query was passing a bare string, causing `sender, content = tuple(message[0])` to fail with "not enough values to unpack" since message[0] was a single character. --- src/interfaces/chat_app/mcp_sse.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/interfaces/chat_app/mcp_sse.py b/src/interfaces/chat_app/mcp_sse.py index f70f62b90..accaed7f1 100644 --- a/src/interfaces/chat_app/mcp_sse.py +++ b/src/interfaces/chat_app/mcp_sse.py @@ -298,7 +298,7 @@ def _tool_query(arguments: Dict[str, Any], wrapper) -> Dict: now = datetime.now(timezone.utc) response, new_conv_id, _, _, error_code = wrapper.chat( - question, + [["User", question]], # same format as last_message from the JS client conversation_id, client_id, False, # is_refresh From 1c1317d3325c2ef811bb479096903c7819f9005b Mon Sep 17 00:00:00 2001 From: Muhammad Hassan Ahmed <33365802+hassan11196@users.noreply.github.com> Date: Sun, 15 Mar 2026 23:49:42 +0000 Subject: [PATCH 11/30] feat: add Claude Desktop and Claude Code tabs to MCP auth page - mcp_auth.html: add two new tabs alongside VS Code and Cursor - Claude Desktop: shows claude_desktop_config.json snippet for macOS/Windows - Claude Code: shows `claude mcp add` CLI command + .mcp.json project config - mcp_sse.py: update module docstring with Claude Desktop and Claude Code examples --- src/interfaces/chat_app/mcp_sse.py | 21 +++++++- .../chat_app/templates/mcp_auth.html | 50 ++++++++++++++++++- 2 files changed, 68 insertions(+), 3 deletions(-) diff --git a/src/interfaces/chat_app/mcp_sse.py b/src/interfaces/chat_app/mcp_sse.py index accaed7f1..82c1c738b 100644 --- a/src/interfaces/chat_app/mcp_sse.py +++ b/src/interfaces/chat_app/mcp_sse.py @@ -1,8 +1,8 @@ """ MCP SSE endpoint – exposes archi's RAG capabilities as MCP tools over HTTP+SSE. -AI assistants in VS Code (GitHub Copilot), Cursor, and any other MCP-compatible -client can connect with just a URL: +AI assistants in VS Code (GitHub Copilot), Cursor, Claude Desktop, Claude Code, +and any other MCP-compatible client can connect with just a URL: http://:/mcp/sse @@ -22,6 +22,23 @@ } } +Claude Desktop (~/Library/Application Support/Claude/claude_desktop_config.json): + { + "mcpServers": { + "archi": { "type": "sse", "url": "http://localhost:7861/mcp/sse" } + } + } + +Claude Code (run once in terminal): + claude mcp add --transport sse archi http://localhost:7861/mcp/sse + + Or add to .mcp.json in your project root: + { + "mcpServers": { + "archi": { "type": "sse", "url": "http://localhost:7861/mcp/sse" } + } + } + Protocol -------- This implements the MCP SSE transport (JSON-RPC 2.0 over Server-Sent Events): diff --git a/src/interfaces/chat_app/templates/mcp_auth.html b/src/interfaces/chat_app/templates/mcp_auth.html index f9b0c89fd..59da4b301 100644 --- a/src/interfaces/chat_app/templates/mcp_auth.html +++ b/src/interfaces/chat_app/templates/mcp_auth.html @@ -46,7 +46,7 @@

MCP Access Token

-

Use this token to connect VS Code or Cursor to archi's MCP server.

+

Use this token to connect VS Code, Cursor, Claude Desktop, or Claude Code to archi's MCP server.

{% if user %}
@@ -82,6 +82,8 @@

Add to your IDE

+ +
@@ -119,6 +121,52 @@

Add to your IDE

}
+ +
+

+ File: ~/Library/Application Support/Claude/claude_desktop_config.json (macOS) + or %APPDATA%\Claude\claude_desktop_config.json (Windows). + Restart Claude Desktop after saving. +

+
{
+  "mcpServers": {
+    "archi": {
+      "type": "sse",
+      "url": "{{ mcp_url }}",
+      "headers": {
+        "Authorization": "Bearer {{ token }}"
+      }
+    }
+  }
+}
+ +
+ +
+

+ Run this once in your terminal to register archi globally. Claude Code stores it in + ~/.claude.json. +

+
claude mcp add --transport sse \
+  --header "Authorization: Bearer {{ token }}" \
+  archi {{ mcp_url }}
+ +

+ Or add it to .mcp.json in your project root for project-scoped access: +

+
{
+  "mcpServers": {
+    "archi": {
+      "type": "sse",
+      "url": "{{ mcp_url }}",
+      "headers": {
+        "Authorization": "Bearer {{ token }}"
+      }
+    }
+  }
+}
+ +
From 88ea4263f3529951b7249f43197afd3a815d5228 Mon Sep 17 00:00:00 2001 From: Muhammad Hassan Ahmed <33365802+hassan11196@users.noreply.github.com> Date: Mon, 16 Mar 2026 00:03:00 +0000 Subject: [PATCH 12/30] chore: remove unused api_key from mcp_server config Auth is now handled via SSO-issued bearer tokens (mcp_tokens table) and the OAuth2 PKCE flow. The static api_key field was never read by any code. --- src/cli/templates/base-config.yaml | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/cli/templates/base-config.yaml b/src/cli/templates/base-config.yaml index 79b7e8671..a736a3b1f 100644 --- a/src/cli/templates/base-config.yaml +++ b/src/cli/templates/base-config.yaml @@ -147,8 +147,6 @@ services: # Public URL of the chat service that MCP clients will connect to. # Defaults to the chat app's hostname and external port. url: "{{ services.mcp_server.url | default('http://' + (services.chat_app.hostname | default('localhost', true)) + ':' + (services.chat_app.external_port | default(7861, true) | string), true) }}" - # Optional bearer token when chat app auth is enabled. - api_key: "{{ services.mcp_server.api_key | default('', true) }}" # HTTP request timeout in seconds. timeout: {{ services.mcp_server.timeout | default(120, true) }} From bdbb01ca384ea6f4a95cf55a93f37e7133cbeefc Mon Sep 17 00:00:00 2001 From: Muhammad Hassan Ahmed <33365802+hassan11196@users.noreply.github.com> Date: Mon, 16 Mar 2026 02:36:42 +0100 Subject: [PATCH 13/30] feat: update MCP server configuration to use npx command with remote arguments --- src/interfaces/chat_app/mcp_sse.py | 16 ++++++++++++---- src/interfaces/chat_app/templates/mcp_auth.html | 12 +++++++----- 2 files changed, 19 insertions(+), 9 deletions(-) diff --git a/src/interfaces/chat_app/mcp_sse.py b/src/interfaces/chat_app/mcp_sse.py index 82c1c738b..6c03e30fe 100644 --- a/src/interfaces/chat_app/mcp_sse.py +++ b/src/interfaces/chat_app/mcp_sse.py @@ -25,7 +25,15 @@ Claude Desktop (~/Library/Application Support/Claude/claude_desktop_config.json): { "mcpServers": { - "archi": { "type": "sse", "url": "http://localhost:7861/mcp/sse" } + "archi": { + "command": "npx", + "args": [ + "mcp-remote", + "http://localhost:7861/mcp/sse", + "--header", + "Authorization: Bearer " + ] + } } } @@ -319,9 +327,9 @@ def _tool_query(arguments: Dict[str, Any], wrapper) -> Dict: conversation_id, client_id, False, # is_refresh - now, # server_received_msg_ts - 0.0, # client_sent_msg_ts (unknown for MCP callers) - 120.0, # client_timeout (seconds) + now, # server_received_msg_ts + now.timestamp(), # client_sent_msg_ts + 120.0, # client_timeout (seconds) None, # config_name (use active config) ) diff --git a/src/interfaces/chat_app/templates/mcp_auth.html b/src/interfaces/chat_app/templates/mcp_auth.html index 59da4b301..50907a414 100644 --- a/src/interfaces/chat_app/templates/mcp_auth.html +++ b/src/interfaces/chat_app/templates/mcp_auth.html @@ -131,11 +131,13 @@

Add to your IDE

{
   "mcpServers": {
     "archi": {
-      "type": "sse",
-      "url": "{{ mcp_url }}",
-      "headers": {
-        "Authorization": "Bearer {{ token }}"
-      }
+      "command": "npx",
+      "args": [
+        "mcp-remote",
+        "{{ mcp_url }}",
+        "--header",
+        "Authorization: Bearer {{ token }}"
+      ]
     }
   }
 }
From 1558348a69964f0d5fd9c0a053e005c510637766 Mon Sep 17 00:00:00 2001 From: Muhammad Hassan Ahmed <33365802+hassan11196@users.noreply.github.com> Date: Mon, 16 Mar 2026 01:42:23 +0000 Subject: [PATCH 14/30] fix: propagate SSO user_id through MCP request pipeline to conversation_metadata MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit user_id was extracted from the bearer token and stored in the session, but was never passed through _dispatch → _call_tool → _tool_query → wrapper.chat, causing conversation_metadata.user_id to always be NULL for MCP requests. Thread user_id from session_entry through the full call chain so it reaches create_conversation() and is written to the DB. --- src/interfaces/chat_app/mcp_sse.py | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/src/interfaces/chat_app/mcp_sse.py b/src/interfaces/chat_app/mcp_sse.py index 6c03e30fe..81ec176d5 100644 --- a/src/interfaces/chat_app/mcp_sse.py +++ b/src/interfaces/chat_app/mcp_sse.py @@ -291,11 +291,11 @@ def _text(text: str) -> Dict: # --------------------------------------------------------------------------- -def _call_tool(name: str, arguments: Dict[str, Any], wrapper) -> Dict: +def _call_tool(name: str, arguments: Dict[str, Any], wrapper, user_id: Optional[str] = None) -> Dict: """Dispatch a tools/call request to the appropriate archi internals.""" try: if name == "archi_query": - return _tool_query(arguments, wrapper) + return _tool_query(arguments, wrapper, user_id) elif name == "archi_list_documents": return _tool_list_documents(arguments, wrapper) elif name == "archi_get_document_content": @@ -313,7 +313,7 @@ def _call_tool(name: str, arguments: Dict[str, Any], wrapper) -> Dict: return _text(f"ERROR: {exc}") -def _tool_query(arguments: Dict[str, Any], wrapper) -> Dict: +def _tool_query(arguments: Dict[str, Any], wrapper, user_id: Optional[str] = None) -> Dict: question = (arguments.get("question") or "").strip() if not question: return _text("ERROR: 'question' is required.") @@ -331,6 +331,7 @@ def _tool_query(arguments: Dict[str, Any], wrapper) -> Dict: now.timestamp(), # client_sent_msg_ts 120.0, # client_timeout (seconds) None, # config_name (use active config) + user_id, # user_id from SSO bearer token ) if error_code is not None: @@ -462,7 +463,7 @@ def _tool_list_agents(wrapper) -> Dict: # --------------------------------------------------------------------------- -def _dispatch(body: Dict, session_queue: queue.Queue, wrapper) -> None: +def _dispatch(body: Dict, session_queue: queue.Queue, wrapper, user_id: Optional[str] = None) -> None: """Process one incoming JSON-RPC message and enqueue the response if needed.""" rpc_id = body.get("id") method = body.get("method", "") @@ -488,6 +489,7 @@ def _dispatch(body: Dict, session_queue: queue.Queue, wrapper) -> None: params.get("name", ""), params.get("arguments") or {}, wrapper, + user_id, ) response = _ok(result, rpc_id) elif method == "ping": @@ -594,12 +596,13 @@ def messages(): return {"error": "unknown or expired session_id"}, 404 q = session_entry["queue"] + user_id = session_entry.get("user_id") body = request.get_json(silent=True) if not body: return {"error": "request body must be valid JSON"}, 400 - _dispatch(body, q, wrapper) + _dispatch(body, q, wrapper, user_id) return "", 202 app.register_blueprint(mcp) From 069f88f98829554a8feed9639968c93ccc42bcc7 Mon Sep 17 00:00:00 2001 From: Muhammad Hassan Ahmed <33365802+hassan11196@users.noreply.github.com> Date: Mon, 16 Mar 2026 02:16:27 +0000 Subject: [PATCH 15/30] Move MCP OAuth endpoints under /mcp/oauth/* and add dynamic client registration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Relocate /authorize → /mcp/oauth/authorize and /token → /mcp/oauth/token - Add /mcp/oauth/register (RFC 7591 dynamic client registration) - Update /.well-known/oauth-authorization-server metadata to point to new paths and advertise registration_endpoint - Add mcp_oauth_clients table to init.sql to persist registered clients - Update mcp_auth.html: IDE config snippets no longer include hardcoded tokens; clients discover OAuth via well-known and handle auth automatically on first use. Manual bearer token moved to an Advanced collapsible section for legacy clients. --- src/cli/templates/init.sql | 8 ++ src/interfaces/chat_app/app.py | 52 +++++++++-- .../chat_app/templates/mcp_auth.html | 89 ++++++++++--------- 3 files changed, 100 insertions(+), 49 deletions(-) diff --git a/src/cli/templates/init.sql b/src/cli/templates/init.sql index a40f7b8ce..7ecbf2ed2 100644 --- a/src/cli/templates/init.sql +++ b/src/cli/templates/init.sql @@ -118,6 +118,14 @@ CREATE TABLE IF NOT EXISTS mcp_auth_codes ( CREATE INDEX IF NOT EXISTS idx_mcp_auth_codes_expires ON mcp_auth_codes(expires_at); +-- OAuth2 dynamic client registrations (RFC 7591) used by MCP clients. +CREATE TABLE IF NOT EXISTS mcp_oauth_clients ( + client_id VARCHAR(32) PRIMARY KEY, -- secrets.token_hex(16) + client_name TEXT, + redirect_uris TEXT[] NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + -- ============================================================================ -- 2. STATIC CONFIGURATION (Deploy-Time) -- ============================================================================ diff --git a/src/interfaces/chat_app/app.py b/src/interfaces/chat_app/app.py index 2c6079ec0..f27133969 100644 --- a/src/interfaces/chat_app/app.py +++ b/src/interfaces/chat_app/app.py @@ -2321,8 +2321,9 @@ def _inject_alerts(): # OAuth2 PKCE endpoints for MCP clients (always registered so MCP # clients can discover and use the authorization server). self.add_endpoint('/.well-known/oauth-authorization-server', 'oauth_metadata', self.oauth_metadata, methods=['GET']) - self.add_endpoint('/authorize', 'oauth_authorize', self.oauth_authorize, methods=['GET']) - self.add_endpoint('/token', 'oauth_token', self.oauth_token, methods=['POST']) + self.add_endpoint('/mcp/oauth/register', 'oauth_register', self.oauth_register, methods=['POST']) + self.add_endpoint('/mcp/oauth/authorize', 'oauth_authorize', self.oauth_authorize, methods=['GET']) + self.add_endpoint('/mcp/oauth/token', 'oauth_token', self.oauth_token, methods=['POST']) # ------------------------------------------------------------------ # OAuth2 PKCE endpoints (used by MCP clients like Claude Desktop) @@ -2333,15 +2334,56 @@ def oauth_metadata(self): base = request.host_url.rstrip('/') return jsonify({ "issuer": base, - "authorization_endpoint": base + "/authorize", - "token_endpoint": base + "/token", + "authorization_endpoint": base + "/mcp/oauth/authorize", + "token_endpoint": base + "/mcp/oauth/token", + "registration_endpoint": base + "/mcp/oauth/register", "response_types_supported": ["code"], "grant_types_supported": ["authorization_code"], "code_challenge_methods_supported": ["S256"], }) + def oauth_register(self): + """POST /mcp/oauth/register — RFC 7591 dynamic client registration. + + MCP clients (mcp-remote, Claude Desktop, VS Code) call this once to + obtain a client_id before starting the authorization flow. We keep + it simple: any caller may register; we persist the client and return + a client_id immediately (no client_secret for public clients). + """ + body = request.get_json(silent=True) or {} + redirect_uris = body.get("redirect_uris", []) + client_name = body.get("client_name", "") + + if not redirect_uris: + return jsonify({"error": "invalid_client_metadata", + "error_description": "redirect_uris is required"}), 400 + + client_id = secrets.token_hex(16) + conn = psycopg2.connect(**self.pg_config) + try: + with conn.cursor() as cur: + cur.execute( + """INSERT INTO mcp_oauth_clients (client_id, client_name, redirect_uris) + VALUES (%s, %s, %s)""", + (client_id, client_name, redirect_uris), + ) + conn.commit() + finally: + conn.close() + + base = request.host_url.rstrip('/') + return jsonify({ + "client_id": client_id, + "client_name": client_name, + "redirect_uris": redirect_uris, + "grant_types": ["authorization_code"], + "response_types": ["code"], + "token_endpoint_auth_method": "none", + "registration_client_uri": base + "/mcp/oauth/register", + }), 201 + def oauth_authorize(self): - """GET /authorize — OAuth2 authorization code endpoint with PKCE. + """GET /mcp/oauth/authorize — OAuth2 authorization code endpoint with PKCE. If the user is not logged in they are redirected to SSO login and returned here afterwards via ``session['sso_next']``. Once logged in diff --git a/src/interfaces/chat_app/templates/mcp_auth.html b/src/interfaces/chat_app/templates/mcp_auth.html index 50907a414..b4eb691ac 100644 --- a/src/interfaces/chat_app/templates/mcp_auth.html +++ b/src/interfaces/chat_app/templates/mcp_auth.html @@ -56,27 +56,21 @@

MCP Access Token

{% if regenerated %}
- Token regenerated. Your previous token is now invalid. Update your IDE config below. + Token regenerated. Your previous token is now invalid.
{% endif %} - -
-

Your token

-
- Keep this token secret – it grants access to archi on your behalf. -
-
-
{{ token }}
- -
+
+ OAuth login is handled automatically. + Paste the snippet for your IDE below — no token required in the config. + Your IDE will open a browser to log you in on first use and manage tokens itself.

Add to your IDE

- Paste one of the snippets below into your IDE's MCP configuration file. + Paste one of the snippets below into your IDE's MCP configuration file. Set it once and never touch it again.

@@ -90,15 +84,13 @@

Add to your IDE

File: .vscode/mcp.json (in your project root) or settings.json under mcp.servers. + VS Code will open a browser to log in on first use — no token needed in the config.

{
   "servers": {
     "archi": {
       "type": "sse",
-      "url": "{{ mcp_url }}",
-      "headers": {
-        "Authorization": "Bearer {{ token }}"
-      }
+      "url": "{{ mcp_url }}"
     }
   }
 }
@@ -107,15 +99,13 @@

Add to your IDE

- File: ~/.cursor/mcp.json + File: ~/.cursor/mcp.json. + Cursor will open a browser to log in on first use — no token needed in the config.

{
   "mcpServers": {
     "archi": {
-      "url": "{{ mcp_url }}",
-      "headers": {
-        "Authorization": "Bearer {{ token }}"
-      }
+      "url": "{{ mcp_url }}"
     }
   }
 }
@@ -126,7 +116,8 @@

Add to your IDE

File: ~/Library/Application Support/Claude/claude_desktop_config.json (macOS) or %APPDATA%\Claude\claude_desktop_config.json (Windows). - Restart Claude Desktop after saving. + Restart Claude Desktop after saving. mcp-remote will open a browser to + log in on first use and store the token locally — no token needed in the config.

{
   "mcpServers": {
@@ -134,9 +125,7 @@ 

Add to your IDE

"command": "npx", "args": [ "mcp-remote", - "{{ mcp_url }}", - "--header", - "Authorization: Bearer {{ token }}" + "{{ mcp_url }}" ] } } @@ -147,11 +136,9 @@

Add to your IDE

Run this once in your terminal to register archi globally. Claude Code stores it in - ~/.claude.json. + ~/.claude.json. It will open a browser to log in on first use.

-
claude mcp add --transport sse \
-  --header "Authorization: Bearer {{ token }}" \
-  archi {{ mcp_url }}
+
claude mcp add --transport sse archi {{ mcp_url }}

Or add it to .mcp.json in your project root for project-scoped access: @@ -160,10 +147,7 @@

Add to your IDE

"mcpServers": { "archi": { "type": "sse", - "url": "{{ mcp_url }}", - "headers": { - "Authorization": "Bearer {{ token }}" - } + "url": "{{ mcp_url }}" } } }
@@ -171,17 +155,34 @@

Add to your IDE

- -
-

Rotate token

-

- Regenerate a new token. Your current token will stop working immediately. -

-
- -
-
+ +
+ + Advanced: manual bearer token + +
+

+ For clients that don't support OAuth, use this static token directly in the + Authorization: Bearer <token> header. + Keep it secret — it grants access to archi on your behalf. +

+
+
{{ token }}
+ +
+
+

Rotate token

+

+ Regenerate a new token. Your current token will stop working immediately. + OAuth sessions are unaffected. +

+
+ +
+
+
+