From 7050e032e82f52c2bcf814018d5120fd71c6f761 Mon Sep 17 00:00:00 2001 From: tanyar09 Date: Tue, 31 Mar 2026 12:15:05 -0400 Subject: [PATCH] Improve MCP tool calling and routing Add explicit JSON tool-call protocol for local providers, improve parsing of JSON-only tool calls, and add heuristic routing to MCP-capable profiles for repo/PR intents. Also document and mount local-cloned MCP servers and expand MCP env var handling. Made-with: Cursor --- .gitignore | 5 ++ create-bot-configs.sh | 11 +++ docker-compose.multi.dev.yml | 4 + docker-compose.multi.env.yml | 4 + docker-compose.multi.yml | 4 + mcp-servers/.gitkeep | 1 + mcp-servers/README.md | 34 ++++++++ nanobot/agent/context.py | 16 ++++ nanobot/agent/loop.py | 19 +++++ nanobot/agent/tool_routing.py | 36 +++++++++ nanobot/agent/tools/mcp.py | 20 ++++- nanobot/providers/custom_provider.py | 72 ++++++++++++----- scripts/setup-mcp-servers.sh | 113 +++++++++++++++++++++++++++ 13 files changed, 318 insertions(+), 21 deletions(-) create mode 100644 mcp-servers/.gitkeep create mode 100644 mcp-servers/README.md create mode 100755 scripts/setup-mcp-servers.sh diff --git a/.gitignore b/.gitignore index 7a059b3..f6c033e 100644 --- a/.gitignore +++ b/.gitignore @@ -22,3 +22,8 @@ poetry.lock .pytest_cache/ botpy.log tests/ + +# Local-cloned MCP servers (kept out of git; clone/build locally) +mcp-servers/* +!mcp-servers/README.md +!mcp-servers/.gitkeep diff --git a/create-bot-configs.sh b/create-bot-configs.sh index 7df03c6..f725a73 100755 --- a/create-bot-configs.sh +++ b/create-bot-configs.sh @@ -18,6 +18,17 @@ cat > ~/.nanobot-user1/config.json << 'EOF' "enabled": true, "allowFrom": ["adayear2025@gmail.com"] } + }, + "tools": { + "mcpServers": { + "gitea": { + "command": "/app/mcp-servers/gitea-mcp/gitea-mcp", + "args": ["-t", "stdio", "--host", "http://10.0.30.169:3000", "-r"], + "env": { + "GITEA_ACCESS_TOKEN": "$NANOBOT_GITLE_TOKEN" + } + } + } } } EOF diff --git a/docker-compose.multi.dev.yml b/docker-compose.multi.dev.yml index 7cd0095..c160ced 100644 --- a/docker-compose.multi.dev.yml +++ b/docker-compose.multi.dev.yml @@ -19,6 +19,8 @@ services: - ~/.nanobot/workspaces/ilia:/workspace # Mount source code for development (changes picked up immediately) - ./nanobot:/app/nanobot:ro # Read-only mount (safer) + # Local-cloned MCP servers (see scripts/setup-mcp-servers.sh) + - ./mcp-servers:/app/mcp-servers:ro # Or use this for read-write (if you edit inside container): # - ./nanobot:/app/nanobot ports: @@ -46,6 +48,7 @@ services: - ~/.nanobot-user2:/root/.nanobot - ~/.nanobot/workspaces/family:/workspace - ./nanobot:/app/nanobot:ro + - ./mcp-servers:/app/mcp-servers:ro ports: - "18791:18790" deploy: @@ -71,6 +74,7 @@ services: - ~/.nanobot-user3:/root/.nanobot - ~/.nanobot/workspaces/wife:/workspace - ./nanobot:/app/nanobot:ro + - ./mcp-servers:/app/mcp-servers:ro ports: - "18792:18790" deploy: diff --git a/docker-compose.multi.env.yml b/docker-compose.multi.env.yml index 1b7d99b..d19ad2f 100644 --- a/docker-compose.multi.env.yml +++ b/docker-compose.multi.env.yml @@ -18,6 +18,8 @@ services: volumes: - ~/.nanobot-user1:/root/.nanobot - ~/.nanobot/workspaces/ilia:/workspace + # Local-cloned MCP servers (see scripts/setup-mcp-servers.sh) + - ./mcp-servers:/app/mcp-servers:ro ports: - "18790:18790" deploy: @@ -42,6 +44,7 @@ services: volumes: - ~/.nanobot-user2:/root/.nanobot - ~/.nanobot/workspaces/family:/workspace + - ./mcp-servers:/app/mcp-servers:ro ports: - "18791:18790" deploy: @@ -66,6 +69,7 @@ services: volumes: - ~/.nanobot-user3:/root/.nanobot - ~/.nanobot/workspaces/wife:/workspace + - ./mcp-servers:/app/mcp-servers:ro ports: - "18792:18790" deploy: diff --git a/docker-compose.multi.yml b/docker-compose.multi.yml index 4cc46aa..a4178c7 100644 --- a/docker-compose.multi.yml +++ b/docker-compose.multi.yml @@ -16,6 +16,8 @@ services: - ~/.nanobot-user1:/root/.nanobot # @ilia — isolated workspace + memory (host: ~/.nanobot/workspaces/ilia) - ~/.nanobot/workspaces/ilia:/workspace + # Local-cloned MCP servers (see scripts/setup-mcp-servers.sh) + - ./mcp-servers:/app/mcp-servers:ro ports: - "18790:18790" deploy: @@ -41,6 +43,7 @@ services: - ~/.nanobot-user2:/root/.nanobot # @family — isolated workspace + memory - ~/.nanobot/workspaces/family:/workspace + - ./mcp-servers:/app/mcp-servers:ro ports: - "18791:18790" deploy: @@ -66,6 +69,7 @@ services: - ~/.nanobot-user3:/root/.nanobot # @wife — isolated workspace + memory - ~/.nanobot/workspaces/wife:/workspace + - ./mcp-servers:/app/mcp-servers:ro ports: - "18792:18790" deploy: diff --git a/mcp-servers/.gitkeep b/mcp-servers/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/mcp-servers/.gitkeep @@ -0,0 +1 @@ + diff --git a/mcp-servers/README.md b/mcp-servers/README.md new file mode 100644 index 0000000..059ccae --- /dev/null +++ b/mcp-servers/README.md @@ -0,0 +1,34 @@ +# Local MCP servers + +This repo uses a **local-clone policy** for MCP servers: clone upstream repos into `./mcp-servers/` and run them from disk (instead of fetching from npm/PyPI at runtime). + +## Gitea MCP + +- **Upstream**: `https://gitea.com/gitea/gitea-mcp.git` +- **Local path**: `mcp-servers/gitea-mcp/` +- **Binary**: `mcp-servers/gitea-mcp/gitea-mcp` + +Build it with: + +```bash +./scripts/setup-mcp-servers.sh gitea +``` + +Then configure nanobot (example): + +```jsonc +{ + "tools": { + "mcpServers": { + "gitea": { + "command": "./mcp-servers/gitea-mcp/gitea-mcp", + "args": ["-t", "stdio", "--host", "http://10.0.30.169:3000"], + "env": { + "GITEA_ACCESS_TOKEN": "$NANOBOT_GITLE_TOKEN" + } + } + } + } +} +``` + diff --git a/nanobot/agent/context.py b/nanobot/agent/context.py index b19764b..5552027 100644 --- a/nanobot/agent/context.py +++ b/nanobot/agent/context.py @@ -89,6 +89,22 @@ You are nanobot, a helpful AI assistant. You have access to tools that allow you - Send messages to users on chat channels - Spawn subagents for complex background tasks +## Tool calling (IMPORTANT) +Some LLM backends may not support native function-calling. When you decide to use a tool, you MUST output a single JSON object in one of these formats (and no other surrounding text): + +1) Standard tool call: +{{"name":"","parameters":{{...}}}} + +2) Calendar shortcut (allowed only for the built-in `calendar` tool): +{{"action":"list_events", ...}} + +After a tool result is returned, respond normally in plain text unless the user asks for another tool action. + +### MCP quick mappings (use these when the intent matches) +- If the user asks for **my Gitea user info** (who am I / my profile / my account): call `mcp_gitea_get_me` with `{{}}`. +- If the user asks for **Gitea MCP server version**: call `mcp_gitea_get_gitea_mcp_server_version` with `{{}}`. +- If the user asks to **list my repos**: call `mcp_gitea_list_my_repos` with pagination defaults. + ## Current Time {now} ({tz}) diff --git a/nanobot/agent/loop.py b/nanobot/agent/loop.py index bd50a1a..c403c08 100644 --- a/nanobot/agent/loop.py +++ b/nanobot/agent/loop.py @@ -311,6 +311,7 @@ class AgentLoop: iteration = 0 final_content = None tools_used: list[str] = [] + empty_final_retry_used = False from nanobot.agent.tool_profiles import ( compute_allowed_tool_names, @@ -418,6 +419,24 @@ class AgentLoop: else: final_content = self._strip_think(response.content) logger.info(f"Final response generated. Content length: {len(final_content) if final_content else 0}") + # Some local OpenAI-compatible backends occasionally return an empty assistant message. + # Retry once with an explicit nudge to either call a tool or answer in text. + if (not final_content or not final_content.strip()) and not empty_final_retry_used: + empty_final_retry_used = True + logger.warning( + "LLM returned empty final content; retrying once with a non-empty response nudge" + ) + messages = messages + [ + { + "role": "system", + "content": ( + "Your previous reply was empty. You MUST either (a) call an appropriate tool, " + "or (b) respond with a short helpful text answer. Do not return an empty message." + ), + } + ] + final_content = None + continue break if final_content is None and iteration >= self.max_iterations: diff --git a/nanobot/agent/tool_routing.py b/nanobot/agent/tool_routing.py index b9ffdc9..9dfc3f3 100644 --- a/nanobot/agent/tool_routing.py +++ b/nanobot/agent/tool_routing.py @@ -27,6 +27,42 @@ async def route_tool_profile( if not profiles: return default_profile + # Heuristic fast-path: if the request clearly needs a dev/forge MCP (PRs, issues, repos), + # prefer an MCP-enabled profile without spending an LLM call. + msg_l = (user_message or "").lower() + needs_forge = any( + k in msg_l + for k in [ + "pull request", + "pull requests", + "open pr", + "open prs", + " list prs", + "pr ", + "prs", + "merge request", + "issue", + "issues", + "gitea", + "repo", + "repository", + "branches", + "commits", + "tags", + "release", + ] + ) + if needs_forge: + # Prefer an explicit "*mcp*" profile key if present, else any profile that enables MCP servers. + for key in profiles.keys(): + if "mcp" in key.lower(): + logger.info(f"Tool router selected profile '{key}' (heuristic)") + return key + for key, p in profiles.items(): + if p.mcp_servers is None or (isinstance(p.mcp_servers, list) and len(p.mcp_servers) > 0): + logger.info(f"Tool router selected profile '{key}' (heuristic)") + return key + lines = [] for name, p in profiles.items(): desc = (p.description or "").strip() or "(no description)" diff --git a/nanobot/agent/tools/mcp.py b/nanobot/agent/tools/mcp.py index dec184f..0e000a2 100644 --- a/nanobot/agent/tools/mcp.py +++ b/nanobot/agent/tools/mcp.py @@ -3,6 +3,7 @@ import asyncio import json import re +import os from contextlib import AsyncExitStack from typing import Any @@ -118,9 +119,26 @@ async def connect_mcp_server( from mcp import ClientSession, StdioServerParameters from mcp.client.stdio import stdio_client + def _expand_env(env: dict[str, str]) -> dict[str, str]: + """ + Expand $VARS in cfg.env using the current process environment. + + This lets configs safely reference secrets that are already injected into the + container environment (e.g. via .env.shared), without duplicating them in JSON: + { "GITEA_ACCESS_TOKEN": "$NANOBOT_GITLE_TOKEN" } + """ + if not env: + return {} + expanded: dict[str, str] = {} + for k, v in env.items(): + if v is None: + continue + expanded[k] = os.path.expandvars(str(v)) + return expanded + if cfg.command: params = StdioServerParameters( - command=cfg.command, args=cfg.args, env=cfg.env or None + command=cfg.command, args=cfg.args, env=_expand_env(cfg.env) or None ) read, write = await stack.enter_async_context(stdio_client(params)) elif cfg.url: diff --git a/nanobot/providers/custom_provider.py b/nanobot/providers/custom_provider.py index dadda1e..02cd966 100644 --- a/nanobot/providers/custom_provider.py +++ b/nanobot/providers/custom_provider.py @@ -59,15 +59,38 @@ class CustomProvider(LLMProvider): for tc in (msg.tool_calls or []) ] - # If no structured tool calls, try to parse from content (Ollama sometimes returns JSON in content) + # If no structured tool calls, try to parse from content (some OpenAI-compatible backends return JSON in content) # Only parse if content looks like it contains a tool call JSON (to avoid false positives) content = msg.content or "" + stripped = content.strip() + # Note: This list should match tools registered in AgentLoop._register_default_tools(). + # MCP tools are registered dynamically and are prefixed with "mcp_" (allow those too). + valid_tools = [ + # File tools + "read_file", "write_file", "edit_file", "list_dir", + # Shell tool + "exec", + # Web tools + "web_search", "web_fetch", + # Communication tools + "message", "spawn", + # Calendar tool + "calendar", + # Cron tool + "cron", + # Email tool + "email", + ] # Check for standard format: {"name": "...", "parameters": {...}} has_standard_format = '"name"' in content and '"parameters"' in content # Check for calendar tool format: {"action": "...", ...} has_calendar_format = '"action"' in content and ("calendar" in content.lower() or any(action in content for action in ["list_events", "create_event", "update_event", "delete_event"])) - if not tool_calls and content and (has_standard_format or has_calendar_format): + # Some backends will return *only* a JSON object as the entire message content. + # If it looks like a JSON object, attempt parsing even if our heuristics missed it. + looks_like_json_object = stripped.startswith("{") and stripped.endswith("}") + + if not tool_calls and content and (has_standard_format or has_calendar_format or looks_like_json_object): import re # Look for JSON tool call patterns: {"name": "exec", "parameters": {...}} or {"action": "list_events", ...} # Find complete JSON objects by matching braces @@ -131,28 +154,11 @@ class CustomProvider(LLMProvider): continue # Handle standard format: {"name": "...", "parameters": {...}} - # Note: This list should match tools registered in AgentLoop._register_default_tools() - valid_tools = [ - # File tools - "read_file", "write_file", "edit_file", "list_dir", - # Shell tool - "exec", - # Web tools - "web_search", "web_fetch", - # Communication tools - "message", "spawn", - # Calendar tool - "calendar", - # Cron tool - "cron", - # Email tool - "email", - ] if (isinstance(tool_obj, dict) and "name" in tool_obj and "parameters" in tool_obj and isinstance(tool_obj["name"], str) and - tool_obj["name"] in valid_tools): + (tool_obj["name"] in valid_tools or tool_obj["name"].startswith("mcp_"))): tool_calls.append(ToolCallRequest( id=f"call_{len(tool_calls)}", name=tool_obj["name"], @@ -166,6 +172,32 @@ class CustomProvider(LLMProvider): pass # If parsing fails, skip this match start_pos = json_start + 1 # Move past this match + + # If we still didn't match embedded objects, try parsing the whole message as a single tool-call JSON object. + if not tool_calls and looks_like_json_object: + try: + tool_obj = json_repair.loads(stripped) + if isinstance(tool_obj, dict) and "action" in tool_obj: + action = tool_obj.get("action") + if action and action in ["list_events", "create_event", "update_event", "delete_event", "delete_events", "check_availability"]: + tool_calls.append(ToolCallRequest( + id="call_0", + name="calendar", + arguments=tool_obj, + )) + content = "" + if isinstance(tool_obj, dict) and "name" in tool_obj and "parameters" in tool_obj: + if isinstance(tool_obj["name"], str) and ( + tool_obj["name"] in valid_tools or tool_obj["name"].startswith("mcp_") + ): + tool_calls.append(ToolCallRequest( + id="call_0", + name=tool_obj["name"], + arguments=tool_obj["parameters"] if isinstance(tool_obj["parameters"], dict) else {"raw": str(tool_obj["parameters"])}, + )) + content = "" + except Exception: + pass u = response.usage return LLMResponse( diff --git a/scripts/setup-mcp-servers.sh b/scripts/setup-mcp-servers.sh new file mode 100755 index 0000000..ffdf0df --- /dev/null +++ b/scripts/setup-mcp-servers.sh @@ -0,0 +1,113 @@ +#!/usr/bin/env bash +# Clone/build local MCP servers into ./mcp-servers (local-clone policy). + +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +MCP_DIR="${REPO_ROOT}/mcp-servers" + +usage() { + cat <<'EOF' +usage: + ./scripts/setup-mcp-servers.sh gitea + +notes: + - clones into ./mcp-servers/ + - builds artifacts needed to run the MCP server locally +EOF +} + +need_cmd() { + local cmd="$1" + if ! command -v "${cmd}" >/dev/null 2>&1; then + echo "error: missing '${cmd}' on PATH" >&2 + return 1 + fi +} + +need_go_min() { + local want_major="$1" + local want_minor="$2" + + local v + v="$(go version 2>/dev/null || true)" + # Example: "go version go1.26.0 linux/amd64" + local ver + ver="$(echo "${v}" | awk '{print $3}' | sed 's/^go//')" + local major minor + major="$(echo "${ver}" | cut -d. -f1)" + minor="$(echo "${ver}" | cut -d. -f2)" + + if [[ -z "${major}" || -z "${minor}" ]]; then + echo "error: could not parse Go version from: ${v}" >&2 + return 1 + fi + + # Compare major/minor only (sufficient for our use). + if (( major < want_major )) || { (( major == want_major )) && (( minor < want_minor )); }; then + echo "error: Go ${want_major}.${want_minor}+ required; found ${ver}" >&2 + return 1 + fi +} + +setup_gitea() { + need_cmd git + + if ! command -v go >/dev/null 2>&1; then + cat <<'EOF' >&2 +error: Go toolchain not found (required to build gitea-mcp). + +install one of: + - Debian/Ubuntu: sudo apt-get update && sudo apt-get install -y golang + - Or install Go from https://go.dev/dl/ + +then rerun: + ./scripts/setup-mcp-servers.sh gitea +EOF + exit 2 + fi + + if ! need_go_min 1 26; then + cat <<'EOF' >&2 + +gitea-mcp currently requires a newer Go toolchain than Debian stable typically ships. +If you already installed a newer Go under /usr/local (example: /usr/local/go1.26/bin/go), +rerun with PATH overridden, e.g.: + + PATH="/usr/local/go1.26/bin:$PATH" ./scripts/setup-mcp-servers.sh gitea +EOF + exit 2 + fi + + mkdir -p "${MCP_DIR}" + + if [[ ! -d "${MCP_DIR}/gitea-mcp/.git" ]]; then + git clone https://gitea.com/gitea/gitea-mcp.git "${MCP_DIR}/gitea-mcp" + else + echo "info: gitea-mcp already cloned, skipping clone" + fi + + (cd "${MCP_DIR}/gitea-mcp" && go build -o gitea-mcp .) + + echo "done: built ${MCP_DIR}/gitea-mcp/gitea-mcp" +} + +main() { + if [[ "${#}" -ne 1 ]]; then + usage + exit 1 + fi + + case "$1" in + gitea) setup_gitea ;; + -h|--help|help) usage ;; + *) + echo "error: unknown target '$1'" >&2 + usage + exit 1 + ;; + esac +} + +main "$@" +