Compare commits
6 Commits
test-ci
...
feature/cl
| Author | SHA1 | Date | |
|---|---|---|---|
| b4a0427494 | |||
| 7050e032e8 | |||
| 93b34bc214 | |||
| a6bd3e0e9b | |||
| 7901f090f9 | |||
| d50183c3d7 |
5
.gitignore
vendored
5
.gitignore
vendored
@ -22,3 +22,8 @@ poetry.lock
|
||||
.pytest_cache/
|
||||
botpy.log
|
||||
tests/
|
||||
|
||||
# Local-cloned MCP servers (kept out of git; clone/build locally)
|
||||
mcp-servers/*
|
||||
!mcp-servers/README.md
|
||||
!mcp-servers/.gitkeep
|
||||
|
||||
@ -59,20 +59,28 @@ Each bot runs in its own Docker container with:
|
||||
```
|
||||
nanobot/
|
||||
├── .env.shared # Shared settings (API keys, model, etc.)
|
||||
├── .env.user1 # Bot 1 overrides
|
||||
├── .env.user2 # Bot 2 overrides
|
||||
├── .env.user3 # Bot 3 overrides
|
||||
├── .env.user1 # Bot 1 (@ilia) overrides
|
||||
├── .env.user2 # Bot 2 (@family) 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.dev.yml # Development compose file
|
||||
│
|
||||
└── ~/.nanobot-user1/
|
||||
└── config.json # Bot 1 channel config (Telegram token, allowFrom)
|
||||
└── ~/.nanobot-user2/
|
||||
└── config.json # Bot 2 channel config
|
||||
├── ~/.nanobot/workspaces/
|
||||
│ ├── ilia/ # Mounted as /workspace for user1 — AGENTS.md, memory/, …
|
||||
│ ├── family/ # user2
|
||||
│ └── wife/ # user3
|
||||
├── ~/.nanobot-user1/
|
||||
│ └── config.json # Bot 1 channel config (Telegram token, allowFrom)
|
||||
├── ~/.nanobot-user2/
|
||||
│ └── config.json # Bot 2 channel config
|
||||
└── ~/.nanobot-user3/
|
||||
└── 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
|
||||
|
||||
1. **Docker Compose** loads environment files:
|
||||
@ -91,6 +99,31 @@ nanobot/
|
||||
|
||||
## 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
|
||||
|
||||
Run the setup script:
|
||||
|
||||
30
agent_workspaces/README.md
Normal file
30
agent_workspaces/README.md
Normal 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 nanobot’s `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`.
|
||||
14
agent_workspaces/family/AGENTS.md
Normal file
14
agent_workspaces/family/AGENTS.md
Normal 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 “what’s 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.
|
||||
7
agent_workspaces/family/SOUL.md
Normal file
7
agent_workspaces/family/SOUL.md
Normal 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.
|
||||
8
agent_workspaces/family/USER.md
Normal file
8
agent_workspaces/family/USER.md
Normal 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.
|
||||
3
agent_workspaces/family/memory/HISTORY.md
Normal file
3
agent_workspaces/family/memory/HISTORY.md
Normal file
@ -0,0 +1,3 @@
|
||||
# Event log — Family
|
||||
|
||||
Append-only style log for this household agent.
|
||||
5
agent_workspaces/family/memory/MEMORY.md
Normal file
5
agent_workspaces/family/memory/MEMORY.md
Normal 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._
|
||||
14
agent_workspaces/ilia/AGENTS.md
Normal file
14
agent_workspaces/ilia/AGENTS.md
Normal 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 Ilia’s 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.
|
||||
7
agent_workspaces/ilia/SOUL.md
Normal file
7
agent_workspaces/ilia/SOUL.md
Normal 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.
|
||||
7
agent_workspaces/ilia/USER.md
Normal file
7
agent_workspaces/ilia/USER.md
Normal 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`).
|
||||
3
agent_workspaces/ilia/memory/HISTORY.md
Normal file
3
agent_workspaces/ilia/memory/HISTORY.md
Normal file
@ -0,0 +1,3 @@
|
||||
# Event log — Ilia
|
||||
|
||||
Append-only style log. Search with grep when recalling past events.
|
||||
5
agent_workspaces/ilia/memory/MEMORY.md
Normal file
5
agent_workspaces/ilia/memory/MEMORY.md
Normal 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._
|
||||
11
agent_workspaces/wife/AGENTS.md
Normal file
11
agent_workspaces/wife/AGENTS.md
Normal file
@ -0,0 +1,11 @@
|
||||
# @wife — Agent instructions
|
||||
|
||||
You are the personal assistant for **Ilia’s 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 user’s formality preferences over time.
|
||||
7
agent_workspaces/wife/SOUL.md
Normal file
7
agent_workspaces/wife/SOUL.md
Normal 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.
|
||||
6
agent_workspaces/wife/USER.md
Normal file
6
agent_workspaces/wife/USER.md
Normal 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.
|
||||
3
agent_workspaces/wife/memory/HISTORY.md
Normal file
3
agent_workspaces/wife/memory/HISTORY.md
Normal file
@ -0,0 +1,3 @@
|
||||
# Event log — Wife
|
||||
|
||||
Append-only style log. Search with grep when recalling past events.
|
||||
5
agent_workspaces/wife/memory/MEMORY.md
Normal file
5
agent_workspaces/wife/memory/MEMORY.md
Normal 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._
|
||||
@ -18,6 +18,17 @@ cat > ~/.nanobot-user1/config.json << 'EOF'
|
||||
"enabled": true,
|
||||
"allowFrom": ["adayear2025@gmail.com"]
|
||||
}
|
||||
},
|
||||
"tools": {
|
||||
"mcpServers": {
|
||||
"gitea": {
|
||||
"command": "/app/mcp-servers/gitea-mcp/gitea-mcp",
|
||||
"args": ["-t", "stdio", "--host", "http://10.0.30.169:3000", "-r"],
|
||||
"env": {
|
||||
"GITEA_ACCESS_TOKEN": "$NANOBOT_GITLE_TOKEN"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
EOF
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
# user1=@ilia, user2=@family, user3=@wife — workspaces ~/.nanobot/workspaces/{ilia,family,wife}
|
||||
# Development version - mounts source code for live updates
|
||||
# Use this when developing nanobot code
|
||||
# Changes to nanobot/ directory will be picked up automatically (may need container restart)
|
||||
@ -15,8 +16,11 @@ services:
|
||||
- .env.user1
|
||||
volumes:
|
||||
- ~/.nanobot-user1:/root/.nanobot
|
||||
- ~/.nanobot/workspaces/ilia:/workspace
|
||||
# Mount source code for development (changes picked up immediately)
|
||||
- ./nanobot:/app/nanobot:ro # Read-only mount (safer)
|
||||
# Local-cloned MCP servers (see scripts/setup-mcp-servers.sh)
|
||||
- ./mcp-servers:/app/mcp-servers:ro
|
||||
# Or use this for read-write (if you edit inside container):
|
||||
# - ./nanobot:/app/nanobot
|
||||
ports:
|
||||
@ -42,7 +46,9 @@ services:
|
||||
- .env.user2
|
||||
volumes:
|
||||
- ~/.nanobot-user2:/root/.nanobot
|
||||
- ~/.nanobot/workspaces/family:/workspace
|
||||
- ./nanobot:/app/nanobot:ro
|
||||
- ./mcp-servers:/app/mcp-servers:ro
|
||||
ports:
|
||||
- "18791:18790"
|
||||
deploy:
|
||||
@ -66,7 +72,9 @@ services:
|
||||
- .env.user3
|
||||
volumes:
|
||||
- ~/.nanobot-user3:/root/.nanobot
|
||||
- ~/.nanobot/workspaces/wife:/workspace
|
||||
- ./nanobot:/app/nanobot:ro
|
||||
- ./mcp-servers:/app/mcp-servers:ro
|
||||
ports:
|
||||
- "18792:18790"
|
||||
deploy:
|
||||
|
||||
@ -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:
|
||||
# - .env.shared: Common settings (API keys, model, etc.) - loaded first
|
||||
# - .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)
|
||||
volumes:
|
||||
- ~/.nanobot-user1:/root/.nanobot
|
||||
- ~/.nanobot/workspaces/ilia:/workspace
|
||||
# Local-cloned MCP servers (see scripts/setup-mcp-servers.sh)
|
||||
- ./mcp-servers:/app/mcp-servers:ro
|
||||
ports:
|
||||
- "18790:18790"
|
||||
deploy:
|
||||
@ -39,6 +43,8 @@ services:
|
||||
- .env.user2 # Bot-specific overrides (loaded second, overrides shared)
|
||||
volumes:
|
||||
- ~/.nanobot-user2:/root/.nanobot
|
||||
- ~/.nanobot/workspaces/family:/workspace
|
||||
- ./mcp-servers:/app/mcp-servers:ro
|
||||
ports:
|
||||
- "18791:18790"
|
||||
deploy:
|
||||
@ -62,6 +68,8 @@ services:
|
||||
- .env.user3 # Bot-specific overrides (loaded second, overrides shared)
|
||||
volumes:
|
||||
- ~/.nanobot-user3:/root/.nanobot
|
||||
- ~/.nanobot/workspaces/wife:/workspace
|
||||
- ./mcp-servers:/app/mcp-servers:ro
|
||||
ports:
|
||||
- "18792:18790"
|
||||
deploy:
|
||||
|
||||
@ -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:
|
||||
nanobot-user1:
|
||||
build:
|
||||
@ -6,8 +9,15 @@ services:
|
||||
container_name: nanobot-user1
|
||||
command: ["gateway"]
|
||||
restart: unless-stopped
|
||||
env_file:
|
||||
- .env.shared
|
||||
- .env.user1
|
||||
volumes:
|
||||
- ~/.nanobot-user1:/root/.nanobot
|
||||
# @ilia — isolated workspace + memory (host: ~/.nanobot/workspaces/ilia)
|
||||
- ~/.nanobot/workspaces/ilia:/workspace
|
||||
# Local-cloned MCP servers (see scripts/setup-mcp-servers.sh)
|
||||
- ./mcp-servers:/app/mcp-servers:ro
|
||||
ports:
|
||||
- "18790:18790"
|
||||
deploy:
|
||||
@ -26,8 +36,14 @@ services:
|
||||
container_name: nanobot-user2
|
||||
command: ["gateway"]
|
||||
restart: unless-stopped
|
||||
env_file:
|
||||
- .env.shared
|
||||
- .env.user2
|
||||
volumes:
|
||||
- ~/.nanobot-user2:/root/.nanobot
|
||||
# @family — isolated workspace + memory
|
||||
- ~/.nanobot/workspaces/family:/workspace
|
||||
- ./mcp-servers:/app/mcp-servers:ro
|
||||
ports:
|
||||
- "18791:18790"
|
||||
deploy:
|
||||
@ -46,8 +62,14 @@ services:
|
||||
container_name: nanobot-user3
|
||||
command: ["gateway"]
|
||||
restart: unless-stopped
|
||||
env_file:
|
||||
- .env.shared
|
||||
- .env.user3
|
||||
volumes:
|
||||
- ~/.nanobot-user3:/root/.nanobot
|
||||
# @wife — isolated workspace + memory
|
||||
- ~/.nanobot/workspaces/wife:/workspace
|
||||
- ./mcp-servers:/app/mcp-servers:ro
|
||||
ports:
|
||||
- "18792:18790"
|
||||
deploy:
|
||||
|
||||
@ -4,6 +4,8 @@ x-common-config: &common-config
|
||||
dockerfile: Dockerfile
|
||||
volumes:
|
||||
- ~/.nanobot:/root/.nanobot
|
||||
# Host repo ./workspace → /workspace in container. Set agents.defaults.workspace to /workspace.
|
||||
- ./workspace:/workspace
|
||||
|
||||
services:
|
||||
nanobot-gateway:
|
||||
|
||||
573
docs/mcp_and_skills_backlog.md
Normal file
573
docs/mcp_and_skills_backlog.md
Normal 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 ~200–400 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
1
mcp-servers/.gitkeep
Normal file
@ -0,0 +1 @@
|
||||
|
||||
34
mcp-servers/README.md
Normal file
34
mcp-servers/README.md
Normal file
@ -0,0 +1,34 @@
|
||||
# Local MCP servers
|
||||
|
||||
This repo uses a **local-clone policy** for MCP servers: clone upstream repos into `./mcp-servers/` and run them from disk (instead of fetching from npm/PyPI at runtime).
|
||||
|
||||
## Gitea MCP
|
||||
|
||||
- **Upstream**: `https://gitea.com/gitea/gitea-mcp.git`
|
||||
- **Local path**: `mcp-servers/gitea-mcp/`
|
||||
- **Binary**: `mcp-servers/gitea-mcp/gitea-mcp`
|
||||
|
||||
Build it with:
|
||||
|
||||
```bash
|
||||
./scripts/setup-mcp-servers.sh gitea
|
||||
```
|
||||
|
||||
Then configure nanobot (example):
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"tools": {
|
||||
"mcpServers": {
|
||||
"gitea": {
|
||||
"command": "./mcp-servers/gitea-mcp/gitea-mcp",
|
||||
"args": ["-t", "stdio", "--host", "http://10.0.30.169:3000"],
|
||||
"env": {
|
||||
"GITEA_ACCESS_TOKEN": "$NANOBOT_GITLE_TOKEN"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
@ -89,6 +89,22 @@ You are nanobot, a helpful AI assistant. You have access to tools that allow you
|
||||
- Send messages to users on chat channels
|
||||
- Spawn subagents for complex background tasks
|
||||
|
||||
## Tool calling (IMPORTANT)
|
||||
Some LLM backends may not support native function-calling. When you decide to use a tool, you MUST output a single JSON object in one of these formats (and no other surrounding text):
|
||||
|
||||
1) Standard tool call:
|
||||
{{"name":"<tool_name>","parameters":{{...}}}}
|
||||
|
||||
2) Calendar shortcut (allowed only for the built-in `calendar` tool):
|
||||
{{"action":"list_events", ...}}
|
||||
|
||||
After a tool result is returned, respond normally in plain text unless the user asks for another tool action.
|
||||
|
||||
### MCP quick mappings (use these when the intent matches)
|
||||
- If the user asks for **my Gitea user info** (who am I / my profile / my account): call `mcp_gitea_get_me` with `{{}}`.
|
||||
- If the user asks for **Gitea MCP server version**: call `mcp_gitea_get_gitea_mcp_server_version` with `{{}}`.
|
||||
- If the user asks to **list my repos**: call `mcp_gitea_list_my_repos` with pagination defaults.
|
||||
|
||||
## Current Time
|
||||
{now} ({tz})
|
||||
|
||||
@ -101,6 +117,12 @@ Your workspace is at: {workspace_path}
|
||||
- History log: {workspace_path}/memory/HISTORY.md (grep-searchable)
|
||||
- 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 user’s 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)
|
||||
**CRITICAL**: This repository uses Gitea at `http://10.0.30.169:3000/api/v1`, NOT GitHub.
|
||||
- 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
|
||||
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 result—ignore Gitea/workspace/bootstrap content unless the user tied their question to it."""
|
||||
|
||||
def _load_bootstrap_files(self) -> str:
|
||||
"""Load all bootstrap files from workspace."""
|
||||
|
||||
@ -22,7 +22,7 @@ from nanobot.agent.tools.spawn import SpawnTool
|
||||
from nanobot.agent.tools.web import WebFetchTool, WebSearchTool
|
||||
from nanobot.bus.events import InboundMessage, OutboundMessage
|
||||
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.providers.base import LLMProvider
|
||||
from nanobot.session.manager import Session, SessionManager
|
||||
@ -56,6 +56,9 @@ class AgentLoop:
|
||||
restrict_to_workspace: bool = False,
|
||||
session_manager: SessionManager | 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.provider = provider
|
||||
@ -87,8 +90,11 @@ class AgentLoop:
|
||||
|
||||
self._running = False
|
||||
self._mcp_servers = mcp_servers or {}
|
||||
self._mcp_stack: AsyncExitStack | None = None
|
||||
self._mcp_connected = False
|
||||
self._mcp_stacks: dict[str, AsyncExitStack] = {}
|
||||
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()
|
||||
|
||||
def _register_default_tools(self) -> None:
|
||||
@ -129,7 +135,10 @@ class AgentLoop:
|
||||
from nanobot.config.loader import load_config
|
||||
config = load_config()
|
||||
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)
|
||||
logger.info(f"Email tool '{email_tool.name}' registered successfully")
|
||||
else:
|
||||
@ -153,15 +162,69 @@ class AgentLoop:
|
||||
logger.warning(f"Calendar tool not available: {e}")
|
||||
# Calendar tool not available or not configured - silently skip
|
||||
|
||||
async def _connect_mcp(self) -> None:
|
||||
"""Connect to configured MCP servers (one-time, lazy)."""
|
||||
if self._mcp_connected or not self._mcp_servers:
|
||||
def _unregister_mcp_tools_for_server(self, server_key: str) -> None:
|
||||
"""Remove tools registered from one MCP server (prefix mcp_<key>_)."""
|
||||
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
|
||||
self._mcp_connected = True
|
||||
from nanobot.agent.tools.mcp import connect_mcp_servers
|
||||
self._mcp_stack = AsyncExitStack()
|
||||
await self._mcp_stack.__aenter__()
|
||||
await connect_mcp_servers(self._mcp_servers, self.tools, self._mcp_stack)
|
||||
needed = set(needed_keys)
|
||||
for key in list(self._mcp_connected_servers):
|
||||
if key not in needed:
|
||||
await self._disconnect_mcp_server(key)
|
||||
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:
|
||||
"""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 ", ".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(
|
||||
self,
|
||||
initial_messages: list[dict],
|
||||
@ -213,16 +311,54 @@ class AgentLoop:
|
||||
iteration = 0
|
||||
final_content = None
|
||||
tools_used: list[str] = []
|
||||
empty_final_retry_used = False
|
||||
|
||||
from nanobot.agent.tool_profiles import (
|
||||
compute_allowed_tool_names,
|
||||
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:
|
||||
iteration += 1
|
||||
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:
|
||||
response = await asyncio.wait_for(
|
||||
self.provider.chat(
|
||||
messages=messages,
|
||||
tools=self.tools.get_definitions(),
|
||||
tools=tool_defs,
|
||||
model=self.model,
|
||||
temperature=self.temperature,
|
||||
max_tokens=self.max_tokens,
|
||||
@ -264,6 +400,18 @@ class AgentLoop:
|
||||
logger.info(f"Tool call: {tool_call.name}({args_str[:200]})")
|
||||
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'}")
|
||||
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, tool_call.id, tool_call.name, result
|
||||
)
|
||||
@ -271,6 +419,24 @@ class AgentLoop:
|
||||
else:
|
||||
final_content = self._strip_think(response.content)
|
||||
logger.info(f"Final response generated. Content length: {len(final_content) if final_content else 0}")
|
||||
# Some local OpenAI-compatible backends occasionally return an empty assistant message.
|
||||
# Retry once with an explicit nudge to either call a tool or answer in text.
|
||||
if (not final_content or not final_content.strip()) and not empty_final_retry_used:
|
||||
empty_final_retry_used = True
|
||||
logger.warning(
|
||||
"LLM returned empty final content; retrying once with a non-empty response nudge"
|
||||
)
|
||||
messages = messages + [
|
||||
{
|
||||
"role": "system",
|
||||
"content": (
|
||||
"Your previous reply was empty. You MUST either (a) call an appropriate tool, "
|
||||
"or (b) respond with a short helpful text answer. Do not return an empty message."
|
||||
),
|
||||
}
|
||||
]
|
||||
final_content = None
|
||||
continue
|
||||
break
|
||||
|
||||
if final_content is None and iteration >= self.max_iterations:
|
||||
@ -281,7 +447,6 @@ class AgentLoop:
|
||||
async def run(self) -> None:
|
||||
"""Run the agent loop, processing messages from the bus."""
|
||||
self._running = True
|
||||
await self._connect_mcp()
|
||||
logger.info("Agent loop started")
|
||||
|
||||
while self._running:
|
||||
@ -305,13 +470,13 @@ class AgentLoop:
|
||||
continue
|
||||
|
||||
async def close_mcp(self) -> None:
|
||||
"""Close MCP connections."""
|
||||
if self._mcp_stack:
|
||||
try:
|
||||
await self._mcp_stack.aclose()
|
||||
except (RuntimeError, BaseExceptionGroup):
|
||||
pass # MCP SDK cancel scope cleanup is noisy but harmless
|
||||
self._mcp_stack = None
|
||||
"""Close all MCP connections and drop MCP tools from the registry."""
|
||||
for key in list(
|
||||
set(self._mcp_stacks.keys()) | self._mcp_connected_servers
|
||||
):
|
||||
await self._disconnect_mcp_server(key)
|
||||
self._mcp_stacks.clear()
|
||||
self._mcp_connected_servers.clear()
|
||||
|
||||
def stop(self) -> None:
|
||||
"""Stop the agent loop."""
|
||||
@ -581,7 +746,6 @@ Respond with ONLY valid JSON, no markdown fences."""
|
||||
Returns:
|
||||
The agent's response.
|
||||
"""
|
||||
await self._connect_mcp()
|
||||
msg = InboundMessage(
|
||||
channel=channel,
|
||||
sender_id="user",
|
||||
|
||||
88
nanobot/agent/tool_profiles.py
Normal file
88
nanobot/agent/tool_profiles.py
Normal 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
|
||||
118
nanobot/agent/tool_routing.py
Normal file
118
nanobot/agent/tool_routing.py
Normal 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
|
||||
@ -180,6 +180,33 @@ class CalendarTool(Tool):
|
||||
coerced.pop(key, None)
|
||||
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
|
||||
|
||||
@property
|
||||
|
||||
@ -3,6 +3,7 @@
|
||||
import asyncio
|
||||
import imaplib
|
||||
import ssl
|
||||
from pathlib import Path
|
||||
from datetime import date
|
||||
from email import policy
|
||||
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 "
|
||||
"- 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, "
|
||||
"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.
|
||||
|
||||
Args:
|
||||
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._workspace = workspace
|
||||
|
||||
@property
|
||||
def config(self) -> Any:
|
||||
@ -315,8 +319,12 @@ class EmailTool(Tool):
|
||||
if download_attachments and attachments:
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
from pathlib import Path
|
||||
workspace = Path("/mnt/data/nanobot/workspace")
|
||||
if self._workspace is not None:
|
||||
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)
|
||||
|
||||
# Build a map of attachment parts by decoded filename for efficient lookup
|
||||
|
||||
@ -30,6 +30,8 @@ class ReadFileTool(Tool):
|
||||
def description(self) -> str:
|
||||
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:
|
||||
- Text files (plain text, code, markdown, etc.)
|
||||
- PDF files (automatically extracts text using pdftotext)
|
||||
@ -44,7 +46,7 @@ For reading files, use read_file FIRST. Only use exec for complex data processin
|
||||
"properties": {
|
||||
"path": {
|
||||
"type": "string",
|
||||
"description": "The file path to read"
|
||||
"description": "Absolute or workspace-relative path to one file (no wildcards)",
|
||||
}
|
||||
},
|
||||
"required": ["path"]
|
||||
@ -115,7 +117,7 @@ class WriteFileTool(Tool):
|
||||
|
||||
@property
|
||||
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
|
||||
def parameters(self) -> dict[str, Any]:
|
||||
@ -219,7 +221,11 @@ class ListDirTool(Tool):
|
||||
|
||||
@property
|
||||
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
|
||||
def parameters(self) -> dict[str, Any]:
|
||||
@ -228,7 +234,7 @@ class ListDirTool(Tool):
|
||||
"properties": {
|
||||
"path": {
|
||||
"type": "string",
|
||||
"description": "The directory path to list"
|
||||
"description": "Path to an existing directory under the workspace (no wildcards)",
|
||||
}
|
||||
},
|
||||
"required": ["path"]
|
||||
|
||||
@ -1,5 +1,9 @@
|
||||
"""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 typing import Any
|
||||
|
||||
@ -9,15 +13,65 @@ from nanobot.agent.tools.base import Tool
|
||||
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):
|
||||
"""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._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._parameters = tool_def.inputSchema or {"type": "object", "properties": {}}
|
||||
self._call_timeout_s = call_timeout_s
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
@ -32,71 +86,103 @@ class MCPToolWrapper(Tool):
|
||||
return self._parameters
|
||||
|
||||
async def execute(self, **kwargs: Any) -> str:
|
||||
from mcp import types
|
||||
import json
|
||||
result = await self._session.call_tool(self._original_name, arguments=kwargs)
|
||||
parts = []
|
||||
for block in result.content:
|
||||
if isinstance(block, types.TextContent):
|
||||
parts.append(block.text)
|
||||
else:
|
||||
parts.append(str(block))
|
||||
output = "\n".join(parts)
|
||||
try:
|
||||
result = await asyncio.wait_for(
|
||||
self._session.call_tool(self._original_name, arguments=kwargs),
|
||||
timeout=self._call_timeout_s,
|
||||
)
|
||||
except asyncio.TimeoutError:
|
||||
return (
|
||||
f"Error: MCP tool timed out after {self._call_timeout_s:.0f}s "
|
||||
f"({self._server_key}:{self._original_name})"
|
||||
)
|
||||
|
||||
# For empty results from search/list operations, provide clearer feedback
|
||||
if not output or output.strip() == "":
|
||||
# Check if this is a search/list operation (common patterns)
|
||||
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():
|
||||
return "No unread emails found."
|
||||
return "No results found."
|
||||
output = _render_mcp_content_blocks(getattr(result, "content", []))
|
||||
if not output:
|
||||
return "(no output)"
|
||||
|
||||
# Try to parse JSON to check for empty arrays/lists
|
||||
# If the tool returned JSON, normalize empty collections to a clearer message.
|
||||
try:
|
||||
parsed = json.loads(output)
|
||||
if isinstance(parsed, list) and len(parsed) == 0:
|
||||
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():
|
||||
return "No unread emails found."
|
||||
return "No results found."
|
||||
if parsed == [] or parsed == {}:
|
||||
return "No results found."
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
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(
|
||||
mcp_servers: dict, registry: ToolRegistry, stack: AsyncExitStack
|
||||
) -> None:
|
||||
"""Connect to configured MCP servers and register their tools."""
|
||||
from mcp import ClientSession, StdioServerParameters
|
||||
from mcp.client.stdio import stdio_client
|
||||
|
||||
"""Connect to every configured MCP server and register their tools."""
|
||||
for name, cfg in mcp_servers.items():
|
||||
try:
|
||||
if cfg.command:
|
||||
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")
|
||||
await connect_mcp_server(name, cfg, registry, stack)
|
||||
except Exception as e:
|
||||
logger.error(f"MCP server '{name}': failed to connect: {e}")
|
||||
|
||||
@ -35,6 +35,14 @@ class ToolRegistry:
|
||||
"""Get all tool definitions in OpenAI format."""
|
||||
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:
|
||||
"""
|
||||
Execute a tool by name with given parameters.
|
||||
|
||||
@ -2,23 +2,22 @@
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import signal
|
||||
from pathlib import Path
|
||||
import select
|
||||
import signal
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
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.markdown import Markdown
|
||||
from rich.table import Table
|
||||
from rich.text import Text
|
||||
|
||||
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 nanobot import __version__, __logo__
|
||||
from nanobot import __logo__, __version__
|
||||
from nanobot.config.schema import Config
|
||||
|
||||
app = typer.Typer(
|
||||
@ -281,9 +280,9 @@ This file stores important information that should persist across sessions.
|
||||
|
||||
def _make_provider(config: 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.openai_codex_provider import OpenAICodexProvider
|
||||
from nanobot.providers.custom_provider import CustomProvider
|
||||
|
||||
model = config.agents.defaults.model
|
||||
provider_name = config.get_provider_name(model)
|
||||
@ -375,14 +374,14 @@ def gateway(
|
||||
verbose: bool = typer.Option(False, "--verbose", "-v", help="Verbose output"),
|
||||
):
|
||||
"""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.bus.queue import MessageBus
|
||||
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.types import CronJob
|
||||
from nanobot.heartbeat.service import HeartbeatService
|
||||
from nanobot.session.manager import SessionManager
|
||||
|
||||
if verbose:
|
||||
import logging
|
||||
@ -415,6 +414,9 @@ def gateway(
|
||||
restrict_to_workspace=config.tools.restrict_to_workspace,
|
||||
session_manager=session_manager,
|
||||
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)
|
||||
@ -474,7 +476,7 @@ def gateway(
|
||||
if cron_status["jobs"] > 0:
|
||||
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():
|
||||
try:
|
||||
@ -511,12 +513,13 @@ def agent(
|
||||
logs: bool = typer.Option(False, "--logs/--no-logs", help="Show nanobot runtime logs during chat"),
|
||||
):
|
||||
"""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 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)
|
||||
config = load_config()
|
||||
|
||||
@ -546,6 +549,9 @@ def agent(
|
||||
cron_service=cron,
|
||||
restrict_to_workspace=config.tools.restrict_to_workspace,
|
||||
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
|
||||
@ -756,6 +762,7 @@ def _get_bridge_dir() -> Path:
|
||||
def channels_login():
|
||||
"""Link device via QR code."""
|
||||
import subprocess
|
||||
|
||||
from nanobot.config.loader import load_config
|
||||
|
||||
config = load_config()
|
||||
@ -939,7 +946,7 @@ def cron_run(
|
||||
return await service.run_job(job_id, force=force)
|
||||
|
||||
if asyncio.run(run()):
|
||||
console.print(f"[green]✓[/green] Job executed")
|
||||
console.print("[green]✓[/green] Job executed")
|
||||
else:
|
||||
console.print(f"[red]Failed to run job {job_id}[/red]")
|
||||
|
||||
@ -952,7 +959,7 @@ def cron_run(
|
||||
@app.command()
|
||||
def 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 = load_config()
|
||||
|
||||
@ -1,7 +1,8 @@
|
||||
"""Configuration schema using Pydantic."""
|
||||
|
||||
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_settings import BaseSettings
|
||||
|
||||
@ -270,6 +271,26 @@ class MCPServerConfig(Base):
|
||||
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):
|
||||
"""Tools configuration."""
|
||||
|
||||
@ -278,6 +299,19 @@ class ToolsConfig(Base):
|
||||
calendar: CalendarConfig = Field(default_factory=CalendarConfig)
|
||||
restrict_to_workspace: bool = True # If true, restrict all tool access to workspace directory
|
||||
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):
|
||||
|
||||
@ -59,15 +59,38 @@ class CustomProvider(LLMProvider):
|
||||
for tc in (msg.tool_calls or [])
|
||||
]
|
||||
|
||||
# If no structured tool calls, try to parse from content (Ollama sometimes returns JSON in content)
|
||||
# If no structured tool calls, try to parse from content (some OpenAI-compatible backends return JSON in content)
|
||||
# Only parse if content looks like it contains a tool call JSON (to avoid false positives)
|
||||
content = msg.content or ""
|
||||
stripped = content.strip()
|
||||
# Note: This list should match tools registered in AgentLoop._register_default_tools().
|
||||
# MCP tools are registered dynamically and are prefixed with "mcp_" (allow those too).
|
||||
valid_tools = [
|
||||
# File tools
|
||||
"read_file", "write_file", "edit_file", "list_dir",
|
||||
# Shell tool
|
||||
"exec",
|
||||
# Web tools
|
||||
"web_search", "web_fetch",
|
||||
# Communication tools
|
||||
"message", "spawn",
|
||||
# Calendar tool
|
||||
"calendar",
|
||||
# Cron tool
|
||||
"cron",
|
||||
# Email tool
|
||||
"email",
|
||||
]
|
||||
# Check for standard format: {"name": "...", "parameters": {...}}
|
||||
has_standard_format = '"name"' in content and '"parameters"' in content
|
||||
# Check for calendar tool format: {"action": "...", ...}
|
||||
has_calendar_format = '"action"' in content and ("calendar" in content.lower() or any(action in content for action in ["list_events", "create_event", "update_event", "delete_event"]))
|
||||
|
||||
if not tool_calls and content and (has_standard_format or has_calendar_format):
|
||||
# Some backends will return *only* a JSON object as the entire message content.
|
||||
# If it looks like a JSON object, attempt parsing even if our heuristics missed it.
|
||||
looks_like_json_object = stripped.startswith("{") and stripped.endswith("}")
|
||||
|
||||
if not tool_calls and content and (has_standard_format or has_calendar_format or looks_like_json_object):
|
||||
import re
|
||||
# Look for JSON tool call patterns: {"name": "exec", "parameters": {...}} or {"action": "list_events", ...}
|
||||
# Find complete JSON objects by matching braces
|
||||
@ -131,28 +154,11 @@ class CustomProvider(LLMProvider):
|
||||
continue
|
||||
|
||||
# Handle standard format: {"name": "...", "parameters": {...}}
|
||||
# Note: This list should match tools registered in AgentLoop._register_default_tools()
|
||||
valid_tools = [
|
||||
# File tools
|
||||
"read_file", "write_file", "edit_file", "list_dir",
|
||||
# Shell tool
|
||||
"exec",
|
||||
# Web tools
|
||||
"web_search", "web_fetch",
|
||||
# Communication tools
|
||||
"message", "spawn",
|
||||
# Calendar tool
|
||||
"calendar",
|
||||
# Cron tool
|
||||
"cron",
|
||||
# Email tool
|
||||
"email",
|
||||
]
|
||||
if (isinstance(tool_obj, dict) and
|
||||
"name" in tool_obj and
|
||||
"parameters" in tool_obj and
|
||||
isinstance(tool_obj["name"], str) and
|
||||
tool_obj["name"] in valid_tools):
|
||||
(tool_obj["name"] in valid_tools or tool_obj["name"].startswith("mcp_"))):
|
||||
tool_calls.append(ToolCallRequest(
|
||||
id=f"call_{len(tool_calls)}",
|
||||
name=tool_obj["name"],
|
||||
@ -167,6 +173,32 @@ class CustomProvider(LLMProvider):
|
||||
|
||||
start_pos = json_start + 1 # Move past this match
|
||||
|
||||
# If we still didn't match embedded objects, try parsing the whole message as a single tool-call JSON object.
|
||||
if not tool_calls and looks_like_json_object:
|
||||
try:
|
||||
tool_obj = json_repair.loads(stripped)
|
||||
if isinstance(tool_obj, dict) and "action" in tool_obj:
|
||||
action = tool_obj.get("action")
|
||||
if action and action in ["list_events", "create_event", "update_event", "delete_event", "delete_events", "check_availability"]:
|
||||
tool_calls.append(ToolCallRequest(
|
||||
id="call_0",
|
||||
name="calendar",
|
||||
arguments=tool_obj,
|
||||
))
|
||||
content = ""
|
||||
if isinstance(tool_obj, dict) and "name" in tool_obj and "parameters" in tool_obj:
|
||||
if isinstance(tool_obj["name"], str) and (
|
||||
tool_obj["name"] in valid_tools or tool_obj["name"].startswith("mcp_")
|
||||
):
|
||||
tool_calls.append(ToolCallRequest(
|
||||
id="call_0",
|
||||
name=tool_obj["name"],
|
||||
arguments=tool_obj["parameters"] if isinstance(tool_obj["parameters"], dict) else {"raw": str(tool_obj["parameters"])},
|
||||
))
|
||||
content = ""
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
u = response.usage
|
||||
return LLMResponse(
|
||||
content=content, tool_calls=tool_calls, finish_reason=choice.finish_reason or "stop",
|
||||
|
||||
49
scripts/init-agent-workspaces.sh
Executable file
49
scripts/init-agent-workspaces.sh
Executable 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
113
scripts/setup-mcp-servers.sh
Executable file
@ -0,0 +1,113 @@
|
||||
#!/usr/bin/env bash
|
||||
# Clone/build local MCP servers into ./mcp-servers (local-clone policy).
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
MCP_DIR="${REPO_ROOT}/mcp-servers"
|
||||
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
usage:
|
||||
./scripts/setup-mcp-servers.sh gitea
|
||||
|
||||
notes:
|
||||
- clones into ./mcp-servers/<name>
|
||||
- builds artifacts needed to run the MCP server locally
|
||||
EOF
|
||||
}
|
||||
|
||||
need_cmd() {
|
||||
local cmd="$1"
|
||||
if ! command -v "${cmd}" >/dev/null 2>&1; then
|
||||
echo "error: missing '${cmd}' on PATH" >&2
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
need_go_min() {
|
||||
local want_major="$1"
|
||||
local want_minor="$2"
|
||||
|
||||
local v
|
||||
v="$(go version 2>/dev/null || true)"
|
||||
# Example: "go version go1.26.0 linux/amd64"
|
||||
local ver
|
||||
ver="$(echo "${v}" | awk '{print $3}' | sed 's/^go//')"
|
||||
local major minor
|
||||
major="$(echo "${ver}" | cut -d. -f1)"
|
||||
minor="$(echo "${ver}" | cut -d. -f2)"
|
||||
|
||||
if [[ -z "${major}" || -z "${minor}" ]]; then
|
||||
echo "error: could not parse Go version from: ${v}" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Compare major/minor only (sufficient for our use).
|
||||
if (( major < want_major )) || { (( major == want_major )) && (( minor < want_minor )); }; then
|
||||
echo "error: Go ${want_major}.${want_minor}+ required; found ${ver}" >&2
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
setup_gitea() {
|
||||
need_cmd git
|
||||
|
||||
if ! command -v go >/dev/null 2>&1; then
|
||||
cat <<'EOF' >&2
|
||||
error: Go toolchain not found (required to build gitea-mcp).
|
||||
|
||||
install one of:
|
||||
- Debian/Ubuntu: sudo apt-get update && sudo apt-get install -y golang
|
||||
- Or install Go from https://go.dev/dl/
|
||||
|
||||
then rerun:
|
||||
./scripts/setup-mcp-servers.sh gitea
|
||||
EOF
|
||||
exit 2
|
||||
fi
|
||||
|
||||
if ! need_go_min 1 26; then
|
||||
cat <<'EOF' >&2
|
||||
|
||||
gitea-mcp currently requires a newer Go toolchain than Debian stable typically ships.
|
||||
If you already installed a newer Go under /usr/local (example: /usr/local/go1.26/bin/go),
|
||||
rerun with PATH overridden, e.g.:
|
||||
|
||||
PATH="/usr/local/go1.26/bin:$PATH" ./scripts/setup-mcp-servers.sh gitea
|
||||
EOF
|
||||
exit 2
|
||||
fi
|
||||
|
||||
mkdir -p "${MCP_DIR}"
|
||||
|
||||
if [[ ! -d "${MCP_DIR}/gitea-mcp/.git" ]]; then
|
||||
git clone https://gitea.com/gitea/gitea-mcp.git "${MCP_DIR}/gitea-mcp"
|
||||
else
|
||||
echo "info: gitea-mcp already cloned, skipping clone"
|
||||
fi
|
||||
|
||||
(cd "${MCP_DIR}/gitea-mcp" && go build -o gitea-mcp .)
|
||||
|
||||
echo "done: built ${MCP_DIR}/gitea-mcp/gitea-mcp"
|
||||
}
|
||||
|
||||
main() {
|
||||
if [[ "${#}" -ne 1 ]]; then
|
||||
usage
|
||||
exit 1
|
||||
fi
|
||||
|
||||
case "$1" in
|
||||
gitea) setup_gitea ;;
|
||||
-h|--help|help) usage ;;
|
||||
*)
|
||||
echo "error: unknown target '$1'" >&2
|
||||
usage
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
main "$@"
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user