From a7c493d82f75c27c2293ea934aae7801b59f1523 Mon Sep 17 00:00:00 2001 From: Yashom Dighe Date: Thu, 12 Mar 2026 08:35:24 -0700 Subject: [PATCH] mcp-server: expose Flight Deck retrieval and note tools via MCP Add a stdio MCP adapter that delegates retrieval and note CRUD to kb-server, defaults reads to view=current, and forces writes through source=api. Topic: mcp-adapter Relative: retrieval-layer --- AGENTS.md | 3 +- ARCHITECTURE.md | 9 ++ README.md | 2 + docs/RELIABILITY.md | 7 + docs/SECURITY.md | 8 + docs/generated/env-catalog.md | 17 +- docs/product-specs/index.md | 2 +- docs/product-specs/mcp-server.md | 49 ++++++ mcp-server/README.md | 9 ++ .../flight_deck_mcp_server.egg-info/PKG-INFO | 10 ++ .../SOURCES.txt | 13 ++ .../dependency_links.txt | 1 + .../entry_points.txt | 2 + .../requires.txt | 6 + .../top_level.txt | 1 + mcp-server/mcp_server/__init__.py | 1 + mcp-server/mcp_server/client.py | 120 ++++++++++++++ mcp-server/mcp_server/config.py | 17 ++ mcp-server/mcp_server/server.py | 153 ++++++++++++++++++ mcp-server/pyproject.toml | 28 ++++ mcp-server/tests/test_client.py | 78 +++++++++ mcp-server/tests/test_server.py | 81 ++++++++++ scripts/generate_context_artifacts.py | 18 ++- 23 files changed, 630 insertions(+), 5 deletions(-) create mode 100644 docs/product-specs/mcp-server.md create mode 100644 mcp-server/README.md create mode 100644 mcp-server/flight_deck_mcp_server.egg-info/PKG-INFO create mode 100644 mcp-server/flight_deck_mcp_server.egg-info/SOURCES.txt create mode 100644 mcp-server/flight_deck_mcp_server.egg-info/dependency_links.txt create mode 100644 mcp-server/flight_deck_mcp_server.egg-info/entry_points.txt create mode 100644 mcp-server/flight_deck_mcp_server.egg-info/requires.txt create mode 100644 mcp-server/flight_deck_mcp_server.egg-info/top_level.txt create mode 100644 mcp-server/mcp_server/__init__.py create mode 100644 mcp-server/mcp_server/client.py create mode 100644 mcp-server/mcp_server/config.py create mode 100644 mcp-server/mcp_server/server.py create mode 100644 mcp-server/pyproject.toml create mode 100644 mcp-server/tests/test_client.py create mode 100644 mcp-server/tests/test_server.py diff --git a/AGENTS.md b/AGENTS.md index a77c7f2..343b645 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -26,6 +26,7 @@ This file is the entry point for agents. It is intentionally short. 2. Read `docs/design-docs/index.md` for design invariants. 3. Pick a domain: - `kb-server`: API, Git workflows, current-view composition. + - `mcp-server`: MCP adapter for structured agent access to notes and context bundles. - `vault-sync`: local mirror and push/pull convergence. 4. Read topical constraints before implementation: - `docs/SECURITY.md` @@ -38,6 +39,7 @@ This file is the entry point for agents. It is intentionally short. ## Domain Maps - Backend domain: `docs/product-specs/kb-server.md` +- MCP adapter domain: `docs/product-specs/mcp-server.md` - Sync client domain: `docs/product-specs/vault-sync.md` - Branching + current view model: `kb-server/BRANCHING_AND_CURRENT_VIEW.md` @@ -86,4 +88,3 @@ Update docs in the same PR when changing: - Security: `docs/SECURITY.md` - Reliability: `docs/RELIABILITY.md` - Autonomous E2E workflow: `docs/runbooks/autonomous-agent-e2e.md` - diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 972e411..dd7df0d 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -23,6 +23,7 @@ review_cycle_days: 21 ## Monorepo Topology - `kb-server/`: authoritative API and worker processes. +- `mcp-server/`: MCP adapter for agent-native note and context access. - `vault-sync/`: local daemon that mirrors and edits through API. - `docs/`: system-of-record documentation for humans and agents. - `scripts/`: repository-level automation for docs quality and generation. @@ -46,6 +47,13 @@ review_cycle_days: 21 - `source=human`: direct commit/push to base branch. - Implements `view=current` as composed, read-only view. +### mcp-server + +- Connects MCP-capable agents to Flight Deck through stdio tools/resources. +- Delegates note CRUD and retrieval requests to `kb-server` over HTTP. +- Defaults reads to `view=current`. +- Forces note mutations through `source=api` so review workflows stay intact. + ### vault-sync - Pulls notes from `view=current`. @@ -97,5 +105,6 @@ review_cycle_days: 21 - `docs/design-docs/index.md` - `docs/product-specs/kb-server.md` - `docs/product-specs/vault-sync.md` +- `docs/product-specs/mcp-server.md` - `docs/SECURITY.md` - `docs/RELIABILITY.md` diff --git a/README.md b/README.md index 5f4be7c..f453987 100644 --- a/README.md +++ b/README.md @@ -5,6 +5,7 @@ A file-first knowledge base with Git-backed approval workflows. Edit notes local ## What it does - **kb-server**: API and workers that manage a Markdown vault, auto-commit to Git, and expose a `current` view (approved content + pending PRs). +- **mcp-server**: MCP adapter that gives agents structured tools/resources over notes and server-built context bundles. - **vault-sync**: Daemon that mirrors the current view to a local folder and pushes your edits back as human-origin commits. ## Get started @@ -22,6 +23,7 @@ A file-first knowledge base with Git-backed approval workflows. Edit notes local | Path | Purpose | | ------------- | -------------------------------------------- | | `kb-server/` | API, Git workflows, current-view composition | +| `mcp-server/` | MCP adapter over kb-server | | `vault-sync/` | Local sync daemon (pull + push) | | `docs/` | Architecture, runbooks, product specs | diff --git a/docs/RELIABILITY.md b/docs/RELIABILITY.md index 4913fc6..9ef5f65 100644 --- a/docs/RELIABILITY.md +++ b/docs/RELIABILITY.md @@ -20,6 +20,7 @@ review_cycle_days: 21 - `kb-server` readiness requires database and Git-backed vault access. - `kb-server` retrieval endpoints should rebuild or refresh in-process graph state when visible note state changes. +- `mcp-server` should surface upstream `kb-server` failures as explicit tool errors rather than hanging or fabricating output. - Autosave worker should tolerate transient Git/network failures. - `vault-sync` should converge after temporary API outages. @@ -56,6 +57,12 @@ review_cycle_days: 21 - Signal: local filesystem remains intact; no destructive cleanup on transient failures. - Recovery check: after API is reachable, next pull repopulates `view=current` and pending local changes push successfully. +### API outage (`mcp-server`) + +- Signal: MCP tools return upstream request failures with `kb-server` status/detail. +- Signal: note/resource reads fail closed rather than returning stale fabricated content. +- Recovery check: once `kb-server` is reachable, the next MCP tool invocation succeeds without restarting the adapter. + ## Runbook Links - `runbooks/deployment.md` diff --git a/docs/SECURITY.md b/docs/SECURITY.md index 8f1de51..9490c8c 100644 --- a/docs/SECURITY.md +++ b/docs/SECURITY.md @@ -27,6 +27,13 @@ review_cycle_days: 21 - Secrets remain in local `.env` files or deployment secret stores. - Docs should only reference secret names, not values. - Generated docs must redact secrets by default. +- `mcp-server` stores `KB_API_KEY` locally and must never echo it back through tool output or logs. + +## MCP Boundary + +- `mcp-server` is a local adapter over `kb-server`, not a second source of truth. +- v1 transport is stdio only; no remote MCP transport is exposed by default. +- MCP writes must use `source=api` and must not expose a path to `source=human`. ## Write Safety @@ -40,5 +47,6 @@ Update this document when changing: - auth middleware/dependency behavior - request validation and path sanitization +- MCP transport or upstream auth handling - external webhook/publish execution semantics - GitHub token scope or PR automation behavior diff --git a/docs/generated/env-catalog.md b/docs/generated/env-catalog.md index 4fccbae..546c4a0 100644 --- a/docs/generated/env-catalog.md +++ b/docs/generated/env-catalog.md @@ -1,22 +1,24 @@ --- owner: platform status: generated -last_verified: 2026-03-06 +last_verified: 2026-03-12 source_of_truth: - ../../kb-server/.env.example - ../../kb-server/app/core/config.py + - ../../mcp-server/mcp_server/config.py - ../../vault-sync/vault_sync/config.py related_code: - ../../scripts/generate_context_artifacts.py related_tests: - ../../kb-server/tests + - ../../mcp-server/tests - ../../vault-sync/tests review_cycle_days: 7 --- # Environment Catalog (Generated) -Generated on `2026-03-06` from settings and env sources. +Generated on `2026-03-12` from settings and env sources. ## kb-server `.env.example` @@ -60,6 +62,17 @@ Generated on `2026-03-06` from settings and env sources. | `api_host` | `"0.0.0.0"` | | `api_port` | `8000` | +## mcp-server Settings Defaults + +| Field | Default Expression | +| --- | --- | +| `kb_server_url` | `"http://127.0.0.1:8000"` | +| `kb_api_key` | `""` | +| `mcp_default_view` | `"current"` | +| `mcp_default_limit` | `Field(default=10, ge=1, le=50)` | +| `mcp_default_token_budget` | `Field(default=4000, ge=1, le=50000)` | +| `mcp_transport` | `"stdio"` | + ## vault-sync Settings Defaults | Field | Default Expression | diff --git a/docs/product-specs/index.md b/docs/product-specs/index.md index fbd9607..5a416c9 100644 --- a/docs/product-specs/index.md +++ b/docs/product-specs/index.md @@ -19,6 +19,7 @@ review_cycle_days: 21 ## Domain Specs - `kb-server.md`: API and branch/workflow product contract. +- `mcp-server.md`: MCP adapter contract for agent tools/resources. - `vault-sync.md`: local sync and human-edit UX contract. ## Scope @@ -31,4 +32,3 @@ Implementation details belong in design docs or code comments. - `../DESIGN.md` - `../PRODUCT_SENSE.md` - `../RELIABILITY.md` - diff --git a/docs/product-specs/mcp-server.md b/docs/product-specs/mcp-server.md new file mode 100644 index 0000000..5fdcdcd --- /dev/null +++ b/docs/product-specs/mcp-server.md @@ -0,0 +1,49 @@ +--- +owner: platform +status: draft +last_verified: 2026-03-12 +source_of_truth: + - ../../mcp-server/mcp_server/server.py + - ../../mcp-server/mcp_server/client.py + - ../../kb-server/app/api/routes/context.py +related_code: + - ../../kb-server/app/api/routes/notes.py +related_tests: + - ../../mcp-server/tests + - ../../kb-server/tests/test_context_api.py +review_cycle_days: 14 +--- + +# Product Spec: mcp-server + +## Purpose + +Expose Flight Deck note operations and context retrieval to MCP-capable agents without duplicating backend approval or Git logic. + +## User-Visible Behavior + +- Provides MCP tools for: + - finding relevant notes + - building bounded context bundles + - listing notes + - reading notes + - writing notes as API-origin changes + - deleting notes as API-origin changes +- Provides a note resource for direct page reads through MCP. +- Defaults reads and retrieval to `view=current`. +- Forces writes to `view=main` and `source=api`. + +## Guardrails + +- Uses `kb-server` as the only backend authority. +- Must be configured with `KB_SERVER_URL` and `KB_API_KEY`. +- Does not expose `source=human`. +- Does not expose publish or prompt workflows in v1. +- v1 transport is stdio. + +## Related Operational Docs + +- `../../mcp-server/README.md` +- `../SECURITY.md` +- `../RELIABILITY.md` +- `../../ARCHITECTURE.md` diff --git a/mcp-server/README.md b/mcp-server/README.md new file mode 100644 index 0000000..069959f --- /dev/null +++ b/mcp-server/README.md @@ -0,0 +1,9 @@ +# mcp-server + +MCP adapter for Flight Deck. This package exposes note operations and context retrieval to MCP-capable agents while delegating all vault, approval, and provenance rules to `kb-server`. + +See: + +- `../docs/product-specs/mcp-server.md` +- `../ARCHITECTURE.md` +- `../docs/SECURITY.md` diff --git a/mcp-server/flight_deck_mcp_server.egg-info/PKG-INFO b/mcp-server/flight_deck_mcp_server.egg-info/PKG-INFO new file mode 100644 index 0000000..58808a0 --- /dev/null +++ b/mcp-server/flight_deck_mcp_server.egg-info/PKG-INFO @@ -0,0 +1,10 @@ +Metadata-Version: 2.4 +Name: flight-deck-mcp-server +Version: 0.1.0 +Summary: MCP adapter for Flight Deck +Requires-Python: >=3.10 +Requires-Dist: httpx>=0.28 +Requires-Dist: mcp>=1.2.0 +Requires-Dist: pydantic-settings>=2.7 +Provides-Extra: dev +Requires-Dist: pytest>=8.0; extra == "dev" diff --git a/mcp-server/flight_deck_mcp_server.egg-info/SOURCES.txt b/mcp-server/flight_deck_mcp_server.egg-info/SOURCES.txt new file mode 100644 index 0000000..49b589e --- /dev/null +++ b/mcp-server/flight_deck_mcp_server.egg-info/SOURCES.txt @@ -0,0 +1,13 @@ +pyproject.toml +flight_deck_mcp_server.egg-info/PKG-INFO +flight_deck_mcp_server.egg-info/SOURCES.txt +flight_deck_mcp_server.egg-info/dependency_links.txt +flight_deck_mcp_server.egg-info/entry_points.txt +flight_deck_mcp_server.egg-info/requires.txt +flight_deck_mcp_server.egg-info/top_level.txt +mcp_server/__init__.py +mcp_server/client.py +mcp_server/config.py +mcp_server/server.py +tests/test_client.py +tests/test_server.py \ No newline at end of file diff --git a/mcp-server/flight_deck_mcp_server.egg-info/dependency_links.txt b/mcp-server/flight_deck_mcp_server.egg-info/dependency_links.txt new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/mcp-server/flight_deck_mcp_server.egg-info/dependency_links.txt @@ -0,0 +1 @@ + diff --git a/mcp-server/flight_deck_mcp_server.egg-info/entry_points.txt b/mcp-server/flight_deck_mcp_server.egg-info/entry_points.txt new file mode 100644 index 0000000..9e3ed1e --- /dev/null +++ b/mcp-server/flight_deck_mcp_server.egg-info/entry_points.txt @@ -0,0 +1,2 @@ +[console_scripts] +flight-deck-mcp = mcp_server.server:main diff --git a/mcp-server/flight_deck_mcp_server.egg-info/requires.txt b/mcp-server/flight_deck_mcp_server.egg-info/requires.txt new file mode 100644 index 0000000..cd34708 --- /dev/null +++ b/mcp-server/flight_deck_mcp_server.egg-info/requires.txt @@ -0,0 +1,6 @@ +httpx>=0.28 +mcp>=1.2.0 +pydantic-settings>=2.7 + +[dev] +pytest>=8.0 diff --git a/mcp-server/flight_deck_mcp_server.egg-info/top_level.txt b/mcp-server/flight_deck_mcp_server.egg-info/top_level.txt new file mode 100644 index 0000000..9354fc7 --- /dev/null +++ b/mcp-server/flight_deck_mcp_server.egg-info/top_level.txt @@ -0,0 +1 @@ +mcp_server diff --git a/mcp-server/mcp_server/__init__.py b/mcp-server/mcp_server/__init__.py new file mode 100644 index 0000000..c6f6b5f --- /dev/null +++ b/mcp-server/mcp_server/__init__.py @@ -0,0 +1 @@ +"""Flight Deck MCP adapter.""" diff --git a/mcp-server/mcp_server/client.py b/mcp-server/mcp_server/client.py new file mode 100644 index 0000000..e0aae30 --- /dev/null +++ b/mcp-server/mcp_server/client.py @@ -0,0 +1,120 @@ +from __future__ import annotations + +from typing import Any + +import httpx + + +class KBServerError(RuntimeError): + def __init__(self, status_code: int, detail: str): + super().__init__(detail) + self.status_code = status_code + self.detail = detail + + +class KBServerClient: + def __init__( + self, + base_url: str, + api_key: str, + timeout: float = 30.0, + transport: httpx.BaseTransport | None = None, + ): + if not api_key: + raise ValueError("KB_API_KEY must be configured") + + self._client = httpx.Client( + base_url=base_url.rstrip("/"), + headers={"X-API-Key": api_key}, + timeout=timeout, + transport=transport, + ) + + def close(self) -> None: + self._client.close() + + def find_notes(self, query: str, *, view: str, limit: int) -> dict[str, Any]: + return self._request( + "POST", + "/context/search", + json={"query": query, "view": view, "limit": limit}, + ) + + def build_context_bundle( + self, + query: str, + *, + view: str, + limit: int, + token_budget: int, + ) -> dict[str, Any]: + return self._request( + "POST", + "/context/bundle", + json={ + "query": query, + "view": view, + "limit": limit, + "token_budget": token_budget, + }, + ) + + def list_notes(self, *, prefix: str = "", view: str) -> list[dict[str, Any]]: + return self._request( + "GET", + "/notes/", + params={"prefix": prefix, "view": view}, + ) + + def read_note(self, path: str, *, view: str) -> dict[str, Any]: + return self._request( + "GET", + f"/notes/{path}", + params={"view": view}, + ) + + def write_note(self, path: str, content: str) -> dict[str, Any]: + return self._request( + "PUT", + f"/notes/{path}", + params={"view": "main", "source": "api"}, + json={"content": content}, + ) + + def delete_note(self, path: str) -> None: + self._request( + "DELETE", + f"/notes/{path}", + params={"source": "api"}, + ) + + def _request( + self, + method: str, + path: str, + *, + params: dict[str, Any] | None = None, + json: dict[str, Any] | None = None, + ) -> Any: + try: + response = self._client.request(method, path, params=params, json=json) + except httpx.HTTPError as exc: + raise KBServerError(502, f"kb-server request failed: {exc}") from exc + + if response.status_code >= 400: + detail = _extract_detail(response) + raise KBServerError(response.status_code, detail) + + if response.status_code == 204: + return None + return response.json() + + +def _extract_detail(response: httpx.Response) -> str: + try: + payload = response.json() + except ValueError: + return response.text.strip() or f"kb-server returned {response.status_code}" + if isinstance(payload, dict) and "detail" in payload: + return str(payload["detail"]) + return str(payload) diff --git a/mcp-server/mcp_server/config.py b/mcp-server/mcp_server/config.py new file mode 100644 index 0000000..2f884cd --- /dev/null +++ b/mcp-server/mcp_server/config.py @@ -0,0 +1,17 @@ +from pydantic import Field +from pydantic_settings import BaseSettings, SettingsConfigDict + + +class MCPServerSettings(BaseSettings): + model_config = SettingsConfigDict( + env_file=".env", + env_file_encoding="utf-8", + extra="ignore", + ) + + kb_server_url: str = "http://127.0.0.1:8000" + kb_api_key: str = "" + mcp_default_view: str = "current" + mcp_default_limit: int = Field(default=10, ge=1, le=50) + mcp_default_token_budget: int = Field(default=4000, ge=1, le=50000) + mcp_transport: str = "stdio" diff --git a/mcp-server/mcp_server/server.py b/mcp-server/mcp_server/server.py new file mode 100644 index 0000000..d08e60a --- /dev/null +++ b/mcp-server/mcp_server/server.py @@ -0,0 +1,153 @@ +from __future__ import annotations + +from functools import wraps +from typing import Any +from urllib.parse import quote, unquote + +from mcp.server.fastmcp import FastMCP + +from mcp_server.client import KBServerClient, KBServerError +from mcp_server.config import MCPServerSettings + + +class FlightDeckMCPAdapter: + def __init__(self, client: KBServerClient, settings: MCPServerSettings): + self.client = client + self.settings = settings + + def find_notes( + self, + query: str, + limit: int | None = None, + view: str | None = None, + ) -> dict[str, Any]: + return self.client.find_notes( + query=query, + view=view or self.settings.mcp_default_view, + limit=limit or self.settings.mcp_default_limit, + ) + + def build_context_bundle( + self, + query: str, + limit: int | None = None, + token_budget: int | None = None, + view: str | None = None, + ) -> dict[str, Any]: + return self.client.build_context_bundle( + query=query, + view=view or self.settings.mcp_default_view, + limit=limit or self.settings.mcp_default_limit, + token_budget=token_budget or self.settings.mcp_default_token_budget, + ) + + def list_notes( + self, + prefix: str = "", + view: str | None = None, + ) -> list[dict[str, Any]]: + return self.client.list_notes( + prefix=prefix, + view=view or self.settings.mcp_default_view, + ) + + def read_note(self, path: str, view: str | None = None) -> dict[str, Any]: + return self.client.read_note(path=path, view=view or self.settings.mcp_default_view) + + def read_note_resource(self, encoded_path: str, view: str) -> str: + note = self.read_note(path=unquote(encoded_path), view=view) + return note["content"] + + def write_note(self, path: str, content: str) -> dict[str, Any]: + return self.client.write_note(path=path, content=content) + + def delete_note(self, path: str) -> dict[str, Any]: + self.client.delete_note(path=path) + return {"path": path, "deleted": True, "source": "api", "view": "main"} + + @staticmethod + def encode_path(path: str) -> str: + return quote(path, safe="") + + +def create_server( + settings: MCPServerSettings | None = None, + client: KBServerClient | None = None, +) -> FastMCP: + settings = settings or MCPServerSettings() + client = client or KBServerClient( + base_url=settings.kb_server_url, + api_key=settings.kb_api_key, + ) + adapter = FlightDeckMCPAdapter(client=client, settings=settings) + mcp = FastMCP("flight-deck", json_response=True) + + def _translate_errors(fn): + @wraps(fn) + def wrapper(*args, **kwargs): + try: + return fn(*args, **kwargs) + except KBServerError as exc: + raise RuntimeError(f"kb-server error ({exc.status_code}): {exc.detail}") from exc + + return wrapper + + @mcp.tool() + @_translate_errors + def find_notes(query: str, limit: int = 10, view: str | None = None) -> dict[str, Any]: + """Find notes relevant to a topic or query.""" + return adapter.find_notes(query=query, limit=limit, view=view) + + @mcp.tool() + @_translate_errors + def build_context_bundle( + query: str, + limit: int = 10, + token_budget: int | None = None, + view: str | None = None, + ) -> dict[str, Any]: + """Return a bounded, ranked context bundle for a topic or query.""" + return adapter.build_context_bundle( + query=query, + limit=limit, + token_budget=token_budget, + view=view, + ) + + @mcp.tool() + @_translate_errors + def list_notes(prefix: str = "", view: str | None = None) -> list[dict[str, Any]]: + """List notes under a prefix.""" + return adapter.list_notes(prefix=prefix, view=view) + + @mcp.tool() + @_translate_errors + def read_note(path: str, view: str | None = None) -> dict[str, Any]: + """Read a note by path.""" + return adapter.read_note(path=path, view=view) + + @mcp.tool() + @_translate_errors + def write_note(path: str, content: str) -> dict[str, Any]: + """Write a note through Flight Deck's API-origin workflow.""" + return adapter.write_note(path=path, content=content) + + @mcp.tool() + @_translate_errors + def delete_note(path: str) -> dict[str, Any]: + """Delete a note through Flight Deck's API-origin workflow.""" + return adapter.delete_note(path=path) + + @mcp.resource("flightdeck://note/{view}/{encoded_path}") + @_translate_errors + def note_resource(view: str, encoded_path: str) -> str: + """Read a note resource using a URL-encoded path.""" + return adapter.read_note_resource(encoded_path=encoded_path, view=view) + + return mcp + + +def main() -> None: + settings = MCPServerSettings() + server = create_server(settings=settings) + server.run(transport=settings.mcp_transport) diff --git a/mcp-server/pyproject.toml b/mcp-server/pyproject.toml new file mode 100644 index 0000000..7a2fce4 --- /dev/null +++ b/mcp-server/pyproject.toml @@ -0,0 +1,28 @@ +[build-system] +requires = ["setuptools>=68"] +build-backend = "setuptools.build_meta" + +[project] +name = "flight-deck-mcp-server" +version = "0.1.0" +description = "MCP adapter for Flight Deck" +requires-python = ">=3.10" +dependencies = [ + "httpx>=0.28", + "mcp>=1.2.0", + "pydantic-settings>=2.7", +] + +[project.optional-dependencies] +dev = [ + "pytest>=8.0", +] + +[project.scripts] +flight-deck-mcp = "mcp_server.server:main" + +[tool.setuptools.packages.find] +include = ["mcp_server*"] + +[tool.pytest.ini_options] +testpaths = ["tests"] diff --git a/mcp-server/tests/test_client.py b/mcp-server/tests/test_client.py new file mode 100644 index 0000000..2bc0463 --- /dev/null +++ b/mcp-server/tests/test_client.py @@ -0,0 +1,78 @@ +from __future__ import annotations + +import json + +import httpx +import pytest + +from mcp_server.client import KBServerClient, KBServerError + + +def _json_response(payload: object, status_code: int = 200) -> httpx.Response: + return httpx.Response( + status_code=status_code, + headers={"content-type": "application/json"}, + text=json.dumps(payload), + ) + + +def test_find_notes_calls_context_search(): + requests: list[httpx.Request] = [] + + def handler(request: httpx.Request) -> httpx.Response: + requests.append(request) + return _json_response({"query": "mcp", "view": "current", "results": []}) + + client = KBServerClient( + base_url="http://fd.test", + api_key="secret", + transport=httpx.MockTransport(handler), + ) + try: + payload = client.find_notes("mcp", view="current", limit=5) + finally: + client.close() + + assert payload["view"] == "current" + assert requests[0].method == "POST" + assert requests[0].url.path == "/context/search" + + +def test_write_note_forces_api_origin(): + captured: list[httpx.Request] = [] + + def handler(request: httpx.Request) -> httpx.Response: + captured.append(request) + return _json_response({"path": "notes/x.md", "content": "body"}) + + client = KBServerClient( + base_url="http://fd.test", + api_key="secret", + transport=httpx.MockTransport(handler), + ) + try: + client.write_note("notes/x.md", "body") + finally: + client.close() + + assert "source=api" in str(captured[0].url) + assert "view=main" in str(captured[0].url) + + +def test_errors_are_raised_with_status_and_detail(): + def handler(_: httpx.Request) -> httpx.Response: + return _json_response({"detail": "Note not found"}, status_code=404) + + client = KBServerClient( + base_url="http://fd.test", + api_key="secret", + transport=httpx.MockTransport(handler), + ) + try: + with pytest.raises(KBServerError) as exc_info: + client.read_note("notes/missing.md", view="current") + finally: + client.close() + + assert exc_info.value.status_code == 404 + assert exc_info.value.detail == "Note not found" diff --git a/mcp-server/tests/test_server.py b/mcp-server/tests/test_server.py new file mode 100644 index 0000000..8ba6b0f --- /dev/null +++ b/mcp-server/tests/test_server.py @@ -0,0 +1,81 @@ +from __future__ import annotations + +from mcp_server.config import MCPServerSettings +from mcp_server.server import FlightDeckMCPAdapter, create_server + + +class DummyClient: + def __init__(self): + self.calls: list[tuple[str, tuple, dict]] = [] + + def find_notes(self, query: str, *, view: str, limit: int): + self.calls.append(("find_notes", (query,), {"view": view, "limit": limit})) + return {"query": query, "view": view, "results": []} + + def build_context_bundle(self, query: str, *, view: str, limit: int, token_budget: int): + self.calls.append( + ("build_context_bundle", (query,), {"view": view, "limit": limit, "token_budget": token_budget}) + ) + return {"query": query, "view": view, "items": []} + + def list_notes(self, *, prefix: str, view: str): + self.calls.append(("list_notes", (), {"prefix": prefix, "view": view})) + return [] + + def read_note(self, path: str, *, view: str): + self.calls.append(("read_note", (path,), {"view": view})) + return {"path": path, "content": "# Note"} + + def write_note(self, path: str, content: str): + self.calls.append(("write_note", (path, content), {})) + return {"path": path, "content": content} + + def delete_note(self, path: str): + self.calls.append(("delete_note", (path,), {})) + return None + + +def _settings() -> MCPServerSettings: + return MCPServerSettings( + kb_server_url="http://fd.test", + kb_api_key="secret", + mcp_default_view="current", + mcp_default_limit=7, + mcp_default_token_budget=321, + ) + + +def test_adapter_defaults_reads_to_current(): + client = DummyClient() + adapter = FlightDeckMCPAdapter(client=client, settings=_settings()) + + payload = adapter.find_notes("mcp") + + assert payload["view"] == "current" + assert client.calls[0] == ("find_notes", ("mcp",), {"view": "current", "limit": 7}) + + +def test_adapter_forces_api_origin_on_delete(): + client = DummyClient() + adapter = FlightDeckMCPAdapter(client=client, settings=_settings()) + + payload = adapter.delete_note("notes/x.md") + + assert payload == {"path": "notes/x.md", "deleted": True, "source": "api", "view": "main"} + assert client.calls[0] == ("delete_note", ("notes/x.md",), {}) + + +def test_note_resource_decodes_path(): + client = DummyClient() + adapter = FlightDeckMCPAdapter(client=client, settings=_settings()) + + content = adapter.read_note_resource("notes%2Ftest.md", "current") + + assert content == "# Note" + assert client.calls[0] == ("read_note", ("notes/test.md",), {"view": "current"}) + + +def test_server_construction_smoke(): + server = create_server(settings=_settings(), client=DummyClient()) + + assert server is not None diff --git a/scripts/generate_context_artifacts.py b/scripts/generate_context_artifacts.py index 2003ad8..bd811dc 100755 --- a/scripts/generate_context_artifacts.py +++ b/scripts/generate_context_artifacts.py @@ -118,10 +118,12 @@ def _write_api_surface() -> None: def _write_env_catalog() -> None: kb_env_path = REPO_ROOT / "kb-server" / ".env.example" kb_config_path = REPO_ROOT / "kb-server" / "app" / "core" / "config.py" + mcp_config_path = REPO_ROOT / "mcp-server" / "mcp_server" / "config.py" vs_config_path = REPO_ROOT / "vault-sync" / "vault_sync" / "config.py" - date = _git_last_change_date([kb_env_path, kb_config_path, vs_config_path]) + date = _git_last_change_date([kb_env_path, kb_config_path, mcp_config_path, vs_config_path]) kb_env = _parse_env_example(kb_env_path) kb_defaults = _parse_settings_defaults(kb_config_path) + mcp_defaults = _parse_settings_defaults(mcp_config_path, class_name="MCPServerSettings") vs_defaults = _parse_settings_defaults(vs_config_path) content = [ @@ -132,11 +134,13 @@ def _write_env_catalog() -> None: "source_of_truth:", " - ../../kb-server/.env.example", " - ../../kb-server/app/core/config.py", + " - ../../mcp-server/mcp_server/config.py", " - ../../vault-sync/vault_sync/config.py", "related_code:", " - ../../scripts/generate_context_artifacts.py", "related_tests:", " - ../../kb-server/tests", + " - ../../mcp-server/tests", " - ../../vault-sync/tests", "review_cycle_days: 7", "---", @@ -166,6 +170,18 @@ def _write_env_catalog() -> None: for key, value in kb_defaults: content.append(f"| `{key}` | `{value}` |") + content.extend( + [ + "", + "## mcp-server Settings Defaults", + "", + "| Field | Default Expression |", + "| --- | --- |", + ] + ) + for key, value in mcp_defaults: + content.append(f"| `{key}` | `{value}` |") + content.extend( [ "",