diff --git a/mcp_servers.d/gnexus-book.json b/mcp_servers.d/gnexus-book.json new file mode 100644 index 0000000..e9197e8 --- /dev/null +++ b/mcp_servers.d/gnexus-book.json @@ -0,0 +1,29 @@ +{ + "transport": "sse", + "url": "http://192.168.1.170:8001/sse", + "groups": { + "read": [ + "search_docs", + "read_doc", + "list_docs", + "list_inventory", + "get_inventory_item", + "get_relationships", + "check_freshness" + ], + "write": [ + "propose_doc_change", + "propose_inventory_item_change", + "apply_pending_change", + "commit_changes", + "update_doc" + ], + "admin": [ + "validate_api", + "git_status", + "git_diff", + "list_pending_changes" + ] + }, + "instructions": "MANDATORY for profiles that expose gnexus-book tools: Before answering any question about infrastructure, servers, services, networks, documentation, or system inventory, call gnexus-book tools first.\n\nUse only gnexus-book tool names that are present in the current tool schema. In Navi they are exposed with the mcp:gnexus-book: prefix (example: mcp:gnexus-book:search_docs), but each profile may expose only some groups. Do not invent or call gnexus-book tools that are not in the current tool list.\n\nQuery mapping by capability:\n- Status or facts about a server/service → search docs first, then read a specific doc or inventory item if those tools are available.\n- Service placement or topology → list inventory and relationships if available.\n- Documentation changes → read the target doc first, then propose a doc or inventory change if write tools are available.\n- Freshness questions → use freshness checks if available.\n- Repository validation/status → use repository tools only if they are available in the current tool schema; otherwise skip this step and continue with available read/write tools.\n\nDo not rely on memory for infrastructure facts. Memory is only for personal user facts and preferences. Always pull infrastructure state from gnexus-book when these tools are available to the active profile.\n\nDo not store raw secrets in documentation.\n\nABSOLUTE RULE — NEVER bypass MCP tools:\nYou MUST NOT use filesystem, terminal, code_exec, or any direct file access to read or write gnexus-book files. The MCP tools are the ONLY valid interface to this knowledge base. Violating this rule bypasses validation, corrupts repository state, and breaks consistency guarantees.\n- To read: use mcp:gnexus-book:search_docs, mcp:gnexus-book:read_doc, mcp:gnexus-book:list_inventory, mcp:gnexus-book:get_inventory_item.\n- To write: use mcp:gnexus-book:propose_doc_change, mcp:gnexus-book:propose_inventory_item_change, mcp:gnexus-book:apply_pending_change, mcp:gnexus-book:commit_changes.\n- NEVER call filesystem write, filesystem smart_edit, terminal, or code_exec on gnexus-book paths.\n\nBefore the final response, decide whether tool execution revealed stable reusable infrastructure facts, service configurations, or relationships. If yes and gnexus-book write tools are available, persist them before answering. If gnexus-book write tools are not available, report the facts that should be persisted. If the fact is user-specific rather than infrastructure documentation, use the memory tool instead. Choose the target based on scope, not habit." +} diff --git a/mcp_servers.d/navi-3d.json b/mcp_servers.d/navi-3d.json new file mode 100644 index 0000000..cc757fe --- /dev/null +++ b/mcp_servers.d/navi-3d.json @@ -0,0 +1,23 @@ +{ + "transport": "stdio", + "command": "/home/gmikcon/Projects/navi-1/mcp-servers/navi-3d/.venv/bin/python", + "args": [ + "-m", + "app.mcp_server" + ], + "cwd": "/home/gmikcon/Projects/navi-1/mcp-servers/navi-3d", + "env": { + "SESSION_FILES_DIR": "/home/gmikcon/Projects/navi-1/session_files", + "NAVI_3D_MCP_TRANSPORT": "stdio" + }, + "groups": { + "modeling": [ + "compile_scad", + "render_stl" + ], + "analysis": [ + "lint_scad" + ] + }, + "instructions": "Navi 3D MCP server provides OpenSCAD-based 3D modeling tools.\n\nUse it when the task involves generating 3D models, rendering previews, or linting OpenSCAD source.\n\nWorkflow:\n1. Write the .scad script to the current session directory via filesystem.\n2. Call lint_scad first to catch common mistakes.\n3. Call compile_scad to produce the STL.\n4. Call render_stl to generate PNG previews.\n5. Use content_publish or share_file to show results to the user.\n\nAll paths are session-scoped. Pass the exact Navi session_id.\n\nABSOLUTE RULE — NEVER bypass MCP tools:\nYou MUST NOT use filesystem, terminal, code_exec, or any direct file access to read or write 3D model files that belong to the navi-3d knowledge base. Use only the MCP tools listed above." +} diff --git a/mcp_servers.d/navi-web.json b/mcp_servers.d/navi-web.json new file mode 100644 index 0000000..a457d04 --- /dev/null +++ b/mcp_servers.d/navi-web.json @@ -0,0 +1,24 @@ +{ + "transport": "stdio", + "command": "/home/gmikcon/Projects/navi-1/mcp-servers/navi-web/.venv/bin/python", + "args": [ + "-m", + "app.mcp_server" + ], + "cwd": "/home/gmikcon/Projects/navi-1/mcp-servers/navi-web", + "env": { + "NAVI_WEB_MCP_TRANSPORT": "stdio" + }, + "groups": { + "search": [ + "web_search" + ], + "browse": [ + "web_view" + ], + "request": [ + "http_request" + ] + }, + "instructions": "Navi Web MCP server provides web search, browsing, and raw HTTP tools.\n\nUse it when the task involves:\n- searching the web for current info, docs, or real-time data;\n- opening a URL in a browser to read human-readable content;\n- making REST API calls, webhooks, or raw HTTP requests.\n\nWorkflow:\n1. search — find relevant pages or facts.\n2. view — open promising URLs to read full content.\n3. request — call APIs or services requiring headers/auth.\n\nAll three tools are stateless and work with public URLs.\nNo session_id or filesystem paths are required.\n\nABSOLUTE RULE — NEVER bypass MCP tools:\nYou MUST NOT use filesystem, terminal, code_exec, or any direct file access to read or write web content. Use only the MCP tools listed above." +} diff --git a/mcp_servers.d/project_health.json b/mcp_servers.d/project_health.json new file mode 100644 index 0000000..b8065e5 --- /dev/null +++ b/mcp_servers.d/project_health.json @@ -0,0 +1,12 @@ +{ + "transport": "stdio", + "command": "/home/gmikcon/Projects/navi-1/mcp-servers/project_health/.venv/bin/python", + "args": ["-m", "app.mcp_server"], + "cwd": "/home/gmikcon/Projects/navi-1/mcp-servers/project_health", + "env": { + "MCP_TRANSPORT": "stdio" + }, + "groups": { + "default": ["get_project_summary", "find_duplicate_files", "get_project_dependencies"] + } +} \ No newline at end of file diff --git a/mcp_servers.d/time_toolkit.json b/mcp_servers.d/time_toolkit.json new file mode 100644 index 0000000..0b30ec9 --- /dev/null +++ b/mcp_servers.d/time_toolkit.json @@ -0,0 +1,17 @@ +{ + "transport": "stdio", + "command": "/home/gmikcon/Projects/navi-1/mcp-servers/time_toolkit/.venv/bin/python", + "args": ["-m", "app.mcp_server"], + "cwd": "/home/gmikcon/Projects/navi-1/mcp-servers/time_toolkit", + "env": { + "MCP_TRANSPORT": "stdio" + }, + "groups": { + "time": [ + "format_datetime", + "calculate_duration", + "add_time", + "parse_natural" + ] + } +} diff --git a/mcp_servers.json b/mcp_servers.json deleted file mode 100644 index 0093882..0000000 --- a/mcp_servers.json +++ /dev/null @@ -1,72 +0,0 @@ -{ - "gnexus-book": { - "transport": "sse", - "url": "http://192.168.1.170:8001/sse", - "groups": { - "read": [ - "search_docs", - "read_doc", - "list_docs", - "list_inventory", - "get_inventory_item", - "get_relationships", - "check_freshness" - ], - "write": [ - "propose_doc_change", - "propose_inventory_item_change", - "apply_pending_change", - "commit_changes", - "update_doc" - ], - "admin": [ - "validate_repository", - "git_status", - "git_diff", - "list_pending_changes" - ] - }, - "instructions": "MANDATORY for profiles that expose gnexus-book tools: Before answering any question about infrastructure, servers, services, networks, documentation, or system inventory, call gnexus-book tools first.\n\nUse only gnexus-book tool names that are present in the current tool schema. In Navi they are exposed with the mcp:gnexus-book: prefix (example: mcp:gnexus-book:search_docs), but each profile may expose only some groups. Do not invent or call gnexus-book tools that are not in the current tool list.\n\nQuery mapping by capability:\n- Status or facts about a server/service → search docs first, then read a specific doc or inventory item if those tools are available.\n- Service placement or topology → list inventory and relationships if available.\n- Documentation changes → read the target doc first, then propose a doc or inventory change if write tools are available.\n- Freshness questions → use freshness checks if available.\n- Repository validation/status → use repository tools only if they are available in the current tool schema; otherwise skip this step and continue with available read/write tools.\n\nDo not rely on memory for infrastructure facts. Memory is only for personal user facts and preferences. Always pull infrastructure state from gnexus-book when these tools are available to the active profile.\n\nDo not store raw secrets in documentation.\n\nABSOLUTE RULE — NEVER bypass MCP tools:\nYou MUST NOT use filesystem, terminal, code_exec, or any direct file access to read or write gnexus-book files. The MCP tools are the ONLY valid interface to this knowledge base. Violating this rule bypasses validation, corrupts repository state, and breaks consistency guarantees.\n- To read: use mcp:gnexus-book:search_docs, mcp:gnexus-book:read_doc, mcp:gnexus-book:list_inventory, mcp:gnexus-book:get_inventory_item.\n- To write: use mcp:gnexus-book:propose_doc_change, mcp:gnexus-book:propose_inventory_item_change, mcp:gnexus-book:apply_pending_change, mcp:gnexus-book:commit_changes.\n- NEVER call filesystem write, filesystem smart_edit, terminal, or code_exec on gnexus-book paths.\n\nBefore the final response, decide whether tool execution revealed stable reusable infrastructure facts, service configurations, or relationships. If yes and gnexus-book write tools are available, persist them before answering. If write tools are not available, report the facts that should be persisted. If the fact is user-specific rather than infrastructure documentation, use the memory tool instead. Choose the target based on scope, not habit." - }, - "navi-3d": { - "transport": "stdio", - "command": "/home/gmikcon/Projects/navi-1/mcp-servers/navi-3d/.venv/bin/python", - "args": ["-m", "app.mcp_server"], - "cwd": "/home/gmikcon/Projects/navi-1/mcp-servers/navi-3d", - "env": { - "SESSION_FILES_DIR": "/home/gmikcon/Projects/navi-1/session_files", - "NAVI_3D_MCP_TRANSPORT": "stdio" - }, - "groups": { - "modeling": [ - "compile_scad", - "render_stl" - ], - "analysis": [ - "lint_scad" - ] - }, - "instructions": "Navi 3D MCP server provides OpenSCAD-based 3D modeling tools.\n\nUse it when the task involves generating 3D models, rendering previews, or linting OpenSCAD source.\n\nWorkflow:\n1. Write the .scad script to the current session directory via filesystem.\n2. Call lint_scad first to catch common mistakes.\n3. Call compile_scad to produce the STL.\n4. Call render_stl to generate PNG previews.\n5. Use content_publish or share_file to show results to the user.\n\nAll paths are session-scoped. Pass the exact Navi session_id.\n\nABSOLUTE RULE — NEVER bypass MCP tools:\nYou MUST NOT use filesystem, terminal, code_exec, or any direct file access to read or write 3D model files that belong to the navi-3d knowledge base. Use only the MCP tools listed above." - }, - "navi-web": { - "transport": "stdio", - "command": "/home/gmikcon/Projects/navi-1/mcp-servers/navi-web/.venv/bin/python", - "args": ["-m", "app.mcp_server"], - "cwd": "/home/gmikcon/Projects/navi-1/mcp-servers/navi-web", - "env": { - "NAVI_WEB_MCP_TRANSPORT": "stdio" - }, - "groups": { - "search": [ - "web_search" - ], - "browse": [ - "web_view" - ], - "request": [ - "http_request" - ] - }, - "instructions": "Navi Web MCP server provides web search, browsing, and raw HTTP tools.\n\nUse it when the task involves:\n- searching the web for current info, docs, or real-time data;\n- opening a URL in a browser to read human-readable content;\n- making REST API calls, webhooks, or raw HTTP requests.\n\nWorkflow:\n1. search — find relevant pages or facts.\n2. view — open promising URLs to read full content.\n3. request — call APIs or services requiring headers/auth.\n\nAll three tools are stateless and work with public URLs.\nNo session_id or filesystem paths are required.\n\nABSOLUTE RULE — NEVER bypass MCP tools:\nYou MUST NOT use filesystem, terminal, code_exec, or any direct file access to read or write web content. Use only the MCP tools listed above." - } -} \ No newline at end of file diff --git a/navi/core/context_builder.py b/navi/core/context_builder.py index dfbd1ce..05349f9 100644 --- a/navi/core/context_builder.py +++ b/navi/core/context_builder.py @@ -245,7 +245,7 @@ """Build a system message with MCP server instructions. Combines server-provided instructions (from MCP initialize handshake) - with overlay instructions from ``mcp_servers.json``. + with overlay instructions from ``mcp_servers.d/*.json``. """ if not self._mcp_manager: return None diff --git a/navi/mcp/config.py b/navi/mcp/config.py index c9ed8a5..a5abfa8 100644 --- a/navi/mcp/config.py +++ b/navi/mcp/config.py @@ -1,11 +1,14 @@ from __future__ import annotations import json +import logging from pathlib import Path from typing import Literal from pydantic import BaseModel, Field +logger = logging.getLogger(__name__) + class McpServerConfig(BaseModel): """Configuration for a single MCP server.""" @@ -39,33 +42,127 @@ return self.transport == "sse" +def _default_dir() -> Path: + """Return the default directory for per-server MCP configs.""" + return Path("mcp_servers.d") + + +def _default_legacy_file() -> Path: + """Return the legacy monolithic config file path.""" + return Path("mcp_servers.json") + + +def _migrate_if_needed() -> None: + """Auto-migrate legacy ``mcp_servers.json`` to ``mcp_servers.d/``. + + Called transparently by :func:`load_mcp_servers` when the legacy file + exists but the directory does not. + """ + legacy = _default_legacy_file() + target_dir = _default_dir() + + if not legacy.exists() or legacy.is_dir(): + return + if target_dir.exists(): + return + + try: + raw = json.loads(legacy.read_text(encoding="utf-8")) + target_dir.mkdir(parents=True, exist_ok=True) + for name, cfg_data in raw.items(): + file_path = target_dir / f"{name}.json" + file_path.write_text( + json.dumps(cfg_data, indent=2, ensure_ascii=False) + "\n", + encoding="utf-8", + ) + # Rename the legacy file so it is no longer picked up. + legacy.rename(legacy.with_suffix(".json.bak")) + logger.info( + "MCP config migrated from %s to %s (%s servers)", + legacy, + target_dir, + len(raw), + ) + except Exception: + logger.warning("MCP config migration failed", exc_info=True) + + def load_mcp_servers(path: str | Path | None = None) -> dict[str, McpServerConfig]: - """Load MCP server configurations from a JSON file. + """Load MCP server configurations. - Default path is ``mcp_servers.json`` in the current working directory. - Returns an empty dict if the file does not exist. + If *path* is a directory (or None), read every ``*.json`` file inside it. + The filename without extension becomes the server name. + + If *path* points to the legacy monolithic ``mcp_servers.json`` file, + it is read directly (and auto-migration to ``mcp_servers.d/`` is attempted). + + Returns an empty dict if nothing is found. """ if path is None: - path = Path("mcp_servers.json") + legacy = _default_legacy_file() + target_dir = _default_dir() + + # Auto-migrate legacy file to directory if needed + if legacy.exists() and not legacy.is_dir() and not target_dir.exists(): + _migrate_if_needed() + + if target_dir.exists() and target_dir.is_dir(): + path = target_dir + elif legacy.exists() and legacy.is_file(): + path = legacy + else: + return {} else: path = Path(path) - if not path.exists(): - return {} + if path.is_dir(): + result: dict[str, McpServerConfig] = {} + for file_path in sorted(path.glob("*.json")): + try: + raw = json.loads(file_path.read_text(encoding="utf-8")) + name = file_path.stem + result[name] = McpServerConfig.model_validate(raw) + except Exception: + logger.warning("Failed to load MCP config from %s", file_path, exc_info=True) + return result - raw = json.loads(path.read_text(encoding="utf-8")) - return {name: McpServerConfig.model_validate(cfg) for name, cfg in raw.items()} + if path.is_file(): + raw = json.loads(path.read_text(encoding="utf-8")) + return {name: McpServerConfig.model_validate(cfg_data) for name, cfg_data in raw.items()} + + return {} -def save_mcp_servers(configs: dict[str, McpServerConfig], path: str | Path | None = None) -> None: - """Write MCP server configurations to a JSON file. +def save_mcp_servers( + configs: dict[str, McpServerConfig], + path: str | Path | None = None, +) -> None: + """Write MCP server configurations. - Default path is ``mcp_servers.json`` in the current working directory. + If *path* is a directory (or None), each server is written to its own + ``.json`` file inside that directory. Any ``*.json`` files for + servers that are no longer in *configs* are removed. + + If *path* points to a file, the legacy monolithic format is used. """ if path is None: - path = Path("mcp_servers.json") + path = _default_dir() else: path = Path(path) - raw = {name: cfg.model_dump() for name, cfg in configs.items()} - path.write_text(json.dumps(raw, indent=2, ensure_ascii=False) + "\n", encoding="utf-8") + if path.is_dir() or (not path.exists() and str(path).endswith(".d")): + path.mkdir(parents=True, exist_ok=True) + for name, cfg in configs.items(): + file_path = path / f"{name}.json" + file_path.write_text( + json.dumps(cfg.model_dump(), indent=2, ensure_ascii=False) + "\n", + encoding="utf-8", + ) + # Clean up stale files + current_names = set(configs) + for file_path in path.glob("*.json"): + if file_path.stem not in current_names: + file_path.unlink() + else: + raw = {name: cfg.model_dump() for name, cfg in configs.items()} + path.write_text(json.dumps(raw, indent=2, ensure_ascii=False) + "\n", encoding="utf-8") diff --git a/navi/mcp/manager.py b/navi/mcp/manager.py index 16c4158..63a4b98 100644 --- a/navi/mcp/manager.py +++ b/navi/mcp/manager.py @@ -89,7 +89,7 @@ def resolve_group(self, server_name: str, group_name: str) -> list[str]: """Return the list of tool names in a server group. - Reads from the static config (``mcp_servers.json``), not from the live + Reads from the static config (``mcp_servers.d/*.json``), not from the live server, so it works even when the server is temporarily disconnected. """ configs = load_mcp_servers(self.config_path) @@ -102,7 +102,7 @@ """Return combined instructions for every connected server. Server-provided instructions (from MCP initialize handshake) are merged - with the overlay ``instructions`` field from ``mcp_servers.json``. + with the overlay ``instructions`` field from ``mcp_servers.d/*.json``. If a selected server is disconnected, only the config overlay is returned. """ configs = load_mcp_servers(self.config_path)