Improve MCP tool calling and routing
Some checks failed
CI / Lint with ruff (pull_request) Failing after 47s
CI / Test Python 3.11 (pull_request) Successful in 51s
CI / Test Python 3.12 (pull_request) Successful in 50s
CI / Build package (pull_request) Has been cancelled

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
This commit is contained in:
tanyar09 2026-03-31 12:15:05 -04:00
parent 93b34bc214
commit 7050e032e8
13 changed files with 318 additions and 21 deletions

5
.gitignore vendored
View File

@ -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

View File

@ -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

View File

@ -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:

View File

@ -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:

View File

@ -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:

1
mcp-servers/.gitkeep Normal file
View File

@ -0,0 +1 @@

34
mcp-servers/README.md Normal file
View File

@ -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"
}
}
}
}
}
```

View File

@ -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":"<tool_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})

View File

@ -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:

View File

@ -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)"

View File

@ -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:

View File

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

113
scripts/setup-mcp-servers.sh Executable file
View File

@ -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/<name>
- 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 "$@"