From cfe7d10cadd2f1996ccdbebee50d8813c960150d Mon Sep 17 00:00:00 2001 From: Pradip Nichite <9526749+PradipNichite@users.noreply.github.com> Date: Fri, 23 Oct 2026 20:14:39 +0530 Subject: [PATCH 1/2] Add MCP server over the existing Database Agent REST API mcp-server/ is a stdio MCP server (database-agent-mcp) with four tools: list_agents, ask_database, list_conversations and get_conversation. Every tool calls the existing REST routes with an existing credential (per-agent keys or the operator key), so authentication, query caps, model configuration, table visibility, SQL access policy and execution stay in the API. Results return the answer, generated SQL, column-labelled rows, chart metadata and the session id used for follow-ups. Two server changes the adapter needs (DA-MCP-001): - Chat with a session_id now requires the session to belong to the agent. History was read by session_id alone, so a key for one agent could replay another agent's conversation into its model context. - Saved-conversation replay additively serves the stored column-labelled result block per turn; existing fields are unchanged. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/standalone-product.yml | 16 + .gitignore | 2 + backend/api/v1/routers/chat.py | 3 +- backend/api/v1/utils/chat_helper.py | 14 + backend/session/db_utils/session_db_utils.py | 7 + docs/source-preservation.json | 18 +- mcp-server/database_agent_mcp/__init__.py | 3 + mcp-server/database_agent_mcp/__main__.py | 3 + mcp-server/database_agent_mcp/client.py | 102 ++++++ mcp-server/database_agent_mcp/config.py | 105 ++++++ mcp-server/database_agent_mcp/server.py | 365 +++++++++++++++++++ mcp-server/pyproject.toml | 22 ++ tests/backend/test_selection_policy.py | 3 +- tests/backend/test_session_scope.py | 132 +++++++ tests/deployment/test_repository.py | 2 +- tests/mcp/fake_api.py | 143 ++++++++ tests/mcp/test_server.py | 216 +++++++++++ 17 files changed, 1150 insertions(+), 6 deletions(-) create mode 100644 mcp-server/database_agent_mcp/__init__.py create mode 100644 mcp-server/database_agent_mcp/__main__.py create mode 100644 mcp-server/database_agent_mcp/client.py create mode 100644 mcp-server/database_agent_mcp/config.py create mode 100644 mcp-server/database_agent_mcp/server.py create mode 100644 mcp-server/pyproject.toml create mode 100644 tests/backend/test_session_scope.py create mode 100644 tests/mcp/fake_api.py create mode 100644 tests/mcp/test_server.py diff --git a/.github/workflows/standalone-product.yml b/.github/workflows/standalone-product.yml index 44c0578..f797057 100644 --- a/.github/workflows/standalone-product.yml +++ b/.github/workflows/standalone-product.yml @@ -38,6 +38,22 @@ jobs: run: | python -m unittest discover -s tests/backend -p test_selection_policy.py -v python -m unittest discover -s tests/backend -p test_fresh_policy_writes.py -v + mcp-adapter: + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: '3.11' + - name: Install the MCP server package + run: python -m pip install --disable-pip-version-check ./mcp-server + - name: Test MCP tools through in-memory and stdio MCP client sessions + run: python -m unittest discover -s tests/mcp -p 'test_*.py' -v + - name: Install only pinned API-test dependencies + run: python -m pip install --disable-pip-version-check fastapi==0.150.0 pydantic==2.14.1 + - name: Verify session continuation stays on its own agent + run: python -m unittest discover -s tests/backend -p test_session_scope.py -v evaluation-reliability: runs-on: ubuntu-latest timeout-minutes: 5 diff --git a/.gitignore b/.gitignore index e099e87..76f0fc2 100644 --- a/.gitignore +++ b/.gitignore @@ -15,6 +15,8 @@ **/*.log logs/ backend/logs/ +mcp-server/build/ +**/*.egg-info/ .pytest_cache/ .ruff_cache/ .coverage diff --git a/backend/api/v1/routers/chat.py b/backend/api/v1/routers/chat.py index 4409741..dc74259 100644 --- a/backend/api/v1/routers/chat.py +++ b/backend/api/v1/routers/chat.py @@ -26,7 +26,7 @@ ) from agent.runner import run_agent_flow from api.v1.utils.chat_helper import ( - get_past_messages_safe, get_owner_email_by_agent_id, + get_past_messages_safe, get_owner_email_by_agent_id, ensure_session_belongs_to_agent, get_agent_with_visibility, get_agent_runtime_db, get_chat_log_for_export, get_session_messages_data, build_chat_response, maybe_upload_csv_export, persist_chat_logs, @@ -217,6 +217,7 @@ async def chat_with_agent( chat_id = str(uuid.uuid4()) session_id = body.session_id + await ensure_session_belongs_to_agent(session_id, agent_id) message_history = await get_past_messages_safe(session_id, user_id, agent_id, body.query) past_messages = message_history["past_messages"] diff --git a/backend/api/v1/utils/chat_helper.py b/backend/api/v1/utils/chat_helper.py index 72b33ac..9e15f9e 100644 --- a/backend/api/v1/utils/chat_helper.py +++ b/backend/api/v1/utils/chat_helper.py @@ -52,6 +52,20 @@ async def get_past_messages_safe(session_id, user_id, agent_id, user_query): return {"past_messages": [], "session_id": session_id} +async def ensure_session_belongs_to_agent(session_id: Optional[str], agent_id: str) -> None: + """Refuse to continue a session that was not created on this agent. + + History is read by session_id alone (get_past_conversation_safe), so an unchecked id would + replay another agent's questions and answers into this agent's model context and append the + new turn to that foreign session. A new conversation (no id) is always allowed. + """ + if not session_id or session_id in ["", "string"]: + return + from session.db_utils.session_db_utils import get_session_details + if not await get_session_details(session_id, agent_id): + raise HTTPException(status_code=404, detail=f"Session {session_id} not found for agent {agent_id}") + + async def get_owner_email_by_agent_id(agent_id: str) -> Optional[str]: try: async for session in get_async_session(): diff --git a/backend/session/db_utils/session_db_utils.py b/backend/session/db_utils/session_db_utils.py index e5c1eff..9e1577d 100644 --- a/backend/session/db_utils/session_db_utils.py +++ b/backend/session/db_utils/session_db_utils.py @@ -428,6 +428,13 @@ async def get_session_data(session_id: str): # The ONLY change to this payload: the chart is sent instead of a # hardcoded None. Every other key, debug_info included, is untouched. assistant_message["graph_output"] = _chart_for_replay(row_obj) + # Additive: the column-labelled result block stored for this turn + # ({columns, rows, row_count, truncated}); `db_output` above keeps bare rows + # without column names. None for turns stored before the block existed. + stored_debug = getattr(row_obj, "debug_info", None) + assistant_message["result"] = ( + stored_debug.get("db_result") if isinstance(stored_debug, dict) else None + ) session_data.append(assistant_message) logger.info(f"Returning {len(session_data)} total messages for session {session_id}") return session_data diff --git a/docs/source-preservation.json b/docs/source-preservation.json index f20e898..3aaf70a 100644 --- a/docs/source-preservation.json +++ b/docs/source-preservation.json @@ -1100,9 +1100,9 @@ }, "backend/api/v1/routers/chat.py": { "baseline_sha256": "2649938a3a23278b9a3ad952853436987b27e2918787af6b813b272059a47d78", - "current_sha256": "754fbc5e7afe30b250cd4926850d6ccff55d80537bd6357d6fef563f77585c0d", - "issue": "DA-011", - "reason": "Central versioned model configuration and explicit nonsecret configuration error response; existing prompts and SQL/security policy semantics preserved. Runtime acceptance pending." + "current_sha256": "cf916be23d558e1b8da82355f21e2cff7ae87f04ee4cfa3a496242c24c192642", + "issue": "DA-011 / DA-MCP-001", + "reason": "Central versioned model configuration and explicit nonsecret configuration error response; existing prompts and SQL/security policy semantics preserved. Runtime acceptance pending. DA-MCP-001: session-agent scope check before history/model." }, "backend/api/v1/utils/tables_helper.py": { "baseline_sha256": "7d02daefc0bd22fe516fdfbfd26d034c3b55c05655fcb1fd4bd9a42029d1d09b", @@ -1121,6 +1121,18 @@ "current_sha256": "b7f39614c9c16ea17ce3418303d567ae0d3762e0b900dd1770f4c0f8c02df1ec", "issue": "DA-013", "reason": "Independently accepted prompt-only business template inheritance of the central model profile; template instructions and SQL/security guards preserved. Fresh runtime acceptance pending." + }, + "backend/api/v1/utils/chat_helper.py": { + "baseline_sha256": "a19c1b056d58c9cf570b77eb5d6ff7e6e845a01648056636ba2b8f6f20d66385", + "current_sha256": "d545db45dc86dc5521a561abb31ea63652f100142d206184e61f59d7823fedd6", + "issue": "DA-MCP-001", + "reason": "Session continuation is scoped to the agent that created the session (cross-agent history replay closed), and saved-conversation replay additively serves the stored column-labelled result block; required by the MCP adapter. No prompt, model, SQL generation or execution change." + }, + "backend/session/db_utils/session_db_utils.py": { + "baseline_sha256": "18e03d5966a451f0b1bc6d5777e2254effc1c1ad248f033d0067c7d7faf64d11", + "current_sha256": "08b3aa026c754695bf795f23191cab5f1445eebe5306cab3ced501b9b0e89499", + "issue": "DA-MCP-001", + "reason": "Session continuation is scoped to the agent that created the session (cross-agent history replay closed), and saved-conversation replay additively serves the stored column-labelled result block; required by the MCP adapter. No prompt, model, SQL generation or execution change." } } } diff --git a/mcp-server/database_agent_mcp/__init__.py b/mcp-server/database_agent_mcp/__init__.py new file mode 100644 index 0000000..18345e7 --- /dev/null +++ b/mcp-server/database_agent_mcp/__init__.py @@ -0,0 +1,3 @@ +"""MCP server for FutureSmart Database Agent: a thin adapter over the product REST API.""" + +__version__ = "0.1.0" diff --git a/mcp-server/database_agent_mcp/__main__.py b/mcp-server/database_agent_mcp/__main__.py new file mode 100644 index 0000000..bc40eef --- /dev/null +++ b/mcp-server/database_agent_mcp/__main__.py @@ -0,0 +1,3 @@ +from .server import main + +main() diff --git a/mcp-server/database_agent_mcp/client.py b/mcp-server/database_agent_mcp/client.py new file mode 100644 index 0000000..2c94ef8 --- /dev/null +++ b/mcp-server/database_agent_mcp/client.py @@ -0,0 +1,102 @@ +"""Thin async client for the existing Database Agent REST API. + +Every product decision — authentication, agent-key scope, query caps, model configuration, +table visibility, SQL access policy and execution — stays in the API. This module only sends +the same requests the UI and direct API clients send. +""" +from __future__ import annotations + +from typing import Any, Dict, List, Optional +from urllib.parse import quote + +import httpx + +from .config import Settings + + +class ApiError(Exception): + """A non-success API response, carrying the server's own (non-secret) detail.""" + + def __init__(self, status: int, detail: str, code: Optional[str] = None): + super().__init__(f"HTTP {status}: {detail}") + self.status = status + self.detail = detail + self.code = code + + +def _detail(response: httpx.Response) -> tuple[str, Optional[str]]: + try: + body = response.json() + except ValueError: + return (response.text or response.reason_phrase or "")[:500], None + detail = body.get("detail", body) if isinstance(body, dict) else body + if isinstance(detail, dict): + return str(detail.get("message") or detail)[:500], detail.get("code") + return str(detail)[:500], None + + +def _seg(value: str) -> str: + return quote(str(value), safe="") + + +class DatabaseAgentApi: + def __init__(self, settings: Settings, transport: Optional[httpx.AsyncBaseTransport] = None): + self.settings = settings + self._http = httpx.AsyncClient( + base_url=settings.api_url + "/", + timeout=httpx.Timeout(settings.timeout, connect=10.0), + transport=transport, + headers={"Accept": "application/json", "User-Agent": "database-agent-mcp"}, + ) + + async def aclose(self) -> None: + await self._http.aclose() + + async def _request(self, method: str, path: str, *, agent_id: Optional[str] = None, + json: Optional[dict] = None, params: Optional[dict] = None) -> Any: + key, _ = self.settings.credential_for(agent_id) + response = await self._http.request(method, path, json=json, params=params, + headers={"X-API-Key": key}) + if response.status_code >= 400: + detail, code = _detail(response) + raise ApiError(response.status_code, detail, code) + return response.json() + + async def list_all_agents(self) -> List[Dict[str, Any]]: + """Operator key only: every agent, across pages.""" + agents: List[Dict[str, Any]] = [] + page = 1 + while True: + body = await self._request("GET", "v1/agents", params={"page": page, "limit": 100}) + agents += (body.get("owned_agents") or []) + (body.get("invited_agents") or []) + if page >= int((body.get("pagination") or {}).get("pages") or 1): + return agents + page += 1 + + async def get_agent(self, agent_id: str) -> Dict[str, Any]: + return (await self._request("GET", f"v1/agents/{_seg(agent_id)}", agent_id=agent_id))["data"] + + async def list_databases(self, agent_id: str) -> List[Dict[str, Any]]: + body = await self._request("GET", f"v1/agents/{_seg(agent_id)}/databases", agent_id=agent_id) + return body.get("data") or [] + + async def ask(self, agent_id: str, question: str, session_id: Optional[str] = None, + chart_output: Optional[bool] = None) -> Dict[str, Any]: + # debug_info=True asks for the SQL/rows block; the agent's own show_sql_query and + # show_db_result settings still decide, server-side, what that block may contain. + body: Dict[str, Any] = {"query": question, "debug_info": True} + if chart_output is not None: + body["chart_output"] = chart_output + if session_id: + path = f"v1/agents/{_seg(agent_id)}/sessions/{_seg(session_id)}/messages" + else: + path = f"v1/agents/{_seg(agent_id)}/chat" + return await self._request("POST", path, agent_id=agent_id, json=body, params={"stream": "false"}) + + async def list_sessions(self, agent_id: str) -> List[Dict[str, Any]]: + body = await self._request("GET", f"v1/agents/{_seg(agent_id)}/sessions", agent_id=agent_id) + return body.get("sessions") or [] + + async def get_session(self, agent_id: str, session_id: str) -> Dict[str, Any]: + return await self._request("GET", f"v1/agents/{_seg(agent_id)}/sessions/{_seg(session_id)}", + agent_id=agent_id) diff --git a/mcp-server/database_agent_mcp/config.py b/mcp-server/database_agent_mcp/config.py new file mode 100644 index 0000000..e72ab29 --- /dev/null +++ b/mcp-server/database_agent_mcp/config.py @@ -0,0 +1,105 @@ +"""Environment configuration for the Database Agent MCP server. + +The server holds no product logic and no database credentials. It only needs the product +API root and one or more existing Database Agent API credentials: + +* ``DATABASE_AGENT_AGENT_KEYS`` — per-agent keys issued by ``POST /v1/agents/{id}/api-keys``, + written as ``AGENT_ID=KEY`` pairs separated by commas or newlines. Recommended: each key + only works on its own agent and cannot administer it. +* ``DATABASE_AGENT_API_KEY`` — the operator (master) key. Reaches every agent; use it only + for a trusted single-operator installation. + +Secrets are never included in ``repr``, logs or error messages. +""" +from __future__ import annotations + +import ipaddress +import os +from dataclasses import dataclass, field +from typing import Dict, Mapping, Optional, Tuple +from urllib.parse import urlsplit + +DEFAULT_API_URL = "http://127.0.0.1:18000/nl2sql/api" +DEFAULT_TIMEOUT_SECONDS = 180.0 + + +class ConfigError(ValueError): + """Invalid or missing configuration. Messages never contain secret values.""" + + +def _is_loopback(host: str) -> bool: + if host == "localhost": + return True + try: + return ipaddress.ip_address(host).is_loopback + except ValueError: + return False + + +def parse_agent_keys(raw: str) -> Dict[str, str]: + keys: Dict[str, str] = {} + for position, item in enumerate(raw.replace("\n", ",").split(","), start=1): + item = item.strip() + if not item: + continue + agent_id, sep, key = item.partition("=") + agent_id, key = agent_id.strip(), key.strip() + if not sep or not agent_id or not key: + raise ConfigError( + f"DATABASE_AGENT_AGENT_KEYS entry {position} is not AGENT_ID=KEY" + ) + if agent_id in keys: + raise ConfigError(f"DATABASE_AGENT_AGENT_KEYS lists agent {agent_id} twice") + keys[agent_id] = key + return keys + + +@dataclass(frozen=True) +class Settings: + api_url: str = DEFAULT_API_URL + operator_key: Optional[str] = field(default=None, repr=False) + agent_keys: Dict[str, str] = field(default_factory=dict, repr=False) + timeout: float = DEFAULT_TIMEOUT_SECONDS + + @classmethod + def from_env(cls, env: Mapping[str, str] = os.environ) -> "Settings": + api_url = (env.get("DATABASE_AGENT_URL") or DEFAULT_API_URL).strip().rstrip("/") + parts = urlsplit(api_url) + if parts.scheme not in ("http", "https") or not parts.hostname: + raise ConfigError("DATABASE_AGENT_URL must be an http(s) URL such as " + DEFAULT_API_URL) + insecure_ok = (env.get("DATABASE_AGENT_ALLOW_INSECURE_HTTP") or "").strip().lower() in ("1", "true", "yes") + if parts.scheme == "http" and not _is_loopback(parts.hostname) and not insecure_ok: + raise ConfigError( + "DATABASE_AGENT_URL uses plain HTTP to a non-loopback host, which would send API keys " + "unencrypted. Use HTTPS, or set DATABASE_AGENT_ALLOW_INSECURE_HTTP=1 for a private network." + ) + + operator_key = (env.get("DATABASE_AGENT_API_KEY") or "").strip() or None + agent_keys = parse_agent_keys(env.get("DATABASE_AGENT_AGENT_KEYS") or "") + if not operator_key and not agent_keys: + raise ConfigError( + "No Database Agent credential configured. Set DATABASE_AGENT_AGENT_KEYS " + "(AGENT_ID=KEY pairs, recommended) or DATABASE_AGENT_API_KEY (operator key)." + ) + + raw_timeout = (env.get("DATABASE_AGENT_TIMEOUT_SECONDS") or "").strip() + try: + timeout = float(raw_timeout) if raw_timeout else DEFAULT_TIMEOUT_SECONDS + except ValueError: + raise ConfigError("DATABASE_AGENT_TIMEOUT_SECONDS must be a number") from None + if timeout <= 0: + raise ConfigError("DATABASE_AGENT_TIMEOUT_SECONDS must be positive") + return cls(api_url=api_url, operator_key=operator_key, agent_keys=agent_keys, timeout=timeout) + + def credential_for(self, agent_id: Optional[str]) -> Tuple[str, str]: + """Return ``(key, kind)`` for a request. A per-agent key wins over the operator key.""" + if agent_id and agent_id in self.agent_keys: + return self.agent_keys[agent_id], "agent_key" + if self.operator_key: + return self.operator_key, "operator_key" + if agent_id: + raise ConfigError( + f"No credential configured for agent {agent_id}. Add it to DATABASE_AGENT_AGENT_KEYS " + "or call list_agents to see the agents this server can reach." + ) + raise ConfigError("This operation needs DATABASE_AGENT_API_KEY (operator key)") diff --git a/mcp-server/database_agent_mcp/server.py b/mcp-server/database_agent_mcp/server.py new file mode 100644 index 0000000..b5b327f --- /dev/null +++ b/mcp-server/database_agent_mcp/server.py @@ -0,0 +1,365 @@ +"""MCP server exposing existing Database Agent capabilities as tools. + +The tools are a presentation layer over the product REST API (see ``client.py``): the same +agent answers the question, with the same authentication, model configuration, table +visibility, SQL access controls and execution pipeline. Nothing here generates or runs SQL. +""" +from __future__ import annotations + +import json +import logging +import sys +from contextlib import asynccontextmanager +from typing import Any, Dict, List, Literal, Optional + +import httpx +from mcp.server.fastmcp import FastMCP +from mcp.server.fastmcp.exceptions import ToolError +from mcp.types import ToolAnnotations +from pydantic import BaseModel, Field + +from .client import ApiError, DatabaseAgentApi +from .config import ConfigError, Settings + +log = logging.getLogger("database_agent_mcp") + +MAX_ROWS_LIMIT = 1000 + +INSTRUCTIONS = """\ +Ask questions of SQL databases through FutureSmart Database Agent. +Start with list_agents to find the agent connected to the data you need, then call +ask_database with a natural-language question. Each answer returns the agent's text answer, +the SQL it generated and ran (when the agent allows showing it), the result rows and any +chart suggestion. Pass the returned session_id to ask_database to ask a follow-up in the same +conversation. list_conversations and get_conversation return saved conversations and results. +The agent runs read-only queries through its own access controls; it does not modify data. +Report the SQL and rows alongside the answer so the user can check it.""" + + +# ── Structured outputs ────────────────────────────────────────────────────────────────────── + +class Database(BaseModel): + db_id: str + name: Optional[str] = None + engine: Optional[str] = None + description: Optional[str] = None + table_count: Optional[int] = None + active: bool = True + + +class Agent(BaseModel): + agent_id: str + name: Optional[str] = None + greeting: Optional[str] = None + credential: Literal["agent_key", "operator_key"] + shows_sql: bool = Field(description="Agent setting: generated SQL may be returned") + shows_rows: bool = Field(description="Agent setting: result rows may be returned") + databases: List[Database] = [] + + +class UnavailableAgent(BaseModel): + agent_id: str + error: str + + +class AgentList(BaseModel): + agents: List[Agent] + unavailable: List[UnavailableAgent] = Field( + default=[], description="Configured agents whose credential was refused or which could not be read") + + +class Column(BaseModel): + name: str + type: Optional[str] = None + + +class ResultRows(BaseModel): + columns: List[Column] + rows: List[Dict[str, Any]] + row_count: int = Field(description="Rows produced by the SQL") + returned: int = Field(description="Rows included here") + truncated: bool = Field(description="True when rows were omitted (see max_rows)") + + +class Chart(BaseModel): + type: Optional[str] = Field(None, description="Suggested chart type, e.g. bar or line") + data: Any = None + metadata: Any = None + + +class Answer(BaseModel): + agent_id: str + session_id: str = Field(description="Pass to ask_database to continue this conversation") + chat_id: Optional[str] = None + question: str + answer: str + answered: bool = Field(description="False when the agent could not answer from the connected data") + sql: Optional[str] = Field(None, description="SQL the agent generated and ran; None if hidden or none ran") + tables: List[str] = [] + database: Optional[str] = None + result: Optional[ResultRows] = None + chart: Optional[Chart] = None + follow_up_questions: List[str] = [] + notes: List[str] = [] + + +class ConversationSummary(BaseModel): + session_id: str + title: Optional[str] = None + first_question: Optional[str] = None + created: Optional[str] = None + + +class ConversationList(BaseModel): + agent_id: str + conversations: List[ConversationSummary] + total: int + + +class SavedTurn(BaseModel): + chat_id: Optional[str] = None + question: Optional[str] = None + answer: Optional[str] = None + timestamp: Optional[str] = None + sql: Optional[str] = None + result: Optional[ResultRows] = None + chart: Optional[Chart] = None + feedback: Optional[str] = None + + +class Conversation(BaseModel): + agent_id: str + session_id: str + title: Optional[str] = None + created: Optional[str] = None + turns: List[SavedTurn] + notes: List[str] = [] + + +# ── Shaping helpers (presentation only) ───────────────────────────────────────────────────── + +def _rows(block: Any, max_rows: int) -> Optional[ResultRows]: + if not isinstance(block, dict): + return None + rows = block.get("rows") or [] + columns = [Column(name=str(c.get("name")), type=c.get("type")) if isinstance(c, dict) else Column(name=str(c)) + for c in (block.get("columns") or [])] + total = int(block.get("row_count") if block.get("row_count") is not None else len(rows)) + kept = rows[:max_rows] + return ResultRows(columns=columns, rows=kept, row_count=total, returned=len(kept), + truncated=bool(block.get("truncated")) or len(kept) < total) + + +def _bare_rows(raw: Any, max_rows: int) -> Optional[ResultRows]: + """Turns saved before per-turn result blocks existed keep rows without column names.""" + if isinstance(raw, str): + try: + raw = json.loads(raw) + except ValueError: + return None + if not isinstance(raw, list): + return None + width = max((len(r) for r in raw if isinstance(r, (list, tuple))), default=1) + names = [f"column_{i + 1}" for i in range(width)] + rows = [dict(zip(names, r)) if isinstance(r, (list, tuple)) else (r if isinstance(r, dict) else {names[0]: r}) + for r in raw] + return _rows({"columns": [{"name": n} for n in names], "rows": rows, "row_count": len(rows)}, max_rows) + + +def _chart(block: Any, max_rows: int) -> Optional[Chart]: + if not isinstance(block, dict) or not any(block.get(k) is not None + for k in ("chart_suggestion", "chart_data", "metadata")): + return None + data = block.get("chart_data") + if isinstance(data, list): + data = data[:max_rows] + return Chart(type=block.get("chart_suggestion"), data=data, metadata=block.get("metadata")) + + +def _bounded(max_rows: int) -> int: + if max_rows < 0: + raise ToolError("max_rows must be 0 or more") + return min(max_rows, MAX_ROWS_LIMIT) + + +def _tool_error(exc: Exception, agent_id: Optional[str] = None) -> ToolError: + where = f" for agent {agent_id}" if agent_id else "" + if isinstance(exc, ConfigError): + return ToolError(str(exc)) + if isinstance(exc, ApiError): + if exc.status == 401: + return ToolError(f"Database Agent rejected the configured credential{where} (HTTP 401): {exc.detail}") + if exc.status == 403: + return ToolError(f"Not permitted{where} (HTTP 403): {exc.detail}") + if exc.status == 404: + return ToolError(f"Not found{where} (HTTP 404): {exc.detail}") + if exc.status == 429: + return ToolError(f"Query limit reached{where} (HTTP 429): {exc.detail}") + if exc.code == "model_configuration_error": + return ToolError(f"Agent model configuration error{where}: {exc.detail}") + return ToolError(f"Database Agent API error{where} (HTTP {exc.status}): {exc.detail}") + if isinstance(exc, httpx.TimeoutException): + return ToolError("Database Agent did not respond in time; raise DATABASE_AGENT_TIMEOUT_SECONDS if " + "answers routinely take longer") + if isinstance(exc, httpx.TransportError): + return ToolError(f"Cannot reach the Database Agent API ({type(exc).__name__})") + return ToolError(f"Unexpected error: {type(exc).__name__}") + + +# ── Server ────────────────────────────────────────────────────────────────────────────────── + +def build_server(settings: Settings, transport: Optional[httpx.AsyncBaseTransport] = None) -> FastMCP: + api = DatabaseAgentApi(settings, transport=transport) + + @asynccontextmanager + async def lifespan(_server): + try: + yield {} + finally: + await api.aclose() + + mcp = FastMCP("database-agent", instructions=INSTRUCTIONS, lifespan=lifespan, log_level="WARNING") + + async def describe(agent: Dict[str, Any], credential: str) -> Agent: + agent_id = agent["agent_id"] + try: + databases = [Database(db_id=d["db_id"], name=d.get("db_name"), engine=d.get("db_engine"), + description=d.get("description"), table_count=d.get("table_count"), + active=bool(d.get("is_active", True))) + for d in await api.list_databases(agent_id)] + except ApiError: + databases = [] + return Agent(agent_id=agent_id, name=agent.get("agent_name"), greeting=agent.get("greet_message"), + credential=credential, shows_sql=bool(agent.get("show_sql_query")), + shows_rows=bool(agent.get("show_db_result")), databases=databases) + + @mcp.tool(annotations=ToolAnnotations(title="List database agents", readOnlyHint=True, openWorldHint=False)) + async def list_agents() -> AgentList: + """List the Database Agent agents this server's credentials can use, with their connected databases.""" + agents: List[Agent] = [] + unavailable: List[UnavailableAgent] = [] + seen = set() + for agent_id in settings.agent_keys: + try: + agents.append(await describe(await api.get_agent(agent_id), "agent_key")) + except Exception as exc: # one refused key must not hide the others + unavailable.append(UnavailableAgent(agent_id=agent_id, error=str(_tool_error(exc, agent_id)))) + seen.add(agent_id) + if settings.operator_key: + try: + for agent in await api.list_all_agents(): + if agent.get("agent_id") and agent["agent_id"] not in seen: + seen.add(agent["agent_id"]) + agents.append(await describe(agent, "operator_key")) + except Exception as exc: + raise _tool_error(exc) from exc + return AgentList(agents=agents, unavailable=unavailable) + + @mcp.tool(annotations=ToolAnnotations(title="Ask a database question", readOnlyHint=False, + destructiveHint=False, idempotentHint=False, openWorldHint=False)) + async def ask_database(agent_id: str, question: str, session_id: Optional[str] = None, + include_chart: Optional[bool] = None, max_rows: int = 100) -> Answer: + """Ask a Database Agent agent a natural-language question about its connected database. + + Returns the answer, the generated SQL, structured result rows and chart metadata. + Omit session_id to start a new conversation; pass a previous answer's session_id to ask a + follow-up with that conversation's context. include_chart defaults to the agent's setting. + max_rows (default 100, up to 1000) limits the rows returned here, not the query itself. + """ + if not question.strip(): + raise ToolError("question must not be empty") + limit = _bounded(max_rows) + try: + body = await api.ask(agent_id, question, session_id=session_id, chart_output=include_chart) + except Exception as exc: + raise _tool_error(exc, agent_id) from exc + + debug = body.get("debug_info") or {} + notes: List[str] = [] + if "sql_query" not in debug: + notes.append("This agent's settings do not allow returning generated SQL.") + if "db_result" not in debug: + notes.append("This agent's settings do not allow returning result rows.") + result = _rows(debug.get("db_result"), limit) + if result and result.truncated: + notes.append(f"Showing {result.returned} of {result.row_count} rows; raise max_rows for more.") + if debug.get("export_url"): + notes.append("A CSV export of the full result is available from the agent's export link.") + return Answer( + agent_id=agent_id, session_id=body.get("session_id") or session_id or "", chat_id=body.get("chat_id"), + question=question, answer=body.get("response") or "", answered=bool(body.get("is_answered")), + sql=debug.get("sql_query") or None, tables=debug.get("selected_tables") or [], + database=debug.get("selected_db_name"), result=result, + chart=_chart(body.get("chart_output"), limit), follow_up_questions=body.get("follow_up") or [], + notes=notes, + ) + + @mcp.tool(annotations=ToolAnnotations(title="List saved conversations", readOnlyHint=True, openWorldHint=False)) + async def list_conversations(agent_id: str, limit: int = 20) -> ConversationList: + """List saved conversations (sessions) on an agent, newest first.""" + try: + sessions = await api.list_sessions(agent_id) + except Exception as exc: + raise _tool_error(exc, agent_id) from exc + items = [ConversationSummary(session_id=s["session_id"], title=s.get("session_title"), + first_question=s.get("display_message"), + created=str(s["created_time"]) if s.get("created_time") else None) + for s in sessions if s.get("session_id")] + return ConversationList(agent_id=agent_id, conversations=items[:max(limit, 0)], total=len(items)) + + @mcp.tool(annotations=ToolAnnotations(title="Get a saved conversation", readOnlyHint=True, openWorldHint=False)) + async def get_conversation(agent_id: str, session_id: str, max_rows: int = 100) -> Conversation: + """Return a saved conversation with each turn's question, answer, SQL, result rows and chart.""" + limit = _bounded(max_rows) + try: + agent = await api.get_agent(agent_id) + body = await api.get_session(agent_id, session_id) + except Exception as exc: + raise _tool_error(exc, agent_id) from exc + # The live chat route applies these agent settings server-side; apply the same + # settings to the saved copy so both tools return the same visibility. + shows_sql, shows_rows = bool(agent.get("show_sql_query")), bool(agent.get("show_db_result")) + turns: Dict[str, SavedTurn] = {} + order: List[str] = [] + for message in body.get("conversation_data") or []: + key = message.get("chat_id") or f"#{len(order)}" + if key not in turns: + turns[key] = SavedTurn(chat_id=message.get("chat_id"), timestamp=message.get("timestamp")) + order.append(key) + turn = turns[key] + if message.get("role") == "user": + turn.question = message.get("message") + continue + turn.answer = message.get("message") + turn.feedback = message.get("user_feedback") + debug = message.get("debug_info") or {} + if shows_sql: + turn.sql = debug.get("sql_query") or None + if shows_rows: + turn.result = _rows(message.get("result"), limit) or _bare_rows(debug.get("db_output"), limit) + turn.chart = _chart(message.get("graph_output"), limit) + notes = [] + if not shows_sql: + notes.append("This agent's settings do not allow returning generated SQL.") + if not shows_rows: + notes.append("This agent's settings do not allow returning result rows.") + return Conversation(agent_id=agent_id, session_id=body.get("session_id") or session_id, + title=body.get("session_title"), + created=str(body["created_time"]) if body.get("created_time") else None, + turns=[turns[k] for k in order], notes=notes) + + return mcp + + +def main() -> None: + logging.basicConfig(level=logging.WARNING, stream=sys.stderr, format="%(name)s: %(message)s") + try: + settings = Settings.from_env() + except ConfigError as exc: + print(f"database-agent-mcp: {exc}", file=sys.stderr) + raise SystemExit(2) from None + build_server(settings).run("stdio") + + +if __name__ == "__main__": + main() diff --git a/mcp-server/pyproject.toml b/mcp-server/pyproject.toml new file mode 100644 index 0000000..2288ac9 --- /dev/null +++ b/mcp-server/pyproject.toml @@ -0,0 +1,22 @@ +[build-system] +requires = ["setuptools>=69"] +build-backend = "setuptools.build_meta" + +[project] +name = "database-agent-mcp" +version = "0.1.0" +description = "MCP server for FutureSmart Database Agent, built on its existing REST API" +readme = "README.md" +license = "Apache-2.0" +requires-python = ">=3.10" +dependencies = [ + "mcp>=1.12,<2", + "httpx>=0.27,<1", + "pydantic>=2.7,<3", +] + +[project.scripts] +database-agent-mcp = "database_agent_mcp.server:main" + +[tool.setuptools] +packages = ["database_agent_mcp"] diff --git a/tests/backend/test_selection_policy.py b/tests/backend/test_selection_policy.py index 0255f69..9831b1a 100644 --- a/tests/backend/test_selection_policy.py +++ b/tests/backend/test_selection_policy.py @@ -229,7 +229,8 @@ async def actual_node_pipeline(**kwargs): JSONResponse=JSONResponse, uuid=uuid, asyncio=asyncio, get_agent_with_visibility=AsyncMock(return_value=(object(), True, True, True)), apply_agent_answer_settings=lambda *args: {}, get_owner_email_by_agent_id=AsyncMock(return_value='synthetic'), - enforce_query_cap=AsyncMock(), get_past_messages_safe=AsyncMock(return_value={'past_messages': [], 'session_id': 'synthetic-session'}), + enforce_query_cap=AsyncMock(), ensure_session_belongs_to_agent=AsyncMock(), + get_past_messages_safe=AsyncMock(return_value={'past_messages': [], 'session_id': 'synthetic-session'}), get_agent_runtime_db=AsyncMock(return_value=({'db_id': 'synthetic-db'}, 0)), run_agent_flow=actual_node_pipeline, _generate_follow_up_safe=AsyncMock(return_value=None), maybe_upload_csv_export=AsyncMock(return_value=None), persist_chat_logs=AsyncMock(), diff --git a/tests/backend/test_session_scope.py b/tests/backend/test_session_scope.py new file mode 100644 index 0000000..de1140d --- /dev/null +++ b/tests/backend/test_session_scope.py @@ -0,0 +1,132 @@ +"""A supplied session_id continues a conversation only on the agent that created it. + +Chat history is read by session_id alone, so without this check a caller holding a key for +agent A could pass a session created on agent B and have B's questions and answers replayed +into A's model context (and appended to B's session). Synthetic boundaries; no external calls. +""" +import asyncio +import importlib.util +import json +import sys +import types +import unittest +import uuid +from unittest.mock import AsyncMock, patch + +from fastapi import BackgroundTasks, Depends, HTTPException, Path, Query +from fastapi.responses import JSONResponse +from starlette.requests import Request +from access_policy_harness import BACKEND, load_function + +sys.path.insert(0, str(BACKEND)) +from agent.utils.model_profiles import ModelConfigurationError + + +def session_store(owner_agent): + """get_session_details as the product implements it: match on session AND agent.""" + async def get_session_details(session_id, agent_id): + if session_id == 'session-of-b' and agent_id == owner_agent: + return {'session_id': session_id, 'created_time': None, 'session_title': 't'} + return None + return types.SimpleNamespace(get_session_details=AsyncMock(side_effect=get_session_details)) + + +class SessionScopeTests(unittest.IsolatedAsyncioTestCase): + def setUp(self): + self.guard = load_function('api/v1/utils/chat_helper.py', 'ensure_session_belongs_to_agent', + HTTPException=HTTPException) + spec = importlib.util.spec_from_file_location('synthetic_chat_schemas', BACKEND/'api/v1/schemas/chat.py') + self.schemas = importlib.util.module_from_spec(spec) + spec.loader.exec_module(self.schemas) + self.history = AsyncMock(return_value={'past_messages': [], 'session_id': 'session-of-b'}) + self.flow = AsyncMock(return_value={'response': 'ok', 'is_answered': True}) + self.endpoint = load_function('api/v1/routers/chat.py', 'chat_with_agent', + Path=Path, Query=Query, Depends=Depends, BackgroundTasks=BackgroundTasks, + ChatRequest=self.schemas.ChatRequest, Request=Request, + get_api_key_or_session=lambda: {}, require_agent_permission=lambda *args: lambda: {}, + HTTPException=HTTPException, ModelConfigurationError=ModelConfigurationError, + JSONResponse=JSONResponse, uuid=uuid, asyncio=asyncio, + get_agent_with_visibility=AsyncMock(return_value=(object(), True, True, True)), + apply_agent_answer_settings=lambda *args: {}, get_owner_email_by_agent_id=AsyncMock(return_value='owner'), + enforce_query_cap=AsyncMock(), ensure_session_belongs_to_agent=self.guard, + get_past_messages_safe=self.history, + get_agent_runtime_db=AsyncMock(return_value=({'db_id': 'db'}, 0)), + run_agent_flow=self.flow, _generate_follow_up_safe=AsyncMock(return_value=None), + maybe_upload_csv_export=AsyncMock(return_value=None), persist_chat_logs=AsyncMock(), + persist_health_event=AsyncMock(), + build_chat_response=lambda result, body, session_id, *rest: {'session_id': session_id, + 'response': result['response']}) + + async def ask(self, agent_id, session_id): + body = self.schemas.ChatRequest(query='how many orders?', session_id=session_id) + with patch.dict(sys.modules, { + 'session': types.ModuleType('session'), 'session.db_utils': types.ModuleType('session.db_utils'), + 'session.db_utils.session_db_utils': session_store('agent-b'), + 'agent.utils.common_utils': types.SimpleNamespace(serialize_db_value=lambda value: value)}): + return await self.endpoint(agent_id=agent_id, stream=False, body=body, + auth={'auth_type': 'agent_api_key', 'agent_id': agent_id}, user={}) + + async def test_foreign_session_is_refused_before_history_or_model(self): + with self.assertRaises(HTTPException) as caught: + await self.ask('agent-a', 'session-of-b') + self.assertEqual(caught.exception.status_code, 404) + self.history.assert_not_awaited() + self.flow.assert_not_awaited() + + async def test_unknown_session_is_refused(self): + with self.assertRaises(HTTPException) as caught: + await self.ask('agent-b', 'never-created') + self.assertEqual(caught.exception.status_code, 404) + self.flow.assert_not_awaited() + + async def test_own_session_continues(self): + response = await self.ask('agent-b', 'session-of-b') + self.assertEqual(response.status_code, 200) + self.assertEqual(json.loads(response.body)['session_id'], 'session-of-b') + self.history.assert_awaited_once() + self.flow.assert_awaited_once() + + async def test_new_conversation_needs_no_session(self): + for missing in (None, '', 'string'): + with self.subTest(session_id=missing): + response = await self.ask('agent-a', missing) + self.assertEqual(response.status_code, 200) + + +class SavedResultReplayTests(unittest.IsolatedAsyncioTestCase): + """GET .../sessions/{id} additively serves the column-labelled block stored per turn.""" + + async def replay(self, debug_info): + from contextlib import asynccontextmanager + from datetime import datetime + from unittest.mock import MagicMock + row = types.SimpleNamespace(chat_id='c1', user_query='totals?', gpt_response='North 250', refined_query=None, + sql_query='SELECT 1', db_output='[["North", 250]]', feedback=None, + created_at=datetime(2026, 10, 23), debug_info=debug_info, + chart_output=None, graph_output=None) + + @asynccontextmanager + async def get_async_session(): + yield types.SimpleNamespace(execute=AsyncMock(return_value=types.SimpleNamespace(all=lambda: [(row,)]))) + + chart = load_function('session/db_utils/session_db_utils.py', '_chart_for_replay') + replay = load_function('session/db_utils/session_db_utils.py', 'get_session_data', + get_async_session=get_async_session, select=MagicMock(), ChatLogs=MagicMock(), + Row=tuple, _chart_for_replay=chart) + return await replay('s1') + + async def test_stored_result_block_is_served_alongside_unchanged_fields(self): + block = {'columns': [{'name': 'region', 'type': 'text'}], 'rows': [{'region': 'North'}], + 'row_count': 1, 'truncated': False} + user, assistant = await self.replay({'db_result': block, 'sql_query': 'SELECT 1'}) + self.assertEqual(assistant['result'], block) + self.assertEqual(assistant['debug_info']['db_output'], '[["North", 250]]') + self.assertNotIn('result', user) + + async def test_turns_without_a_stored_block_replay_none(self): + _, assistant = await self.replay(None) + self.assertIsNone(assistant['result']) + + +if __name__ == '__main__': + unittest.main(verbosity=2) diff --git a/tests/deployment/test_repository.py b/tests/deployment/test_repository.py index ce77862..6db1af3 100644 --- a/tests/deployment/test_repository.py +++ b/tests/deployment/test_repository.py @@ -57,7 +57,7 @@ def test_layout_contains_only_clear_product_entry_points(self): files = tracked_files() roots = {p.split("/")[0] for p in files} allowed = {".env.example", ".gitattributes", ".github", ".gitignore", "README.md", "LICENSE", "NOTICE", - "backend", "frontend", "deploy", "docs", "tests", "internal", "compose.yaml"} + "backend", "frontend", "mcp-server", "deploy", "docs", "tests", "internal", "compose.yaml"} self.assertEqual(roots - allowed, set()) for prefix in ("saas db/", "saas evals/", "spider evals/", "evals/", "scripts/", "standalone/"): self.assertFalse(any(p.startswith(prefix) for p in files), prefix) diff --git a/tests/mcp/fake_api.py b/tests/mcp/fake_api.py new file mode 100644 index 0000000..7279384 --- /dev/null +++ b/tests/mcp/fake_api.py @@ -0,0 +1,143 @@ +"""Synthetic stand-in for the Database Agent REST API, for MCP adapter contract tests. + +It reproduces the response shapes and the credential rules the adapter relies on (operator +key reaches every agent; an agent key reaches only its own agent and no agent-less route; +sessions continue only on the agent that created them). Product behaviour itself is verified +against the real API; see docs/mcp.md. +""" +import json +import re +import threading +import uuid +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer + +OPERATOR_KEY = "synthetic-operator-key" +AGENT_KEYS = {"key-sales": "NL2SQL_SALES", "key-hidden": "NL2SQL_HIDDEN"} +AGENTS = { + "NL2SQL_SALES": {"agent_id": "NL2SQL_SALES", "agent_name": "Sales", "greet_message": "Hi", + "show_sql_query": True, "show_db_result": True, "system_prompt": "private"}, + "NL2SQL_HIDDEN": {"agent_id": "NL2SQL_HIDDEN", "agent_name": "Hidden SQL", "greet_message": None, + "show_sql_query": False, "show_db_result": False}, + "NL2SQL_OTHER": {"agent_id": "NL2SQL_OTHER", "agent_name": "Other", "greet_message": None, + "show_sql_query": True, "show_db_result": True}, +} +SQL = "SELECT region, SUM(amount) AS total FROM orders GROUP BY region ORDER BY region" +ROWS = [{"region": "North", "total": "250.00"}, {"region": "South", "total": "200.00"}] + + +class FakeApi: + def __init__(self): + self.sessions = {} # session_id -> {"agent_id": ..., "turns": [...]} + self.requests = [] # (method, path, credential kind) — never the key itself + self.limited = set() + + def auth(self, key, agent_id): + if key == OPERATOR_KEY: + return "operator", None + if key in AGENT_KEYS: + if not agent_id: + return None, (401, "This API key is agent-scoped and cannot be used on this endpoint") + if AGENT_KEYS[key] != agent_id: + return None, (403, "This API key is not valid for this agent") + return "agent", None + return None, (401, "Not authenticated: valid session or API key required") + + def handle(self, method, path, key, body): + path = path.split("?", 1)[0] + match = re.match(r"^/nl2sql/api/v1/agents(?:/([^/]+))?(/.*)?$", path) + if not match: + return 404, {"detail": "Not Found"} + agent_id, rest = match.group(1), match.group(2) or "" + kind, error = self.auth(key, agent_id) + self.requests.append((method, path, kind)) + if error: + return error[0], {"detail": error[1]} + if agent_id and agent_id not in AGENTS: + return 404, {"detail": f"Agent with ID {agent_id} not found"} + if method == "GET" and not agent_id: + agents = list(AGENTS.values()) + return 200, {"owned_agents": agents[:2], "invited_agents": agents[2:], + "pagination": {"total": 3, "page": 1, "limit": 100, "pages": 1}} + if method == "GET" and rest == "": + return 200, {"data": AGENTS[agent_id]} + if method == "GET" and rest == "/databases": + return 200, {"data": [{"db_id": "db-1", "db_name": "sample_sales", "db_engine": "postgresql", + "db_host": "sample-db", "db_user": "sample_reader", "db_password": "***", + "description": "Synthetic orders", "table_count": 1, "is_active": True}]} + if method == "POST" and (rest == "/chat" or re.match(r"^/sessions/[^/]+/messages$", rest)): + if agent_id in self.limited: + return 429, {"detail": "Query limit reached: 20 of 20 in the last 24 hours."} + session_id = rest.split("/")[2] if rest.startswith("/sessions/") else body.get("session_id") + if session_id: + if self.sessions.get(session_id, {}).get("agent_id") != agent_id: + return 404, {"detail": f"Session {session_id} not found for agent {agent_id}"} + else: + session_id = str(uuid.uuid4()) + self.sessions[session_id] = {"agent_id": agent_id, "turns": []} + return 200, self.answer(agent_id, session_id, body) + session = re.match(r"^/sessions(?:/([^/]+))?$", rest) + if method == "GET" and session: + if not session.group(1): + return 200, {"sessions": [{"session_id": sid, "session_title": "Regions", + "display_message": s["turns"][0]["query"], "created_time": "2026-10-23T10:00:00"} + for sid, s in self.sessions.items() if s["agent_id"] == agent_id]} + stored = self.sessions.get(session.group(1)) + if not stored or stored["agent_id"] != agent_id: + return 404, {"detail": f"Session {session.group(1)} not found for agent {agent_id}"} + data = [] + for turn in stored["turns"]: + data.append({"chat_id": turn["chat_id"], "role": "user", "message": turn["query"]}) + data.append({"chat_id": turn["chat_id"], "role": "assistant", "message": turn["answer"], + "user_feedback": None, + "debug_info": {"query": turn["query"], "sql_query": SQL, + "db_output": json.dumps([[r["region"], r["total"]] for r in ROWS])}, + "graph_output": turn["chart"], "result": turn["block"]}) + return 200, {"session_id": session.group(1), "session_title": "Regions", + "created_time": "2026-10-23T10:00:00", "conversation_data": data} + return 404, {"detail": "Not Found"} + + def answer(self, agent_id, session_id, body): + agent = AGENTS[agent_id] + block = {"columns": [{"name": "region", "type": "text"}, {"name": "total", "type": "numeric"}], + "rows": ROWS, "row_count": 2, "truncated": False} + chart = {"chart_suggestion": "bar", "chart_data": [{"label": r["region"], "value": float(r["total"])} for r in ROWS], + "metadata": {"x_axis": "region", "y_axis": "total"}} + prior = len(self.sessions[session_id]["turns"]) + text = "North: 250.00, South: 200.00." if not prior else f"Follow-up {prior} answered with earlier context." + chat_id = str(uuid.uuid4()) + self.sessions[session_id]["turns"].append({"chat_id": chat_id, "query": body["query"], "answer": text, + "chart": chart, "block": block}) + debug = {"query": body["query"], "refined_query": body["query"], "selected_db_id": "db-1", + "selected_db_name": "sample_sales"} if body.get("debug_info") else None + if debug is not None and agent["show_sql_query"]: + debug.update(sql_query=SQL, selected_tables=["orders"]) + if debug is not None and agent["show_db_result"]: + debug["db_result"] = block + with_chart = body.get("chart_output", True) + return {"response": text, "session_id": session_id, "chat_id": chat_id, "follow_up": None, + "is_answered": True, "trace_id": None, "debug_info": debug, + "chart_output": chart if with_chart else None, "graph_output": chart if with_chart else None} + + +def serve(api: FakeApi): + """Run the fake on a loopback port in a thread; returns (server, base_url).""" + class Handler(BaseHTTPRequestHandler): + def _reply(self): + length = int(self.headers.get("Content-Length") or 0) + body = json.loads(self.rfile.read(length) or b"{}") if length else {} + status, payload = api.handle(self.command, self.path, self.headers.get("X-API-Key"), body) + data = json.dumps(payload).encode() + self.send_response(status) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(data))) + self.end_headers() + self.wfile.write(data) + + do_GET = do_POST = _reply + + def log_message(self, *args): + pass + + server = ThreadingHTTPServer(("127.0.0.1", 0), Handler) + threading.Thread(target=server.serve_forever, daemon=True).start() + return server, f"http://127.0.0.1:{server.server_address[1]}/nl2sql/api" diff --git a/tests/mcp/test_server.py b/tests/mcp/test_server.py new file mode 100644 index 0000000..2e34d94 --- /dev/null +++ b/tests/mcp/test_server.py @@ -0,0 +1,216 @@ +"""MCP adapter contract tests through real MCP client sessions (in-memory and stdio). + +No product API, model or database is involved: tests/mcp/fake_api.py stands in for the REST +API. They check what the adapter itself is responsible for: tool schemas, credential +selection, structured results, conversation continuation, visibility and error mapping. +""" +import json +import os +from pathlib import Path +import sys +import unittest + +import httpx +from mcp import ClientSession +from mcp.client.stdio import StdioServerParameters, stdio_client +from mcp.shared.memory import create_connected_server_and_client_session + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT / "mcp-server")) +sys.path.insert(0, str(Path(__file__).resolve().parent)) + +from database_agent_mcp.config import ConfigError, Settings, parse_agent_keys # noqa: E402 +from database_agent_mcp.server import build_server # noqa: E402 +from fake_api import OPERATOR_KEY, FakeApi, serve # noqa: E402 + +URL = "http://127.0.0.1:18000/nl2sql/api" + + +def transport(api): + def handler(request: httpx.Request) -> httpx.Response: + body = json.loads(request.content) if request.content else {} + status, payload = api.handle(request.method, request.url.path, request.headers.get("X-API-Key"), body) + return httpx.Response(status, json=payload) + return httpx.MockTransport(handler) + + +class ConfigTests(unittest.TestCase): + def test_agent_keys_parse_and_reject_malformed_without_echoing_secrets(self): + self.assertEqual(parse_agent_keys(" A=k1,\nB = k2 ,"), {"A": "k1", "B": "k2"}) + for bad in ("A", "=secret-value", "A=", "A=x,A=y"): + with self.subTest(bad=bad), self.assertRaises(ConfigError) as caught: + parse_agent_keys(bad) + self.assertNotIn("secret-value", str(caught.exception)) + + def test_requires_a_credential_and_refuses_plain_http_to_remote_hosts(self): + with self.assertRaises(ConfigError): + Settings.from_env({}) + with self.assertRaises(ConfigError): + Settings.from_env({"DATABASE_AGENT_URL": "http://agent.example.com/nl2sql/api", + "DATABASE_AGENT_API_KEY": "k"}) + settings = Settings.from_env({"DATABASE_AGENT_URL": "https://agent.example.com/nl2sql/api/", + "DATABASE_AGENT_AGENT_KEYS": "A=k"}) + self.assertEqual(settings.api_url, "https://agent.example.com/nl2sql/api") + self.assertNotIn("'k'", repr(settings)) + + def test_agent_key_wins_and_missing_credential_is_explicit(self): + settings = Settings(agent_keys={"A": "ka"}, operator_key="op") + self.assertEqual(settings.credential_for("A"), ("ka", "agent_key")) + self.assertEqual(settings.credential_for("B"), ("op", "operator_key")) + with self.assertRaisesRegex(ConfigError, "No credential configured for agent B"): + Settings(agent_keys={"A": "ka"}).credential_for("B") + + +class InMemoryToolTests(unittest.IsolatedAsyncioTestCase): + async def call(self, settings, name, arguments=None, api=None): + self.api = api or getattr(self, "api", None) or FakeApi() + server = build_server(settings, transport=transport(self.api)) + async with create_connected_server_and_client_session(server) as client: + return await client.call_tool(name, arguments or {}) + + async def test_tools_publish_input_and_output_schemas(self): + server = build_server(Settings(agent_keys={"NL2SQL_SALES": "key-sales"}), transport=transport(FakeApi())) + async with create_connected_server_and_client_session(server) as client: + tools = {t.name: t for t in (await client.list_tools()).tools} + self.assertEqual(set(tools), {"list_agents", "ask_database", "list_conversations", "get_conversation"}) + self.assertEqual(tools["ask_database"].inputSchema["required"], ["agent_id", "question"]) + self.assertIn("result", tools["ask_database"].outputSchema["properties"]) + self.assertTrue(tools["list_agents"].annotations.readOnlyHint) + self.assertFalse(tools["ask_database"].annotations.destructiveHint) + + async def test_agent_keys_list_only_their_agents_and_never_call_agentless_routes(self): + result = await self.call(Settings(agent_keys={"NL2SQL_SALES": "key-sales", "NL2SQL_OTHER": "key-sales"}), + "list_agents") + self.assertFalse(result.isError) + data = result.structuredContent + self.assertEqual([a["agent_id"] for a in data["agents"]], ["NL2SQL_SALES"]) + self.assertEqual(data["agents"][0]["databases"][0]["name"], "sample_sales") + self.assertNotIn("db_host", json.dumps(data)) + self.assertNotIn("system_prompt", json.dumps(data)) + self.assertEqual(data["unavailable"][0]["agent_id"], "NL2SQL_OTHER") + self.assertIn("HTTP 403", data["unavailable"][0]["error"]) + self.assertNotIn(("GET", "/nl2sql/api/v1/agents", "agent"), self.api.requests) + + async def test_operator_key_lists_every_agent(self): + result = await self.call(Settings(operator_key=OPERATOR_KEY), "list_agents") + self.assertEqual({a["agent_id"] for a in result.structuredContent["agents"]}, + {"NL2SQL_SALES", "NL2SQL_HIDDEN", "NL2SQL_OTHER"}) + self.assertEqual({a["credential"] for a in result.structuredContent["agents"]}, {"operator_key"}) + + async def test_ask_returns_sql_rows_answer_and_chart_then_continues_the_session(self): + settings = Settings(agent_keys={"NL2SQL_SALES": "key-sales"}) + first = await self.call(settings, "ask_database", + {"agent_id": "NL2SQL_SALES", "question": "Total amount by region?"}) + self.assertFalse(first.isError, first.content) + answer = first.structuredContent + self.assertIn("SUM(amount)", answer["sql"]) + self.assertEqual(answer["result"]["rows"], [{"region": "North", "total": "250.00"}, + {"region": "South", "total": "200.00"}]) + self.assertEqual([c["name"] for c in answer["result"]["columns"]], ["region", "total"]) + self.assertEqual(answer["chart"]["type"], "bar") + self.assertEqual(answer["tables"], ["orders"]) + self.assertTrue(answer["answered"]) + follow = await self.call(settings, "ask_database", {"agent_id": "NL2SQL_SALES", "question": "And North only?", + "session_id": answer["session_id"]}) + self.assertEqual(follow.structuredContent["session_id"], answer["session_id"]) + self.assertIn("earlier context", follow.structuredContent["answer"]) + self.assertIn(("POST", f"/nl2sql/api/v1/agents/NL2SQL_SALES/sessions/{answer['session_id']}/messages", "agent"), + self.api.requests) + + saved = await self.call(settings, "get_conversation", + {"agent_id": "NL2SQL_SALES", "session_id": answer["session_id"]}) + turns = saved.structuredContent["turns"] + self.assertEqual([t["question"] for t in turns], ["Total amount by region?", "And North only?"]) + self.assertEqual(turns[0]["result"]["rows"][0], {"region": "North", "total": "250.00"}) + self.assertIn("SUM(amount)", turns[0]["sql"]) + listed = await self.call(settings, "list_conversations", {"agent_id": "NL2SQL_SALES"}) + self.assertEqual(listed.structuredContent["conversations"][0]["session_id"], answer["session_id"]) + + async def test_max_rows_truncates_only_the_returned_copy(self): + result = await self.call(Settings(operator_key=OPERATOR_KEY), "ask_database", + {"agent_id": "NL2SQL_SALES", "question": "q", "max_rows": 1}) + rows = result.structuredContent["result"] + self.assertEqual((rows["returned"], rows["row_count"], rows["truncated"]), (1, 2, True)) + self.assertEqual(len(result.structuredContent["chart"]["data"]), 1) + + async def test_agent_visibility_settings_hold_for_live_and_saved_results(self): + settings = Settings(agent_keys={"NL2SQL_HIDDEN": "key-hidden"}) + live = (await self.call(settings, "ask_database", {"agent_id": "NL2SQL_HIDDEN", "question": "q"})).structuredContent + self.assertIsNone(live["sql"]) + self.assertIsNone(live["result"]) + self.assertEqual(len(live["notes"]), 2) + saved = (await self.call(settings, "get_conversation", + {"agent_id": "NL2SQL_HIDDEN", "session_id": live["session_id"]})).structuredContent + self.assertIsNone(saved["turns"][0]["sql"]) + self.assertIsNone(saved["turns"][0]["result"]) + + async def test_authorization_failures_are_tool_errors_with_the_server_reason(self): + api = FakeApi() + cases = [ + (Settings(agent_keys={"NL2SQL_SALES": "revoked"}), "NL2SQL_SALES", "HTTP 401"), + (Settings(agent_keys={"NL2SQL_OTHER": "key-sales"}), "NL2SQL_OTHER", "HTTP 403"), + (Settings(agent_keys={"NL2SQL_SALES": "key-sales"}), "NL2SQL_OTHER", "No credential configured"), + ] + for settings, agent_id, expected in cases: + with self.subTest(agent_id=agent_id, expected=expected): + result = await self.call(settings, "ask_database", {"agent_id": agent_id, "question": "q"}, api=api) + self.assertTrue(result.isError) + self.assertIn(expected, result.content[0].text) + self.assertNotIn("key-sales", result.content[0].text) + self.assertNotIn("revoked", result.content[0].text) + + async def test_foreign_or_unknown_session_and_query_cap_are_reported(self): + api = FakeApi() + own = await self.call(Settings(operator_key=OPERATOR_KEY), "ask_database", + {"agent_id": "NL2SQL_OTHER", "question": "q"}, api=api) + foreign = await self.call(Settings(agent_keys={"NL2SQL_SALES": "key-sales"}), "ask_database", + {"agent_id": "NL2SQL_SALES", "question": "q", + "session_id": own.structuredContent["session_id"]}, api=api) + self.assertTrue(foreign.isError) + self.assertIn("HTTP 404", foreign.content[0].text) + api.limited.add("NL2SQL_SALES") + capped = await self.call(Settings(agent_keys={"NL2SQL_SALES": "key-sales"}), "ask_database", + {"agent_id": "NL2SQL_SALES", "question": "q"}, api=api) + self.assertTrue(capped.isError) + self.assertIn("Query limit reached", capped.content[0].text) + + async def test_unreachable_api_is_a_clear_tool_error(self): + server = build_server(Settings(api_url="http://127.0.0.1:9/nl2sql/api", operator_key="op", timeout=2)) + async with create_connected_server_and_client_session(server) as client: + result = await client.call_tool("list_agents", {}) + self.assertTrue(result.isError) + self.assertIn("Cannot reach the Database Agent API", result.content[0].text) + + +class StdioEntrypointTests(unittest.IsolatedAsyncioTestCase): + """The packaged command, launched the way Claude Code launches it, over stdio.""" + + async def test_stdio_server_answers_through_the_rest_api(self): + api = FakeApi() + server, url = serve(api) + self.addCleanup(server.shutdown) + env = {**os.environ, "PYTHONPATH": str(ROOT / "mcp-server"), "DATABASE_AGENT_URL": url, + "DATABASE_AGENT_AGENT_KEYS": "NL2SQL_SALES=key-sales"} + env.pop("DATABASE_AGENT_API_KEY", None) + params = StdioServerParameters(command=sys.executable, args=["-m", "database_agent_mcp"], env=env) + async with stdio_client(params) as (read, write), ClientSession(read, write) as client: + init = await client.initialize() + self.assertEqual(init.serverInfo.name, "database-agent") + agents = await client.call_tool("list_agents", {}) + self.assertEqual(agents.structuredContent["agents"][0]["agent_id"], "NL2SQL_SALES") + answer = await client.call_tool("ask_database", {"agent_id": "NL2SQL_SALES", "question": "Totals?"}) + self.assertFalse(answer.isError) + self.assertEqual(answer.structuredContent["result"]["row_count"], 2) + + async def test_missing_configuration_exits_with_a_message(self): + import subprocess + env = {k: v for k, v in os.environ.items() if not k.startswith("DATABASE_AGENT_")} + env["PYTHONPATH"] = str(ROOT / "mcp-server") + done = subprocess.run([sys.executable, "-m", "database_agent_mcp"], env=env, capture_output=True, + text=True, timeout=60, stdin=subprocess.DEVNULL) + self.assertEqual(done.returncode, 2) + self.assertIn("No Database Agent credential configured", done.stderr) + + +if __name__ == "__main__": + unittest.main(verbosity=2) From f624e6abb3d35fd850e48b0b5ff93028f5210aff Mon Sep 17 00:00:00 2001 From: Pradip Nichite <9526749+PradipNichite@users.noreply.github.com> Date: Fri, 23 Oct 2026 20:36:52 +0530 Subject: [PATCH 2/2] Document the MCP server, Claude Code setup and the API alternative docs/mcp.md covers the tools, credentials, a Claude Code .mcp.json that keeps keys out of files, claude mcp add, the equivalent REST calls, security notes and how the integration was verified. README and docs/api.md link to it, and docs/api.md lists the session routes. Co-Authored-By: Claude Opus 5.5 --- README.md | 5 ++ docs/api.md | 6 +- docs/mcp.md | 165 +++++++++++++++++++++++++++++++++++++++++++ mcp-server/README.md | 22 ++++++ 4 files changed, 197 insertions(+), 1 deletion(-) create mode 100644 docs/mcp.md create mode 100644 mcp-server/README.md diff --git a/README.md b/README.md index c53a71b..18179f0 100644 --- a/README.md +++ b/README.md @@ -32,6 +32,10 @@ Open source · Apache-2.0 · Self-hostable - **Use the API or customise the application.** Call the same agents from your own software through the [direct product API](docs/api.md), or change the open-source implementation and its [model configuration](docs/model-configuration.md). +- **Ask from Claude Code and other MCP clients.** The included + [MCP server](docs/mcp.md) exposes your agents as tools: ask a question and get + the answer, SQL, rows and chart metadata, continue the conversation, or read + saved results. ![Database Agent answering a synthetic sales question](docs/images/sales-overview.png) @@ -152,6 +156,7 @@ you intend to erase this installation's data. - [Configuration and authentication](docs/configuration.md) - [Direct product API and OpenAPI reference](docs/api.md) +- [MCP server for Claude Code and other MCP clients](docs/mcp.md) - [AWS deployment: ECS/Fargate and static UI](docs/aws-deployment.md) - [Development and reproducible evaluation](docs/development.md) - [Architecture](docs/architecture.md) and [source layout](docs/source-layout.md) diff --git a/docs/api.md b/docs/api.md index a2caf0b..9745289 100644 --- a/docs/api.md +++ b/docs/api.md @@ -3,7 +3,8 @@ Local API root: `http://127.0.0.1:18000/nl2sql/api`. Interactive reference: [/nl2sql/api/docs](http://127.0.0.1:18000/nl2sql/api/docs). Machine contract: [/nl2sql/api/openapi.json](http://127.0.0.1:18000/nl2sql/api/openapi.json). -The UI calls the same product API through its same-origin proxy. +The UI calls the same product API through its same-origin proxy. MCP clients +such as Claude Code can call it through the [MCP server](mcp.md). Trusted operator clients use the private `FASTAPI_SECRET_KEY` as `X-API-Key`. The agent-key endpoint issues a narrower agent credential for chat. Keep both @@ -19,6 +20,9 @@ uses the signed HTTP-only session cookie; no hosted Auth service is required. | `POST /v1/agents/{id}/databases/{db_id}/tables` | Supply table descriptions/visibility | | `POST /v1/agents/{id}/api-keys` | Issue an agent key; never log the returned key | | `POST /v1/agents/{id}/chat?stream=false` | Direct question with JSON response | +| `POST /v1/agents/{id}/sessions/{session_id}/messages?stream=false` | Follow-up in a conversation; the session must belong to this agent | +| `GET /v1/agents/{id}/sessions` | Saved conversations on the agent | +| `GET /v1/agents/{id}/sessions/{session_id}` | A saved conversation, each answer with its SQL, `result` rows and chart | Use the generated OpenAPI contract for complete request/response fields and streaming options. `tests/evaluation/run.py` is a small working client example: diff --git a/docs/mcp.md b/docs/mcp.md new file mode 100644 index 0000000..9574f95 --- /dev/null +++ b/docs/mcp.md @@ -0,0 +1,165 @@ +# MCP server + +`mcp-server/` lets MCP-compatible AI applications, such as Claude Code, use your +Database Agent agents as tools. It is a thin adapter over the +[direct product API](api.md): every tool call is an ordinary API request made +with an existing Database Agent credential. Authentication, agent-key scope, +query limits, model configuration, table visibility, SQL access controls and +query execution all stay in the API. The adapter has no database credentials and +does not generate or run SQL. + +## Tools + +| Tool | What it does | API route | +|---|---|---| +| `list_agents` | Agents these credentials can use, with connected databases (name, engine, description, table count; never host, user or password) | `GET /v1/agents`, `GET /v1/agents/{id}`, `GET /v1/agents/{id}/databases` | +| `ask_database` | Ask a question; returns the answer, generated SQL, column-labelled rows, chart suggestion and metadata, and a `session_id` | `POST /v1/agents/{id}/chat?stream=false` | +| `ask_database` with `session_id` | Follow-up question using that conversation's context | `POST /v1/agents/{id}/sessions/{session_id}/messages` | +| `list_conversations` | Saved conversations on an agent, newest first | `GET /v1/agents/{id}/sessions` | +| `get_conversation` | A saved conversation: each turn's question, answer, SQL, rows and chart | `GET /v1/agents/{id}/sessions/{session_id}` | + +Every tool returns MCP structured content with a published output schema. +`max_rows` (default 100, up to 1000) limits only the rows copied into the tool +result; `row_count` and `truncated` report the full result. SQL and rows are +returned only when the agent's own settings allow them (`show_sql_query`, +`show_db_result`); otherwise the result says so in `notes`. Errors from the API +are returned as MCP tool errors with the server's reason, for example +`Not permitted for agent NL2SQL_… (HTTP 403): This API key is not valid for this agent`. + +## Install + +You need Python 3.10 or later and a running Database Agent API (the +[local installation](../README.md#install) or your own deployment). + +```sh +pip install "git+https://github.com/FutureSmart-AI/database-agent.git#subdirectory=mcp-server" +``` + +This installs the `database-agent-mcp` command. From a clone you can instead run +`pip install ./mcp-server`, or skip installing with +`uvx --from ./mcp-server database-agent-mcp`. + +## Credentials + +Use per-agent API keys. A key works only on the agent it was issued for and +cannot manage keys, collaborators, the database connection or the agent itself. +Issue one in the UI under **Share → API**, or with the operator key: + +```sh +curl -s -X POST "http://127.0.0.1:18000/nl2sql/api/v1/agents/$AGENT_ID/api-keys" \ + -H "X-API-Key: $FASTAPI_SECRET_KEY" -H "Content-Type: application/json" -d '{}' +``` + +| Setting | Purpose | +|---|---| +| `DATABASE_AGENT_URL` | API root; default `http://127.0.0.1:18000/nl2sql/api`. Plain HTTP is refused for non-loopback hosts unless `DATABASE_AGENT_ALLOW_INSECURE_HTTP=1` | +| `DATABASE_AGENT_AGENT_KEYS` | `AGENT_ID=KEY` pairs, comma- or newline-separated. Recommended | +| `DATABASE_AGENT_API_KEY` | Operator (master) key, `FASTAPI_SECRET_KEY`. Reaches every agent; only for a trusted single-operator installation | +| `DATABASE_AGENT_TIMEOUT_SECONDS` | Per-request timeout; default 180 | + +If both are set, an agent's own key is used for that agent and the operator key +for the rest. The server reads these from its environment and never prints them. + +## Claude Code + +Add a `.mcp.json` to your project. Claude Code expands the environment variables +when it starts the server, so the file holds no key: + +```json +{ + "mcpServers": { + "database-agent": { + "command": "database-agent-mcp", + "env": { + "DATABASE_AGENT_URL": "${DATABASE_AGENT_URL:-http://127.0.0.1:18000/nl2sql/api}", + "DATABASE_AGENT_AGENT_KEYS": "${DATABASE_AGENT_AGENT_KEYS}" + } + } + } +} +``` + +Set `DATABASE_AGENT_AGENT_KEYS` (for example `NL2SQL_1A2B3C4D=`) in the +shell that starts Claude Code. To register the server for every project instead, +use `claude mcp add`; it stores the values you pass in your Claude Code +configuration file: + +```sh +claude mcp add database-agent --scope user \ + -e DATABASE_AGENT_URL=http://127.0.0.1:18000/nl2sql/api \ + -e "DATABASE_AGENT_AGENT_KEYS=$AGENT_ID=$AGENT_KEY" \ + -- database-agent-mcp +``` + +`claude mcp list` should show `database-agent: database-agent-mcp - ✔ Connected`. +Then ask in Claude Code, for example with the agent from +[Try the sample](../README.md#try-the-sample): + +> Using the database-agent tools, what is the total amount for each region in +> orders? Show the SQL and rows. Then ask a follow-up in the same conversation: +> which region has the higher total? + +Claude Code calls `list_agents`, then `ask_database`, then `ask_database` again +with the returned `session_id`. The expected rows are **North: 250.00** and +**South: 200.00**, and the follow-up answers North. Claude Code asks permission +before using each tool unless you allow it. + +Other MCP clients use the same command and environment variables over stdio. + +## The API alternative + +The tools add no capability the API lacks. To call the agent directly: + +```sh +curl -s -X POST "http://127.0.0.1:18000/nl2sql/api/v1/agents/$AGENT_ID/chat?stream=false" \ + -H "X-API-Key: $AGENT_KEY" -H "Content-Type: application/json" \ + -d '{"query": "What is the total amount for each region in orders?", "debug_info": true}' +``` + +The response carries `response`, `session_id`, `chat_id`, `is_answered`, +`debug_info.sql_query`, `debug_info.db_result` (`columns`, `rows`, `row_count`) +and `chart_output`. Send a follow-up to +`POST /v1/agents/{id}/sessions/{session_id}/messages` with the same body. See +[api.md](api.md) and the OpenAPI reference for every field. + +## Security notes + +- The agent's source credentials should be a dedicated read-only database + account. The adapter cannot widen what that account or the agent's table + visibility allows; it only forwards questions. +- A conversation can be continued only on the agent that created it: a + `session_id` from another agent returns HTTP 404 before any history is read + or any model is called. Deployments running an API version older than this + change do not perform that check. +- Saved conversations replayed through the API include SQL and rows regardless + of the agent's visibility settings. The adapter applies the agent's + `show_sql_query` and `show_db_result` to them, matching live answers, but a + caller using the API directly with the same key can read them. +- An agent key can list and read every conversation on its own agent, not only + the ones it created. + +## Verification + +`tests/mcp` checks the adapter through real MCP client sessions, in memory and +over stdio, against a synthetic API with the product's credential rules. CI runs +it with `tests/backend/test_session_scope.py`, which covers the session check +and saved-result replay in the API itself. + +The integration was also run end to end on a disposable installation of this +branch (Compose stack, default model profile, the synthetic `sample_sales` +database with its read-only account) with two clients: the MCP Python SDK over +stdio, and Claude Code (`claude -p` with the `.mcp.json` above). That run covered: + +- listing agents, asking with SQL, rows and a bar-chart suggestion, a follow-up + in the same session, and reading the saved conversation (North 250.00, + South 200.00); +- an agent configured to hide SQL and rows, live and saved; +- authorization failures: an unknown key (401), another agent's key (403), a + session from another agent (404) and an agent with no configured key; +- read-only access: requests to delete, drop or update data produced no SQL, + and the database still held its four original rows afterwards; a table the + database account can read but the agent's table visibility excludes was not + queried. + +Charts are produced when the question asks for one ("Show a bar chart") and the +agent's chart setting allows it; `include_chart` sets that setting per question. diff --git a/mcp-server/README.md b/mcp-server/README.md new file mode 100644 index 0000000..cbef025 --- /dev/null +++ b/mcp-server/README.md @@ -0,0 +1,22 @@ +# database-agent-mcp + +MCP server for [FutureSmart Database Agent](https://github.com/FutureSmart-AI/database-agent). +It lets MCP-compatible applications such as Claude Code list your Database Agent +agents, ask natural-language database questions, get back the answer, generated +SQL, structured rows and chart metadata, continue conversations, and read saved +results. + +It is a thin adapter over the Database Agent REST API, using an existing API +credential. The API keeps authentication, query limits, model configuration, +table visibility, SQL access controls and execution. + +```sh +pip install "git+https://github.com/FutureSmart-AI/database-agent.git#subdirectory=mcp-server" +claude mcp add database-agent --scope user \ + -e DATABASE_AGENT_URL=http://127.0.0.1:18000/nl2sql/api \ + -e "DATABASE_AGENT_AGENT_KEYS=$AGENT_ID=$AGENT_KEY" \ + -- database-agent-mcp +``` + +See [docs/mcp.md](../docs/mcp.md) for tools, credentials, configuration, the +Claude Code `.mcp.json` example and the equivalent direct API calls.