Newer
Older
gnexus-book / server / app / mcp_server.py
@Eugene Sukhodolskiy Eugene Sukhodolskiy 19 hours ago 9 KB Document proactive MCP usage for agents
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


MCP_INSTRUCTIONS = """
Gnexus Book is the canonical knowledge base for the owner's digital, server,
network, project, smart-home, and local infrastructure.

This MCP server is not a generic file browser. It is the preferred operational
interface for AI agents that need infrastructure context or need to maintain the
knowledge base. Use it proactively when a user request mentions, depends on, or
may be affected by documented infrastructure.

Use Gnexus Book before answering or acting when the task involves:
- hosts, VPS, VMs, servers, routers, networks, domains, ports, traffic routes, or VPNs;
- services such as GitBucket, nginx proxies, smart-home, auth, storage, media, or project deployments;
- repository/project ownership, activity, deployment mapping, or source-of-truth questions;
- credentials/access methods, but never request or store raw secrets in documentation;
- checking whether information is stale, incomplete, already documented, or safe to change;
- updating documentation after discovering new facts.

Typical read workflow:
1. search_docs(query) for natural-language lookup.
2. read_doc(path) for canonical narrative context.
3. list_inventory(type) or get_inventory_item(type, id) for structured facts.
4. get_relationships() when dependencies, upstreams, domains, hosts, or unresolved references matter.
5. check_freshness() and validate_repository() when making maintenance decisions.

Typical write workflow:
1. Prefer propose_doc_change or propose_inventory_item_change for non-trivial changes.
2. Use apply_pending_change to validate and apply.
3. Use commit_changes with a short factual summary.
4. For simple full-document replacements, update_doc may create, apply, validate, and commit in one call.

Rules:
- Do not store raw passwords, tokens, private keys, recovery codes, session cookies, or secret config values.
- IP addresses, hostnames, usernames, domains, routes, ports, and credential references may be documented when useful.
- Treat Git history as the rollback mechanism; still keep changes focused and commit summaries clear.
- If documentation conflicts with live observations, say so and update the knowledge base when appropriate.
- If the user asks about infrastructure and you have this MCP server available, do not wait for the user to say "look in the knowledge base"; consult it yourself.
""".strip()


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=MCP_INSTRUCTIONS,
)


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()