Compare commits

...

6 Commits

Author SHA1 Message Date
b4a0427494 Merge pull request 'feature/tool-profiles-routing' (#4) from feature/tool-profiles-routing into feature/cleanup-providers-llama-only
Reviewed-on: #4
2026-03-31 11:22:41 -05:00
7050e032e8 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
2026-03-31 12:15:05 -04:00
93b34bc214 Enhance CalendarTool action handling
- Introduce logic to set default action to "list_events" when `action` is omitted and only a single key-value pair is present.
- Expand handling for various input scenarios to ensure correct action assignment based on known actions and input keys.
- Improve robustness of the coerced dictionary to accommodate different input formats.

Made-with: Cursor
2026-03-30 14:15:09 -04:00
a6bd3e0e9b Agent: tool profiles and profile-scoped MCP connections
- Extend tool profile helpers for MCP server key resolution and filtering
- Lazily connect/disconnect MCP servers per active profile in AgentLoop
- Harden MCP client (timeouts, tool naming, connect_mcp_server entry)
- Adjust context and tool modules to align with profile-aware tooling
- docker-compose: minor gateway/workspace notes

Made-with: Cursor
2026-03-30 13:27:46 -04:00
7901f090f9 Add per-agent workspaces and MCP/skills backlog doc
- Add agent_workspaces/{ilia,family,wife} skeletons (AGENTS, USER, SOUL, memory)
- Add scripts/init-agent-workspaces.sh to populate ~/.nanobot/workspaces/
- Mount ~/.nanobot/workspaces/{ilia,family,wife} in multi compose as /workspace
- Document Step 0 and layout in DOCKER_MULTI_BOT_GUIDE.md
- Track docs/mcp_and_skills_backlog.md (force-add; docs/ is gitignored)

Made-with: Cursor
2026-03-30 13:20:38 -04:00
d50183c3d7 feat(agent): tool profiles and LLM router (rebased on docker/merge base)
- tools.toolProfiles / tools.toolRouting in config; filter tools per turn
- Router picks profile; expandOnMissingTool widens to full registry once
- Wire gateway and CLI AgentLoop; ToolRegistry.get_definitions_subset
- Ruff: fix tool_routing exception handling and format touched files

Made-with: Cursor
2026-03-27 14:30:15 -04:00
40 changed files with 1817 additions and 223 deletions

5
.gitignore vendored
View File

@ -22,3 +22,8 @@ poetry.lock
.pytest_cache/ .pytest_cache/
botpy.log botpy.log
tests/ tests/
# Local-cloned MCP servers (kept out of git; clone/build locally)
mcp-servers/*
!mcp-servers/README.md
!mcp-servers/.gitkeep

View File

@ -59,20 +59,28 @@ Each bot runs in its own Docker container with:
``` ```
nanobot/ nanobot/
├── .env.shared # Shared settings (API keys, model, etc.) ├── .env.shared # Shared settings (API keys, model, etc.)
├── .env.user1 # Bot 1 overrides ├── .env.user1 # Bot 1 (@ilia) overrides
├── .env.user2 # Bot 2 overrides ├── .env.user2 # Bot 2 (@family) overrides
├── .env.user3 # Bot 3 overrides ├── .env.user3 # Bot 3 (@wife) overrides
├── agent_workspaces/ # Templates copied by scripts/init-agent-workspaces.sh
├── scripts/init-agent-workspaces.sh
├── docker-compose.multi.env.yml # Production compose file ├── docker-compose.multi.env.yml # Production compose file
├── docker-compose.multi.dev.yml # Development compose file ├── docker-compose.multi.dev.yml # Development compose file
└── ~/.nanobot-user1/ ├── ~/.nanobot/workspaces/
└── config.json # Bot 1 channel config (Telegram token, allowFrom) │ ├── ilia/ # Mounted as /workspace for user1 — AGENTS.md, memory/, …
└── ~/.nanobot-user2/ │ ├── family/ # user2
└── config.json # Bot 2 channel config │ └── wife/ # user3
├── ~/.nanobot-user1/
│ └── config.json # Bot 1 channel config (Telegram token, allowFrom)
├── ~/.nanobot-user2/
│ └── config.json # Bot 2 channel config
└── ~/.nanobot-user3/ └── ~/.nanobot-user3/
└── config.json # Bot 3 channel config └── config.json # Bot 3 channel config
``` ```
`./workspace` in the repo remains for **single-bot** `docker-compose.yml` only; multi-bot uses `~/.nanobot/workspaces/*` per container.
### Configuration Loading ### Configuration Loading
1. **Docker Compose** loads environment files: 1. **Docker Compose** loads environment files:
@ -91,6 +99,31 @@ nanobot/
## Setup Instructions ## Setup Instructions
### Step 0: Per-agent workspaces (personalities + isolated memory)
Multi-bot compose mounts **separate** workspace directories so each bot has its own `AGENTS.md`, `SOUL.md`, `USER.md`, and `memory/` (no shared `./workspace`).
On the host, from the repo root:
```bash
./scripts/init-agent-workspaces.sh
```
This creates:
```
~/.nanobot/workspaces/
ilia/ # nanobot-user1 — dev / infra persona
family/ # nanobot-user2 — household persona
wife/ # nanobot-user3 — personal assistant persona
```
Templates live in-repo under `agent_workspaces/`. Re-run the script anytime: it **skips** files that already exist. Adjust ownership if Docker runs as root:
```bash
sudo chown -R "$(whoami):$(whoami)" ~/.nanobot/workspaces
```
### Step 1: Create Environment Files ### Step 1: Create Environment Files
Run the setup script: Run the setup script:

View File

@ -0,0 +1,30 @@
# Agent workspace skeletons
These directories are **templates** for per-agent workspaces on the host:
`~/.nanobot/workspaces/ilia/`
`~/.nanobot/workspaces/family/`
`~/.nanobot/workspaces/wife/`
Each contains bootstrap files (`AGENTS.md`, `USER.md`, `SOUL.md`) and `memory/` (`MEMORY.md`, `HISTORY.md`) loaded by nanobots `ContextBuilder` and `MemoryStore`.
## Initialise on the host
From the repo root (after clone):
```bash
chmod +x scripts/init-agent-workspaces.sh
./scripts/init-agent-workspaces.sh
```
Override destination root (default `$HOME/.nanobot`):
```bash
NANOBOT_HOME=/path/to/.nanobot ./scripts/init-agent-workspaces.sh
```
The script **does not overwrite** existing files so you can safely re-run after editing.
## Docker
Multi-bot compose mounts each path into `/workspace` in the matching container. See `DOCKER_MULTI_BOT_GUIDE.md`.

View File

@ -0,0 +1,14 @@
# @family — Agent instructions
You are the **family** assistant: shared calendar, household coordination, and kid- or home-related questions.
## Scope
- Schedules, reminders, and “whats this week” style questions.
- Simple web lookups (school, activities, recipes) when tools allow.
- Warm, inclusive language for all family members.
## Out of scope
- Production servers, SSH, Proxmox, or source-code repositories unless explicitly asked by an adult and tools are available.
## Tone
Friendly, organized, patient. Offer clear summaries and next steps.

View File

@ -0,0 +1,7 @@
# Personality — @family
**Voice:** Warm, clear, and reassuring. Good with busy parents and kids contexts.
**Values:** Inclusivity, clarity on dates/times, respect for privacy between family members where relevant.
**Avoid:** Cold or corporate tone; assumption that everyone shares one email account.

View File

@ -0,0 +1,8 @@
# User profile — Family
This workspace represents the **household** (not one individual). List members, ages if relevant, schools, and recurring commitments.
## Edit this file
- Family members and how you refer to them.
- Default calendar names or shared inboxes (if any).
- Anything the agent should know for scheduling and coordination.

View File

@ -0,0 +1,3 @@
# Event log — Family
Append-only style log for this household agent.

View File

@ -0,0 +1,5 @@
# Long-term memory — Family
Household-level facts (recurring events, preferences, school names). **Do not store secrets** (passwords, full IDs).
_Empty placeholder — add bullet facts here over time._

View File

@ -0,0 +1,14 @@
# @ilia — Agent instructions
You are the personal assistant for **Ilia**. You focus on development, homelab infrastructure, code review, and technical research.
## Scope
- Software development (Gitea, PRs, issues, shell, git) and clear technical explanations.
- Homelab / Proxmox / networking when those tools are available.
- Email and calendar for Ilias accounts when configured.
## Tone
Concise, accurate, and direct. Prefer actionable steps over long preambles.
## Tools
Use nanobot tools as configured for this instance. Do not assume tools that are not in your tool list.

View File

@ -0,0 +1,7 @@
# Personality — @ilia
**Voice:** Technical, calm, efficient. Short paragraphs. No fluff.
**Values:** Correctness, security-minded defaults, reproducible steps.
**Avoid:** Unnecessary apologies, over-explaining basic concepts unless asked.

View File

@ -0,0 +1,7 @@
# User profile — Ilia
**Name:** Ilia
**Role:** Primary operator of this nanobot stack; dev and infra.
## Edit this file
Add preferences, timezone, important contacts, repos, and anything this agent should remember about *you* (not generic assistant behavior — that belongs in `SOUL.md` / `AGENTS.md`).

View File

@ -0,0 +1,3 @@
# Event log — Ilia
Append-only style log. Search with grep when recalling past events.

View File

@ -0,0 +1,5 @@
# Long-term memory — Ilia
Facts and preferences worth keeping across sessions. The agent may update this file when you confirm something should be remembered.
_Empty placeholder — add bullet facts here over time._

View File

@ -0,0 +1,11 @@
# @wife — Agent instructions
You are the personal assistant for **Ilias wife**. Focus on her calendar, email (when connected), daily tasks, and practical lookups.
## Scope
- Scheduling, reminders, messages, and life-admin tasks.
- Summaries of mail or web pages when tools allow.
- Respectful, private handling of personal topics.
## Tone
Supportive and efficient. Match the users formality preferences over time.

View File

@ -0,0 +1,7 @@
# Personality — @wife
**Voice:** Friendly, attentive, and tactful.
**Values:** Privacy, accuracy on appointments and commitments, gentle reminders.
**Avoid:** Dismissive or overly technical jargon unless the user prefers it.

View File

@ -0,0 +1,6 @@
# User profile — Wife
**Name:** _(preferred name / how to address her)_
## Edit this file
Add preferences, timezone, health or routine notes *you are comfortable storing in plain text*, and communication preferences.

View File

@ -0,0 +1,3 @@
# Event log — Wife
Append-only style log. Search with grep when recalling past events.

View File

@ -0,0 +1,5 @@
# Long-term memory — Wife
Facts and preferences worth keeping across sessions. The agent may update when you confirm.
_Empty placeholder — add bullet facts here over time._

View File

@ -18,6 +18,17 @@ cat > ~/.nanobot-user1/config.json << 'EOF'
"enabled": true, "enabled": true,
"allowFrom": ["adayear2025@gmail.com"] "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 EOF

View File

@ -1,3 +1,4 @@
# user1=@ilia, user2=@family, user3=@wife — workspaces ~/.nanobot/workspaces/{ilia,family,wife}
# Development version - mounts source code for live updates # Development version - mounts source code for live updates
# Use this when developing nanobot code # Use this when developing nanobot code
# Changes to nanobot/ directory will be picked up automatically (may need container restart) # Changes to nanobot/ directory will be picked up automatically (may need container restart)
@ -15,8 +16,11 @@ services:
- .env.user1 - .env.user1
volumes: volumes:
- ~/.nanobot-user1:/root/.nanobot - ~/.nanobot-user1:/root/.nanobot
- ~/.nanobot/workspaces/ilia:/workspace
# Mount source code for development (changes picked up immediately) # Mount source code for development (changes picked up immediately)
- ./nanobot:/app/nanobot:ro # Read-only mount (safer) - ./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): # Or use this for read-write (if you edit inside container):
# - ./nanobot:/app/nanobot # - ./nanobot:/app/nanobot
ports: ports:
@ -42,7 +46,9 @@ services:
- .env.user2 - .env.user2
volumes: volumes:
- ~/.nanobot-user2:/root/.nanobot - ~/.nanobot-user2:/root/.nanobot
- ~/.nanobot/workspaces/family:/workspace
- ./nanobot:/app/nanobot:ro - ./nanobot:/app/nanobot:ro
- ./mcp-servers:/app/mcp-servers:ro
ports: ports:
- "18791:18790" - "18791:18790"
deploy: deploy:
@ -66,7 +72,9 @@ services:
- .env.user3 - .env.user3
volumes: volumes:
- ~/.nanobot-user3:/root/.nanobot - ~/.nanobot-user3:/root/.nanobot
- ~/.nanobot/workspaces/wife:/workspace
- ./nanobot:/app/nanobot:ro - ./nanobot:/app/nanobot:ro
- ./mcp-servers:/app/mcp-servers:ro
ports: ports:
- "18792:18790" - "18792:18790"
deploy: deploy:

View File

@ -1,3 +1,4 @@
# Multi-bot: user1 = @ilia, user2 = @family, user3 = @wife (see ~/.nanobot/workspaces/* and scripts/init-agent-workspaces.sh).
# Using separate env files per container: # Using separate env files per container:
# - .env.shared: Common settings (API keys, model, etc.) - loaded first # - .env.shared: Common settings (API keys, model, etc.) - loaded first
# - .env.user1, .env.user2, .env.user3: Bot-specific overrides - loaded after # - .env.user1, .env.user2, .env.user3: Bot-specific overrides - loaded after
@ -16,6 +17,9 @@ services:
- .env.user1 # Bot-specific overrides (loaded second, overrides shared) - .env.user1 # Bot-specific overrides (loaded second, overrides shared)
volumes: volumes:
- ~/.nanobot-user1:/root/.nanobot - ~/.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: ports:
- "18790:18790" - "18790:18790"
deploy: deploy:
@ -39,6 +43,8 @@ services:
- .env.user2 # Bot-specific overrides (loaded second, overrides shared) - .env.user2 # Bot-specific overrides (loaded second, overrides shared)
volumes: volumes:
- ~/.nanobot-user2:/root/.nanobot - ~/.nanobot-user2:/root/.nanobot
- ~/.nanobot/workspaces/family:/workspace
- ./mcp-servers:/app/mcp-servers:ro
ports: ports:
- "18791:18790" - "18791:18790"
deploy: deploy:
@ -62,6 +68,8 @@ services:
- .env.user3 # Bot-specific overrides (loaded second, overrides shared) - .env.user3 # Bot-specific overrides (loaded second, overrides shared)
volumes: volumes:
- ~/.nanobot-user3:/root/.nanobot - ~/.nanobot-user3:/root/.nanobot
- ~/.nanobot/workspaces/wife:/workspace
- ./mcp-servers:/app/mcp-servers:ro
ports: ports:
- "18792:18790" - "18792:18790"
deploy: deploy:

View File

@ -1,3 +1,6 @@
# Multi-bot: nanobot-user1 = @ilia, user2 = @family, user3 = @wife.
# Each container uses ~/.nanobot/workspaces/<name>/ → /workspace (run scripts/init-agent-workspaces.sh first).
services: services:
nanobot-user1: nanobot-user1:
build: build:
@ -6,8 +9,15 @@ services:
container_name: nanobot-user1 container_name: nanobot-user1
command: ["gateway"] command: ["gateway"]
restart: unless-stopped restart: unless-stopped
env_file:
- .env.shared
- .env.user1
volumes: volumes:
- ~/.nanobot-user1:/root/.nanobot - ~/.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: ports:
- "18790:18790" - "18790:18790"
deploy: deploy:
@ -26,8 +36,14 @@ services:
container_name: nanobot-user2 container_name: nanobot-user2
command: ["gateway"] command: ["gateway"]
restart: unless-stopped restart: unless-stopped
env_file:
- .env.shared
- .env.user2
volumes: volumes:
- ~/.nanobot-user2:/root/.nanobot - ~/.nanobot-user2:/root/.nanobot
# @family — isolated workspace + memory
- ~/.nanobot/workspaces/family:/workspace
- ./mcp-servers:/app/mcp-servers:ro
ports: ports:
- "18791:18790" - "18791:18790"
deploy: deploy:
@ -46,8 +62,14 @@ services:
container_name: nanobot-user3 container_name: nanobot-user3
command: ["gateway"] command: ["gateway"]
restart: unless-stopped restart: unless-stopped
env_file:
- .env.shared
- .env.user3
volumes: volumes:
- ~/.nanobot-user3:/root/.nanobot - ~/.nanobot-user3:/root/.nanobot
# @wife — isolated workspace + memory
- ~/.nanobot/workspaces/wife:/workspace
- ./mcp-servers:/app/mcp-servers:ro
ports: ports:
- "18792:18790" - "18792:18790"
deploy: deploy:

View File

@ -4,6 +4,8 @@ x-common-config: &common-config
dockerfile: Dockerfile dockerfile: Dockerfile
volumes: volumes:
- ~/.nanobot:/root/.nanobot - ~/.nanobot:/root/.nanobot
# Host repo ./workspace → /workspace in container. Set agents.defaults.workspace to /workspace.
- ./workspace:/workspace
services: services:
nanobot-gateway: nanobot-gateway:

View File

@ -0,0 +1,573 @@
# MCP Integrations & Skills Backlog
> **Living document** — update this file as items are implemented, reprioritized, or new candidates emerge.
>
> Last updated: 2026-03-30
---
## Table of Contents
1. [Current State](#current-state)
2. [Security: Local-Clone Policy](#security-local-clone-policy)
3. [Shortlist — Next Phase](#shortlist--next-phase)
4. [Backlog — Later](#backlog--later)
5. [Skill Catalog](#skill-catalog)
6. [Phase 1 Priorities](#phase-1-priorities)
7. [Implementation Notes](#implementation-notes)
---
## Current State
| Category | What we have today |
|---|---|
| **Built-in tools** | `filesystem` (read/write/edit/list), `exec` (shell), `web` (search + fetch), `message`, `spawn`, `cron`, `email` (IMAP), `calendar` (Google Calendar via built-in tool) |
| **MCP servers** | 1 connected — Gmail MCP (`@gongrzhe/server-gmail-autoauth-mcp`, stdio/npx). See [docs/gmail_mcp_setup.md](gmail_mcp_setup.md). |
| **Skills** | 10 bundled in `nanobot/skills/`: `github`, `gitea`, `calendar`, `cron`, `weather`, `summarize`, `tmux`, `clawhub`, `skill-creator`, `memory` |
| **Agent architecture** | 3 named agents, each running as a **separate Docker container** with its own workspace, personality, and memory (Option B). See below. |
| **Config schema** | `tools.mcpServers``MCPServerConfig` (stdio or HTTP), `tools.toolProfiles``ToolProfileConfig` can further filter tools within a single agent. See `nanobot/config/schema.py`. |
### Agent Workspaces
Each agent is a separate nanobot instance (Docker container) with an isolated workspace under `~/.nanobot/workspaces/`. The workspace contains bootstrap files (`AGENTS.md`, `SOUL.md`, `USER.md`) that define the agent's personality and instructions, plus a `memory/` directory for long-term memory that is private to that agent.
```
~/.nanobot/workspaces/
├── ilia/ # @ilia — personal dev, infra, research
│ ├── AGENTS.md # Dev/infra-focused instructions
│ ├── USER.md # Ilia's profile, preferences
│ ├── SOUL.md # Personality: technical, concise
│ └── memory/
│ └── MEMORY.md
├── family/ # @family — shared household agent
│ ├── AGENTS.md # Family scheduling, coordination
│ ├── USER.md # Family members, kids' info
│ ├── SOUL.md # Personality: warm, organized
│ └── memory/
│ └── MEMORY.md
└── wife/ # @wife — personal assistant for wife
├── AGENTS.md # Personal tasks, calendar, email
├── USER.md # Wife's profile, preferences
├── SOUL.md # Personality: friendly, helpful
└── memory/
└── MEMORY.md
```
Each container mounts its workspace and its own `config.json` (with agent-specific MCP servers, channels, and `allowFrom` lists). Compose service names are `nanobot-user1``user3`.
| Service | Persona | Config dir | Workspace (host → `/workspace`) | Typical channels |
|---|---|---|---|---|
| `nanobot-user1` | @ilia | `~/.nanobot-user1/` | `~/.nanobot/workspaces/ilia` | Telegram, email (Ilia) |
| `nanobot-user2` | @family | `~/.nanobot-user2/` | `~/.nanobot/workspaces/family` | Family Telegram |
| `nanobot-user3` | @wife | `~/.nanobot-user3/` | `~/.nanobot/workspaces/wife` | Telegram, email (wife) |
_Use `scripts/init-agent-workspaces.sh` to create the three workspace trees under `~/.nanobot/workspaces/`._
---
## Security: Local-Clone Policy
All new MCP servers are **cloned locally** into the repository rather than fetched at runtime from npm/PyPI registries. This gives us:
- **Audit control** — we can review every line before running it.
- **Reproducibility** — pinned commits, no surprise upstream updates.
- **Air-gap friendliness** — works on isolated networks after initial clone.
### Directory layout
```
nanobot/
├── mcp-servers/ # <-- NEW: local MCP server clones
│ ├── gitea-mcp/ # git clone from gitea.com/gitea/gitea-mcp
│ ├── google-calendar-mcp/ # git clone from github.com/nspady/google-calendar-mcp
│ ├── mcp-proxmox/ # git clone from github.com/antonio-mello-ai/mcp-proxmox
│ └── fetch-browser/ # git clone from github.com/TheSethRose/Fetch-Browser
├── nanobot/
├── docs/
└── ...
```
### Config pattern (local stdio)
```jsonc
{
"tools": {
"mcpServers": {
"gitea": {
"command": "./mcp-servers/gitea-mcp/gitea-mcp",
"args": ["--token", "$NANOBOT_GITLE_TOKEN", "--url", "http://10.0.30.169:3000"],
"env": {}
}
}
}
}
```
Each server's README in `mcp-servers/<name>/` documents build steps, required env vars, and update procedure.
---
## Shortlist — Next Phase
These are the 4 MCP servers we plan to integrate in the immediate next phase. Each entry is detailed enough to create implementation tickets directly.
---
### S1. Gitea MCP
| Field | Detail |
|---|---|
| **Upstream** | `gitea.com/gitea/gitea-mcp` (official, Go, v1.0.2, 56 stars, Apache-2.0) |
| **Transport** | Stdio (recommended) or SSE |
| **Auth** | Gitea personal-access token — reuse existing `$NANOBOT_GITLE_TOKEN` |
| **Complexity** | **Low** — token and network route to `http://10.0.30.169:3000` already exist |
| **Replaces** | Current curl-based `gitea` skill and hardcoded API commands in `AGENTS.md` |
| **Target agents** | `@ilia` only (dev tooling; not exposed to `@family` or `@wife`) |
#### User stories
- **US-G1**: As `@ilia`, I can say "list open PRs on nanobot" and get a formatted summary without writing curl commands.
- **US-G2**: As `@ilia`, I can say "search code for `MCPServerConfig`" and the agent returns matching files and lines from Gitea.
- **US-G3**: As `@ilia`, I can say "create an issue titled 'Add Proxmox MCP' with label `enhancement`" and the agent creates it in Gitea.
- **US-G4**: As `@ilia`, I can say "show diff for PR #42" and get a readable summary of changes.
#### Technical notes
- **Build**: Go 1.24+. Clone, `go build`, produces single binary `gitea-mcp`.
- **Local clone path**: `mcp-servers/gitea-mcp/`
- **Config entry**:
```jsonc
"gitea": {
"command": "./mcp-servers/gitea-mcp/gitea-mcp",
"args": [],
"env": {
"GITEA_URL": "http://10.0.30.169:3000",
"GITEA_TOKEN": "$NANOBOT_GITLE_TOKEN"
}
}
```
- **Expected tool names**: `mcp_gitea_list_repos`, `mcp_gitea_search_code`, `mcp_gitea_create_issue`, `mcp_gitea_list_pulls`, etc.
- **Safety**: Read operations are safe. Issue/PR creation and file writes should require user confirmation via tool-profile constraints.
---
### S2. Google Calendar MCP
| Field | Detail |
|---|---|
| **Upstream** | `github.com/nspady/google-calendar-mcp` (TypeScript, v2.6.1, 1071 stars, MIT) |
| **Transport** | Stdio via `node` |
| **Auth** | Google OAuth2 (same pattern as Gmail MCP — credentials in `~/.gmail-mcp/`) |
| **Complexity** | **Medium** — OAuth flow is already a solved pattern from Gmail MCP setup; multi-calendar config adds small overhead |
| **Complements** | Existing built-in `calendar` tool; MCP version adds multi-calendar, recurring events, and free/busy queries |
| **Target agents** | All three — `@ilia`, `@family`, `@wife` (each with their own calendar scope) |
#### User stories
- **US-C1**: As `@family`, I can ask "what's on the family calendar this week?" and get a merged view of all family members' events.
- **US-C2**: As `@ilia`, I can say "find a free 1-hour slot tomorrow afternoon" and the agent checks busy/free across my calendars.
- **US-C3**: As `@family`, I can say "add 'Soccer practice' to the family calendar on Saturday at 10am" and it creates the event.
- **US-C4**: As `@ilia`, I can say "reschedule my 2pm meeting to 4pm" and the agent updates the event after confirmation.
- **US-C5**: As `@wife`, I can say "what do I have on Thursday?" and see only events on my personal calendar.
#### Technical notes
- **Build**: `npm install` in cloned repo, run via `node dist/index.js`.
- **Local clone path**: `mcp-servers/google-calendar-mcp/`
- **OAuth setup**: Same Google Cloud project as Gmail MCP. Enable Calendar API, reuse existing OAuth client. Token stored alongside Gmail tokens.
- **Config entry**:
```jsonc
"google_calendar": {
"command": "node",
"args": ["./mcp-servers/google-calendar-mcp/dist/index.js"],
"env": {
"GOOGLE_OAUTH_CREDENTIALS": "~/.gmail-mcp/gcp-oauth.keys.json"
}
}
```
- **Expected tool names**: `mcp_google_calendar_list_events`, `mcp_google_calendar_create_event`, `mcp_google_calendar_freebusy`, `mcp_google_calendar_update_event`, `mcp_google_calendar_delete_event`
- **Migration path**: Phase out built-in `calendar` tool once MCP version is validated. Keep both available during transition via tool profiles.
---
### S3. Proxmox MCP
| Field | Detail |
|---|---|
| **Upstream** | `github.com/antonio-mello-ai/mcp-proxmox` (Python, pip-installable, MIT) |
| **Transport** | Stdio via `python -m mcp_proxmox` |
| **Auth** | Proxmox API token (user `nanobot@pam!mcp-token` + secret) |
| **Complexity** | **Medium** — requires network route to Proxmox cluster API, API token creation on Proxmox, and careful permission scoping |
| **New capability** | Homelab infrastructure visibility and management from chat |
| **Target agents** | `@ilia` only (infrastructure admin; never exposed to `@family` or `@wife`) |
#### User stories
- **US-P1**: As `@ilia`, I can say "show me the status of all VMs" and get a table of names, states, CPU, and RAM usage.
- **US-P2**: As `@ilia`, I can say "how much storage is left on the cluster?" and get aggregate numbers.
- **US-P3**: As `@ilia`, I can say "restart the dev-runner VM" and the agent does so after asking for confirmation.
- **US-P4**: As `@ilia`, I can say "take a snapshot of the nanobot VM before I upgrade" and the agent creates a named snapshot.
#### Technical notes
- **Build**: `pip install -e ./mcp-servers/mcp-proxmox/` into nanobot's venv, or use a dedicated venv.
- **Local clone path**: `mcp-servers/mcp-proxmox/`
- **Proxmox setup**:
1. Create API token: Datacenter → Permissions → API Tokens → Add (`nanobot@pam`, token ID `mcp-token`).
2. Assign minimum roles: `PVEAuditor` for read-only, `PVEVMAdmin` for lifecycle ops (Phase 1 starts read-only).
3. Store token secret in `~/.nanobot/config.json` env or in a `.env` file.
- **Config entry**:
```jsonc
"proxmox": {
"command": "python",
"args": ["-m", "mcp_proxmox"],
"env": {
"PROXMOX_HOST": "https://10.0.30.1:8006",
"PROXMOX_TOKEN_ID": "nanobot@pam!mcp-token",
"PROXMOX_TOKEN_SECRET": "$PROXMOX_TOKEN_SECRET",
"PROXMOX_VERIFY_SSL": "false"
}
}
```
- **Expected tool names**: `mcp_proxmox_list_nodes`, `mcp_proxmox_list_vms`, `mcp_proxmox_list_containers`, `mcp_proxmox_vm_status`, `mcp_proxmox_start_vm`, `mcp_proxmox_stop_vm`, `mcp_proxmox_create_snapshot`, `mcp_proxmox_list_storage`
- **Safety**: Phase 1 deploys with `PVEAuditor` role (read-only). Write operations (start/stop/snapshot) added in Phase 2 behind confirmation prompts. Restricted to `@ilia` profile only — never exposed to `@family`.
---
### S4. Web Fetch / Scraping MCP
| Field | Detail |
|---|---|
| **Upstream** | `github.com/TheSethRose/Fetch-Browser` (TypeScript, headless Chromium, MIT) |
| **Alt candidate** | `github.com/odgrim/mcp-fetch` (TypeScript, Puppeteer, simpler) |
| **Transport** | Stdio via `node` |
| **Auth** | None — no API keys required |
| **Complexity** | **Low** — clone, `npm install`, run; headless Chromium bundled by Puppeteer/Playwright |
| **Augments** | Built-in `web_fetch` tool (which does basic HTTP GET without JS rendering) |
| **Target agents** | All three — `@ilia`, `@family`, `@wife` |
#### User stories
- **US-W1**: As `@ilia`, I can say "fetch the Proxmox release notes page and summarize what's new" and the agent renders the JS-heavy page and extracts content.
- **US-W2**: As `@family`, I can say "get the lunch menu from the school website" and the agent scrapes the dynamically loaded content.
- **US-W3**: As `@ilia`, I can say "grab the pricing table from this SaaS page" and get structured data back.
- **US-W4**: As `@wife`, I can say "find me the best-rated recipe for lasagna" and the agent fetches and summarizes real recipe pages.
#### Technical notes
- **Build**: `npm install` in cloned repo.
- **Local clone path**: `mcp-servers/fetch-browser/`
- **Config entry**:
```jsonc
"web_scraper": {
"command": "node",
"args": ["./mcp-servers/fetch-browser/dist/index.js"],
"env": {}
}
```
- **Expected tool names**: `mcp_web_scraper_fetch_url`, `mcp_web_scraper_search_google`, `mcp_web_scraper_screenshot`
- **Resource note**: Headless Chromium uses ~200400 MB RAM per instance. Consider setting a process timeout or pool limit.
- **Safety**: Read-only by nature. No write side-effects. Safe for both `@ilia` and `@family`.
---
## Backlog — Later
Items below are future candidates, not yet scheduled. Grouped by domain. Each includes a candidate upstream project where one exists.
### Family / Life
| # | Integration | Upstream candidate | Notes |
|---|---|---|---|
| B-F1 | **CalDAV MCP** | `github.com/dominik1001/caldav-mcp` (Python, v0.4.0) | Universal calendar protocol. Enables Nextcloud, iCloud, ownCloud calendars. Useful if family moves off Google. |
| B-F2 | **Shared Todo / Household Tasks MCP** | `github.com/thijs-hakkenberg/mcp_todo` (Python, git-backed) | Git-backed collaborative task list with assignees, due dates, priorities, Kanban web UI, and Telegram bot. Good fit for family chores and grocery lists. |
| B-F3 | **Microsoft To Do MCP** | `github.com/akkilesh-a/microsoft-todo-mcp-server-self-hosted` (TypeScript) | Self-hosted HTTP transport. 15 tools for full task CRUD. Only relevant if family adopts Microsoft ecosystem. |
| B-F4 | **Home Assistant MCP** | TBD (community projects emerging) | Smart home control — lights, thermostat, locks, sensors. Requires Home Assistant instance on LAN. |
| B-F5 | **Shared Documents MCP** | TBD (Nextcloud WebDAV or Google Drive MCP) | Access family shared documents, photos, notes from chat. |
### Research
| # | Integration | Upstream candidate | Notes |
|---|---|---|---|
| B-R1 | **PDF RAG MCP** | `github.com/wesleygriffin/pdfrag` (Python, ChromaDB + sentence-transformers) | Semantic search over PDF papers. OCR support for scanned docs. Persistent vector index. |
| B-R2 | **Knowledge Base / Notes RAG MCP** | `github.com/alejandro-ao/RAG-MCP` (Python, FastMCP + ChromaDB) | Ingest markdown notes, docs, slides. Query with natural language. Supports LlamaParse for multi-format ETL. |
| B-R3 | **Zotero / Reference Manager MCP** | TBD | If user manages academic references in Zotero. Would expose library search, citation export, PDF retrieval. |
| B-R4 | **Arxiv / Semantic Scholar MCP** | TBD (API wrappers exist) | Direct paper search and metadata retrieval from academic APIs. |
### Dev / Infra
| # | Integration | Upstream candidate | Notes |
|---|---|---|---|
| B-D1 | **Filesystem MCP** | `github.com/mark3labs/mcp-filesystem-server` (Go, 622 stars) | Richer file ops than nanobot built-in (search, diff, metadata, copy trees). Useful for workspace automation. |
| B-D2 | **Docker / Portainer MCP** | `github.com/AI-Engineerings-at/homelab-mcp-bundle` (includes Portainer) | Container lifecycle, image management, compose operations. |
| B-D3 | **CI/CD Pipeline MCP** | TBD (Gitea Actions API or Drone) | Query pipeline status, trigger builds, view logs. Partially achievable through Gitea MCP's API. |
| B-D4 | **Logs & Monitoring MCP** | `github.com/AI-Engineerings-at/homelab-mcp-bundle` (includes Grafana, Uptime Kuma) | Query Grafana dashboards, check uptime monitors, search Loki logs. |
| B-D5 | **Backup Status MCP** | TBD (Proxmox Backup Server API or restic wrapper) | Check last backup timestamps, success/failure, storage usage. Could be a thin wrapper skill rather than full MCP. |
| B-D6 | **Database MCP** | TBD (PostgreSQL / SQLite MCP servers exist) | Run read-only queries against app databases for debugging and reporting. |
---
## Skill Catalog
Skills are higher-level task patterns that compose one or more tools (built-in or MCP) into a reusable workflow. Each skill lives as a `SKILL.md` in `nanobot/skills/<name>/` and is loaded by the skills system.
Because agents are **separate containers with separate workspaces**, a skill is available to an agent only if (a) the skill file is present in that workspace's `skills/` dir or in the shared bundled skills, and (b) the MCP servers it depends on are configured in that agent's `config.json`.
### Legend
| Column | Meaning |
|---|---|
| **Skill** | Natural-language trigger name |
| **Description** | What the skill does |
| **MCP deps** | Which MCP servers must be connected in the agent's config |
| **Built-in deps** | Which nanobot built-in tools are also needed |
| **Target agents** | Which agent containers should have this skill deployed (`@ilia`, `@family`, `@wife`) |
| **Safety tier** | `read-only` / `write-confirm` (mutates after user confirmation) / `admin` (restricted + confirm) |
---
### Scheduling Skills
| Skill | Description | MCP deps | Built-in deps | Target agents | Safety tier |
|---|---|---|---|---|---|
| **Plan my week** | List events across all calendars for the next 7 days, highlight conflicts, suggest time blocks for focus work | Google Calendar MCP | — | `@ilia`, `@family`, `@wife` | read-only |
| **Reschedule meeting** | Find a specific event, propose 3 alternative conflict-free times, update the event after user picks one | Google Calendar MCP | — | `@ilia`, `@wife` | write-confirm |
| **Find conflict-free times** | Query free/busy across calendars for a given duration and date range, return available slots | Google Calendar MCP | — | `@ilia`, `@family`, `@wife` | read-only |
### Email Skills
| Skill | Description | MCP deps | Built-in deps | Target agents | Safety tier |
|---|---|---|---|---|---|
| **Triage inbox** | Fetch unread emails, categorize by urgency (action-required / FYI / low-priority), surface top action items | Gmail MCP | `read_emails` | `@ilia`, `@wife` | read-only |
| **Draft replies** | For each action-required email, generate a draft reply. Present drafts for user approval before sending | Gmail MCP | — | `@ilia`, `@wife` | write-confirm |
| **Summarize today's mail** | Produce a concise digest of all emails received today, grouped by sender or topic | Gmail MCP | `read_emails` | `@ilia`, `@family`, `@wife` | read-only |
### Research Skills
| Skill | Description | MCP deps | Built-in deps | Target agents | Safety tier |
|---|---|---|---|---|---|
| **Find relevant papers** | Web-search for papers on a given topic, fetch top results, return title + abstract + URL for each | Web Fetch MCP | `web_search` | `@ilia` | read-only |
| **Summarize URL/PDF** | Fetch a URL (with JS rendering if needed) or read a local PDF, produce a structured summary | Web Fetch MCP | `read_file` | `@ilia`, `@family`, `@wife` | read-only |
| **Generate experiment checklist** | Given a goal description, produce a structured checklist of steps, tools needed, and success criteria | — | — | `@ilia` | read-only |
### Infra Skills
| Skill | Description | MCP deps | Built-in deps | Target agents | Safety tier |
|---|---|---|---|---|---|
| **Show VM status** | List all VMs/containers across Proxmox nodes with state, CPU%, RAM%, and uptime | Proxmox MCP | — | `@ilia` | read-only |
| **Restart non-critical service** | Stop and start a VM by name, but only if it is tagged `non-critical`. Refuse if tagged `critical`. Requires confirmation | Proxmox MCP | — | `@ilia` | admin |
| **Summarize cluster resources** | Aggregate CPU, RAM, and storage usage across all Proxmox nodes, flag any node above 80% utilization | Proxmox MCP | — | `@ilia` | read-only |
| **Pre-upgrade snapshot** | Before a maintenance window, create a named snapshot of specified VMs. Requires confirmation | Proxmox MCP | — | `@ilia` | admin |
### Dev Skills
| Skill | Description | MCP deps | Built-in deps | Target agents | Safety tier |
|---|---|---|---|---|---|
| **Summarize open PRs** | List all open PRs on the nanobot repo with title, author, age, review status, and CI state | Gitea MCP | — | `@ilia` | read-only |
| **Triage Gitea issues** | Fetch open issues, group by label, suggest priority ordering based on age and activity | Gitea MCP | — | `@ilia` | read-only |
| **Search codebase** | Search Gitea-hosted code for a symbol or string pattern, return matching files and line numbers | Gitea MCP | — | `@ilia` | read-only |
| **Create issue from chat** | Turn a conversation excerpt into a well-formatted Gitea issue with title, description, and labels. Requires confirmation | Gitea MCP | — | `@ilia` | write-confirm |
---
## Phase 1 Priorities
These are the items we commit to implementing first, chosen for maximum daily value with manageable complexity.
### Phase 1 MCP Integrations
| Priority | MCP Server | Rationale |
|---|---|---|
| **P1** | **Gitea MCP** | Directly replaces fragile curl-based Gitea access scattered across `AGENTS.md` and the `gitea` skill. Token and network route already exist. Aligns with daily dev workflow — PRs, issues, code search are used every day. |
| **P2** | **Google Calendar MCP** | Complements the existing built-in `calendar` tool with multi-calendar views and free/busy queries. OAuth is already a solved pattern from Gmail MCP. Deployed to all three agents — `@ilia` (work calendar), `@family` (shared family calendar), `@wife` (personal calendar). |
| **P3** | **Proxmox MCP** | Homelab infrastructure is checked frequently but currently requires opening the Proxmox web UI. Starting with read-only (`PVEAuditor`) makes it safe to deploy immediately. Write ops follow in a later phase. |
### Phase 1 Skills
| Priority | Skill | MCP dep | Agents | Safety | Why first |
|---|---|---|---|---|---|
| **S1** | Summarize open PRs | Gitea MCP | `@ilia` | read-only | Used daily; validates Gitea MCP end-to-end |
| **S2** | Plan my week | Google Calendar MCP | `@ilia`, `@family`, `@wife` | read-only | High value for every agent; validates Calendar MCP |
| **S3** | Triage inbox | Gmail MCP (already live) | `@ilia`, `@wife` | read-only | Formalizes an existing ad-hoc pattern; no new MCP needed |
| **S4** | Show VM status | Proxmox MCP | `@ilia` | read-only | Safe first infra skill; validates Proxmox MCP |
| **S5** | Summarize today's mail | Gmail MCP (already live) | `@ilia`, `@family`, `@wife` | read-only | Daily value for all agents; no new MCP needed |
---
## Implementation Notes
### Local clone workflow
```bash
# One-time setup
mkdir -p mcp-servers && cd mcp-servers
# Gitea MCP (Go)
git clone https://gitea.com/gitea/gitea-mcp.git
cd gitea-mcp && go build -o gitea-mcp . && cd ..
# Google Calendar MCP (TypeScript)
git clone https://github.com/nspady/google-calendar-mcp.git
cd google-calendar-mcp && npm install && npm run build && cd ..
# Proxmox MCP (Python)
git clone https://github.com/antonio-mello-ai/mcp-proxmox.git
cd mcp-proxmox && pip install -e . && cd ..
# Fetch Browser (TypeScript)
git clone https://github.com/TheSethRose/Fetch-Browser.git fetch-browser
cd fetch-browser && npm install && npm run build && cd ..
```
To update a server: `cd mcp-servers/<name> && git pull && <rebuild>`. Pin to a known-good commit with `git checkout <sha>` for production stability.
### Per-agent MCP wiring
Since each agent is a separate Docker container, MCP servers are configured in each agent's own `config.json`. An agent only gets the MCP servers listed in its config -- no routing needed.
**`~/.nanobot-user1/config.json`** (@ilia — all MCP servers):
```jsonc
{
"tools": {
"mcpServers": {
"gmail_mcp": { "command": "npx", "args": ["-y", "@gongrzhe/server-gmail-autoauth-mcp"] },
"gitea": { "command": "./mcp-servers/gitea-mcp/gitea-mcp", "args": [], "env": { "GITEA_URL": "http://10.0.30.169:3000", "GITEA_TOKEN": "$NANOBOT_GITLE_TOKEN" } },
"google_calendar": { "command": "node", "args": ["./mcp-servers/google-calendar-mcp/dist/index.js"], "env": { "GOOGLE_OAUTH_CREDENTIALS": "~/.gmail-mcp/gcp-oauth.keys.json" } },
"proxmox": { "command": "python", "args": ["-m", "mcp_proxmox"], "env": { "PROXMOX_HOST": "https://10.0.30.1:8006", "PROXMOX_TOKEN_ID": "nanobot@pam!mcp-token", "PROXMOX_TOKEN_SECRET": "$PROXMOX_TOKEN_SECRET", "PROXMOX_VERIFY_SSL": "false" } },
"web_scraper": { "command": "node", "args": ["./mcp-servers/fetch-browser/dist/index.js"], "env": {} }
}
}
}
```
**`~/.nanobot-user2/config.json`** (@family — scheduling + web only, no dev/infra):
```jsonc
{
"tools": {
"mcpServers": {
"google_calendar": { "command": "node", "args": ["./mcp-servers/google-calendar-mcp/dist/index.js"], "env": { "GOOGLE_OAUTH_CREDENTIALS": "~/.gmail-mcp/gcp-oauth.keys.json" } },
"web_scraper": { "command": "node", "args": ["./mcp-servers/fetch-browser/dist/index.js"], "env": {} }
}
}
}
```
**`~/.nanobot-user3/config.json`** (@wife — email + calendar + web, no dev/infra):
```jsonc
{
"tools": {
"mcpServers": {
"gmail_mcp": { "command": "npx", "args": ["-y", "@gongrzhe/server-gmail-autoauth-mcp"] },
"google_calendar": { "command": "node", "args": ["./mcp-servers/google-calendar-mcp/dist/index.js"], "env": { "GOOGLE_OAUTH_CREDENTIALS": "~/.gmail-mcp/gcp-oauth.keys.json" } },
"web_scraper": { "command": "node", "args": ["./mcp-servers/fetch-browser/dist/index.js"], "env": {} }
}
}
}
```
**MCP server allocation summary:**
| MCP Server | `@ilia` | `@family` | `@wife` |
|---|---|---|---|
| Gmail MCP | yes | -- | yes |
| Gitea MCP | yes | -- | -- |
| Google Calendar MCP | yes | yes | yes |
| Proxmox MCP | yes | -- | -- |
| Web Fetch MCP | yes | yes | yes |
Key points:
- `@family` and `@wife` never see Gitea or Proxmox tools -- those MCP servers are simply absent from their configs.
- `@family` has no email MCP (it's a shared household bot, not tied to one inbox). It still has the built-in `calendar` and `web` tools.
- Each container spawns its own MCP server processes via stdio from the shared `mcp-servers/` directory (mounted read-only into all containers).
### Safety tiers
| Tier | Behavior | Implementation |
|---|---|---|
| **read-only** | Tool executes immediately, no confirmation prompt | Default for query/list/search operations |
| **write-confirm** | Agent presents a summary of what it will do, waits for user "yes" before executing | Enforced in SKILL.md instructions: "Before calling `create_event`, show the user the details and ask for confirmation" |
| **admin** | Same as write-confirm but tool is only available in the `@ilia` container | Enforced by omitting the MCP server from other agents' `config.json` + SKILL.md confirmation instructions |
With separate containers, the strongest security boundary is **not configuring an MCP server at all** in an agent's config. Proxmox and Gitea are never in `@family` or `@wife` configs, so those agents physically cannot call those tools.
Phase 1 deploys **only read-only skills**. Write skills (draft replies, reschedule meeting, create issue, restart VM) are Phase 2 once we validate the read paths.
### Skill file template
New skills follow the existing format in `nanobot/skills/`:
```markdown
---
name: summarize-open-prs
description: "List and summarize all open pull requests on the nanobot Gitea repo."
metadata: {"nanobot":{"emoji":"📋","requires":{"mcp":["gitea"]}}}
---
# Summarize Open PRs
## When to use
User asks about open PRs, pending reviews, or code review status.
## Steps
1. Call `mcp_gitea_list_pulls` with state=open.
2. For each PR, extract: title, author, created date, review status, CI status.
3. Format as a numbered list sorted by age (oldest first).
4. Highlight PRs with no reviews or failing CI.
## Safety
Read-only. No confirmation needed.
```
### Docker considerations
All three containers (`nanobot-user1`, `nanobot-user2`, `nanobot-user3`) share the same Docker image. MCP server processes are spawned inside each container as needed. The Dockerfile must include:
- **Go** (for Gitea MCP binary — or copy pre-built binary)
- **Node.js 18+** (for Calendar MCP and Fetch Browser)
- **Python pip deps** (for Proxmox MCP — install into the same venv or a sidecar)
- **Chromium** (for Fetch Browser headless rendering — `npx puppeteer browsers install chrome` or use Playwright)
The `mcp-servers/` directory is mounted read-only into all containers so each agent can spawn the MCP servers listed in its config. Alternatively, build MCP binaries in a multi-stage Docker build and copy only the artifacts into the image.
**Volume mounts (per container)** — compose services remain `nanobot-user1` / `user2` / `user3`; they map to `@ilia` / `@family` / `@wife` workspaces.
```yaml
nanobot-user1: # @ilia
volumes:
- ~/.nanobot-user1:/root/.nanobot
- ~/.nanobot/workspaces/ilia:/workspace
# Optional: ./mcp-servers:/app/mcp-servers:ro
nanobot-user2: # @family
volumes:
- ~/.nanobot-user2:/root/.nanobot
- ~/.nanobot/workspaces/family:/workspace
nanobot-user3: # @wife
volumes:
- ~/.nanobot-user3:/root/.nanobot
- ~/.nanobot/workspaces/wife:/workspace
```
### Rollout sequence
```
Week 1: Clone repos, build locally, verify each MCP server starts and lists tools
Week 2: Wire Gitea MCP + "Summarize open PRs" skill, validate end-to-end
Week 3: Wire Calendar MCP + "Plan my week" skill, formalize "Triage inbox" skill
Week 4: Wire Proxmox MCP (read-only) + "Show VM status" skill
Week 5: Add "Summarize today's mail" skill, integrate Web Fetch MCP
Week 6: Retrospective, update this document, plan Phase 2 write-skills
```
---
## Changelog
| Date | Change |
|---|---|
| 2026-03-30 | Updated to reflect multi-container workspace architecture (Option B). Added `@wife` as third agent. Rewrote per-agent MCP wiring with separate config.json per container. Updated skill assignments across all three agents. |
| 2026-03-30 | Initial version — shortlist (4 MCP), backlog (16 ideas), skill catalog (16 skills), Phase 1 defined (3 MCP + 5 skills) |

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 - Send messages to users on chat channels
- Spawn subagents for complex background tasks - 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 ## Current Time
{now} ({tz}) {now} ({tz})
@ -101,6 +117,12 @@ Your workspace is at: {workspace_path}
- History log: {workspace_path}/memory/HISTORY.md (grep-searchable) - History log: {workspace_path}/memory/HISTORY.md (grep-searchable)
- Custom skills: {workspace_path}/skills/{{skill-name}}/SKILL.md - Custom skills: {workspace_path}/skills/{{skill-name}}/SKILL.md
**Filesystem tools (read_file, write_file, edit_file, list_dir):** Use paths **under this workspace root only** (`{workspace_path}`). Do not invent other roots (e.g. `/mnt/data/...` on a host) unless you know they are valid on this runtime. **`list_dir` takes one directory path**no wildcards (never pass `*.pdf` in the path). To find PDFs, `list_dir("{workspace_path}")` (or a subfolder) and filter for `.pdf` names, or use `exec` with `find` under that directory.
**Answering after tools:** When a tool already returned what the user needs, base your reply **only on that tool output**same topic as the users question, no hijacking.
- After **`list_dir`:** If they asked for PDFs (or another extension), list **only** matching names (paths under `{workspace_path}` if useful). If none, say so briefly. No essays, no calling the folder "code" unless they asked for analysis.
- After **`read_emails`:** Answer **only** from the email text the tool returned (From, Subject, Date, attachments, downloaded paths, body as needed). Do **not** switch to unrelated topics (Git, Gitea, this repo, workspace docs, coding help, general chit-chat). Do **not** apologize at length or describe "what an email is". Match the question: e.g. latest email sender + subject (+ date) in a few lines unless they asked for the full body.
## Gitea API (This Repository) ## Gitea API (This Repository)
**CRITICAL**: This repository uses Gitea at `http://10.0.30.169:3000/api/v1`, NOT GitHub. **CRITICAL**: This repository uses Gitea at `http://10.0.30.169:3000/api/v1`, NOT GitHub.
- Repository: `ilia/nanobot` - Repository: `ilia/nanobot`
@ -120,7 +142,7 @@ Always be helpful, accurate, and concise. Before calling tools, briefly tell the
When remembering something important, write to {workspace_path}/memory/MEMORY.md When remembering something important, write to {workspace_path}/memory/MEMORY.md
To recall past events, grep {workspace_path}/memory/HISTORY.md To recall past events, grep {workspace_path}/memory/HISTORY.md
IMPORTANT: For email queries (latest email, email sender, inbox, etc.), ALWAYS use the read_emails tool. NEVER use exec() with mail/tail/awk commands or read_file() on /var/mail - those will not work. The read_emails tool is the only way to access emails.""" IMPORTANT: For email queries (latest email, email sender, inbox, etc.), ALWAYS use the read_emails tool. NEVER use exec() with mail/tail/awk commands or read_file() on /var/mail - those will not work. The read_emails tool is the only way to access emails. Once read_emails returns, your assistant reply must **only** satisfy that email question from the tool resultignore Gitea/workspace/bootstrap content unless the user tied their question to it."""
def _load_bootstrap_files(self) -> str: def _load_bootstrap_files(self) -> str:
"""Load all bootstrap files from workspace.""" """Load all bootstrap files from workspace."""

View File

@ -22,7 +22,7 @@ from nanobot.agent.tools.spawn import SpawnTool
from nanobot.agent.tools.web import WebFetchTool, WebSearchTool from nanobot.agent.tools.web import WebFetchTool, WebSearchTool
from nanobot.bus.events import InboundMessage, OutboundMessage from nanobot.bus.events import InboundMessage, OutboundMessage
from nanobot.bus.queue import MessageBus from nanobot.bus.queue import MessageBus
from nanobot.config.schema import ExecToolConfig from nanobot.config.schema import ExecToolConfig, ToolRoutingConfig
from nanobot.cron.service import CronService from nanobot.cron.service import CronService
from nanobot.providers.base import LLMProvider from nanobot.providers.base import LLMProvider
from nanobot.session.manager import Session, SessionManager from nanobot.session.manager import Session, SessionManager
@ -56,6 +56,9 @@ class AgentLoop:
restrict_to_workspace: bool = False, restrict_to_workspace: bool = False,
session_manager: SessionManager | None = None, session_manager: SessionManager | None = None,
mcp_servers: dict | None = None, mcp_servers: dict | None = None,
tool_profiles: dict | None = None,
default_tool_profile: str = "default",
tool_routing: ToolRoutingConfig | None = None,
): ):
self.bus = bus self.bus = bus
self.provider = provider self.provider = provider
@ -87,8 +90,11 @@ class AgentLoop:
self._running = False self._running = False
self._mcp_servers = mcp_servers or {} self._mcp_servers = mcp_servers or {}
self._mcp_stack: AsyncExitStack | None = None self._mcp_stacks: dict[str, AsyncExitStack] = {}
self._mcp_connected = False self._mcp_connected_servers: set[str] = set()
self._tool_profiles: dict = tool_profiles or {}
self._default_tool_profile = default_tool_profile
self._tool_routing = tool_routing or ToolRoutingConfig()
self._register_default_tools() self._register_default_tools()
def _register_default_tools(self) -> None: def _register_default_tools(self) -> None:
@ -129,7 +135,10 @@ class AgentLoop:
from nanobot.config.loader import load_config from nanobot.config.loader import load_config
config = load_config() config = load_config()
if config.channels.email.enabled: if config.channels.email.enabled:
email_tool = EmailTool(email_config=config.channels.email) email_tool = EmailTool(
email_config=config.channels.email,
workspace=self.workspace,
)
self.tools.register(email_tool) self.tools.register(email_tool)
logger.info(f"Email tool '{email_tool.name}' registered successfully") logger.info(f"Email tool '{email_tool.name}' registered successfully")
else: else:
@ -153,15 +162,69 @@ class AgentLoop:
logger.warning(f"Calendar tool not available: {e}") logger.warning(f"Calendar tool not available: {e}")
# Calendar tool not available or not configured - silently skip # Calendar tool not available or not configured - silently skip
async def _connect_mcp(self) -> None: def _unregister_mcp_tools_for_server(self, server_key: str) -> None:
"""Connect to configured MCP servers (one-time, lazy).""" """Remove tools registered from one MCP server (prefix mcp_<key>_)."""
if self._mcp_connected or not self._mcp_servers: prefix = f"mcp_{server_key}_"
for name in list(self.tools.tool_names):
if name.startswith(prefix):
self.tools.unregister(name)
async def _disconnect_mcp_server(self, server_key: str) -> None:
"""Close one MCP server and remove its tools (used when switching tool profiles)."""
stack = self._mcp_stacks.pop(server_key, None)
if stack is not None:
try:
await stack.aclose()
except (RuntimeError, BaseExceptionGroup):
pass
self._unregister_mcp_tools_for_server(server_key)
self._mcp_connected_servers.discard(server_key)
logger.info(f"MCP server '{server_key}': disconnected")
async def _sync_mcp_to_profile_needs(self, needed_keys: list[str]) -> None:
"""
Ensure only MCP servers in needed_keys are connected: tear down extras, connect missing.
When tools.toolProfiles is empty, pass the full configured key list so all servers stay up.
"""
if not self._mcp_servers:
return return
self._mcp_connected = True needed = set(needed_keys)
from nanobot.agent.tools.mcp import connect_mcp_servers for key in list(self._mcp_connected_servers):
self._mcp_stack = AsyncExitStack() if key not in needed:
await self._mcp_stack.__aenter__() await self._disconnect_mcp_server(key)
await connect_mcp_servers(self._mcp_servers, self.tools, self._mcp_stack) connect_order = [k for k in self._mcp_servers.keys() if k in needed]
await self._ensure_mcp_servers_connected(connect_order)
async def _ensure_mcp_servers_connected(self, server_keys: list[str]) -> None:
"""Lazily connect MCP servers (each gets its own AsyncExitStack for per-server teardown)."""
if not self._mcp_servers or not server_keys:
return
pending = [
k
for k in server_keys
if k in self._mcp_servers and k not in self._mcp_connected_servers
]
if not pending:
return
from nanobot.agent.tools.mcp import connect_mcp_server
for key in pending:
stack = AsyncExitStack()
await stack.__aenter__()
try:
await connect_mcp_server(
key, self._mcp_servers[key], self.tools, stack
)
self._mcp_stacks[key] = stack
self._mcp_connected_servers.add(key)
except Exception as e:
logger.error(f"MCP server '{key}': failed to connect: {e}")
try:
await stack.aclose()
except (RuntimeError, BaseExceptionGroup):
pass
def _set_tool_context(self, channel: str, chat_id: str) -> None: def _set_tool_context(self, channel: str, chat_id: str) -> None:
"""Update context for all tools that need routing info.""" """Update context for all tools that need routing info."""
@ -194,6 +257,41 @@ class AgentLoop:
return f'{tc.name}("{val[:40]}")' if len(val) > 40 else f'{tc.name}("{val}")' return f'{tc.name}("{val[:40]}")' if len(val) > 40 else f'{tc.name}("{val}")'
return ", ".join(_fmt(tc) for tc in tool_calls) return ", ".join(_fmt(tc) for tc in tool_calls)
@staticmethod
def _extract_routing_text(messages: list[dict]) -> str:
"""Last user message text (string or multimodal) for the tool-profile router."""
for m in reversed(messages):
if m.get("role") != "user":
continue
c = m.get("content")
if isinstance(c, str):
return c.strip()
if isinstance(c, list):
parts: list[str] = []
for block in c:
if isinstance(block, dict) and block.get("type") == "text":
parts.append(str(block.get("text") or ""))
return "\n".join(parts).strip()
return ""
async def _pick_tool_profile(self, user_text: str) -> str:
"""Resolve profile key when tools.toolProfiles is configured."""
if not self._tool_profiles:
return self._default_tool_profile
if self._tool_routing.enabled:
from nanobot.agent.tool_routing import route_tool_profile
return await route_tool_profile(
self.provider,
model=self.model,
user_message=user_text,
profiles=self._tool_profiles,
default_profile=self._default_tool_profile,
temperature=self._tool_routing.router_temperature,
max_tokens=self._tool_routing.router_max_tokens,
)
return self._default_tool_profile
async def _run_agent_loop( async def _run_agent_loop(
self, self,
initial_messages: list[dict], initial_messages: list[dict],
@ -213,16 +311,54 @@ class AgentLoop:
iteration = 0 iteration = 0
final_content = None final_content = None
tools_used: list[str] = [] tools_used: list[str] = []
empty_final_retry_used = False
from nanobot.agent.tool_profiles import (
compute_allowed_tool_names,
mcp_keys_to_connect,
)
from nanobot.agent.tool_routing import is_tool_not_found_error
configured_mcp = list(self._mcp_servers.keys())
tools_expanded = False
allowed_names: set[str] | None = None
if self._tool_profiles:
routing_text = self._extract_routing_text(initial_messages)
profile_key = await self._pick_tool_profile(routing_text)
prof = self._tool_profiles[profile_key]
await self._sync_mcp_to_profile_needs(
mcp_keys_to_connect(prof, configured_mcp)
)
always = set(self._tool_routing.always_include_tools)
allowed_names = compute_allowed_tool_names(
self.tools,
prof,
configured_mcp,
always,
)
logger.info(
f"Tool profile '{profile_key}': {len(allowed_names)}/{len(self.tools)} tools exposed"
)
else:
await self._sync_mcp_to_profile_needs(configured_mcp)
tools_full = self.tools.get_definitions()
while iteration < self.max_iterations: while iteration < self.max_iterations:
iteration += 1 iteration += 1
logger.debug(f"Agent loop iteration {iteration}/{self.max_iterations}, calling LLM provider...") logger.debug(f"Agent loop iteration {iteration}/{self.max_iterations}, calling LLM provider...")
if allowed_names is not None and not tools_expanded:
tool_defs = self.tools.get_definitions_subset(allowed_names)
else:
tool_defs = tools_full
try: try:
response = await asyncio.wait_for( response = await asyncio.wait_for(
self.provider.chat( self.provider.chat(
messages=messages, messages=messages,
tools=self.tools.get_definitions(), tools=tool_defs,
model=self.model, model=self.model,
temperature=self.temperature, temperature=self.temperature,
max_tokens=self.max_tokens, max_tokens=self.max_tokens,
@ -264,6 +400,18 @@ class AgentLoop:
logger.info(f"Tool call: {tool_call.name}({args_str[:200]})") logger.info(f"Tool call: {tool_call.name}({args_str[:200]})")
result = await self.tools.execute(tool_call.name, tool_call.arguments) result = await self.tools.execute(tool_call.name, tool_call.arguments)
logger.info(f"Tool result length: {len(result) if result else 0}, preview: {result[:200] if result else 'None'}") logger.info(f"Tool result length: {len(result) if result else 0}, preview: {result[:200] if result else 'None'}")
if (
allowed_names is not None
and self._tool_routing.expand_on_missing_tool
and not tools_expanded
and is_tool_not_found_error(result)
):
tools_expanded = True
await self._sync_mcp_to_profile_needs(configured_mcp)
tools_full = self.tools.get_definitions()
logger.info(
"Expanded tool set to full registry (missing tool after profile filter)"
)
messages = self.context.add_tool_result( messages = self.context.add_tool_result(
messages, tool_call.id, tool_call.name, result messages, tool_call.id, tool_call.name, result
) )
@ -271,6 +419,24 @@ class AgentLoop:
else: else:
final_content = self._strip_think(response.content) final_content = self._strip_think(response.content)
logger.info(f"Final response generated. Content length: {len(final_content) if final_content else 0}") 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 break
if final_content is None and iteration >= self.max_iterations: if final_content is None and iteration >= self.max_iterations:
@ -281,7 +447,6 @@ class AgentLoop:
async def run(self) -> None: async def run(self) -> None:
"""Run the agent loop, processing messages from the bus.""" """Run the agent loop, processing messages from the bus."""
self._running = True self._running = True
await self._connect_mcp()
logger.info("Agent loop started") logger.info("Agent loop started")
while self._running: while self._running:
@ -305,13 +470,13 @@ class AgentLoop:
continue continue
async def close_mcp(self) -> None: async def close_mcp(self) -> None:
"""Close MCP connections.""" """Close all MCP connections and drop MCP tools from the registry."""
if self._mcp_stack: for key in list(
try: set(self._mcp_stacks.keys()) | self._mcp_connected_servers
await self._mcp_stack.aclose() ):
except (RuntimeError, BaseExceptionGroup): await self._disconnect_mcp_server(key)
pass # MCP SDK cancel scope cleanup is noisy but harmless self._mcp_stacks.clear()
self._mcp_stack = None self._mcp_connected_servers.clear()
def stop(self) -> None: def stop(self) -> None:
"""Stop the agent loop.""" """Stop the agent loop."""
@ -581,7 +746,6 @@ Respond with ONLY valid JSON, no markdown fences."""
Returns: Returns:
The agent's response. The agent's response.
""" """
await self._connect_mcp()
msg = InboundMessage( msg = InboundMessage(
channel=channel, channel=channel,
sender_id="user", sender_id="user",

View File

@ -0,0 +1,88 @@
"""Tool profile: compute which tools are visible to the LLM for a given config profile."""
from __future__ import annotations
from typing import TYPE_CHECKING
from loguru import logger
from nanobot.config.schema import ToolProfileConfig
if TYPE_CHECKING:
from nanobot.agent.tools.registry import ToolRegistry
def mcp_server_for_tool(tool_name: str, mcp_server_keys: list[str]) -> str | None:
"""
Infer MCP server config key from nanobot's tool name pattern mcp_<serverKey>_<mcpToolName>.
Server keys are matched longest-first so names with underscores resolve unambiguously.
"""
prefix = "mcp_"
if not tool_name.startswith(prefix):
return None
rest = tool_name[len(prefix) :]
for key in sorted(mcp_server_keys, key=len, reverse=True):
sep = f"{key}_"
if rest.startswith(sep):
return key
return None
def mcp_keys_to_connect(
profile: ToolProfileConfig, configured_mcp_keys: list[str]
) -> list[str]:
"""
Config keys for MCP servers to connect for this profile, in config order.
None on profile.mcp_servers means all configured servers; [] means none.
Unknown keys in the profile list are logged and skipped.
"""
if not configured_mcp_keys:
return []
configured_set = set(configured_mcp_keys)
if profile.mcp_servers is None:
return list(configured_mcp_keys)
out: list[str] = []
for k in profile.mcp_servers:
if k in configured_set:
out.append(k)
else:
logger.warning(
f"tools.toolProfiles entry references unknown MCP server {k!r}; "
"not in tools.mcpServers keys"
)
return out
def compute_allowed_tool_names(
registry: ToolRegistry,
profile: ToolProfileConfig,
mcp_server_keys: list[str],
always_include: set[str],
) -> set[str]:
"""Union of profile-filtered builtins + MCP tools + always-include (intersected with registered names)."""
all_names = set(registry.tool_names)
mcp_keys = list(mcp_server_keys)
builtins = {n for n in all_names if mcp_server_for_tool(n, mcp_keys) is None}
if profile.builtin_tools is None:
allowed_builtins = set(builtins)
else:
allowed_builtins = set(profile.builtin_tools) & builtins
if profile.mcp_servers is None:
allowed_mcp = {
n for n in all_names if mcp_server_for_tool(n, mcp_keys) is not None
}
else:
allow_srv = set(profile.mcp_servers)
allowed_mcp = {
n
for n in all_names
if (srv := mcp_server_for_tool(n, mcp_keys)) is not None and srv in allow_srv
}
extras = always_include & all_names
return allowed_builtins | allowed_mcp | extras

View File

@ -0,0 +1,118 @@
"""LLM-based router: choose a tools.toolProfiles key from the user message."""
from __future__ import annotations
import json_repair
from loguru import logger
from nanobot.config.schema import ToolProfileConfig
from nanobot.providers.base import LLMProvider
async def route_tool_profile(
provider: LLMProvider,
*,
model: str,
user_message: str,
profiles: dict[str, ToolProfileConfig],
default_profile: str,
temperature: float = 0.2,
max_tokens: int = 128,
) -> str:
"""
Ask a small LLM call to return JSON {"profile": "<key>"}.
Falls back to default_profile on any failure or unknown key.
"""
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)"
lines.append(f"- {name}: {desc}")
catalog = "\n".join(lines)
allowed = ", ".join(f'"{k}"' for k in profiles)
system = (
"You are a tool-profile router. Pick exactly one profile key for the assistant's next turn. "
"Respond with JSON only: {\"profile\": \"<key>\"} where <key> is one of: "
f"{allowed}. "
"Prefer narrower profiles when the request is clearly scoped (e.g. only read files). "
"Use the broadest profile only when multiple unrelated capabilities are needed."
)
user = f"Available profiles:\n{catalog}\n\nUser message:\n{user_message.strip()[:8000]}"
try:
response = await provider.chat(
messages=[
{"role": "system", "content": system},
{"role": "user", "content": user},
],
tools=None,
model=model,
temperature=temperature,
max_tokens=max_tokens,
)
text = (response.content or "").strip()
if not text:
return default_profile
if text.startswith("```"):
text = text.split("\n", 1)[-1].rsplit("```", 1)[0].strip()
data = json_repair.loads(text)
if not isinstance(data, dict):
return default_profile
name = data.get("profile")
if isinstance(name, str) and name in profiles:
logger.info(f"Tool router selected profile '{name}'")
return name
logger.warning(f"Tool router returned invalid profile {name!r}, using default")
except (TypeError, ValueError) as e:
logger.warning(f"Tool router JSON parse failed: {e}")
except Exception as e:
logger.warning(f"Tool router failed: {e}")
return default_profile
def is_tool_not_found_error(result: str) -> bool:
"""Detect registry execute() message for missing tools."""
if not result:
return False
return result.startswith("Error: Tool '") and "' not found" in result

View File

@ -179,7 +179,34 @@ class CalendarTool(Tool):
coerced["action"] = "list_events" if value == "calendar" else value coerced["action"] = "list_events" if value == "calendar" else value
coerced.pop(key, None) coerced.pop(key, None)
break break
# Models often omit `action` and pass only a range ("this week", "1 week") under a junk key.
if not coerced.get("action"):
_known_actions = {
"list_events",
"create_event",
"delete_event",
"delete_events",
"update_event",
"check_availability",
"calendar",
}
_create_keys = {"title", "start_time", "end_time", "event_id", "event_ids", "description", "location", "attendees"}
if len(coerced) == 1:
only_k, only_v = next(iter(coerced.items()))
if only_k == "time_min" and isinstance(only_v, str):
coerced["action"] = "list_events"
elif isinstance(only_v, str) and only_k not in _create_keys:
if only_v in _known_actions:
coerced["action"] = "list_events" if only_v == "calendar" else only_v
coerced.pop(only_k, None)
else:
coerced = {"action": "list_events", "time_min": only_v}
else:
coerced["action"] = "list_events"
else:
coerced = {**coerced, "action": "list_events"}
return coerced return coerced
@property @property

View File

@ -3,6 +3,7 @@
import asyncio import asyncio
import imaplib import imaplib
import ssl import ssl
from pathlib import Path
from datetime import date from datetime import date
from email import policy from email import policy
from email.header import decode_header, make_header from email.header import decode_header, make_header
@ -36,17 +37,20 @@ class EmailTool(Tool):
"unread_only (bool, default false), mark_seen (bool, default false), download_attachments (bool, default false " "unread_only (bool, default false), mark_seen (bool, default false), download_attachments (bool, default false "
"- set to true to download all attachments to workspace), attachment_name (string, optional - filter emails by " "- set to true to download all attachments to workspace), attachment_name (string, optional - filter emails by "
"attachment filename, case-insensitive partial match). Returns formatted email list with sender, subject, date, " "attachment filename, case-insensitive partial match). Returns formatted email list with sender, subject, date, "
"attachments (if any), downloaded file paths (if downloaded), and body." "attachments (if any), downloaded file paths (if downloaded), and body. After you receive this output, your "
"reply to the user must address their email question using only this data—no unrelated topics."
) )
def __init__(self, email_config: Any = None): def __init__(self, email_config: Any = None, workspace: Path | None = None):
""" """
Initialize email tool with email configuration. Initialize email tool with email configuration.
Args: Args:
email_config: Optional EmailConfig instance. If None, loads from config. email_config: Optional EmailConfig instance. If None, loads from config.
workspace: Directory for downloaded attachments (defaults to config workspace_path).
""" """
self._email_config = email_config self._email_config = email_config
self._workspace = workspace
@property @property
def config(self) -> Any: def config(self) -> Any:
@ -315,8 +319,12 @@ class EmailTool(Tool):
if download_attachments and attachments: if download_attachments and attachments:
import logging import logging
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
from pathlib import Path if self._workspace is not None:
workspace = Path("/mnt/data/nanobot/workspace") workspace = Path(self._workspace).expanduser().resolve()
else:
from nanobot.config.loader import load_config
workspace = load_config().workspace_path.expanduser().resolve()
workspace.mkdir(parents=True, exist_ok=True) workspace.mkdir(parents=True, exist_ok=True)
# Build a map of attachment parts by decoded filename for efficient lookup # Build a map of attachment parts by decoded filename for efficient lookup

View File

@ -28,7 +28,9 @@ class ReadFileTool(Tool):
@property @property
def description(self) -> str: def description(self) -> str:
return """Read the contents of a file at the given path. return """Read the contents of a file at the given path.
`path` must be a single file path under the configured workspace (no `*` globs).
ALWAYS use this tool to read files - it supports: ALWAYS use this tool to read files - it supports:
- Text files (plain text, code, markdown, etc.) - Text files (plain text, code, markdown, etc.)
@ -44,7 +46,7 @@ For reading files, use read_file FIRST. Only use exec for complex data processin
"properties": { "properties": {
"path": { "path": {
"type": "string", "type": "string",
"description": "The file path to read" "description": "Absolute or workspace-relative path to one file (no wildcards)",
} }
}, },
"required": ["path"] "required": ["path"]
@ -115,7 +117,7 @@ class WriteFileTool(Tool):
@property @property
def description(self) -> str: def description(self) -> str:
return "Write content to a file at the given path. Creates parent directories if needed. IMPORTANT: Always provide both 'path' and 'content' parameters. If no full path is specified, use the workspace directory (/mnt/data/nanobot/workspace/)." return "Write content to a file at the given path. Creates parent directories if needed. IMPORTANT: Always provide both 'path' and 'content' parameters. Paths must be under the workspace root from the system prompt (no globs)."
@property @property
def parameters(self) -> dict[str, Any]: def parameters(self) -> dict[str, Any]:
@ -219,7 +221,11 @@ class ListDirTool(Tool):
@property @property
def description(self) -> str: def description(self) -> str:
return "List the contents of a directory." return (
"List files and subfolders in one directory. "
"`path` must be a directory that exists under the workspace root—no `*` or `*.pdf` wildcards. "
"To list PDFs, list the directory and read names ending in .pdf, or use exec with find."
)
@property @property
def parameters(self) -> dict[str, Any]: def parameters(self) -> dict[str, Any]:
@ -228,7 +234,7 @@ class ListDirTool(Tool):
"properties": { "properties": {
"path": { "path": {
"type": "string", "type": "string",
"description": "The directory path to list" "description": "Path to an existing directory under the workspace (no wildcards)",
} }
}, },
"required": ["path"] "required": ["path"]

View File

@ -1,5 +1,9 @@
"""MCP client: connects to MCP servers and wraps their tools as native nanobot tools.""" """MCP client: connects to MCP servers and wraps their tools as native nanobot tools."""
import asyncio
import json
import re
import os
from contextlib import AsyncExitStack from contextlib import AsyncExitStack
from typing import Any from typing import Any
@ -9,15 +13,65 @@ from nanobot.agent.tools.base import Tool
from nanobot.agent.tools.registry import ToolRegistry from nanobot.agent.tools.registry import ToolRegistry
_SAFE_TOOL_NAME_RE = re.compile(r"[^A-Za-z0-9_]+")
def _normalize_tool_segment(segment: str) -> str:
"""
Normalize MCP server/tool names into a safe function name segment.
- Replace non [A-Za-z0-9_] with underscore
- Collapse repeated underscores
- Trim leading/trailing underscores
- Ensure non-empty
"""
s = _SAFE_TOOL_NAME_RE.sub("_", (segment or "").strip())
s = re.sub(r"_+", "_", s).strip("_")
return s or "tool"
def _render_mcp_content_blocks(blocks: list[Any]) -> str:
"""Render MCP content blocks into a stable, readable string."""
from mcp import types
parts: list[str] = []
for block in blocks or []:
if isinstance(block, types.TextContent):
parts.append(block.text)
continue
# Prefer structured JSON for non-text blocks when possible.
dump = getattr(block, "model_dump", None)
if callable(dump):
try:
parts.append(json.dumps(dump(), ensure_ascii=False, indent=2))
continue
except Exception:
pass
parts.append(str(block))
return "\n".join([p for p in parts if p is not None]).strip()
class MCPToolWrapper(Tool): class MCPToolWrapper(Tool):
"""Wraps a single MCP server tool as a nanobot Tool.""" """Wraps a single MCP server tool as a nanobot Tool."""
def __init__(self, session, server_name: str, tool_def): def __init__(
self,
session,
*,
server_key: str,
tool_def,
registered_name: str,
call_timeout_s: float = 30.0,
):
self._session = session self._session = session
self._original_name = tool_def.name self._original_name = tool_def.name
self._name = f"mcp_{server_name}_{tool_def.name}" self._server_key = server_key
self._name = registered_name
self._description = tool_def.description or tool_def.name self._description = tool_def.description or tool_def.name
self._parameters = tool_def.inputSchema or {"type": "object", "properties": {}} self._parameters = tool_def.inputSchema or {"type": "object", "properties": {}}
self._call_timeout_s = call_timeout_s
@property @property
def name(self) -> str: def name(self) -> str:
@ -32,71 +86,103 @@ class MCPToolWrapper(Tool):
return self._parameters return self._parameters
async def execute(self, **kwargs: Any) -> str: async def execute(self, **kwargs: Any) -> str:
from mcp import types try:
import json result = await asyncio.wait_for(
result = await self._session.call_tool(self._original_name, arguments=kwargs) self._session.call_tool(self._original_name, arguments=kwargs),
parts = [] timeout=self._call_timeout_s,
for block in result.content: )
if isinstance(block, types.TextContent): except asyncio.TimeoutError:
parts.append(block.text) return (
else: f"Error: MCP tool timed out after {self._call_timeout_s:.0f}s "
parts.append(str(block)) f"({self._server_key}:{self._original_name})"
output = "\n".join(parts) )
# For empty results from search/list operations, provide clearer feedback output = _render_mcp_content_blocks(getattr(result, "content", []))
if not output or output.strip() == "": if not output:
# Check if this is a search/list operation (common patterns) return "(no output)"
if "search" in self._original_name.lower() or "list" in self._original_name.lower():
if "unread" in str(kwargs).lower() or "is:unread" in str(kwargs).lower(): # If the tool returned JSON, normalize empty collections to a clearer message.
return "No unread emails found."
return "No results found."
# Try to parse JSON to check for empty arrays/lists
try: try:
parsed = json.loads(output) parsed = json.loads(output)
if isinstance(parsed, list) and len(parsed) == 0: if parsed == [] or parsed == {}:
if "search" in self._original_name.lower() or "list" in self._original_name.lower(): return "No results found."
if "unread" in str(kwargs).lower() or "is:unread" in str(kwargs).lower():
return "No unread emails found."
return "No results found."
except (json.JSONDecodeError, ValueError): except (json.JSONDecodeError, ValueError):
pass # Not JSON, continue with original output pass # Not JSON, continue with original output
return output or "(no output)" return output
async def connect_mcp_server(
name: str, cfg: Any, registry: ToolRegistry, stack: AsyncExitStack
) -> None:
"""Connect one MCP server and register its tools (used for lazy profile-scoped connections)."""
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=_expand_env(cfg.env) or None
)
read, write = await stack.enter_async_context(stdio_client(params))
elif cfg.url:
from mcp.client.streamable_http import streamable_http_client
read, write, _ = await stack.enter_async_context(
streamable_http_client(cfg.url)
)
else:
logger.warning(f"MCP server '{name}': no command or url configured, skipping")
return
session = await stack.enter_async_context(ClientSession(read, write))
await session.initialize()
tools = await session.list_tools()
for tool_def in tools.tools:
safe_server = _normalize_tool_segment(name)
safe_tool = _normalize_tool_segment(tool_def.name)
base = f"mcp_{safe_server}_{safe_tool}"
registered_name = base
i = 2
while registry.has(registered_name):
registered_name = f"{base}_{i}"
i += 1
wrapper = MCPToolWrapper(
session,
server_key=name,
tool_def=tool_def,
registered_name=registered_name,
)
registry.register(wrapper)
logger.debug(f"MCP: registered tool '{wrapper.name}' from server '{name}'")
logger.info(f"MCP server '{name}': connected, {len(tools.tools)} tools registered")
async def connect_mcp_servers( async def connect_mcp_servers(
mcp_servers: dict, registry: ToolRegistry, stack: AsyncExitStack mcp_servers: dict, registry: ToolRegistry, stack: AsyncExitStack
) -> None: ) -> None:
"""Connect to configured MCP servers and register their tools.""" """Connect to every configured MCP server and register their tools."""
from mcp import ClientSession, StdioServerParameters
from mcp.client.stdio import stdio_client
for name, cfg in mcp_servers.items(): for name, cfg in mcp_servers.items():
try: try:
if cfg.command: await connect_mcp_server(name, cfg, registry, stack)
params = StdioServerParameters(
command=cfg.command, args=cfg.args, env=cfg.env or None
)
read, write = await stack.enter_async_context(stdio_client(params))
elif cfg.url:
from mcp.client.streamable_http import streamable_http_client
read, write, _ = await stack.enter_async_context(
streamable_http_client(cfg.url)
)
else:
logger.warning(f"MCP server '{name}': no command or url configured, skipping")
continue
session = await stack.enter_async_context(ClientSession(read, write))
await session.initialize()
tools = await session.list_tools()
for tool_def in tools.tools:
wrapper = MCPToolWrapper(session, name, tool_def)
registry.register(wrapper)
logger.debug(f"MCP: registered tool '{wrapper.name}' from server '{name}'")
logger.info(f"MCP server '{name}': connected, {len(tools.tools)} tools registered")
except Exception as e: except Exception as e:
logger.error(f"MCP server '{name}': failed to connect: {e}") logger.error(f"MCP server '{name}': failed to connect: {e}")

View File

@ -8,44 +8,52 @@ from nanobot.agent.tools.base import Tool
class ToolRegistry: class ToolRegistry:
""" """
Registry for agent tools. Registry for agent tools.
Allows dynamic registration and execution of tools. Allows dynamic registration and execution of tools.
""" """
def __init__(self): def __init__(self):
self._tools: dict[str, Tool] = {} self._tools: dict[str, Tool] = {}
def register(self, tool: Tool) -> None: def register(self, tool: Tool) -> None:
"""Register a tool.""" """Register a tool."""
self._tools[tool.name] = tool self._tools[tool.name] = tool
def unregister(self, name: str) -> None: def unregister(self, name: str) -> None:
"""Unregister a tool by name.""" """Unregister a tool by name."""
self._tools.pop(name, None) self._tools.pop(name, None)
def get(self, name: str) -> Tool | None: def get(self, name: str) -> Tool | None:
"""Get a tool by name.""" """Get a tool by name."""
return self._tools.get(name) return self._tools.get(name)
def has(self, name: str) -> bool: def has(self, name: str) -> bool:
"""Check if a tool is registered.""" """Check if a tool is registered."""
return name in self._tools return name in self._tools
def get_definitions(self) -> list[dict[str, Any]]: def get_definitions(self) -> list[dict[str, Any]]:
"""Get all tool definitions in OpenAI format.""" """Get all tool definitions in OpenAI format."""
return [tool.to_schema() for tool in self._tools.values()] return [tool.to_schema() for tool in self._tools.values()]
def get_definitions_subset(self, names: set[str]) -> list[dict[str, Any]]:
"""Tool definitions for the given names only (preserves registration order)."""
out: list[dict[str, Any]] = []
for key, tool in self._tools.items():
if key in names:
out.append(tool.to_schema())
return out
async def execute(self, name: str, params: dict[str, Any]) -> str: async def execute(self, name: str, params: dict[str, Any]) -> str:
""" """
Execute a tool by name with given parameters. Execute a tool by name with given parameters.
Args: Args:
name: Tool name. name: Tool name.
params: Tool parameters. params: Tool parameters.
Returns: Returns:
Tool execution result as string. Tool execution result as string.
Raises: Raises:
KeyError: If tool not found. KeyError: If tool not found.
""" """
@ -62,14 +70,14 @@ class ToolRegistry:
return await tool.execute(**coerced_params) return await tool.execute(**coerced_params)
except Exception as e: except Exception as e:
return f"Error executing {name}: {str(e)}" return f"Error executing {name}: {str(e)}"
@property @property
def tool_names(self) -> list[str]: def tool_names(self) -> list[str]:
"""Get list of registered tool names.""" """Get list of registered tool names."""
return list(self._tools.keys()) return list(self._tools.keys())
def __len__(self) -> int: def __len__(self) -> int:
return len(self._tools) return len(self._tools)
def __contains__(self, name: str) -> bool: def __contains__(self, name: str) -> bool:
return name in self._tools return name in self._tools

View File

@ -2,23 +2,22 @@
import asyncio import asyncio
import os import os
import signal
from pathlib import Path
import select import select
import signal
import sys import sys
from pathlib import Path
import typer import typer
from prompt_toolkit import PromptSession
from prompt_toolkit.formatted_text import HTML
from prompt_toolkit.history import FileHistory
from prompt_toolkit.patch_stdout import patch_stdout
from rich.console import Console from rich.console import Console
from rich.markdown import Markdown from rich.markdown import Markdown
from rich.table import Table from rich.table import Table
from rich.text import Text from rich.text import Text
from prompt_toolkit import PromptSession from nanobot import __logo__, __version__
from prompt_toolkit.formatted_text import HTML
from prompt_toolkit.history import FileHistory
from prompt_toolkit.patch_stdout import patch_stdout
from nanobot import __version__, __logo__
from nanobot.config.schema import Config from nanobot.config.schema import Config
app = typer.Typer( app = typer.Typer(
@ -159,9 +158,9 @@ def onboard():
from nanobot.config.loader import get_config_path, load_config, save_config from nanobot.config.loader import get_config_path, load_config, save_config
from nanobot.config.schema import Config from nanobot.config.schema import Config
from nanobot.utils.helpers import get_workspace_path from nanobot.utils.helpers import get_workspace_path
config_path = get_config_path() config_path = get_config_path()
if config_path.exists(): if config_path.exists():
console.print(f"[yellow]Config already exists at {config_path}[/yellow]") console.print(f"[yellow]Config already exists at {config_path}[/yellow]")
console.print(" [bold]y[/bold] = overwrite with defaults (existing values will be lost)") console.print(" [bold]y[/bold] = overwrite with defaults (existing values will be lost)")
@ -177,17 +176,17 @@ def onboard():
else: else:
save_config(Config()) save_config(Config())
console.print(f"[green]✓[/green] Created config at {config_path}") console.print(f"[green]✓[/green] Created config at {config_path}")
# Create workspace # Create workspace
workspace = get_workspace_path() workspace = get_workspace_path()
if not workspace.exists(): if not workspace.exists():
workspace.mkdir(parents=True, exist_ok=True) workspace.mkdir(parents=True, exist_ok=True)
console.print(f"[green]✓[/green] Created workspace at {workspace}") console.print(f"[green]✓[/green] Created workspace at {workspace}")
# Create default bootstrap files # Create default bootstrap files
_create_workspace_templates(workspace) _create_workspace_templates(workspace)
console.print(f"\n{__logo__} nanobot is ready!") console.print(f"\n{__logo__} nanobot is ready!")
console.print("\nNext steps:") console.print("\nNext steps:")
console.print(" 1. Add your API key to [cyan]~/.nanobot/config.json[/cyan]") console.print(" 1. Add your API key to [cyan]~/.nanobot/config.json[/cyan]")
@ -239,13 +238,13 @@ Information about the user goes here.
- Language: (your preferred language) - Language: (your preferred language)
""", """,
} }
for filename, content in templates.items(): for filename, content in templates.items():
file_path = workspace / filename file_path = workspace / filename
if not file_path.exists(): if not file_path.exists():
file_path.write_text(content) file_path.write_text(content)
console.print(f" [dim]Created {filename}[/dim]") console.print(f" [dim]Created {filename}[/dim]")
# Create memory directory and MEMORY.md # Create memory directory and MEMORY.md
memory_dir = workspace / "memory" memory_dir = workspace / "memory"
memory_dir.mkdir(exist_ok=True) memory_dir.mkdir(exist_ok=True)
@ -268,7 +267,7 @@ This file stores important information that should persist across sessions.
(Things to remember) (Things to remember)
""") """)
console.print(" [dim]Created memory/MEMORY.md[/dim]") console.print(" [dim]Created memory/MEMORY.md[/dim]")
history_file = memory_dir / "HISTORY.md" history_file = memory_dir / "HISTORY.md"
if not history_file.exists(): if not history_file.exists():
history_file.write_text("") history_file.write_text("")
@ -281,9 +280,9 @@ This file stores important information that should persist across sessions.
def _make_provider(config: Config): def _make_provider(config: Config):
"""Create the appropriate LLM provider from config.""" """Create the appropriate LLM provider from config."""
from nanobot.providers.custom_provider import CustomProvider
from nanobot.providers.litellm_provider import LiteLLMProvider from nanobot.providers.litellm_provider import LiteLLMProvider
from nanobot.providers.openai_codex_provider import OpenAICodexProvider from nanobot.providers.openai_codex_provider import OpenAICodexProvider
from nanobot.providers.custom_provider import CustomProvider
model = config.agents.defaults.model model = config.agents.defaults.model
provider_name = config.get_provider_name(model) provider_name = config.get_provider_name(model)
@ -310,7 +309,7 @@ def _make_provider(config: Config):
airllm_config = getattr(config.providers, "airllm", None) airllm_config = getattr(config.providers, "airllm", None)
model_path = None model_path = None
compression = None compression = None
# Try to get model from airllm config's api_key field (repurposed as model path) # Try to get model from airllm config's api_key field (repurposed as model path)
# or from the default model # or from the default model
if airllm_config and airllm_config.api_key: if airllm_config and airllm_config.api_key:
@ -325,7 +324,7 @@ def _make_provider(config: Config):
else: else:
model_path = model model_path = model
hf_token = None hf_token = None
# Check for compression setting in extra_headers or api_base # Check for compression setting in extra_headers or api_base
if airllm_config: if airllm_config:
if airllm_config.api_base: if airllm_config.api_base:
@ -335,7 +334,7 @@ def _make_provider(config: Config):
# Check for HF token in extra_headers # Check for HF token in extra_headers
if not hf_token and airllm_config.extra_headers and "hf_token" in airllm_config.extra_headers: if not hf_token and airllm_config.extra_headers and "hf_token" in airllm_config.extra_headers:
hf_token = airllm_config.extra_headers["hf_token"] hf_token = airllm_config.extra_headers["hf_token"]
return AirLLMProvider( return AirLLMProvider(
api_key=airllm_config.api_key if airllm_config else None, api_key=airllm_config.api_key if airllm_config else None,
api_base=compression if compression else None, api_base=compression if compression else None,
@ -375,30 +374,30 @@ def gateway(
verbose: bool = typer.Option(False, "--verbose", "-v", help="Verbose output"), verbose: bool = typer.Option(False, "--verbose", "-v", help="Verbose output"),
): ):
"""Start the nanobot gateway.""" """Start the nanobot gateway."""
from nanobot.config.loader import load_config, get_data_dir
from nanobot.bus.queue import MessageBus
from nanobot.agent.loop import AgentLoop from nanobot.agent.loop import AgentLoop
from nanobot.bus.queue import MessageBus
from nanobot.channels.manager import ChannelManager from nanobot.channels.manager import ChannelManager
from nanobot.session.manager import SessionManager from nanobot.config.loader import get_data_dir, load_config
from nanobot.cron.service import CronService from nanobot.cron.service import CronService
from nanobot.cron.types import CronJob from nanobot.cron.types import CronJob
from nanobot.heartbeat.service import HeartbeatService from nanobot.heartbeat.service import HeartbeatService
from nanobot.session.manager import SessionManager
if verbose: if verbose:
import logging import logging
logging.basicConfig(level=logging.DEBUG) logging.basicConfig(level=logging.DEBUG)
console.print(f"{__logo__} Starting nanobot gateway on port {port}...") console.print(f"{__logo__} Starting nanobot gateway on port {port}...")
config = load_config() config = load_config()
bus = MessageBus() bus = MessageBus()
provider = _make_provider(config) provider = _make_provider(config)
session_manager = SessionManager(config.workspace_path) session_manager = SessionManager(config.workspace_path)
# Create cron service first (callback set after agent creation) # Create cron service first (callback set after agent creation)
cron_store_path = get_data_dir() / "cron" / "jobs.json" cron_store_path = get_data_dir() / "cron" / "jobs.json"
cron = CronService(cron_store_path) cron = CronService(cron_store_path)
# Create agent with cron service # Create agent with cron service
agent = AgentLoop( agent = AgentLoop(
bus=bus, bus=bus,
@ -415,8 +414,11 @@ def gateway(
restrict_to_workspace=config.tools.restrict_to_workspace, restrict_to_workspace=config.tools.restrict_to_workspace,
session_manager=session_manager, session_manager=session_manager,
mcp_servers=config.tools.mcp_servers, mcp_servers=config.tools.mcp_servers,
tool_profiles=config.tools.tool_profiles,
default_tool_profile=config.tools.default_tool_profile,
tool_routing=config.tools.tool_routing,
) )
# Set cron callback (needs agent) # Set cron callback (needs agent)
async def on_cron_job(job: CronJob) -> str | None: async def on_cron_job(job: CronJob) -> str | None:
"""Execute a cron job through the agent.""" """Execute a cron job through the agent."""
@ -449,33 +451,33 @@ def gateway(
)) ))
return response return response
cron.on_job = on_cron_job cron.on_job = on_cron_job
# Create heartbeat service # Create heartbeat service
async def on_heartbeat(prompt: str) -> str: async def on_heartbeat(prompt: str) -> str:
"""Execute heartbeat through the agent.""" """Execute heartbeat through the agent."""
return await agent.process_direct(prompt, session_key="heartbeat") return await agent.process_direct(prompt, session_key="heartbeat")
heartbeat = HeartbeatService( heartbeat = HeartbeatService(
workspace=config.workspace_path, workspace=config.workspace_path,
on_heartbeat=on_heartbeat, on_heartbeat=on_heartbeat,
interval_s=30 * 60, # 30 minutes interval_s=30 * 60, # 30 minutes
enabled=True enabled=True
) )
# Create channel manager # Create channel manager
channels = ChannelManager(config, bus) channels = ChannelManager(config, bus)
if channels.enabled_channels: if channels.enabled_channels:
console.print(f"[green]✓[/green] Channels enabled: {', '.join(channels.enabled_channels)}") console.print(f"[green]✓[/green] Channels enabled: {', '.join(channels.enabled_channels)}")
else: else:
console.print("[yellow]Warning: No channels enabled[/yellow]") console.print("[yellow]Warning: No channels enabled[/yellow]")
cron_status = cron.status() cron_status = cron.status()
if cron_status["jobs"] > 0: if cron_status["jobs"] > 0:
console.print(f"[green]✓[/green] Cron: {cron_status['jobs']} scheduled jobs") console.print(f"[green]✓[/green] Cron: {cron_status['jobs']} scheduled jobs")
console.print(f"[green]✓[/green] Heartbeat: every 30m") console.print("[green]✓[/green] Heartbeat: every 30m")
async def run(): async def run():
try: try:
await cron.start() await cron.start()
@ -492,7 +494,7 @@ def gateway(
cron.stop() cron.stop()
agent.stop() agent.stop()
await channels.stop_all() await channels.stop_all()
asyncio.run(run()) asyncio.run(run())
@ -511,15 +513,16 @@ def agent(
logs: bool = typer.Option(False, "--logs/--no-logs", help="Show nanobot runtime logs during chat"), logs: bool = typer.Option(False, "--logs/--no-logs", help="Show nanobot runtime logs during chat"),
): ):
"""Interact with the agent directly.""" """Interact with the agent directly."""
from nanobot.config.loader import load_config, get_data_dir
from nanobot.bus.queue import MessageBus
from nanobot.agent.loop import AgentLoop
from nanobot.cron.service import CronService
from loguru import logger from loguru import logger
from nanobot.agent.loop import AgentLoop
from nanobot.bus.queue import MessageBus
from nanobot.config.loader import get_data_dir, load_config
from nanobot.cron.service import CronService
# Load config (this also loads .env file into environment) # Load config (this also loads .env file into environment)
config = load_config() config = load_config()
bus = MessageBus() bus = MessageBus()
provider = _make_provider(config) provider = _make_provider(config)
@ -531,7 +534,7 @@ def agent(
logger.enable("nanobot") logger.enable("nanobot")
else: else:
logger.disable("nanobot") logger.disable("nanobot")
agent_loop = AgentLoop( agent_loop = AgentLoop(
bus=bus, bus=bus,
provider=provider, provider=provider,
@ -546,8 +549,11 @@ def agent(
cron_service=cron, cron_service=cron,
restrict_to_workspace=config.tools.restrict_to_workspace, restrict_to_workspace=config.tools.restrict_to_workspace,
mcp_servers=config.tools.mcp_servers, mcp_servers=config.tools.mcp_servers,
tool_profiles=config.tools.tool_profiles,
default_tool_profile=config.tools.default_tool_profile,
tool_routing=config.tools.tool_routing,
) )
# Show spinner when logs are off (no output to miss); skip when logs are on # Show spinner when logs are off (no output to miss); skip when logs are on
def _thinking_ctx(): def _thinking_ctx():
if logs: if logs:
@ -573,7 +579,7 @@ def agent(
console.print(f"[red]Error: {e}[/red]") console.print(f"[red]Error: {e}[/red]")
console.print(f"[dim]{traceback.format_exc()}[/dim]") console.print(f"[dim]{traceback.format_exc()}[/dim]")
raise raise
asyncio.run(run_once()) asyncio.run(run_once())
else: else:
# Interactive mode # Interactive mode
@ -586,7 +592,7 @@ def agent(
os._exit(0) os._exit(0)
signal.signal(signal.SIGINT, _exit_on_sigint) signal.signal(signal.SIGINT, _exit_on_sigint)
async def run_interactive(): async def run_interactive():
try: try:
while True: while True:
@ -601,7 +607,7 @@ def agent(
_restore_terminal() _restore_terminal()
console.print("\nGoodbye!") console.print("\nGoodbye!")
break break
with _thinking_ctx(): with _thinking_ctx():
response = await agent_loop.process_direct(user_input, session_id, on_progress=_cli_progress) response = await agent_loop.process_direct(user_input, session_id, on_progress=_cli_progress)
_print_agent_response(response, render_markdown=markdown) _print_agent_response(response, render_markdown=markdown)
@ -615,7 +621,7 @@ def agent(
break break
finally: finally:
await agent_loop.close_mcp() await agent_loop.close_mcp()
asyncio.run(run_interactive()) asyncio.run(run_interactive())
@ -672,7 +678,7 @@ def channels_status():
"" if mc.enabled else "", "" if mc.enabled else "",
mc_base mc_base
) )
# Telegram # Telegram
tg = config.channels.telegram tg = config.channels.telegram
tg_config = f"token: {tg.token[:10]}..." if tg.token else "[dim]not configured[/dim]" tg_config = f"token: {tg.token[:10]}..." if tg.token else "[dim]not configured[/dim]"
@ -698,57 +704,57 @@ def _get_bridge_dir() -> Path:
"""Get the bridge directory, setting it up if needed.""" """Get the bridge directory, setting it up if needed."""
import shutil import shutil
import subprocess import subprocess
# User's bridge location # User's bridge location
user_bridge = Path.home() / ".nanobot" / "bridge" user_bridge = Path.home() / ".nanobot" / "bridge"
# Check if already built # Check if already built
if (user_bridge / "dist" / "index.js").exists(): if (user_bridge / "dist" / "index.js").exists():
return user_bridge return user_bridge
# Check for npm # Check for npm
if not shutil.which("npm"): if not shutil.which("npm"):
console.print("[red]npm not found. Please install Node.js >= 18.[/red]") console.print("[red]npm not found. Please install Node.js >= 18.[/red]")
raise typer.Exit(1) raise typer.Exit(1)
# Find source bridge: first check package data, then source dir # Find source bridge: first check package data, then source dir
pkg_bridge = Path(__file__).parent.parent / "bridge" # nanobot/bridge (installed) pkg_bridge = Path(__file__).parent.parent / "bridge" # nanobot/bridge (installed)
src_bridge = Path(__file__).parent.parent.parent / "bridge" # repo root/bridge (dev) src_bridge = Path(__file__).parent.parent.parent / "bridge" # repo root/bridge (dev)
source = None source = None
if (pkg_bridge / "package.json").exists(): if (pkg_bridge / "package.json").exists():
source = pkg_bridge source = pkg_bridge
elif (src_bridge / "package.json").exists(): elif (src_bridge / "package.json").exists():
source = src_bridge source = src_bridge
if not source: if not source:
console.print("[red]Bridge source not found.[/red]") console.print("[red]Bridge source not found.[/red]")
console.print("Try reinstalling: pip install --force-reinstall nanobot") console.print("Try reinstalling: pip install --force-reinstall nanobot")
raise typer.Exit(1) raise typer.Exit(1)
console.print(f"{__logo__} Setting up bridge...") console.print(f"{__logo__} Setting up bridge...")
# Copy to user directory # Copy to user directory
user_bridge.parent.mkdir(parents=True, exist_ok=True) user_bridge.parent.mkdir(parents=True, exist_ok=True)
if user_bridge.exists(): if user_bridge.exists():
shutil.rmtree(user_bridge) shutil.rmtree(user_bridge)
shutil.copytree(source, user_bridge, ignore=shutil.ignore_patterns("node_modules", "dist")) shutil.copytree(source, user_bridge, ignore=shutil.ignore_patterns("node_modules", "dist"))
# Install and build # Install and build
try: try:
console.print(" Installing dependencies...") console.print(" Installing dependencies...")
subprocess.run(["npm", "install"], cwd=user_bridge, check=True, capture_output=True) subprocess.run(["npm", "install"], cwd=user_bridge, check=True, capture_output=True)
console.print(" Building...") console.print(" Building...")
subprocess.run(["npm", "run", "build"], cwd=user_bridge, check=True, capture_output=True) subprocess.run(["npm", "run", "build"], cwd=user_bridge, check=True, capture_output=True)
console.print("[green]✓[/green] Bridge ready\n") console.print("[green]✓[/green] Bridge ready\n")
except subprocess.CalledProcessError as e: except subprocess.CalledProcessError as e:
console.print(f"[red]Build failed: {e}[/red]") console.print(f"[red]Build failed: {e}[/red]")
if e.stderr: if e.stderr:
console.print(f"[dim]{e.stderr.decode()[:500]}[/dim]") console.print(f"[dim]{e.stderr.decode()[:500]}[/dim]")
raise typer.Exit(1) raise typer.Exit(1)
return user_bridge return user_bridge
@ -756,18 +762,19 @@ def _get_bridge_dir() -> Path:
def channels_login(): def channels_login():
"""Link device via QR code.""" """Link device via QR code."""
import subprocess import subprocess
from nanobot.config.loader import load_config from nanobot.config.loader import load_config
config = load_config() config = load_config()
bridge_dir = _get_bridge_dir() bridge_dir = _get_bridge_dir()
console.print(f"{__logo__} Starting bridge...") console.print(f"{__logo__} Starting bridge...")
console.print("Scan the QR code to connect.\n") console.print("Scan the QR code to connect.\n")
env = {**os.environ} env = {**os.environ}
if config.channels.whatsapp.bridge_token: if config.channels.whatsapp.bridge_token:
env["BRIDGE_TOKEN"] = config.channels.whatsapp.bridge_token env["BRIDGE_TOKEN"] = config.channels.whatsapp.bridge_token
try: try:
subprocess.run(["npm", "start"], cwd=bridge_dir, check=True, env=env) subprocess.run(["npm", "start"], cwd=bridge_dir, check=True, env=env)
except subprocess.CalledProcessError as e: except subprocess.CalledProcessError as e:
@ -791,23 +798,23 @@ def cron_list(
"""List scheduled jobs.""" """List scheduled jobs."""
from nanobot.config.loader import get_data_dir from nanobot.config.loader import get_data_dir
from nanobot.cron.service import CronService from nanobot.cron.service import CronService
store_path = get_data_dir() / "cron" / "jobs.json" store_path = get_data_dir() / "cron" / "jobs.json"
service = CronService(store_path) service = CronService(store_path)
jobs = service.list_jobs(include_disabled=all) jobs = service.list_jobs(include_disabled=all)
if not jobs: if not jobs:
console.print("No scheduled jobs.") console.print("No scheduled jobs.")
return return
table = Table(title="Scheduled Jobs") table = Table(title="Scheduled Jobs")
table.add_column("ID", style="cyan") table.add_column("ID", style="cyan")
table.add_column("Name") table.add_column("Name")
table.add_column("Schedule") table.add_column("Schedule")
table.add_column("Status") table.add_column("Status")
table.add_column("Next Run") table.add_column("Next Run")
import time import time
from datetime import datetime as _dt from datetime import datetime as _dt
from zoneinfo import ZoneInfo from zoneinfo import ZoneInfo
@ -819,7 +826,7 @@ def cron_list(
sched = f"{job.schedule.expr or ''} ({job.schedule.tz})" if job.schedule.tz else (job.schedule.expr or "") sched = f"{job.schedule.expr or ''} ({job.schedule.tz})" if job.schedule.tz else (job.schedule.expr or "")
else: else:
sched = "one-time" sched = "one-time"
# Format next run # Format next run
next_run = "" next_run = ""
if job.state.next_run_at_ms: if job.state.next_run_at_ms:
@ -829,11 +836,11 @@ def cron_list(
next_run = _dt.fromtimestamp(ts, tz).strftime("%Y-%m-%d %H:%M") next_run = _dt.fromtimestamp(ts, tz).strftime("%Y-%m-%d %H:%M")
except Exception: except Exception:
next_run = time.strftime("%Y-%m-%d %H:%M", time.localtime(ts)) next_run = time.strftime("%Y-%m-%d %H:%M", time.localtime(ts))
status = "[green]enabled[/green]" if job.enabled else "[dim]disabled[/dim]" status = "[green]enabled[/green]" if job.enabled else "[dim]disabled[/dim]"
table.add_row(job.id, job.name, sched, status, next_run) table.add_row(job.id, job.name, sched, status, next_run)
console.print(table) console.print(table)
@ -853,7 +860,7 @@ def cron_add(
from nanobot.config.loader import get_data_dir from nanobot.config.loader import get_data_dir
from nanobot.cron.service import CronService from nanobot.cron.service import CronService
from nanobot.cron.types import CronSchedule from nanobot.cron.types import CronSchedule
if tz and not cron_expr: if tz and not cron_expr:
console.print("[red]Error: --tz can only be used with --cron[/red]") console.print("[red]Error: --tz can only be used with --cron[/red]")
raise typer.Exit(1) raise typer.Exit(1)
@ -870,10 +877,10 @@ def cron_add(
else: else:
console.print("[red]Error: Must specify --every, --cron, or --at[/red]") console.print("[red]Error: Must specify --every, --cron, or --at[/red]")
raise typer.Exit(1) raise typer.Exit(1)
store_path = get_data_dir() / "cron" / "jobs.json" store_path = get_data_dir() / "cron" / "jobs.json"
service = CronService(store_path) service = CronService(store_path)
job = service.add_job( job = service.add_job(
name=name, name=name,
schedule=schedule, schedule=schedule,
@ -882,7 +889,7 @@ def cron_add(
to=to, to=to,
channel=channel, channel=channel,
) )
console.print(f"[green]✓[/green] Added job '{job.name}' ({job.id})") console.print(f"[green]✓[/green] Added job '{job.name}' ({job.id})")
@ -893,10 +900,10 @@ def cron_remove(
"""Remove a scheduled job.""" """Remove a scheduled job."""
from nanobot.config.loader import get_data_dir from nanobot.config.loader import get_data_dir
from nanobot.cron.service import CronService from nanobot.cron.service import CronService
store_path = get_data_dir() / "cron" / "jobs.json" store_path = get_data_dir() / "cron" / "jobs.json"
service = CronService(store_path) service = CronService(store_path)
if service.remove_job(job_id): if service.remove_job(job_id):
console.print(f"[green]✓[/green] Removed job {job_id}") console.print(f"[green]✓[/green] Removed job {job_id}")
else: else:
@ -911,10 +918,10 @@ def cron_enable(
"""Enable or disable a job.""" """Enable or disable a job."""
from nanobot.config.loader import get_data_dir from nanobot.config.loader import get_data_dir
from nanobot.cron.service import CronService from nanobot.cron.service import CronService
store_path = get_data_dir() / "cron" / "jobs.json" store_path = get_data_dir() / "cron" / "jobs.json"
service = CronService(store_path) service = CronService(store_path)
job = service.enable_job(job_id, enabled=not disable) job = service.enable_job(job_id, enabled=not disable)
if job: if job:
status = "disabled" if disable else "enabled" status = "disabled" if disable else "enabled"
@ -931,15 +938,15 @@ def cron_run(
"""Manually run a job.""" """Manually run a job."""
from nanobot.config.loader import get_data_dir from nanobot.config.loader import get_data_dir
from nanobot.cron.service import CronService from nanobot.cron.service import CronService
store_path = get_data_dir() / "cron" / "jobs.json" store_path = get_data_dir() / "cron" / "jobs.json"
service = CronService(store_path) service = CronService(store_path)
async def run(): async def run():
return await service.run_job(job_id, force=force) return await service.run_job(job_id, force=force)
if asyncio.run(run()): if asyncio.run(run()):
console.print(f"[green]✓[/green] Job executed") console.print("[green]✓[/green] Job executed")
else: else:
console.print(f"[red]Failed to run job {job_id}[/red]") console.print(f"[red]Failed to run job {job_id}[/red]")
@ -952,7 +959,7 @@ def cron_run(
@app.command() @app.command()
def status(): def status():
"""Show nanobot status.""" """Show nanobot status."""
from nanobot.config.loader import load_config, get_config_path from nanobot.config.loader import get_config_path, load_config
config_path = get_config_path() config_path = get_config_path()
config = load_config() config = load_config()
@ -967,7 +974,7 @@ def status():
from nanobot.providers.registry import PROVIDERS from nanobot.providers.registry import PROVIDERS
console.print(f"Model: {config.agents.defaults.model}") console.print(f"Model: {config.agents.defaults.model}")
# Check API keys from registry # Check API keys from registry
for spec in PROVIDERS: for spec in PROVIDERS:
p = getattr(config.providers, spec.name, None) p = getattr(config.providers, spec.name, None)

View File

@ -1,7 +1,8 @@
"""Configuration schema using Pydantic.""" """Configuration schema using Pydantic."""
from pathlib import Path from pathlib import Path
from pydantic import BaseModel, Field, ConfigDict
from pydantic import BaseModel, ConfigDict, Field, model_validator
from pydantic.alias_generators import to_camel from pydantic.alias_generators import to_camel
from pydantic_settings import BaseSettings from pydantic_settings import BaseSettings
@ -270,6 +271,26 @@ class MCPServerConfig(Base):
url: str = "" # HTTP: streamable HTTP endpoint URL url: str = "" # HTTP: streamable HTTP endpoint URL
class ToolProfileConfig(Base):
"""Subset of tools exposed to the LLM when this profile is active."""
description: str = "" # Shown to the router model when toolRouting is enabled
builtin_tools: list[str] | None = None # None = all non-MCP tools; [] = none (except always-include)
mcp_servers: list[str] | None = None # None = all configured MCP servers; [] = no MCP tools
class ToolRoutingConfig(Base):
"""Optional LLM router that picks a tool profile from the user message (phase 2)."""
enabled: bool = False
router_temperature: float = 0.2
router_max_tokens: int = 128
# Always merged into the allowed set (if registered), e.g. channel reply + subagent spawn
always_include_tools: list[str] = Field(default_factory=lambda: ["message", "spawn"])
# If the model calls a missing tool, retry the loop once with all tools registered
expand_on_missing_tool: bool = True
class ToolsConfig(Base): class ToolsConfig(Base):
"""Tools configuration.""" """Tools configuration."""
@ -278,6 +299,19 @@ class ToolsConfig(Base):
calendar: CalendarConfig = Field(default_factory=CalendarConfig) calendar: CalendarConfig = Field(default_factory=CalendarConfig)
restrict_to_workspace: bool = True # If true, restrict all tool access to workspace directory restrict_to_workspace: bool = True # If true, restrict all tool access to workspace directory
mcp_servers: dict[str, MCPServerConfig] = Field(default_factory=dict) mcp_servers: dict[str, MCPServerConfig] = Field(default_factory=dict)
tool_profiles: dict[str, ToolProfileConfig] = Field(default_factory=dict)
default_tool_profile: str = "default"
tool_routing: ToolRoutingConfig = Field(default_factory=ToolRoutingConfig)
@model_validator(mode="after")
def _tool_profiles_consistent(self) -> "ToolsConfig":
if self.tool_profiles and self.default_tool_profile not in self.tool_profiles:
raise ValueError(
f"defaultToolProfile '{self.default_tool_profile}' is missing from tools.toolProfiles"
)
if self.tool_routing.enabled and not self.tool_profiles:
raise ValueError("toolRouting.enabled requires a non-empty tools.toolProfiles map")
return self
class Config(BaseSettings): class Config(BaseSettings):

View File

@ -59,15 +59,38 @@ class CustomProvider(LLMProvider):
for tc in (msg.tool_calls or []) 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) # Only parse if content looks like it contains a tool call JSON (to avoid false positives)
content = msg.content or "" 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": {...}} # Check for standard format: {"name": "...", "parameters": {...}}
has_standard_format = '"name"' in content and '"parameters"' in content has_standard_format = '"name"' in content and '"parameters"' in content
# Check for calendar tool format: {"action": "...", ...} # 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"])) 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 import re
# Look for JSON tool call patterns: {"name": "exec", "parameters": {...}} or {"action": "list_events", ...} # Look for JSON tool call patterns: {"name": "exec", "parameters": {...}} or {"action": "list_events", ...}
# Find complete JSON objects by matching braces # Find complete JSON objects by matching braces
@ -131,28 +154,11 @@ class CustomProvider(LLMProvider):
continue continue
# Handle standard format: {"name": "...", "parameters": {...}} # 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 if (isinstance(tool_obj, dict) and
"name" in tool_obj and "name" in tool_obj and
"parameters" in tool_obj and "parameters" in tool_obj and
isinstance(tool_obj["name"], str) 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( tool_calls.append(ToolCallRequest(
id=f"call_{len(tool_calls)}", id=f"call_{len(tool_calls)}",
name=tool_obj["name"], name=tool_obj["name"],
@ -166,6 +172,32 @@ class CustomProvider(LLMProvider):
pass # If parsing fails, skip this match pass # If parsing fails, skip this match
start_pos = json_start + 1 # Move past 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 u = response.usage
return LLMResponse( return LLMResponse(

View File

@ -0,0 +1,49 @@
#!/usr/bin/env bash
# Create ~/.nanobot/workspaces/{ilia,family,wife}/ from repo templates (Option B).
# Does not overwrite existing files — safe to re-run.
set -euo pipefail
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
NANOBOT_HOME="${NANOBOT_HOME:-$HOME/.nanobot}"
DEST="${NANOBOT_HOME}/workspaces"
SKEL="${REPO_ROOT}/agent_workspaces"
if [[ ! -d "${SKEL}/ilia" ]]; then
echo "error: missing ${SKEL}/ilia — run from nanobot repo root" >&2
exit 1
fi
install_skel() {
local agent="$1"
local d="${DEST}/${agent}"
mkdir -p "${d}/memory"
for f in AGENTS.md USER.md SOUL.md; do
if [[ ! -f "${d}/${f}" ]]; then
cp "${SKEL}/${agent}/${f}" "${d}/${f}"
echo "created ${d}/${f}"
else
echo "skip (exists): ${d}/${f}"
fi
done
for f in MEMORY.md HISTORY.md; do
if [[ ! -f "${d}/memory/${f}" ]]; then
cp "${SKEL}/${agent}/memory/${f}" "${d}/memory/${f}"
echo "created ${d}/memory/${f}"
else
echo "skip (exists): ${d}/memory/${f}"
fi
done
}
echo "NANOBOT_HOME=${NANOBOT_HOME}"
echo "DEST=${DEST}"
mkdir -p "${DEST}"
for agent in ilia family wife; do
echo "--- ${agent} ---"
install_skel "${agent}"
done
echo "done. Fix ownership if needed, e.g.:"
echo " sudo chown -R \"\$(whoami):\$(whoami)\" \"${DEST}\""

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