diff --git a/manuals/write_mcp_server.md b/manuals/write_mcp_server.md new file mode 100644 index 0000000..a0e8ec0 --- /dev/null +++ b/manuals/write_mcp_server.md @@ -0,0 +1,224 @@ +# Writing MCP Servers for Navi + +This manual describes how to create, test, register, and maintain MCP servers that extend Navi's capabilities. Read this **before** you start building a new server. + +## 1. Philosophy: Why MCP instead of user tools? + +MCP servers run in isolated processes and communicate via the Model Context Protocol. They **cannot** crash Navi's core, they can be reloaded without restarting the server, and they scale to complex external integrations (APIs, databases, browsers, etc.). The trade-off is slightly more boilerplate than a single `tools/foo.py` file. + +**Rule:** Every new capability that is not trivial (more than a simple datetime or notes lookup) should be built as an MCP server. + +## 2. Directory structure + +MCP servers live under: + +``` +mcp-servers// +├── pyproject.toml +├── README.md +└── app/ + ├── __init__.py + └── mcp_server.py +``` + +- `` — snake_case or kebab-case. Must match the key you will use in `mcp_servers.d/.json`. +- `pyproject.toml` — Python package metadata and dependencies. +- `app/mcp_server.py` — the actual server code (FastMCP). + +## 3. Creating a new server from the template + +Use the built-in tool `create_mcp_server` (preferred) or copy the template manually: + +```bash +cp -r mcp-servers/_template mcp-servers/my_server +cd mcp-servers/my_server +# Edit pyproject.toml: change name, description, add dependencies +# Edit app/mcp_server.py: add your tools and instructions +``` + +### 3.1 pyproject.toml + +Minimal required fields: + +```toml +[build-system] +requires = ["setuptools>=61.0"] +build-backend = "setuptools.build_meta" + +[project] +name = "mcp-server-myserver" +version = "0.1.0" +description = "What this server does" +requires-python = ">=3.11" +dependencies = [ + "mcp>=1.27", + "pydantic>=2.0", + # add your own: httpx, asyncpg, playwright, etc. +] + +[project.scripts] +mcp-server-myserver = "app.mcp_server:main" + +[tool.setuptools.packages.find] +where = ["."] +include = ["app*"] +``` + +### 3.2 app/mcp_server.py + +Read the template at `mcp-servers/_template/app/mcp_server.py` first. It contains a working hello-world server with extensive inline comments. + +Key sections you must edit: + +1. **`INSTRUCTIONS`** — These are injected into Navi's system prompt. Describe: + - What this server does and when to use it. + - Recommended workflow (order of tool calls). + - ABSOLUTE RULE about never bypassing these tools with filesystem/terminal. + +2. **`mcp = FastMCP("name", instructions=INSTRUCTIONS)`** — The name should match the directory key. + +3. **Tool functions** — Each tool: + - Is an `async def`. + - Uses `@mcp.tool(name="tool_name")`. + - Parameters use `Annotated[T, Field(description="...")]` — never plain types. + - Returns a plain `str` (JSON string for structured data is fine). + - Raises on real errors. + - Validates required params explicitly. + +Example: + +```python +@mcp.tool(name="search_docs") +async def search_docs_tool( + query: Annotated[str, Field(description="Search query string.")], + limit: Annotated[int, Field(description="Max results.")] = 10, +) -> str: + """Search the documentation index.""" + if not query.strip(): + raise ValueError("query is required and cannot be empty.") + results = await _do_search(query, limit) + return json.dumps(results, ensure_ascii=False, indent=2) +``` + +## 4. Environment and installation + +After creating files, you **must**: + +1. Create a virtual environment: + ```bash + python -m venv .venv + source .venv/bin/activate + pip install -e . + ``` + +2. Verify the server starts without crashing: + ```bash + timeout 5 python -m app.mcp_server || true + ``` + If it prints a traceback, fix the code before proceeding. + +## 5. Registering the server in Navi + +Create a file `mcp_servers.d/.json` in the project root. The filename (without `.json`) becomes the server name. Example for a server named `my_server`: + +```json +{ + "transport": "stdio", + "command": "/absolute/path/to/mcp-servers/my_server/.venv/bin/python", + "args": ["-m", "app.mcp_server"], + "cwd": "/absolute/path/to/mcp-servers/my_server", + "env": { + "MCP_TRANSPORT": "stdio" + }, + "groups": { + "default": ["search_docs", "read_doc"] + }, + "instructions": "Optional extra instructions merged with the server's own INSTRUCTIONS." +} +``` + +**Critical fields:** +- `command` — absolute path to the venv's Python binary. +- `cwd` — absolute path to the server directory. +- The filename must be `.json` (e.g. `my_server.json`). +- `args` — usually `["-m", "app.mcp_server"]`. +- `groups` — organize tools into named groups so profiles can reference them cleanly. + +After editing `mcp_servers.d/.json`, call `reload_tools` to connect the server and register its tools. + +## 6. Testing + +### 6.1 Check connection + +Call `mcp_status`. You should see your server as `connected` with the correct tool count. + +### 6.2 Test each tool + +Call `test_mcp_tool` for every tool your server exposes: + +``` +test_mcp_tool(server_name="my_server", tool_name="search_docs", arguments={"query": "hello", "limit": 3}) +``` + +If any tool fails, read the error output, fix the code in `app/mcp_server.py`, and repeat. + +### 6.3 Manual stderr inspection + +If `mcp_status` shows `disconnected` but the code looks correct, inspect stderr manually: + +```bash +cd mcp-servers/my_server +.venv/bin/python -m app.mcp_server 2>&1 | head -n 20 +``` + +## 7. Updating an MCP server + +1. Edit the code in `mcp-servers//app/mcp_server.py`. +2. (Optional) If you added new dependencies, edit `pyproject.toml` and run `pip install -e .` inside the venv. +3. Call `reload_tools` to reconnect the server and re-register tools. +4. Call `test_mcp_tool` to verify. + +## 8. Deleting an MCP server + +1. Remove the server directory or move it to a backup location. +2. Remove the entry from `mcp_servers.d/.json`. +3. Call `reload_tools`. + +## 9. Connecting an external MCP server + +If the server was written by someone else: + +1. Clone or place the server code on disk. +2. Create its venv and install dependencies. +3. Read its README to learn tool names and required environment variables. +4. Add an entry to `mcp_servers.d/.json` with the correct `command`, `cwd`, `args`, and `env`. +5. Define `groups` mapping the tools into logical sets. +6. Call `reload_tools`. +7. Call `test_mcp_tool` for a representative tool. + +## 10. Common mistakes and debugging + +| Symptom | Cause | Fix | +|---|---|---| +| `mcp_status` shows `disconnected` | Wrong `command` or `cwd` path | Double-check absolute paths | +| Traceback on startup | Syntax error or missing import | Run `python -m py_compile app/mcp_server.py` | +| `test_mcp_tool` returns `is_error=True` | Tool raised an exception | Fix the tool logic; check parameter validation | +| Tool schema missing descriptions | Used plain types instead of `Annotated[..., Field(...)]` | Add `Field(description=...)` to every parameter | +| Navi never calls the server | Profile does not map the server in `mcp_servers` | Edit the profile's `config.json` and add the server groups | +| Navi bypasses MCP with filesystem | `INSTRUCTIONS` missing ABSOLUTE RULE | Add explicit rule in server INSTRUCTIONS | + +## 11. Workflow checklist for Navi + +When asked to create a new MCP server: + +1. Read this manual (`manuals/write_mcp_server.md`). +2. Read the template (`mcp-servers/_template/app/mcp_server.py`). +3. Call `create_mcp_server(name=..., description=...)` to scaffold the directory. +4. Edit `app/mcp_server.py` iteratively using `filesystem`. +5. Validate syntax: `code_exec` or `terminal` with `python -m py_compile ...`. +6. Test startup: `terminal` with `timeout 5 python -m app.mcp_server`. +7. Edit `mcp_servers.d/.json` via `filesystem` to register the server. +8. Call `reload_tools`. +9. Call `test_mcp_tool` for every tool. +10. Write good `INSTRUCTIONS` inside `mcp_server.py`. +11. Report results to the user. diff --git a/mcp-servers/_template/README.md b/mcp-servers/_template/README.md new file mode 100644 index 0000000..b6dfa3f --- /dev/null +++ b/mcp-servers/_template/README.md @@ -0,0 +1,2 @@ +# MCP Server Template +## Замени это описание на описание своего сервера diff --git a/mcp-servers/_template/app/__init__.py b/mcp-servers/_template/app/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/mcp-servers/_template/app/__init__.py diff --git a/mcp-servers/_template/app/mcp_server.py b/mcp-servers/_template/app/mcp_server.py new file mode 100644 index 0000000..0afdd02 --- /dev/null +++ b/mcp-servers/_template/app/mcp_server.py @@ -0,0 +1,104 @@ +"""MCP server template — heavily commented reference for Navi. + +Copy this file into your new server at: + mcp-servers//app/mcp_server.py + +Then edit it, adding your own tools and instructions. +""" + +from __future__ import annotations + +import json +import os +from typing import Annotated, Any + +from mcp.server.fastmcp import FastMCP +from pydantic import Field + +# ── 1. INSTRUCTIONS ────────────────────────────────────────────────────── +# These instructions are sent to Navi during the MCP handshake. +# They become part of Navi's system prompt when this server is enabled +# in a profile. Write them carefully — they tell Navi WHEN and HOW to +# use your tools. Include workflow, rules, and an ABSOLUTE RULE about +# never bypassing MCP with filesystem/terminal. +# ──────────────────────────────────────────────────────────────────────── +INSTRUCTIONS = """ +Replace this with instructions for Navi. + +Example: + MyServer MCP server provides X and Y tools. + + Use it when the task involves: + - doing something that only this server can do; + - ... + + Workflow: + 1. tool_a — do step one. + 2. tool_b — do step two. + + ABSOLUTE RULE — NEVER bypass MCP tools: + You MUST NOT use filesystem, terminal, code_exec, or any direct + file access for operations covered by this server. +""".strip() + + +# ── 2. FastMCP INSTANCE ───────────────────────────────────────────────── +# Create the server. The name should match the directory name under +# mcp-servers/ and the entry in mcp_servers.json. +mcp = FastMCP("template", instructions=INSTRUCTIONS) + + +# ── 3. HELPER ───────────────────────────────────────────────────────── +# A small helper to return JSON strings from tools. MCP tools return +# plain text — JSON is a good convention for structured data. +def _json(data: Any) -> str: + return json.dumps(data, ensure_ascii=False, indent=2) + + +# ── 4. TOOL DEFINITIONS ──────────────────────────────────────────────── +# EVERY @mcp.tool MUST be defined HERE, BEFORE the main() block below. +# mcp.run() in main() enters an infinite stdio loop — tools defined AFTER +# that line are NEVER registered and will be invisible to Navi. +# +# Rules: +# - async def only +# - return str (plain text or JSON) +# - raise Exception on real errors (FastMCP will catch and report) +# - use .get() for optional params; validate required params explicitly +# - Parameters MUST use Annotated[..., Field(description=...)] +# ──────────────────────────────────────────────────────────────────────── + +@mcp.tool(name="hello") +async def hello_tool( + name: Annotated[str, Field(description="Name to greet.")], +) -> str: + """Say hello to someone.""" + return f"Hello, {name}!" + + +@mcp.tool(name="add") +async def add_tool( + a: Annotated[int, Field(description="First number.")], + b: Annotated[int, Field(description="Second number.")], +) -> str: + """Add two numbers.""" + result = a + b + return _json({"a": a, "b": b, "result": result}) + + +# ── 5. MAIN / TRANSPORT ──────────────────────────────────────────────── +# ⚠ CRITICAL: Do NOT define any @mcp.tool functions after this line. +# mcp.run() blocks forever — tools placed below are NEVER registered. +# +# The server supports stdio (default), sse, and streamable-http. +# Navi always connects via stdio — so keep transport="stdio" as default. +# The TRANSPORT env var lets you test other transports manually. +def main() -> None: + transport = os.environ.get("MCP_TRANSPORT", "stdio") + if transport not in {"stdio", "sse", "streamable-http"}: + raise SystemExit("MCP_TRANSPORT must be stdio, sse, or streamable-http") + mcp.run(transport=transport) # type: ignore[arg-type] + + +if __name__ == "__main__": + main() diff --git a/mcp-servers/_template/pyproject.toml b/mcp-servers/_template/pyproject.toml new file mode 100644 index 0000000..56b9cdc --- /dev/null +++ b/mcp-servers/_template/pyproject.toml @@ -0,0 +1,20 @@ +[build-system] +requires = ["setuptools>=61.0"] +build-backend = "setuptools.build_meta" + +[project] +name = "mcp-server-template" +version = "0.1.0" +description = "MCP server template — replace with your server name and description" +requires-python = ">=3.11" +dependencies = [ + "mcp>=1.27", + "pydantic>=2.0", +] + +[project.scripts] +mcp-server-template = "app.mcp_server:main" + +[tool.setuptools.packages.find] +where = ["."] +include = ["app*"] diff --git a/mcp-servers/project_health/README.md b/mcp-servers/project_health/README.md new file mode 100644 index 0000000..f9c6237 --- /dev/null +++ b/mcp-servers/project_health/README.md @@ -0,0 +1,24 @@ +# MCP Server: project_health + +Analyzes codebase health, including statistics, markers (TODO/FIXME), secrets detection, duplicate files, and dependency analysis. + +## Tools + +TODO: list tools and their purposes. + +## Setup + +```bash +python -m venv .venv +source .venv/bin/activate +pip install -e . +``` + +## Navi registration + +Create a file `mcp_servers.d/project_health.json` with the server config: +- transport: stdio +- command: absolute path to `.venv/bin/python` +- args: `["-m", "app.mcp_server"]` +- cwd: absolute path to this directory +- groups: map tool names to logical groups diff --git a/mcp-servers/project_health/app/__init__.py b/mcp-servers/project_health/app/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/mcp-servers/project_health/app/__init__.py diff --git a/mcp-servers/project_health/app/mcp_server.py b/mcp-servers/project_health/app/mcp_server.py new file mode 100644 index 0000000..fb64e07 --- /dev/null +++ b/mcp-servers/project_health/app/mcp_server.py @@ -0,0 +1,233 @@ +"""MCP server for project_health — Analyzes project structure, finds duplicates, and detects dependencies.""" + +from __future__ import annotations + +import hashlib +import json +import os +import re +from pathlib import Path +from typing import Annotated, Any + +from mcp.server.fastmcp import FastMCP +from pydantic import Field + +INSTRUCTIONS = """ +project_health provides tools to analyze the health and structure of a codebase. + +Use it when the task involves: +- summarizing project statistics (files, lines, languages). +- finding TODO/FIXME markers or potential secrets in the codebase. +- identifying duplicate files based on content. +- detecting project dependencies from configuration files. + +Workflow: +1. get_project_summary — check the overall state and potential issues. +2. find_duplicate_files — clean up redundant files. +3. get_project_dependencies — understand the project's external requirements. + +ABSOLUTE RULE — NEVER bypass MCP tools: +You MUST NOT use filesystem, terminal, code_exec, or any direct file access for operations covered by this server. +""".strip() + +mcp = FastMCP("project_health", instructions=INSTRUCTIONS) + + +def _json(data: Any) -> str: + return json.dumps(data, ensure_ascii=False, indent=2) + + +# ── TOOL DEFINITIONS ──────────────────────────────────────── +# ALL @mcp.tool decorators MUST be placed here, BEFORE main(). + +@mcp.tool(name="get_project_summary") +async def get_project_summary( + path: Annotated[str, Field(description="Absolute path to the project root.")], +) -> str: + """Summarize project stats, markers (TODO/FIXME), and potential secrets.""" + root = Path(path) + if not root.is_dir(): + return _json({"error": f"Path {path} is not a directory."}) + + stats = {"total_files": 0, "total_lines": 0, "languages": {}} + markers = [] + secrets_found = [] + + # Patterns for secrets + secret_patterns = { + "API Key": re.compile(r"(?i)(api[_-]?key|token|secret|password|auth)[\\s:=]+['\"][a-zA-Z0-9]{16,}[\'\"]"), + "Generic Secret": re.compile(r"(?i)password\s*=\s*['\"][^'\"]+['\"]"), + } + + exclude_dirs = {".git", "node_modules", "__pycache__", ".venv", "venv", ".pytest_cache", "dist", "build"} + + for dirpath, dirnames, filenames in os.walk(root): + # Prune excluded directories + dirnames[:] = [d for d in dirnames if d not in exclude_dirs] + + for filename in filenames: + file_path = Path(dirpath) / filename + try: + # Skip binary files or very large files for summary + if file_path.stat().st_size > 1_000_000: # 1MB limit for scanning + continue + + stats["total_files"] += 1 + + # Determine language by extension + ext = file_path.suffix.lower() + if ext in ['.py']: lang = 'Python' + elif ext in ['.js', '.ts']: lang = 'JavaScript/TypeScript' + elif ext in ['.md']: lang = 'Markdown' + elif ext in ['.json']: lang = 'JSON' + elif ext in ['.toml']: lang = 'TOML' + elif ext in ['.c', '.cpp', '.h']: lang = 'C/C++' + else: lang = 'Other' + + stats["languages"][lang] = stats["languages"].get(lang, 0) + 1 + + # Read content for markers and secrets + with open(file_path, 'r', encoding='utf-8', errors='ignore') as f: + lines = f.readlines() + stats["total_lines"] += len(lines) + + for i, line in enumerate(lines, 1): + # Check for TODO/FIXME + if "TODO" in line or "FIXME" in line: + markers.append({ + "file": str(file_path.relative_to(root)), + "line": i, + "content": line.strip() + }) + + # Check for secrets + for name, pattern in secret_patterns.items(): + if pattern.search(line): + secrets_found.append({ + "file": str(file_path.relative_to(root)), + "line": i, + "type": name + }) + except Exception: + continue + + return _json({ + "file_stats": stats, + "markers": markers, + "secrets_found": secrets_found + }) + + +@mcp.tool(name="find_duplicate_files") +async def find_duplicate_files( + path: Annotated[str, Field(description="Absolute path to the project root.")], +) -> str: + """Find files with identical content using SHA256 hashing.""" + root = Path(path) + if not root.is_dir(): + return _json({"error": f"Path {path} is not a directory."}) + + hashes = {} # hash -> [list of paths] + exclude_dirs = {".git", "node_modules", "__pycache__", ".venv", "venv"} + + for dirpath, dirnames, filenames in os.walk(root): + dirnames[:] = [d for d in dirnames if d not in exclude_dirs] + + for filename in filenames: + file_path = Path(dirpath) / filename + try: + # Only hash files up to 5MB to avoid performance issues + if file_path.stat().st_size > 5_000_000: + continue + + hasher = hashlib.sha256() + with open(file_path, 'rb') as f: + while chunk := f.read(8192): + hasher.update(chunk) + + file_hash = hasher.hexdigest() + rel_path = str(file_path.relative_to(root)) + + if file_hash in hashes: + hashes[file_hash].append(rel_path) + else: + hashes[file_hash] = [rel_path] + except Exception: + continue + + # Filter only groups that have more than one file + duplicates = [paths for paths in hashes.values() if len(paths) > 1] + return _json({"duplicate_groups": duplicates}) + + +@mcp.tool(name="get_project_dependencies") +async def get_project_dependencies( + path: Annotated[str, Field(description="Absolute path to the project root.")], +) -> str: + """Identify dependencies by parsing common configuration files.""" + root = Path(path) + if not root.is_dir(): + return _json({"error": f"Path {path} is not a directory."}) + + dependencies = { + "python": [], + "javascript": [], + "other": [] + } + + # Check pyproject.toml + pyproject = root / "pyproject.toml" + if pyproject.exists(): + try: + content = pyproject.read_text(encoding='utf-8') + # Simple regex to find dependencies in pyproject.toml + deps = re.findall(r'dependencies\s*=\s*\[(.*?)\]', content, re.DOTALL) + if deps: + # Clean up the matches + dep_list = [d.strip().strip('"').strip("'") for d in re.split(r',', deps[0])] + dependencies["python"].extend([d for d in dep_list if d]) + except Exception: + pass + + # Check requirements.txt + req_txt = root / "requirements.txt" + if req_txt.exists(): + try: + content = req_txt.read_text(encoding='utf-8') + deps = [line.strip() for line in content.splitlines() if line.strip() and not line.startswith("#")] + dependencies["python"].extend(deps) + except Exception: + pass + + # Check package.json + package_json = root / "package.json" + if package_json.exists(): + try: + data = json.loads(package_json.read_text(encoding='utf-8')) # Note: error handling needed + # This is a simplified parser + deps = data.get("dependencies", {}) + dev_deps = data.get("devDependencies", {}) + dependencies["javascript"].extend(list(deps.keys()) + list(dev_deps.keys())) + except Exception: + # Fallback to simple regex if JSON is messy or encoding fails + try: + content = package_json.read_text(encoding='utf-8') + deps = re.findall(r'"([^"]+)":\s*"[^"]*"', content) + dependencies["javascript"].extend(deps) + except Exception: + pass + + return _json(dependencies) + + + + +# ── MAIN / TRANSPORT ────────────────────────────────────────────────── + +def main() -> None: + transport = os.environ.get("MCP_TRANSPORT", "stdio") + mcp.run(transport=transport) + + +if __name__ == "__main__": + main() diff --git a/mcp-servers/project_health/pyproject.toml b/mcp-servers/project_health/pyproject.toml new file mode 100644 index 0000000..5d0c2d9 --- /dev/null +++ b/mcp-servers/project_health/pyproject.toml @@ -0,0 +1,21 @@ +[build-system] +requires = ["setuptools>=61.0"] +build-backend = "setuptools.build_meta" + +[project] +name = "mcp-server-project_health" +version = "0.1.0" +description = "Analyzes codebase health, including statistics, markers (TODO/FIXME), secrets detection, duplicate files, and dependency analysis." +requires-python = ">=3.11" +dependencies = [ + "mcp>=1.27", + "pydantic>=2.0", + +] + +[project.scripts] +mcp-server-project_health = "app.mcp_server:main" + +[tool.setuptools.packages.find] +where = ["."] +include = ["app*"] diff --git a/mcp-servers/time_toolkit/README.md b/mcp-servers/time_toolkit/README.md new file mode 100644 index 0000000..0b751f1 --- /dev/null +++ b/mcp-servers/time_toolkit/README.md @@ -0,0 +1,24 @@ +# MCP Server: time_toolkit + +Utilities for formatting, calculating differences, time arithmetic, and parsing natural language dates. + +## Tools + +TODO: list tools and their purposes. + +## Setup + +```bash +python -m venv .venv +source .venv/bin/activate +pip install -e . +``` + +## Navi registration + +Create a file `mcp_servers.d/time_toolkit.json` with the server config: +- transport: stdio +- command: absolute path to `.venv/bin/python` +- args: `["-m", "app.mcp_server"]` +- cwd: absolute path to this directory +- groups: map tool names to logical groups diff --git a/mcp-servers/time_toolkit/app/__init__.py b/mcp-servers/time_toolkit/app/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/mcp-servers/time_toolkit/app/__init__.py diff --git a/mcp-servers/time_toolkit/app/mcp_server.py b/mcp-servers/time_toolkit/app/mcp_server.py new file mode 100644 index 0000000..0f130d5 --- /dev/null +++ b/mcp-servers/time_toolkit/app/mcp_server.py @@ -0,0 +1,270 @@ +"""MCP server for time_toolkit — A toolkit for advanced datetime manipulation and natural language parsing.""" + +from __future__ import annotations + +import json +import os +import re +from datetime import datetime, timedelta, timezone +from enum import Enum +from typing import Annotated, Any +from zoneinfo import ZoneInfo + +from mcp.server.fastmcp import FastMCP +from pydantic import Field + +INSTRUCTIONS = """ +time_toolkit provides tools for advanced datetime manipulation and natural language parsing. + +Use it when the task involves: +- Formatting ISO strings into various human-readable or standard formats. +- Calculating the duration between two timestamps in different units. +- Adding or subtracting time intervals from a specific datetime. +- Parsing natural language time expressions (e.g., "tomorrow", "in 2 hours") into ISO timestamps. + +Workflow: +1. parse_natural — convert natural language to an ISO string. +2. format_datetime — format the resulting ISO string for display. +3. add_time — perform arithmetic on the datetime. +4. calculate_duration — find the difference between two points in time. + +ABSOLUTE RULE — NEVER bypass MCP tools: +You MUST NOT use filesystem, terminal, code_exec, or any direct file access for operations covered by this server. +""".strip() + +mcp = FastMCP("time_toolkit", instructions=INSTRUCTIONS) + + +def _json(data: Any) -> str: + return json.dumps(data, ensure_ascii=False, indent=2) + + +class OutputFormat(str, Enum): + HUMAN = "human" + ISO = "iso" + RFC2822 = "rfc2822" + SHORT = "short" + + +class DurationUnit(str, Enum): + SECONDS = "seconds" + MINUTES = "minutes" + HOURS = "hours" + DAYS = "days" + WEEKS = "weeks" + MONTHS = "months" + YEARS = "years" + + +# ── TOOL DEFINITIONS ── + +@mcp.tool(name="format_datetime") +async def format_datetime( + iso_string: Annotated[str, Field(description="Input ISO 8601 string.")], + output_format: Annotated[OutputFormat, Field(description="The desired output format: 'human', 'iso', 'rfc2822', or 'short'.")], + target_timezone: Annotated[str, Field(description="IANA timezone name (e.g., 'UTC', 'Europe/Moscow'). Default is 'UTC'.")] = "UTC", +) -> str: + """Formats an ISO 8601 string into a specified format and timezone.""" + try: + dt_str = iso_string.replace("Z", "+00:00") + dt = datetime.fromisoformat(dt_str) + + if dt.tzinfo is None: + dt = dt.replace(tzinfo=timezone.utc) + + tz = ZoneInfo(target_timezone) + dt_localized = dt.astimezone(tz) + + if output_format == OutputFormat.ISO: + formatted = dt_localized.isoformat() + elif output_format == OutputFormat.RFC2822: + formatted = dt_localized.strftime("%a, %d %b %Y %H:%M:%S %z") + elif output_format == OutputFormat.SHORT: + formatted = dt_localized.strftime("%d.%m.%Y %H:%M") + else: # human + formatted = dt_localized.strftime("%d %B %Y, %H:%M") + + return _json({ + "formatted": formatted, + "timezone": target_timezone, + "original_iso": iso_string + }) + except Exception as e: + return _json({"error": str(e)}) + + +@mcp.tool(name="calculate_duration") +async def calculate_duration( + start_iso: Annotated[str, Field(description="Start ISO 8601 string.")], + end_iso: Annotated[str, Field(description="End ISO 8601 string.")], + unit: Annotated[DurationUnit, Field(description="The unit for the duration value.")] +) -> str: + """Calculates the duration between two ISO 8601 timestamps in the specified unit.""" + try: + start_dt = datetime.fromisoformat(start_iso.replace("Z", "+00:00")) + end_dt = datetime.fromisoformat(end_iso.replace("Z", "+00:00")) + + diff = end_dt - start_dt + seconds = diff.total_seconds() + + if unit == DurationUnit.SECONDS: + value = seconds + elif unit == DurationUnit.MINUTES: + value = seconds / 60 + elif unit == DurationUnit.HOURS: + value = seconds / 3600 + elif unit == DurationUnit.DAYS: + value = seconds / 86400 + elif unit == DurationUnit.WEEKS: + value = seconds / (86400 * 7) + elif unit == DurationUnit.MONTHS: + value = seconds / (86400 * 30) + elif unit == DurationUnit.YEARS: + value = seconds / (86400 * 365) + else: + raise ValueError(f"Unsupported unit: {unit}") + + return _json({ + "value": round(float(value), 4), + "unit": unit.value, + "start_iso": start_iso, + "end_iso": end_iso + }) + except Exception as e: + return _json({"error": str(e)}) + + +@mcp.tool(name="add_time") +async def add_time( + iso_string: Annotated[str, Field(description="Base ISO 8601 string.")], + value: Annotated[int, Field(description="The amount of time to add (can be negative).")], + unit: Annotated[DurationUnit, Field(description="The unit of the value to add.")] +) -> str: + """Adds or subtracts a specified amount of time from an ISO 8601 timestamp.""" + try: + dt = datetime.fromisoformat(iso_string.replace("Z", "+00:00")) + + if unit == DurationUnit.SECONDS: + delta = timedelta(seconds=value) + elif unit == Tuple[DurationUnit, str] if False else DurationUnit.MINUTES: + delta = timedelta(minutes=value) + elif unit == DurationUnit.MINUTES: + delta = timedelta(minutes=value) + elif unit == DurationUnit.HOURS: + delta = timedelta(hours=value) + elif unit == DurationUnit.DAYS: + delta = timedelta(days=value) + elif unit == DurationUnit.WEEKS: + delta = timedelta(weeks=value) + elif unit == DurationUnit.MONTHS: + delta = timedelta(days=value * 30) + elif unit == DurationUnit.YEARS: + delta = timedelta(days=value * 365) + else: + raise ValueError(f"Unsupported unit: {unit}") + + result_dt = dt + delta + op_str = f"{'+' if value >= 0 else ''}{value} {unit.value}" + + return _json({ + "result_iso": result_dt.isoformat(), + "original_iso": iso_string, + "operation": op_str + }) + except Exception as e: + return _json({"error": str(e)}) + + +@mcp.tool(name="parse_natural") +async def parse_natural( + text: Annotated[str, Field(description="Natural language time expression (e.g., 'tomorrow', 'in 2 hours').")], +) -> str: + """Parses natural language time expressions into ISO 8601 timestamps.""" + try: + now = datetime.now(timezone.utc) + text = text.lower().strip() + + result_dt = None + is_relative = False + + # Absolute simple cases + if text == "now": + result_dt = now + elif text == "today": + result_dt = now.replace(hour=0, minute=0, second=0, microsecond=0) + elif text == "tomorrow": + result_dt = (now + timedelta(days=1)).replace(hour=0, minute=0, second=0, microsecond=0) + elif text == "yesterday": + result_dt = (now - timedelta(days=1)).replace(hour=0, minute=0, second=0, microsecond=0) + + # Relative "in X units" + elif re.match(r"^in (\d+) (second|minute|hour|day|week|month|year)s?$", text): + match = re.match(r"^in (\d+) (second|minute|hour|day|week|month|year)s?$", text) + val = int(match.group(1)) + unit = match.group(2) + is_relative = True + if unit == "second": delta = timedelta(seconds=val) + elif unit == "minute": delta = timedelta(minutes=val) + elif unit == "hour": delta = timedelta(hours=val) + elif unit == "day": delta = timedelta(days=val) + elif unit == "week": delta = timedelta(weeks=val) + elif unit == "month": delta = timedelta(days=val * 30) + elif unit == "year": delta = timedelta(days=val * 365) + result_dt = now + delta + + # Relative "X units ago" + elif reint := re.match(r"^(\d+) (second|minute|hour|day|week|month|year)s? ago$", text): + match = reint + val = int(match.group(1)) + unit = match.group(2) + is_relative = True + if unit == "second": delta = timedelta(seconds=val) + elif unit == "minute": delta = timedelta(minutes=val) + elif unit == "hour": delta = timedelta(hours=val) + elif unit == "day": delta = timedelta(days=val) + elif unit == "week": delta = timedelta(weeks=val) + elif unit == "month": delta = timedelta(days=val * 30) + elif unit == "year": delta = timedelta(days=val * 365) + result_dt = now - delta + + # "next Monday", "last Friday" + elif "next" in text or "last" in text: + days_map = {"monday": 0, "tuesday": 1, "wednesday": 2, "thursday": 3, "friday": 4, "saturday": 5, "sunday": 6} + parts = text.split() + day_name = parts[-1] + if day_name in days_map: + is_relative = True + target_day_idx = days_map[day_name] + current_day_idx = now.weekday() + + if "next" in text: + diff = (target_day_idx - current_day_idx) % 7 + if diff == 0: diff = 7 + result_dt = (now + timedelta(days=diff)).replace(hour=0, minute=0, second=0, microsecond=0) + elif "last" in text: + diff = (current_day_idx - target_day_idx) % 7 + if diff == 0: diff = 7 + result_dt = (now - timedelta(days=diff)).replace(hour=0, minute=0, second=0, microsecond=0) + + if result_dt: + return _json({ + "iso": resultint_dt.isoformat() if (resultint_dt := result_dt) else "", + "parsed_from": text, + "type": "relative" if is_relative else "absolute" + }) + else: + return _json({"error": "could not parse"}) + + except Exception as e: + return _json({"error": str(e)}) + + +# ── MAIN / TRANSPORT ── + +def main() -> None: + transport = os.environ.get("MCP_TRANSPORT", "stdio") + mcp.run(transport=transport) + + +if __name__ == "__main__": + main diff --git a/mcp-servers/time_toolkit/pyproject.toml b/mcp-servers/time_toolkit/pyproject.toml new file mode 100644 index 0000000..859a890 --- /dev/null +++ b/mcp-servers/time_toolkit/pyproject.toml @@ -0,0 +1,21 @@ +[build-system] +requires = ["setuptools>=61.0"] +build-backend = "setuptools.build_meta" + +[project] +name = "mcp-server-time_toolkit" +version = "0.1.0" +description = "Utilities for formatting, calculating differences, time arithmetic, and parsing natural language dates." +requires-python = ">=3.11" +dependencies = [ + "mcp>=1.27", + "pydantic>=2.0", + +] + +[project.scripts] +mcp-server-time_toolkit = "app.mcp_server:main" + +[tool.setuptools.packages.find] +where = ["."] +include = ["app*"] 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 cc2093b..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/ubuntu/navi-1/mcp-servers/navi-3d/.venv/bin/python", - "args": ["-m", "app.mcp_server"], - "cwd": "/home/ubuntu/navi-1/mcp-servers/navi-3d", - "env": { - "SESSION_FILES_DIR": "/home/ubuntu/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/ubuntu/navi-1/mcp-servers/navi-web/.venv/bin/python", - "args": ["-m", "app.mcp_server"], - "cwd": "/home/ubuntu/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/api/routes/admin.py b/navi/api/routes/admin.py index 63001eb..55a63e7 100644 --- a/navi/api/routes/admin.py +++ b/navi/api/routes/admin.py @@ -404,7 +404,7 @@ async def admin_get_mcp_config( user: Annotated[User, Depends(require_admin)], ) -> dict: - """Return the current mcp_servers.json configuration.""" + """Return the current MCP server configurations from mcp_servers.d/.""" from navi.mcp.config import load_mcp_servers configs = load_mcp_servers() @@ -416,7 +416,7 @@ body: dict, user: Annotated[User, Depends(require_admin)], ) -> dict: - """Replace mcp_servers.json with the provided configuration.""" + """Replace all MCP server configurations (bulk update).""" from navi.mcp.config import McpServerConfig, save_mcp_servers validated: dict[str, McpServerConfig] = {} @@ -434,6 +434,62 @@ return {"ok": True} +@router.get("/mcp/config/{server_name}") +async def admin_get_single_mcp_config( + server_name: str, + user: Annotated[User, Depends(require_admin)], +) -> dict: + """Return the configuration for a single MCP server.""" + from navi.mcp.config import load_mcp_servers + + configs = load_mcp_servers() + cfg = configs.get(server_name) + if cfg is None: + raise HTTPException(status_code=404, detail=f"MCP server '{server_name}' not found") + return cfg.model_dump() + + +@router.put("/mcp/config/{server_name}") +async def admin_update_single_mcp_config( + server_name: str, + body: dict, + user: Annotated[User, Depends(require_admin)], +) -> dict: + """Create or update a single MCP server configuration.""" + from navi.mcp.config import McpServerConfig, load_mcp_servers, save_mcp_servers + + try: + validated = McpServerConfig.model_validate(body) + except Exception as exc: + raise HTTPException( + status_code=400, + detail=f"Invalid config for server '{server_name}': {exc}", + ) from exc + + configs = load_mcp_servers() + configs[server_name] = validated + save_mcp_servers(configs) + log.info("admin.mcp_config_updated_single", server=server_name, admin_id=user.id) + return {"ok": True} + + +@router.delete("/mcp/config/{server_name}") +async def admin_delete_single_mcp_config( + server_name: str, + user: Annotated[User, Depends(require_admin)], +) -> dict: + """Remove a single MCP server configuration.""" + from navi.mcp.config import load_mcp_servers, save_mcp_servers + + configs = load_mcp_servers() + if server_name not in configs: + raise HTTPException(status_code=404, detail=f"MCP server '{server_name}' not found") + del configs[server_name] + save_mcp_servers(configs) + log.info("admin.mcp_config_deleted_single", server=server_name, admin_id=user.id) + return {"ok": True} + + @router.post("/mcp/{server_name}/reconnect") async def admin_reconnect_mcp_server( server_name: str, 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/core/registry.py b/navi/core/registry.py index a27ee4c..6a60183 100644 --- a/navi/core/registry.py +++ b/navi/core/registry.py @@ -8,6 +8,7 @@ from navi.profiles.base import AgentProfile from navi.tools import ( CodeExecTool, + CreateMcpServerTool, DeleteToolTool, FilesystemTool, ImageViewTool, @@ -19,6 +20,7 @@ ScratchpadTool, SwitchProfileTool, TerminalTool, + TestMcpToolTool, TestToolTool, TodoTool, Tool, @@ -190,11 +192,14 @@ manual_tool = ToolManualTool(registry=tools) memory_tool = MemoryTool(memory_store) if memory_store else None mcp_status_tool = McpStatusTool() + create_mcp_server_tool = CreateMcpServerTool() + test_mcp_tool_tool = TestMcpToolTool() builtins = [FilesystemTool(ai_helper=ai_helper), CodeExecTool(), TerminalTool(), SshExecTool(), ImageViewTool(), ShareFileTool(), ContentPublishTool(), TestToolTool(), TodoTool(), ScratchpadTool(), ReflectTool(ai_helper=ai_helper), - reload_tool, write_tool, delete_tool, list_tool, manual_tool, mcp_status_tool] + reload_tool, write_tool, delete_tool, list_tool, manual_tool, + mcp_status_tool, create_mcp_server_tool, test_mcp_tool_tool] if memory_tool: builtins.append(memory_tool) for builtin in builtins: diff --git a/navi/main.py b/navi/main.py index 918520a..8aaa71e 100644 --- a/navi/main.py +++ b/navi/main.py @@ -86,7 +86,7 @@ mcp_manager = await get_mcp_manager() tool_registry = get_tool_registry() await register_mcp_tools(tool_registry, mcp_manager) - for tool_name in ("reload_tools", "mcp_status", "spawn_agent", "list_tools"): + for tool_name in ("reload_tools", "mcp_status", "test_mcp_tool", "spawn_agent", "list_tools"): tool = tool_registry.get(tool_name) if hasattr(tool, "_mcp_manager"): tool._mcp_manager = mcp_manager diff --git a/navi/mcp/client.py b/navi/mcp/client.py index 0a94267..94f5e3c 100644 --- a/navi/mcp/client.py +++ b/navi/mcp/client.py @@ -93,7 +93,9 @@ if not self._connected: return try: - await self._cleanup() + await asyncio.wait_for(self._cleanup(), timeout=5.0) + except asyncio.TimeoutError: + logger.warning("MCP server %r disconnect timed out, forcing cleanup", self.name) except (asyncio.CancelledError, RuntimeError): # Graceful shutdown during app teardown — SSE transport teardown # throws CancelledError / RuntimeError from anyio task scopes. 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) diff --git a/navi/profiles/tool_developer/config.json b/navi/profiles/tool_developer/config.json index ec20881..1216e63 100644 --- a/navi/profiles/tool_developer/config.json +++ b/navi/profiles/tool_developer/config.json @@ -1,12 +1,12 @@ { "id": "tool_developer", "name": "Tool Developer", - "description": "Write, test, and debug custom tools to extend Navi's capabilities.", - "short_description": "Writing, testing, and debugging Navi's own Python tools.", + "description": "Write, test, and debug MCP servers to extend Navi's capabilities.", + "short_description": "Building MCP servers that add new tools to Navi via isolated processes.", "full_description": { - "specialization": "Writing new Python tools that extend Navi's capabilities, debugging and fixing existing tools, hot-reloading the tool registry, and testing tool behavior. Full access to tools directory, test runner, and reload mechanism.", - "when_to_use": "When the user asks to create a new Navi tool, modify an existing tool, fix a broken tool, or test tool functionality. Not for general software development — use developer for that.", - "key_tools": "write_tool, reload_tools, delete_tool, test_tool, filesystem, terminal, code_exec, memory" + "specialization": "Creating new MCP servers using the Model Context Protocol. Scaffolds server directories, writes tool code, registers servers in mcp_servers.d/.json, tests tools, and writes server instructions. Does NOT write old-style user tools (tools/*.py).", + "when_to_use": "When the user asks to create a new tool, capability, or integration for Navi. Any non-trivial extension should be an MCP server.", + "key_tools": "create_mcp_server, test_mcp_tool, mcp_status, reload_tools, filesystem, code_exec, terminal, tool_manual, spawn_agent" }, "llm_backend": "ollama", "model": [ @@ -35,12 +35,8 @@ "code_exec", "terminal", "image_view", - "write_tool", - "reload_tools", - "delete_tool", "list_tools", "tool_manual", - "test_tool", "share_file", "content_publish" ], @@ -56,14 +52,15 @@ "image_view", "memory", "reload_tools", - "delete_tool", "list_tools", "tool_manual", - "test_tool", "spawn_agent", "share_file", "content_publish", - "gmail" + "gmail", + "create_mcp_server", + "test_mcp_tool", + "mcp_status" ], "planning_mandatory": false, "planning_phase1_enabled": true, @@ -71,12 +68,5 @@ "planning_phase3_enabled": true, "top_k": 40, "top_p": 0.85, - "num_thread": 11, - "mcp_servers": { - "navi-web": [ - "search", - "view", - "request" - ] - } -} \ No newline at end of file + "num_thread": 11 +} diff --git a/navi/profiles/tool_developer/subagent_system_prompt.txt b/navi/profiles/tool_developer/subagent_system_prompt.txt index b11063e..8666883 100644 --- a/navi/profiles/tool_developer/subagent_system_prompt.txt +++ b/navi/profiles/tool_developer/subagent_system_prompt.txt @@ -1,16 +1,132 @@ -You are a focused tool development sub-agent. The main agent receives only your final output — it cannot see your tool calls or intermediate thinking. +You are a focused development sub-agent for the MCP Server Developer profile. The main agent receives only your final output — it cannot see your tool calls or intermediate thinking. -Rules: -- Complete ALL assigned work: write the file, run test_tool, fix until it passes. Never stop before the test passes. -- Use `write_tool` to create new tool files — it validates format and registers the tool. Use `filesystem` only for editing/fixing. -- Never skip test_tool. A tool that is not tested is not done. -- If test_tool fails, read the error, fix the file, run test_tool again. Repeat until passing. -- Return concise evidence: file path, tool contract, key implementation notes, and test_tool result. Include raw output only for failures or exact values the main agent must cite. -- Do not ask for clarification. Make reasonable implementation choices and proceed. -- Do not address the user. Your output goes to the main agent. +Your job is to implement MCP server code, run validation, and return concise evidence to the main agent. The main agent will then register, connect, and test the server. + +--- + +## ABSOLUTE RULES + +1. **NEVER call `reload_tools`, `test_mcp_tool`, or `mcp_status`.** These are the main agent's job. Your job ends when the code compiles and smoke-test passes. Report back — do NOT try to connect or test the MCP server yourself. + +2. **NEVER use `write_tool`, `delete_tool`, or `test_tool`.** These are deprecated and unavailable. + +3. **NEVER run the server without `timeout 5`.** MCP servers run forever. The ONLY valid smoke-test command is: + ```bash + cd mcp-servers/ && timeout 5 .venv/bin/python -m app.mcp_server + ``` + This command exits automatically after 5 seconds. Running without `timeout` will hang forever. + +4. **For `filesystem write`, always pass `path` (not `destination`).** Example: + ```json + {"action": "write", "path": "mcp-servers/my_server/app/mcp_server.py", "content": "..."} + ``` + +--- + +## Canonical MCP server format + +Every `mcp_server.py` MUST follow this exact structure. Do NOT deviate. + +```python +"""MCP server for — .""" + +from __future__ import annotations + +import json +import os +from typing import Annotated, Any + +from mcp.server.fastmcp import FastMCP +from pydantic import Field + +INSTRUCTIONS = """ + provides X and Y tools. + +Use it when the task involves: +- doing something only this server handles; +- ... + +Workflow: +1. tool_a — step one. +2. tool_b — step two. + +ABSOLUTE RULE — NEVER bypass MCP tools: +You MUST NOT use filesystem, terminal, code_exec, or any direct file access for operations covered by this server. +""".strip() + +mcp = FastMCP("", instructions=INSTRUCTIONS) + + +def _json(data: Any) -> str: + return json.dumps(data, ensure_ascii=False, indent=2) + + +# ── TOOL DEFINITIONS ────────────────────────────────────────────────── +# ALL @mcp.tool decorators MUST be placed here, BEFORE main(). +# After mcp.run() in main(), the server blocks forever — tools defined +# after that line are NEVER registered. + +@mcp.tool(name="example_tool") +async def example_tool( + param: Annotated[str, Field(description="Description of param.")], +) -> str: + """One-line docstring.""" + return _json({"param": param, "result": "ok"}) + + +# ── MAIN / TRANSPORT ────────────────────────────────────────────────── +# Do NOT define any tools below this line. + +def main() -> None: + transport = os.environ.get("MCP_TRANSPORT", "stdio") + mcp.run(transport=transport) + + +if __name__ == "__main__": + main() +``` + +### Format rules +- Every parameter MUST use `Annotated[..., Field(description=...)]`. +- Every tool MUST be `async def` and return `str` (plain text or JSON via `_json`). +- `INSTRUCTIONS` MUST include: what the server does, when to use it, workflow, and an ABSOLUTE RULE. +- The `FastMCP` name MUST match the directory name under `mcp-servers/`. +- ALL tools go between the `TOOL DEFINITIONS` and `MAIN / TRANSPORT` markers. + +--- + +## Workflow + +1. **Write code**: Use `filesystem` to write `mcp-servers//app/mcp_server.py`. +2. **Code review**: Use `filesystem` action `query` on `mcp-servers//app/mcp_server.py` with question: "Check these 4 critical patterns: 1) `main()` is called with parentheses at the very end, 2) all `@mcp.tool` decorators appear before `main()`, 3) every parameter uses `Annotated[..., Field(description=...)]`, 4) `INSTRUCTIONS` is not empty." +3. **Validate syntax**: `python -m py_compile mcp-servers//app/mcp_server.py` +4. **Smoke test**: + ```bash + cd mcp-servers/ && timeout 5 .venv/bin/python -m app.mcp_server; echo "EXIT_CODE=$?" + ``` + - **CRITICAL: read EXIT_CODE.** + - `EXIT_CODE=124` → `timeout` killed the server. **This is SUCCESS.** The server ran until killed. + - `EXIT_CODE=0` → server exited ON ITS OWN before 5 seconds. **This is FAILURE.** Check that `main()` is called with parentheses at the bottom of the file. + - `EXIT_CODE=1` (or any other) → traceback or crash. Read the error and fix. + - Repeat until you get exit code 124. +5. **Report back**: Return the Summary block below. Do NOT call `reload_tools` or `test_mcp_tool`. + +--- + +## Summary format End your response with: + ## Summary - File written: -- Test result: passed / failed (with error if failed) -- What the tool does (one sentence) +- Syntax check: passed / failed +- Smoke test (timeout 5): passed / failed (with error if failed) +- Tools implemented: +- Key implementation notes (one sentence per tool) + +--- + +## Other rules +- Complete ALL assigned work before reporting. Never stop before validation passes. +- Do not ask for clarification. Make reasonable choices and proceed. +- Do not address the user. Your output goes to the main agent only. diff --git a/navi/profiles/tool_developer/system_prompt.txt b/navi/profiles/tool_developer/system_prompt.txt index 96210ca..d250c1b 100644 --- a/navi/profiles/tool_developer/system_prompt.txt +++ b/navi/profiles/tool_developer/system_prompt.txt @@ -1,124 +1,191 @@ -Mode: tool developer — write, test, and register new user tools. +Mode: MCP Server Developer — create, test, and register MCP servers that extend Navi's capabilities. ## Role -You are a Builder and Orchestrator. You understand the task, read the relevant existing code yourself, and decide what to implement inline vs. what to delegate. You always verify and test the final result — that part never gets delegated. +You are a Builder. You write MCP servers — isolated Python processes that expose tools via the Model Context Protocol. You scaffold directories, implement tools, register servers in `mcp_servers.d/.json`, test them, and write instructions that help Navi use them correctly. + +**You do NOT write old-style user tools (`tools/*.py`).** Those tools are deprecated. Every new capability must be an MCP server. + +--- + +## Prerequisites — read BEFORE building + +Every time you are asked to create a new MCP server: +1. Call `tool_manual("write_mcp_server")` to read the full manual. +2. Read `mcp-servers/_template/app/mcp_server.py` to see the annotated template. +3. Then proceed to implementation. + +--- + +## Workflow + +### Step 1 — Scaffold +Call `create_mcp_server(name=..., description=...)` to create the directory, venv, and dependencies. + +### Step 2 — Implement +Edit `mcp-servers//app/mcp_server.py` via `filesystem`: +- Write a clear `INSTRUCTIONS` string — it becomes part of Navi's system prompt. +- Add `@mcp.tool(name=...)` functions. +- Use `Annotated[..., Field(description=...)]` for every parameter. +- Return plain `str` from every tool. +- Raise on errors. + +### Step 3 — Code review (if implementing inline) +Use `filesystem` action `query` on `mcp-servers//app/mcp_server.py` with question: "Check 4 critical patterns: 1) `main()` called with parentheses at the end, 2) all `@mcp.tool` before `main()`, 3) every parameter uses `Annotated[..., Field()]`, 4) `INSTRUCTIONS` is not empty." + +### Step 4 — Validate syntax +Use `code_exec` or `terminal` to run: +```bash +python -m py_compile mcp-servers//app/mcp_server.py +``` + +### Step 5 — Test startup +Use `terminal` ONLY for a quick smoke test with `timeout`: +```bash +cd mcp-servers/ +timeout 5 .venv/bin/python -m app.mcp_server; echo "EXIT_CODE=$?" +``` +- **EXIT_CODE=124** → `timeout` killed the server. **SUCCESS.** +- **EXIT_CODE=0** → server exited ON ITS OWN. **FAILURE.** Check that `main()` is called with parentheses. +- **Other** → traceback. Read and fix. + +**NEVER run without `timeout`** — MCP servers block forever. + +Repeat until you get exit code 124. + +### Step 6 — Register in Navi +Create `mcp_servers.d/.json` (project root) via `filesystem`. The file must contain: +- `transport`: `stdio` +- `command`: absolute path to `.venv/bin/python` +- `args`: `["-m", "app.mcp_server"]` +- `cwd`: absolute path to `mcp-servers//` +- `env`: `{"MCP_TRANSPORT": "stdio"}` +- `groups`: map each tool name to a logical group + +The filename determines the server name — use `.json` (e.g. `my_server.json`). + +### Step 7 — Connect +Call `reload_tools` (this is a built-in tool you can invoke). This reconnects all MCP servers and registers their tools. You do NOT need to run the server manually in the terminal. + +**ABSOLUTE RULE — `reload_tools` is mandatory before any `test_mcp_tool` call:** +If you just created or registered a server (Steps 1 or 6), you MUST call `reload_tools` BEFORE calling `test_mcp_tool`. `auto_register` does NOT automatically connect the server. Calling `test_mcp_tool` before `reload_tools` will always fail with "not connected" and wastes an iteration. + +### Step 8 — Test every tool +Call `test_mcp_tool(server_name=..., tool_name=..., arguments=...)` for every tool. Iterate until all pass. + +If `test_mcp_tool` returns "MCP server '' is not connected", do this in order: +1. Check `mcp_status` to see if the server is listed as disconnected. +2. Inspect `mcp_servers.d/.json` to verify `command` and `cwd` are correct absolute paths. +3. Call `reload_tools` again. +4. Call `test_mcp_tool` again. +5. If still failing, fix the code in `mcp_server.py` and repeat from Step 3 (code review + syntax + smoke-test). + +### Step 9 — Verify with `mcp_status` +Use `mcp_status` ONLY to confirm the server appears as connected with the correct tool count. Do NOT use it for testing individual tools. + +### Step 10 — Report +Tell the user what was created, which tools are available, and how to use them. + +--- + +## Writing `INSTRUCTIONS` for an MCP server + +The `INSTRUCTIONS` string inside `mcp_server.py` is injected into Navi's system prompt. Write it carefully: + +1. **What the server does** — clear one-sentence summary. +2. **When to use it** — specific scenarios. +3. **Workflow** — recommended order of tool calls. +4. **ABSOLUTE RULE** — explicitly state that the user MUST NOT bypass these tools with `filesystem`, `terminal`, `code_exec`, or direct file access for operations covered by this server. + +Example: +``` +MyServer provides X and Y tools. + +Use it when the task involves: +- doing something only this server handles; +- ... + +Workflow: +1. tool_a — step one. +2. tool_b — step two. + +ABSOLUTE RULE — NEVER bypass MCP tools: +You MUST NOT use filesystem, terminal, code_exec, or any direct file access for operations covered by this server. Use only the MCP tools listed above. +``` --- ## Orchestration model ### Implement inline when -- Quick fix or small edit to an existing tool (1–3 file edits). -- Simple new tool with no external API (datetime, calculator, string util, etc.). +- The server has 1–3 simple tools (no external APIs). +- Quick edit to an existing MCP server. ### Spawn a sub-agent for implementation when -- New tool requires external API, significant logic, or multiple files. -- The write+debug loop would likely take 10+ tool calls — delegate the full implementation to a sub-agent with a precise spec, then you verify the result. -- Use `tool_developer` profile for implementation sub-agents — they get `write_tool`, `test_tool`, and know the tool format. +- The server has many tools, complex logic, or external API integration. +- The implementation would likely take 10+ tool calls. -### Spawn a sub-agent for research when -- Exploring an external API or an unfamiliar codebase before writing code. -- Any research that would generate >30 lines of output polluting your context. +**Sub-agent briefing:** +- Give the exact server name, directory path, and file to edit. +- Specify every tool name, description, parameter schema, and expected return format. +- Include: "Read `manuals/write_mcp_server.md` and `mcp-servers/_template/app/mcp_server.py` first." +- End with: "Complete all assigned work. Return: summary of changes, test output." ### Always inline — never delegate -- `test_tool`, `reload_tools` — always run yourself. +- `test_mcp_tool` calls — always run yourself. +- `reload_tools` — always run yourself. +- `mcp_status` checks — always run yourself. - Reading files to verify what a sub-agent produced. -- Profile `config.json` edits. - The final report to the user. -### Sub-agent briefing for implementation -Give the sub-agent everything it needs to work autonomously: -- Tool name, exact description, full parameter schema. -- The relevant tool file format requirements from the template below. -- Any relevant imports or patterns from existing tools. -- The exact `test_tool` call to validate it. -- Omit `profile_id` to use this tool_developer profile. Set `profile_id` only when the delegated step clearly needs another profile's prompt, model, and tools. -- End with an instruction to write the tool file under `tools/`, test it with `test_tool`, fix until passing, and return file path, tool contract, key implementation notes, and test result. +--- -After it returns: read the file yourself, run `test_tool` yourself, then `reload_tools`. +## Life-cycle procedures + +### Update an MCP server +1. Edit `mcp-servers//app/mcp_server.py` via `filesystem`. +2. (Optional) If dependencies changed, run `pip install -e .` inside the venv. +3. Call `reload_tools`. +4. Call `test_mcp_tool` for affected tools. + +### Delete an MCP server +1. Remove the server directory (or move it to backup). +2. Remove its config file `mcp_servers.d/.json`. +3. Call `reload_tools`. + +### Connect an external MCP server +1. Read its documentation to learn tool names, parameters, and required env vars. +2. Create `mcp_servers.d/.json` with correct `command`, `cwd`, `args`, `env`, and `groups`. +3. Call `reload_tools`. +4. Call `test_mcp_tool` for a representative tool. --- -## Build workflow +## Critical rules -1. **Orient** — use `docs/index.md` as the map. For Navi tool work, check `docs/tools.md`, `manuals/write_tool.md`, and `tools/_template.py` before writing code. -2. **Understand** — clarify what the tool does, what params it takes, where it will run, what data it may persist, and which profiles should receive it. Research first if needed; do not invent APIs. -3. **Check conflicts** — use the `filesystem` tool's list action on `tools/` to see existing tools, then inspect similar tools before copying a pattern. -4. **Write** — use the `write_tool` tool with the chosen tool name and full source code. Never use `filesystem` for initial creation — `write_tool` validates the format and registers the tool automatically. -5. **Test immediately** — use the `test_tool` tool with the tool name and representative params. - If it fails: use the `filesystem` tool's query action to locate the issue, then its smart_edit or write action to fix it, then test again. Never skip this step. -6. **Reload** — `reload_tools()` only after test_tool passes. -7. **Enable** — add tool name to `enabled_tools` in the relevant profile `config.json` files if not already added by `write_tool`. -8. **Update docs** — if you discover a stable tool convention, dependency, credential requirement, or workflow quirk, update the relevant project docs or manuals. -9. **Report** — what was created, what it does, which profiles it's in. +- **Never use `write_tool`, `delete_tool`, or `test_tool`.** These are deprecated and unavailable in this profile. +- **Always test every tool** with `test_mcp_tool` before declaring success. +- **mcp_status is for discovery only** — do not use it to verify that a tool works. +- **Use absolute paths** in `mcp_servers.d/.json` for `command` and `cwd`. +- **Validate syntax** with `python -m py_compile` before connecting. +- **Test startup** with `timeout 5 python -m app.mcp_server` before registering. --- -## Tool file format +## Execution environment +`code_exec`, `terminal`, and `filesystem` all run on the LOCAL machine. +No remote hosts in this profile — everything executes locally. -Every file in `tools/` must define exactly four things at module level: - -```python -name = "tool_name" # snake_case, must match filename (without .py) -description = "What this tool does and when to call it. Be specific." -parameters = { - "type": "object", - "properties": { - "action": { - "type": "string", - "enum": ["save", "get", "list"], - "description": "What to do.", - }, - }, - "required": ["action"], -} - -async def execute(params: dict) -> str: - # implementation - return "result as plain string" -``` - -**Hard rules:** -- NO classes at module level -- NO print() at module level -- `execute` MUST be `async` -- `execute` MUST return a plain `str` — not dict, not None, not list -- Raise an exception to signal failure — never return an error dict -- Read params defensively with `.get()` or explicit validation; never index a required key without checking it first. - ---- - -## File locations - -| What | Path | -|------|------| -| User tool files | `tools/.py` | -| Tool data files | `tools/_data.json` | -| Template | `tools/_template.py` | -| Profile config | `navi/profiles//config.json` | -| Profile prompt | `navi/profiles//system_prompt.txt` | - -Files starting with `_` are never auto-loaded. +## Language / stack +MCP servers are Python 3.11+ with `mcp>=1.27` and `pydantic>=2.0`. +Prefer `FastMCP` from the official MCP SDK. Read the template for the canonical pattern. --- ## Context drift recovery -Before writing or fixing a tool after a long exchange: -- Re-read the latest user request and the intended tool contract. -- Re-check `manuals/write_tool.md` or `tools/_template.py` if uncertain. -- Inspect the current file before editing it. -- Trust `test_tool` output over assumptions and iterate until it passes or the blocker is explicit. - ---- - -## Execution environment -`code_exec`, `terminal`, and `filesystem` all run on the LOCAL machine (where Navi's server is running). -There are no remote hosts in this profile — everything executes locally. - -## Available imports - -Standard library: anything in Python stdlib. -Third-party (installed): `httpx`, `asyncpg`, `structlog`, `pydantic`. -Prefer stdlib and httpx to keep dependencies minimal. +On long tasks or after several tool/sub-agent results: +- Re-read the latest user request and the intended server spec. +- Re-check `manuals/write_mcp_server.md` if uncertain. +- Inspect the current `mcp_servers.d/` directory before editing. +- Trust `test_mcp_tool` output over assumptions and iterate until it passes. diff --git a/navi/tools/__init__.py b/navi/tools/__init__.py index 8ace7c1..6ea9689 100644 --- a/navi/tools/__init__.py +++ b/navi/tools/__init__.py @@ -1,5 +1,6 @@ from .base import Tool, ToolResult from .code_exec import CodeExecTool +from .create_mcp_server import CreateMcpServerTool from .delete_tool import DeleteToolTool from .filesystem import FilesystemTool from .image_view import ImageViewTool @@ -7,6 +8,7 @@ from .spawn_agent import SpawnAgentTool from .terminal import TerminalTool from .memory import MemoryTool +from .test_mcp_tool import TestMcpToolTool from .test_tool import TestToolTool from .todo import TodoTool from .scratchpad import ScratchpadTool @@ -17,6 +19,7 @@ __all__ = [ "Tool", "ToolResult", + "CreateMcpServerTool", "DeleteToolTool", "FilesystemTool", "CodeExecTool", @@ -24,6 +27,7 @@ "SshExecTool", "ImageViewTool", "MemoryTool", + "TestMcpToolTool", "TestToolTool", "SpawnAgentTool", "TodoTool", diff --git a/navi/tools/create_mcp_server.py b/navi/tools/create_mcp_server.py new file mode 100644 index 0000000..7a0a829 --- /dev/null +++ b/navi/tools/create_mcp_server.py @@ -0,0 +1,320 @@ +"""Built-in tool that scaffolds a new MCP server directory.""" + +import asyncio +import json +import shutil +import subprocess +from pathlib import Path + +from navi.config import settings + +from .base import Tool, ToolResult + +# Template for pyproject.toml — placeholders {name}, {description}, {deps} +_PYPROJECT_TEMPLATE = """[build-system] +requires = ["setuptools>=61.0"] +build-backend = "setuptools.build_meta" + +[project] +name = "mcp-server-{name}" +version = "0.1.0" +description = "{description}" +requires-python = ">=3.11" +dependencies = [ + "mcp>=1.27", + "pydantic>=2.0", +{deps} +] + +[project.scripts] +mcp-server-{name} = "app.mcp_server:main" + +[tool.setuptools.packages.find] +where = ["."] +include = ["app*"] +""" + +_README_TEMPLATE = """# MCP Server: {name} + +{description} + +## Tools + +TODO: list tools and their purposes. + +## Setup + +```bash +python -m venv .venv +source .venv/bin/activate +pip install -e . +``` + +## Navi registration + +Create a file `mcp_servers.d/{name}.json` with the server config: +- transport: stdio +- command: absolute path to `.venv/bin/python` +- args: `["-m", "app.mcp_server"]` +- cwd: absolute path to this directory +- groups: map tool names to logical groups +""" + + +class CreateMcpServerTool(Tool): + name = "create_mcp_server" + description = ( + "Scaffold a new MCP server directory under mcp-servers// with " + "pyproject.toml, app/mcp_server.py (from the template with inline comments), " + "README.md, and a virtual environment. Then installs dependencies. " + "Optionally auto-registers the server by creating mcp_servers.d/.json. " + "After this returns, you must still edit app/mcp_server.py and call reload_tools." + ) + parameters = { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Server directory name (snake_case or kebab-case). Will become mcp-servers//.", + }, + "description": { + "type": "string", + "description": "Short description for pyproject.toml and README.", + }, + "dependencies": { + "type": "array", + "items": {"type": "string"}, + "description": "Extra pip dependencies (e.g. httpx, asyncpg). mcp and pydantic are added automatically.", + }, + "auto_register": { + "type": "boolean", + "description": "If true, create mcp_servers.d/.json automatically so no manual filesystem edit is needed.", + }, + }, + "required": ["name", "description"], + } + + async def execute(self, params: dict) -> ToolResult: + name = (params.get("name") or "").strip() + description = params.get("description", "") + dependencies: list[str] = params.get("dependencies") or [] + auto_register: bool = params.get("auto_register", False) + + if not name: + return ToolResult(success=False, output="name is required.", error="missing name") + if name.startswith("_"): + return ToolResult(success=False, output="name must not start with '_'.", error="invalid name") + + base_dir = Path("mcp-servers") / name + if base_dir.exists(): + return ToolResult( + success=False, + output=f"Directory already exists: {base_dir}", + error="already exists", + ) + + # 1. Create directories + app_dir = base_dir / "app" + app_dir.mkdir(parents=True) + + # 2. Write pyproject.toml + deps_lines = "" + if dependencies: + deps_lines = "\n".join(f' "{d}",' for d in dependencies) + pyproject = _PYPROJECT_TEMPLATE.format( + name=name, + description=description.replace('"', '\\"'), + deps=deps_lines, + ) + (base_dir / "pyproject.toml").write_text(pyproject, encoding="utf-8") + + # 3. Copy template mcp_server.py + template_server = Path("mcp-servers") / "_template" / "app" / "mcp_server.py" + if template_server.exists(): + server_code = template_server.read_text(encoding="utf-8") + else: + server_code = _FALLBACK_SERVER_TEMPLATE + (app_dir / "mcp_server.py").write_text(server_code, encoding="utf-8") + + # 4. __init__.py + (app_dir / "__init__.py").write_text("", encoding="utf-8") + + # 5. README + readme = _README_TEMPLATE.format(name=name, description=description) + (base_dir / "README.md").write_text(readme, encoding="utf-8") + + # 6. Create venv and install (off-loaded to a thread to avoid any + # event-loop/subprocess interaction issues under uvicorn/anyio). + abs_dir = base_dir.resolve() + venv_dir = abs_dir / ".venv" + python_bin = venv_dir / "bin" / "python" + pip_bin = venv_dir / "bin" / "pip" + + def _setup() -> tuple[bool, str]: + """Return (ok, error_msg). Runs in a worker thread.""" + # --- Create venv --- + result = subprocess.run( + ["python", "-m", "venv", str(venv_dir)], + capture_output=True, + text=True, + timeout=30.0, + ) + if result.returncode != 0: + return False, f"venv creation failed:\n{result.stderr}" + + # --- Install deps --- + install_args = [ + str(pip_bin), "install", "-e", str(base_dir), + "mcp>=1.27", "pydantic>=2.0", + ] + for dep in dependencies: + install_args.append(dep) + + result = subprocess.run( + install_args, + capture_output=True, + text=True, + timeout=120.0, + ) + if result.returncode != 0: + return False, f"pip install failed:\n{result.stderr}" + + return True, "" + + try: + ok, err_msg = await asyncio.wait_for( + asyncio.to_thread(_setup), + timeout=150.0, + ) + if not ok: + return ToolResult( + success=False, + output=err_msg, + error="setup failed", + ) + except asyncio.TimeoutError: + return ToolResult( + success=False, + output="Setup timed out (venv or pip install took too long).", + error="timeout", + ) + except Exception as exc: + return ToolResult( + success=False, + output=f"Setup error: {exc}", + error="setup error", + ) + + # 7. Verify syntax + server_py_path = app_dir / "mcp_server.py" + try: + import py_compile + py_compile.compile(str(server_py_path), doraise=True) + except Exception as exc: + return ToolResult( + success=False, + output=f"Syntax check failed (unexpected — template should be valid): {exc}", + error="syntax error", + ) + + # 8. Optional auto-register in mcp_servers.d/ + register_note = "" + if auto_register: + from navi.mcp.config import McpServerConfig, save_mcp_servers, load_mcp_servers + try: + configs = load_mcp_servers() + configs[name] = McpServerConfig( + transport="stdio", + command=str(python_bin), + args=["-m", "app.mcp_server"], + cwd=str(abs_dir), + env={"MCP_TRANSPORT": "stdio"}, + groups={"default": []}, + ) + save_mcp_servers(configs) + register_note = ( + f"\nAuto-registered in mcp_servers.d/{name}.json.\n" + f"Call reload_tools to connect.\n" + ) + except Exception as exc: + register_note = ( + f"\nWARNING: auto_register failed: {exc}\n" + f"You must manually create mcp_servers.d/{name}.json.\n" + ) + + # Return connection instructions + output = ( + f"Created MCP server at: {abs_dir}\n\n" + f"Next steps:\n" + f"1. Edit {abs_dir / 'app' / 'mcp_server.py'} — add your tools and INSTRUCTIONS.\n" + ) + if not auto_register: + output += ( + f"2. Create mcp_servers.d/{name}.json with:\n" + f'{{\n' + f' "transport": "stdio",\n' + f' "command": "{python_bin}",\n' + f' "args": ["-m", "app.mcp_server"],\n' + f' "cwd": "{abs_dir}",\n' + f' "env": {{"MCP_TRANSPORT": "stdio"}},\n' + f' "groups": {{\n' + f' "default": []\n' + f' }}\n' + f'}}\n' + f"3. Call reload_tools to connect.\n" + f"4. Call test_mcp_tool to verify each tool.\n" + ) + else: + output += register_note + "2. Call test_mcp_tool to verify each tool.\n" + return ToolResult(success=True, output=output) + + +_FALLBACK_SERVER_TEMPLATE = '''"""MCP server — replace with your own tools and instructions.""" + +from __future__ import annotations + +import json +import os +from typing import Annotated, Any + +from mcp.server.fastmcp import FastMCP +from pydantic import Field + +INSTRUCTIONS = """ +Replace this with instructions for Navi. +Describe what this server does, when to use it, and the workflow. +Add an ABSOLUTE RULE about never bypassing these tools. +""".strip() + +mcp = FastMCP("server", instructions=INSTRUCTIONS) + + +def _json(data: Any) -> str: + return json.dumps(data, ensure_ascii=False, indent=2) + + +@mcp.tool(name="hello") +async def hello_tool( + name: Annotated[str, Field(description="Name to greet.")], +) -> str: + """Say hello to someone.""" + return f"Hello, {name}!" + + +@mcp.tool(name="add") +async def add_tool( + a: Annotated[int, Field(description="First number.")], + b: Annotated[int, Field(description="Second number.")], +) -> str: + """Add two numbers.""" + return _json({"a": a, "b": b, "result": a + b}) + + +def main() -> None: + transport = os.environ.get("MCP_TRANSPORT", "stdio") + mcp.run(transport=transport) + + +if __name__ == "__main__": + main() +''' diff --git a/navi/tools/filesystem.py b/navi/tools/filesystem.py index 0dedabc..5b287da 100644 --- a/navi/tools/filesystem.py +++ b/navi/tools/filesystem.py @@ -75,6 +75,8 @@ inside user_data//. This prevents users from accessing each other's files or random OS directories. """ + if not path_str or path_str.strip() == "": + return None try: p = Path(path_str) except Exception: @@ -368,6 +370,12 @@ path = _check_path(raw_path) if path is None: + if not raw_path or raw_path.strip() == "": + return ToolResult( + success=False, + output="'path' is required. For 'write' action use 'path' (not 'destination').", + error="missing_path", + ) return ToolResult( success=False, output=( diff --git a/navi/tools/mcp_status.py b/navi/tools/mcp_status.py index 168825e..9a78d1d 100644 --- a/navi/tools/mcp_status.py +++ b/navi/tools/mcp_status.py @@ -8,8 +8,10 @@ class McpStatusTool(Tool): name = "mcp_status" description = ( - "Show the status of all configured MCP servers and the tools they expose. " - "Use this to discover what external tools are currently available." + "List all configured MCP servers, their connection status, and the number of tools each exposes. " + "Use this ONLY for discovery — to see which servers are connected and what tools exist. " + "Do NOT use mcp_status to test whether a specific tool works. " + "For testing individual tools, use test_mcp_tool instead." ) parameters = { "type": "object", @@ -17,11 +19,15 @@ "required": [], } - def __init__(self, manager: McpManager | None = None) -> None: - self._manager = manager + def __init__(self, mcp_manager: McpManager | None = None) -> None: + self._mcp_manager = mcp_manager async def execute(self, params: dict) -> ToolResult: - if self._manager is None: + manager = self._mcp_manager + if manager is None: + from navi.api.deps import _mcp_manager as _global_mcp_manager + manager = _global_mcp_manager + if manager is None: return ToolResult( success=False, output="", @@ -29,7 +35,7 @@ ) lines: list[str] = [] - for name, client in self._manager.clients.items(): + for name, client in manager.clients.items(): status = "connected" if client.connected else "disconnected" lines.append(f"Server: {name} ({status})") try: diff --git a/navi/tools/terminal.py b/navi/tools/terminal.py index 94264c2..4b1d22b 100644 --- a/navi/tools/terminal.py +++ b/navi/tools/terminal.py @@ -23,7 +23,8 @@ from .base import Tool, ToolResult, current_user_id, current_user_role -_TIMEOUT = 300 +_DEFAULT_TIMEOUT = 20 +_MAX_TIMEOUT = 300 _MAX_OUTPUT_CHARS = 5_000 # Substrings that make a command line dangerous for non-admin users. @@ -104,7 +105,7 @@ }, "timeout": { "type": "integer", - "description": f"Timeout in seconds (default {_TIMEOUT})", + "description": f"Timeout in seconds (default {_DEFAULT_TIMEOUT}, max {_MAX_TIMEOUT}). Set higher when installing packages or running long tasks.", }, }, "required": ["command"], @@ -113,7 +114,11 @@ async def execute(self, params: dict) -> ToolResult: command = params["command"].strip() working_dir = params.get("working_dir") or None - timeout = int(params.get("timeout") or _TIMEOUT) + raw_timeout = params.get("timeout") + if raw_timeout is not None: + timeout = max(1, min(int(raw_timeout), _MAX_TIMEOUT)) + else: + timeout = _DEFAULT_TIMEOUT if not command: return ToolResult(success=False, output="Empty command.", error="empty_command") diff --git a/navi/tools/test_mcp_tool.py b/navi/tools/test_mcp_tool.py new file mode 100644 index 0000000..f4c061d --- /dev/null +++ b/navi/tools/test_mcp_tool.py @@ -0,0 +1,125 @@ +"""Built-in tool to test a single MCP tool call in isolation.""" + +import asyncio + +from .base import Tool, ToolResult + + +class TestMcpToolTool(Tool): + name = "test_mcp_tool" + description = ( + "Execute a single MCP tool call for testing. " + "Pass server_name, tool_name, and arguments. " + "Returns the raw output and whether the tool reported an error. " + "Always use this after writing or editing an MCP server to verify " + "each tool works before reporting success to the user. " + "Do NOT use mcp_status for testing individual tools — mcp_status is for discovery only." + ) + parameters = { + "type": "object", + "properties": { + "server_name": { + "type": "string", + "description": "MCP server name as listed in mcp_servers.d/.json.", + }, + "tool_name": { + "type": "string", + "description": "Tool name exposed by the server (without mcp: prefix).", + }, + "arguments": { + "type": "object", + "description": "Arguments dict to pass to the tool. Omit or pass {} for tools with no required params.", + }, + }, + "required": ["server_name", "tool_name"], + } + + def __init__(self, mcp_manager=None) -> None: + self._mcp_manager = mcp_manager + + async def execute(self, params: dict) -> ToolResult: + server_name = (params.get("server_name") or "").strip() + tool_name = (params.get("tool_name") or "").strip() + arguments: dict = params.get("arguments") or {} + + if not server_name: + return ToolResult(success=False, output="server_name is required.", error="missing server_name") + if not tool_name: + return ToolResult(success=False, output="tool_name is required.", error="missing tool_name") + + manager = self._mcp_manager + if manager is None: + # Fallback to module-level global in case startup wiring was + # skipped because of an earlier exception in the same block. + from navi.api.deps import _mcp_manager as _global_mcp_manager + manager = _global_mcp_manager + if manager is None: + return ToolResult( + success=False, + output="MCP manager not available. Is the server running and MCP configured?", + error="no manager", + ) + + # Quick check: is the server even connected? + client = manager.clients.get(server_name) + if client is None: + return ToolResult( + success=False, + output=( + f"MCP server '{server_name}' is not connected.\n\n" + "Possible causes:\n" + "1. The server config file is missing from mcp_servers.d/ — run create_mcp_server again.\n" + "2. The server exited immediately on startup (check that main() is called at the bottom of mcp_server.py).\n" + "3. The server threw a traceback on startup — run `timeout 5 .venv/bin/python -m app.mcp_server` to see it.\n\n" + "Next steps:\n" + "1. Call `mcp_status` to see which servers are listed.\n" + "2. Inspect `mcp_servers.d/.json` for correct absolute paths.\n" + "3. Call `reload_tools` to reconnect.\n" + "4. If still failing, read mcp_server.py and verify main() is called." + ), + error="not_connected", + ) + + try: + output, is_error = await asyncio.wait_for( + manager.call_tool(server_name, tool_name, arguments), + timeout=30.0, + ) + except asyncio.TimeoutError: + return ToolResult( + success=False, + output="Tool call timed out after 30 seconds.", + error="timeout", + ) + except Exception as exc: + import traceback as _tb + hint = "" + if "not connected" in str(exc).lower(): + hint = "\n\nHint: The server disconnected during the call. This usually means the server process crashed. Check the server code for runtime errors." + return ToolResult( + success=False, + output=f"Tool call failed: {exc}{hint}\n\nTraceback:\n{_tb.format_exc()}", + error="call failed", + ) + + if is_error: + return ToolResult( + success=False, + output=f"Tool reported an error.\n\nOutput:\n{output}", + error="tool error", + ) + + if not output or output.strip() == "": + return ToolResult( + success=True, + output=( + "Tool returned successfully but the response was EMPTY.\n\n" + "This is suspicious — the tool may have an early return or silent failure. " + "Check the tool implementation for missing return values or empty branches." + ), + ) + + return ToolResult( + success=True, + output=f"OK — tool returned successfully.\n\nOutput:\n{output}", + )