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
This commit is contained in:
parent
93b34bc214
commit
7050e032e8
5
.gitignore
vendored
5
.gitignore
vendored
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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
1
mcp-servers/.gitkeep
Normal file
@ -0,0 +1 @@
|
||||
|
||||
34
mcp-servers/README.md
Normal file
34
mcp-servers/README.md
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
@ -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})
|
||||
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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)"
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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
113
scripts/setup-mcp-servers.sh
Executable 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 "$@"
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user