Newer
Older
gnexus-book / server / app / mcp_server.py
@Eugene Sukhodolskiy Eugene Sukhodolskiy 1 day ago 7 KB Add Gnexus Book MCP server
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()