diff --git a/server/README.md b/server/README.md index e1a26a6..8b7aae2 100644 --- a/server/README.md +++ b/server/README.md @@ -1,6 +1,6 @@ # Gnexus Book Server -FastAPI backend for reading Gnexus Book documentation and inventory. +FastAPI backend and MCP server for reading and maintaining Gnexus Book documentation and inventory. ## Development @@ -14,6 +14,49 @@ uvicorn app.main:app --reload --host 127.0.0.1 --port 8080 ``` +## MCP Server + +The MCP server exposes the same repository, inventory, validation, pending-change, and Git commit logic through MCP tools and resources. + +Run over stdio: + +```bash +cd server +.venv/bin/python -m app.mcp_server +``` + +Optional environment: + +- `GNEXUS_BOOK_REPO_ROOT=/path/to/gnexus-book` +- `GNEXUS_BOOK_MCP_TRANSPORT=stdio|sse|streamable-http` + +Core tools: + +- `search_docs(query)` +- `read_doc(path)` +- `list_docs()` +- `list_inventory(inventory_type?)` +- `get_inventory_item(inventory_type, item_id)` +- `get_relationships()` +- `validate_repository()` +- `check_freshness()` +- `git_status()` +- `git_diff(files?)` +- `propose_doc_change(path, content, summary, reason?)` +- `propose_inventory_item_change(inventory_type, item_id, patch, summary, reason?, mode?)` +- `apply_pending_change(change_id)` +- `commit_changes(summary, files, details?)` +- `update_doc(path, content, summary, reason?)` + +Useful resources: + +- `gnexus-book://docs` +- `gnexus-book://inventory` +- `gnexus-book://relationships` +- `gnexus-book://freshness` +- `gnexus-book://validation` +- `gnexus-book://git/status` + ## Current API - Swagger UI: `GET /api-docs` @@ -40,7 +83,6 @@ ## Current Limitations -- Main documentation and inventory files are read-only. - `POST /changes` can create pending change records under `90-maintenance/pending-changes/`. - `POST /changes/{id}/apply` can apply `kind=doc` and `kind=inventory-item` changes after validation. - No authentication yet. diff --git a/server/app/main.py b/server/app/main.py index 9bce044..711e535 100644 --- a/server/app/main.py +++ b/server/app/main.py @@ -19,7 +19,7 @@ app = FastAPI( title="Gnexus Book Server", version="0.1.0", - description="Read-only documentation and inventory API for Gnexus Book.", + description="Documentation, inventory, validation, and maintenance API for Gnexus Book.", docs_url="/api-docs", swagger_ui_oauth2_redirect_url="/api-docs/oauth2-redirect", redoc_url="/api-redoc", diff --git a/server/app/mcp_server.py b/server/app/mcp_server.py new file mode 100644 index 0000000..1f34898 --- /dev/null +++ b/server/app/mcp_server.py @@ -0,0 +1,229 @@ +from __future__ import annotations + +import json +import os +from pathlib import Path +from typing import Any, Literal + +from mcp.server.fastmcp import FastMCP + +from .config import Settings +from .docs_repository import DocsRepository +from .freshness import build_freshness_report +from .git_adapter import CommitRequest, GitAdapter +from .inventory import InventoryRepository +from .pending_changes import PendingChangeRepository, ProposedChangeRequest +from .relationships import build_relationships +from .validation import validate_repository + + +def _settings() -> Settings: + repo_root = os.environ.get("GNEXUS_BOOK_REPO_ROOT") + return Settings() if not repo_root else Settings(repo_root=Path(repo_root)) + + +def _docs() -> DocsRepository: + return DocsRepository(_settings()) + + +def _inventory() -> InventoryRepository: + return InventoryRepository(_settings()) + + +def _pending() -> PendingChangeRepository: + return PendingChangeRepository(_settings()) + + +def _git() -> GitAdapter: + return GitAdapter(_settings()) + + +def _json(data: Any) -> str: + return json.dumps(data, ensure_ascii=False, indent=2) + + +def search_docs(query: str) -> list[dict[str, object]]: + """Search Markdown documents by plain text query.""" + return _docs().search(query) + + +def read_doc(path: str) -> dict[str, object]: + """Read a Markdown document by repository-relative path.""" + return _docs().read_doc(path) + + +def list_docs() -> list[dict[str, object]]: + """List Markdown documents with titles and frontmatter.""" + return _docs().list_docs() + + +def list_inventory(inventory_type: str | None = None) -> object: + """List inventory types or read one parsed inventory type.""" + repo = _inventory() + if inventory_type: + return repo.read_parsed(inventory_type) + return repo.list_types() + + +def get_inventory_item(inventory_type: str, item_id: str) -> dict[str, Any]: + """Read a single inventory item by type and id.""" + return _inventory().read_item(inventory_type, item_id) + + +def get_relationships() -> dict[str, object]: + """Build current inventory relationship graph.""" + return build_relationships(_inventory()) + + +def validate_repository_tool() -> dict[str, object]: + """Validate schemas, docs, inventory links, ids, and secret patterns.""" + return validate_repository(_settings()) + + +def check_freshness() -> dict[str, object]: + """Return documentation freshness report.""" + return build_freshness_report(_settings()) + + +def git_status() -> dict[str, Any]: + """Return local Git status for the documentation repository.""" + return _git().status() + + +def git_diff(files: list[str] | None = None) -> dict[str, str]: + """Return local Git diff, optionally limited to repository-relative files.""" + return _git().diff(files) + + +def list_pending_changes() -> list[dict[str, Any]]: + """List pending/applied/rejected change records.""" + return _pending().list() + + +def propose_doc_change(path: str, content: str, summary: str, reason: str = "") -> dict[str, Any]: + """Create a pending Markdown document change.""" + request = ProposedChangeRequest( + kind="doc", + target=path, + summary=summary, + reason=reason, + payload={"content": content}, + ) + return _pending().create(request) + + +def propose_inventory_item_change( + inventory_type: str, + item_id: str, + patch: dict[str, Any], + summary: str, + reason: str = "", + mode: Literal["update", "create"] = "update", +) -> dict[str, Any]: + """Create a pending inventory item patch or creation request.""" + request = ProposedChangeRequest( + kind="inventory-item", + target=f"{inventory_type}/{item_id}", + summary=summary, + reason=reason, + payload={"mode": mode, "patch": patch}, + ) + return _pending().create(request) + + +def apply_pending_change(change_id: str) -> dict[str, Any]: + """Apply one pending change after repository validation.""" + return _pending().apply(change_id) + + +def commit_changes(summary: str, files: list[str], details: str = "") -> dict[str, Any]: + """Validate repository and create a local Git commit for selected files.""" + return _git().commit(CommitRequest(summary=summary, details=details, files=files)) + + +def update_doc(path: str, content: str, summary: str, reason: str = "") -> dict[str, Any]: + """Create, apply, validate, and commit a Markdown document change.""" + change = propose_doc_change(path=path, content=content, summary=summary, reason=reason) + applied = apply_pending_change(str(change["id"])) + pending_path = f"90-maintenance/pending-changes/{change['id']}.json" + commit = commit_changes(summary=summary, files=[path, pending_path], details=reason) + return {"change": applied, "commit": commit} + + +mcp = FastMCP( + "gnexus-book", + instructions=( + "Gnexus Book MCP exposes infrastructure documentation, inventory, " + "validation, pending changes, and local Git commit tools. " + "Do not store raw secrets in documentation." + ), +) + + +mcp.tool(name="search_docs")(search_docs) +mcp.tool(name="read_doc")(read_doc) +mcp.tool(name="list_docs")(list_docs) +mcp.tool(name="list_inventory")(list_inventory) +mcp.tool(name="get_inventory_item")(get_inventory_item) +mcp.tool(name="get_relationships")(get_relationships) +mcp.tool(name="validate_repository")(validate_repository_tool) +mcp.tool(name="check_freshness")(check_freshness) +mcp.tool(name="git_status")(git_status) +mcp.tool(name="git_diff")(git_diff) +mcp.tool(name="list_pending_changes")(list_pending_changes) +mcp.tool(name="propose_doc_change")(propose_doc_change) +mcp.tool(name="propose_inventory_item_change")(propose_inventory_item_change) +mcp.tool(name="apply_pending_change")(apply_pending_change) +mcp.tool(name="commit_changes")(commit_changes) +mcp.tool(name="update_doc")(update_doc) + + +@mcp.resource("gnexus-book://docs", mime_type="application/json") +def docs_resource() -> str: + return _json(list_docs()) + + +@mcp.resource("gnexus-book://docs/{path}", mime_type="application/json") +def doc_resource(path: str) -> str: + return _json(read_doc(path)) + + +@mcp.resource("gnexus-book://inventory", mime_type="application/json") +def inventory_resource() -> str: + return _json(list_inventory()) + + +@mcp.resource("gnexus-book://inventory/{inventory_type}", mime_type="application/json") +def inventory_type_resource(inventory_type: str) -> str: + return _json(list_inventory(inventory_type)) + + +@mcp.resource("gnexus-book://relationships", mime_type="application/json") +def relationships_resource() -> str: + return _json(get_relationships()) + + +@mcp.resource("gnexus-book://freshness", mime_type="application/json") +def freshness_resource() -> str: + return _json(check_freshness()) + + +@mcp.resource("gnexus-book://validation", mime_type="application/json") +def validation_resource() -> str: + return _json(validate_repository_tool()) + + +@mcp.resource("gnexus-book://git/status", mime_type="application/json") +def git_status_resource() -> str: + return _json(git_status()) + + +def main() -> None: + transport = os.environ.get("GNEXUS_BOOK_MCP_TRANSPORT", "stdio") + if transport not in {"stdio", "sse", "streamable-http"}: + raise SystemExit("GNEXUS_BOOK_MCP_TRANSPORT must be stdio, sse, or streamable-http") + mcp.run(transport=transport) # type: ignore[arg-type] + + +if __name__ == "__main__": + main() diff --git a/server/pyproject.toml b/server/pyproject.toml index 01ec7f9..a47d1b4 100644 --- a/server/pyproject.toml +++ b/server/pyproject.toml @@ -1,11 +1,12 @@ [project] name = "gnexus-book-server" version = "0.1.0" -description = "Read-only API and validation foundation for Gnexus Book documentation." +description = "API, MCP server, and validation foundation for Gnexus Book documentation." requires-python = ">=3.11" dependencies = [ "fastapi>=0.115.0", "jsonschema>=4.23.0", + "mcp>=1.27.0", "uvicorn[standard]>=0.30.0", "pyyaml>=6.0.0", ] diff --git a/server/tests/test_mcp_server.py b/server/tests/test_mcp_server.py new file mode 100644 index 0000000..6a31bdf --- /dev/null +++ b/server/tests/test_mcp_server.py @@ -0,0 +1,43 @@ +from __future__ import annotations + +import asyncio + +from app.mcp_server import ( + get_inventory_item, + list_inventory, + mcp, + read_doc, + search_docs, + validate_repository_tool, +) + + +def test_mcp_lists_core_tools() -> None: + tools = asyncio.run(mcp.list_tools()) + names = {tool.name for tool in tools} + + assert "search_docs" in names + assert "read_doc" in names + assert "list_inventory" in names + assert "update_doc" in names + assert "commit_changes" in names + + +def test_mcp_tool_functions_read_repository() -> None: + doc = read_doc("10-systems/applications/gitbucket.md") + assert doc["path"] == "10-systems/applications/gitbucket.md" + assert "GitBucket" in doc["body"] + + results = search_docs("GitBucket") + assert any(result["path"] == "10-systems/applications/gitbucket.md" for result in results) + + inventory_types = list_inventory() + assert "services" in inventory_types + + service = get_inventory_item("services", "gitbucket") + assert service["name"] == "GitBucket" + + +def test_mcp_validation_tool_is_clean() -> None: + report = validate_repository_tool() + assert report["status"] == "ok"