diff --git a/mcp_servers.json b/mcp_servers.json index ca54a8a..2d9b84e 100644 --- a/mcp_servers.json +++ b/mcp_servers.json @@ -1,6 +1,30 @@ { "gnexus-book": { "transport": "sse", - "url": "http://192.168.1.170:8001/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" + ] + } } } diff --git a/navi/api/deps.py b/navi/api/deps.py index f022719..57ca137 100644 --- a/navi/api/deps.py +++ b/navi/api/deps.py @@ -148,5 +148,8 @@ backend_registry: Annotated[BackendRegistry, Depends(get_backend_registry)], cp_registry: Annotated[ContextProviderRegistry, Depends(get_cp_registry)], ) -> Agent: - return Agent(session_store, profile_registry, tool_registry, backend_registry, - workers=get_workers(), memory_store=get_memory_store(), cp_registry=cp_registry) + return Agent( + session_store, profile_registry, tool_registry, backend_registry, + workers=get_workers(), memory_store=get_memory_store(), + cp_registry=cp_registry, mcp_manager=_mcp_manager, + ) diff --git a/navi/api/routes/agents.py b/navi/api/routes/agents.py index 10b4124..dc099f4 100644 --- a/navi/api/routes/agents.py +++ b/navi/api/routes/agents.py @@ -27,6 +27,7 @@ "name": p.name, "description": p.description, "enabled_tools": p.enabled_tools, + "mcp_servers": p.mcp_servers, "llm_backend": p.llm_backend, "model": p.model, "temperature": p.temperature, diff --git a/navi/core/agent.py b/navi/core/agent.py index dcae151..70900cc 100644 --- a/navi/core/agent.py +++ b/navi/core/agent.py @@ -198,6 +198,7 @@ workers: list["Worker"] | None = None, memory_store: "MemoryStore | None" = None, cp_registry: "ContextProviderRegistry | None" = None, + mcp_manager=None, ) -> None: self._sessions = session_store self._profiles = profile_registry @@ -206,6 +207,7 @@ self._workers: list["Worker"] = workers or [] self._memory = memory_store self._cp_registry = cp_registry + self._mcp_manager = mcp_manager self._ctx_builder = ContextBuilder( profile_registry=profile_registry, memory_store=memory_store, @@ -225,7 +227,7 @@ raise SessionNotFound(session_id) profile = self._profiles.get(session.profile_id) - tools = self._tool_list(profile.enabled_tools) + tools = self._tool_list(profile.enabled_tools, profile.mcp_servers) tool_schemas = [t.schema() for t in tools] llm = self._get_backend(profile.llm_backend) @@ -622,7 +624,7 @@ raise SessionNotFound(session_id) profile = self._profiles.get(session.profile_id) - tools = self._tool_list(profile.enabled_tools) + tools = self._tool_list(profile.enabled_tools, profile.mcp_servers) tool_schemas = [t.schema() for t in tools] llm = self._get_backend(profile.llm_backend) @@ -1020,7 +1022,7 @@ if fresh and fresh.profile_id != session.profile_id: session.profile_id = fresh.profile_id profile = self._profiles.get(session.profile_id) - tools = self._tool_list(profile.enabled_tools) + tools = self._tool_list(profile.enabled_tools, profile.mcp_servers) tool_schemas = [t.schema() for t in tools] llm = self._get_backend(profile.llm_backend) log.info( @@ -1131,12 +1133,33 @@ imgs = sum(500 for m in context if m.images) return chars // 4 + imgs - def _tool_list(self, enabled: list[str]) -> list[Tool]: + def _tool_list( + self, + enabled: list[str], + mcp_servers: dict[str, list[str]] | None = None, + ) -> list[Tool]: names = list(enabled) extra = _load_user_enabled_tools() for name in extra: if name not in names: names.append(name) + + # Expand MCP server groups into concrete tool names + if mcp_servers and self._mcp_manager: + for server_name, groups in mcp_servers.items(): + if "*" in groups: + # All registered tools for this server + prefix = f"mcp_{server_name}_" + for tool in self._tools.all(): + if tool.name.startswith(prefix) and tool.name not in names: + names.append(tool.name) + else: + for group_name in groups: + for tool_name in self._mcp_manager.resolve_group(server_name, group_name): + full_name = f"mcp_{server_name}_{tool_name}" + if full_name not in names: + names.append(full_name) + result = [] for name in names: try: diff --git a/navi/mcp/config.py b/navi/mcp/config.py index 6aec728..74d337d 100644 --- a/navi/mcp/config.py +++ b/navi/mcp/config.py @@ -22,6 +22,10 @@ url: str | None = None headers: dict[str, str] | None = None + # tool groups: name -> list of tool names exposed by this server. + # Profiles reference groups by name instead of listing individual tools. + groups: dict[str, list[str]] = Field(default_factory=dict) + @property def is_stdio(self) -> bool: return self.transport == "stdio" diff --git a/navi/mcp/manager.py b/navi/mcp/manager.py index 3c2ff4c..f5b967c 100644 --- a/navi/mcp/manager.py +++ b/navi/mcp/manager.py @@ -82,6 +82,18 @@ logger.warning("MCP server %r list_tools failed: %s", name, exc) return out + 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 + server, so it works even when the server is temporarily disconnected. + """ + configs = load_mcp_servers(self.config_path) + cfg = configs.get(server_name) + if cfg is None: + return [] + return list(cfg.groups.get(group_name, [])) + async def call_tool(self, server_name: str, tool_name: str, arguments: dict[str, Any] | None = None) -> str: """Proxy a tool call to the named server.""" client = self._clients.get(server_name) diff --git a/navi/profiles/base.py b/navi/profiles/base.py index c6132b9..83a3037 100644 --- a/navi/profiles/base.py +++ b/navi/profiles/base.py @@ -102,6 +102,10 @@ # Global providers (global_provider=True) are always injected regardless of this list. context_providers: list[str] = Field(default_factory=list) + # MCP servers referenced by this profile. + # Format: {"server_name": ["group1", "group2"]} or {"server_name": ["*"]} for all tools. + mcp_servers: dict[str, list[str]] = Field(default_factory=dict) + @field_validator("model", mode="before") @classmethod def _coerce_model(cls, v): diff --git a/navi/profiles/discuss/config.json b/navi/profiles/discuss/config.json index d077bcd..e5838ce 100644 --- a/navi/profiles/discuss/config.json +++ b/navi/profiles/discuss/config.json @@ -24,14 +24,11 @@ "tool_manual", "switch_profile", "share_file", - "filesystem", - "mcp_gnexus-book_search_docs", - "mcp_gnexus-book_read_doc", - "mcp_gnexus-book_list_docs", - "mcp_gnexus-book_list_inventory", - "mcp_gnexus-book_get_inventory_item", - "mcp_gnexus-book_get_relationships" + "filesystem" ], + "mcp_servers": { + "gnexus-book": ["read"] + }, "subagent_tools": [], "planning_enabled": false, "planning_mandatory": false, diff --git a/navi/profiles/loader.py b/navi/profiles/loader.py index 1b33149..0cc9a01 100644 --- a/navi/profiles/loader.py +++ b/navi/profiles/loader.py @@ -96,6 +96,7 @@ subagent_think_enabled=config.get("subagent_think_enabled", None), subagent_system_prompt=subagent_system_prompt, context_providers=config.get("context_providers", []), + mcp_servers=config.get("mcp_servers", {}), )) log.debug("profile.loader.loaded", profile_id=config["id"]) @@ -146,6 +147,7 @@ "subagent_think_enabled": profile.subagent_think_enabled, "enabled_tools": profile.enabled_tools, "context_providers": profile.context_providers, + "mcp_servers": profile.mcp_servers, } config_file = profile_dir / "config.json" diff --git a/navi/profiles/server_admin/config.json b/navi/profiles/server_admin/config.json index f549bed..43f36c2 100644 --- a/navi/profiles/server_admin/config.json +++ b/navi/profiles/server_admin/config.json @@ -60,24 +60,11 @@ "spawn_agent", "share_file", "content_publish", - "gmail", - "mcp_gnexus-book_search_docs", - "mcp_gnexus-book_read_doc", - "mcp_gnexus-book_list_docs", - "mcp_gnexus-book_list_inventory", - "mcp_gnexus-book_get_inventory_item", - "mcp_gnexus-book_get_relationships", - "mcp_gnexus-book_validate_repository", - "mcp_gnexus-book_check_freshness", - "mcp_gnexus-book_git_status", - "mcp_gnexus-book_git_diff", - "mcp_gnexus-book_list_pending_changes", - "mcp_gnexus-book_propose_doc_change", - "mcp_gnexus-book_propose_inventory_item_change", - "mcp_gnexus-book_apply_pending_change", - "mcp_gnexus-book_commit_changes", - "mcp_gnexus-book_update_doc" + "gmail" ], + "mcp_servers": { + "gnexus-book": ["read", "write", "admin"] + }, "planning_mandatory": false, "planning_phase1_enabled": true, "planning_phase2_enabled": true,