diff --git a/README.md b/README.md index 3be6e10..0090851 100644 --- a/README.md +++ b/README.md @@ -1,43 +1,158 @@ # NanoClaw MCP Extension -## Overview -This is an extension for the Pi Coding Agent (`@mariozechner/pi-coding-agent`) that integrates tools from a NanoClaw Model Context Protocol (MCP) server. It allows the Pi agent to dynamically fetch and execute tools exposed by a remote or local NanoClaw server. +External agent access for [NanoClaw](https://github.com/qwibitai/nanoclaw) via the [Model Context Protocol](https://modelcontextprotocol.io/). Contains both the **server-side skill** (for NanoClaw) and a **client extension** (for pi.dev). ## How it works -When a new Pi session starts, the extension connects to the NanoClaw MCP server via a `StreamableHTTPClientTransport`. It authenticates using a Bearer token, queries the available tools (`listTools`), and automatically registers them within the Pi environment so the AI can use them. -## Configuration -The extension is configured using the following environment variables: +``` +External Agent ──SSH tunnel──▶ localhost:3002/mcp + │ + Bearer token auth + │ + MCP Channel (mcp:*) + │ + ┌──────┴──────┐ + │ GroupQueue │ + └──────┬──────┘ + │ + Container Agent (same image, + workspace, tools as Telegram) +``` -* `NANOCLAW_MCP_TOKEN` **(Required)**: The authentication token for the MCP server. If missing, the extension will skip loading the tools. -* `NANOCLAW_MCP_URL` **(Optional)**: The URL of the NanoClaw MCP server endpoint. Defaults to `http://localhost:3002/mcp`. +An MCP server registers as a NanoClaw channel. External agents connect via Streamable HTTP, send messages through the `chat` tool, and receive the assistant's response. Messages use an isolated `mcp:*` JID namespace — other channels (Telegram, WhatsApp) see nothing. -## Usage & Example +## Repository Structure -1. Ensure the extension is placed in your Pi extensions folder (or configured in your workspace to be loaded). -2. Set the necessary environment variables and start Pi: +``` +├── nanoclaw-skill/ # Server-side: NanoClaw skill +│ ├── SKILL.md # Installation instructions +│ ├── src/mcp-server.ts # MCP server + channel implementation +│ └── modify/ # Intent files for existing file modifications +├── index.ts # Client-side: pi.dev extension +├── mcp_test.mjs # Test scripts +└── README.md +``` + +--- + +## Server Setup (NanoClaw) + +### Option A: Using the NanoClaw skill + +If you have Claude Code available: + +``` +/add-mcp +``` + +Or manually follow the instructions in [`nanoclaw-skill/SKILL.md`](nanoclaw-skill/SKILL.md). + +### Option B: Manual installation + +1. Install dependencies: + ```bash + npm install @modelcontextprotocol/sdk zod + ``` + +2. Copy `nanoclaw-skill/src/mcp-server.ts` to `src/mcp-server.ts` + +3. Add `import '../mcp-server.js';` to `src/channels/index.ts` + +4. Add `MCP_API_KEY` and `MCP_PORT` to `src/config.ts` (see SKILL.md for details) + +5. Add MCP server startup to `src/index.ts` (see SKILL.md for details) + +6. Configure `.env`: + ``` + MCP_API_KEY= + MCP_PORT=3002 + ``` + +7. Build and restart: + ```bash + npm run build + sudo systemctl restart nanoclaw + ``` + +### Security + +The MCP server binds to `127.0.0.1` only. Clients connect via SSH tunnel: + +```bash +ssh -L 3002:localhost:3002 user@your-server -N +``` + +--- + +## Client Setup (pi.dev) + +### Configuration + +Set environment variables: + +| Variable | Required | Default | Description | +|----------|----------|---------|-------------| +| `NANOCLAW_MCP_TOKEN` | yes | — | Bearer token (= `MCP_API_KEY` from server) | +| `NANOCLAW_MCP_URL` | no | `http://localhost:3002/mcp` | MCP server URL | + +### Usage + +1. Place the extension in your Pi extensions folder +2. Start the SSH tunnel (if server is remote) +3. Start Pi: ```bash export NANOCLAW_MCP_TOKEN="your-secret-token" -# Optional: export NANOCLAW_MCP_URL="http://localhost:3002/mcp" - pi ``` -3. When Pi starts, you will see a notification in the UI: - `Connected to nanoclaw MCP server. Loaded X tools: tool_name, another_tool` -4. You can now prompt the AI in the chat to use any of the dynamically loaded tools. +Pi will auto-discover and register the available tools (`chat`, `list_groups`). -## Connecting via SSH Tunnel +### Generic MCP Client -Often, the NanoClaw MCP server is running on a remote machine (e.g., a production server or a different development environment) and shouldn't be exposed directly to the public internet. +Any MCP client can connect. Example configuration: -In this case, you can use an **SSH Tunnel** to securely forward the traffic. - -**Example:** -Forward remote port `3002` to your local port `3002`: -```bash -ssh -L 3002:localhost:3002 user@remote-nanoclaw-host +```json +{ + "mcpServers": { + "nanoclaw": { + "type": "streamable-http", + "url": "http://localhost:3002/mcp", + "headers": { + "Authorization": "Bearer " + } + } + } +} ``` -Once the tunnel is active, you can simply run Pi locally without changing the `NANOCLAW_MCP_URL` (since it defaults to `http://localhost:3002/mcp`). The traffic will be securely routed through the SSH tunnel to the NanoClaw instance. +--- + +## Available MCP Tools + +### `chat` + +Send a message to the assistant and receive a response. + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `message` | string | yes | The message to send | +| `group` | string | no | Target group (default: main) | + +**Response time:** ~2-3 min cold start, ~5-10s warm (container reuse within idle timeout). + +### `list_groups` + +Lists available groups. No parameters. + +--- + +## Troubleshooting + +| Symptom | Cause | Fix | +|---------|-------|-----| +| Connection refused | SSH tunnel not running | Start tunnel: `ssh -L 3002:localhost:3002 ...` | +| 401 Unauthorized | Token mismatch | Check `MCP_API_KEY` in `.env` matches client token | +| Slow first response | Cold start | Expected (~2-3 min). Subsequent calls are fast. | +| `(no response)` | Container timeout | Check `docker logs ` for errors | +| MCP server not starting | Missing config | Verify `MCP_API_KEY` in `.env`, rebuild, restart | diff --git a/nanoclaw-skill/SKILL.md b/nanoclaw-skill/SKILL.md new file mode 100644 index 0000000..6952c5a --- /dev/null +++ b/nanoclaw-skill/SKILL.md @@ -0,0 +1,210 @@ +--- +name: add-mcp +description: Add an MCP (Model Context Protocol) server to NanoClaw so external agents can chat with the assistant via Streamable HTTP. Includes SSH tunnel setup for secure access. +--- + +# Add MCP Server + +This skill adds an MCP server to NanoClaw, allowing external agents (Claude Code, pi.dev, custom clients) to have a dialog with the assistant. + +## Phase 1: Pre-flight + +### Check if already applied + +Check if `src/mcp-server.ts` exists. If it does, skip to Phase 3 (Setup). The code changes are already in place. + +## Phase 2: Apply Code Changes + +### Install dependencies + +```bash +npm install @modelcontextprotocol/sdk zod +``` + +### Copy source file + +Copy `src/mcp-server.ts` from the skill directory into the project: + +```bash +cp ${CLAUDE_SKILL_DIR}/src/mcp-server.ts src/mcp-server.ts +``` + +### Modify channel barrel file + +Read the intent file at `${CLAUDE_SKILL_DIR}/modify/src/channels/index.ts.intent.md`, then apply it to `src/channels/index.ts`. + +Add this import line (before or after existing channel imports): + +```typescript +// mcp +import '../mcp-server.js'; +``` + +### Modify config.ts + +Add `'MCP_API_KEY'` and `'MCP_PORT'` to the `readEnvFile` array in `src/config.ts`: + +```typescript +const envConfig = readEnvFile([ + // ... existing keys ... + 'MCP_API_KEY', + 'MCP_PORT', +]); +``` + +Add these exports (before or after existing config exports): + +```typescript +// MCP server for external agent access +export const MCP_API_KEY = + process.env.MCP_API_KEY || envConfig.MCP_API_KEY || ''; +export const MCP_PORT = parseInt( + process.env.MCP_PORT || envConfig.MCP_PORT || '3002', + 10, +); +``` + +### Modify index.ts + +Add imports at the top: + +```typescript +import { startMcpServer } from './mcp-server.js'; +import { MCP_API_KEY, MCP_PORT } from './config.js'; +``` + +Note: `MCP_API_KEY` and `MCP_PORT` should be added to the existing config import, not a separate one. + +In the `main()` function, after `startCredentialProxy(...)`, add: + +```typescript +// Start external MCP server if API key is configured +let mcpServer: ReturnType | undefined; +if (MCP_API_KEY) { + mcpServer = startMcpServer(MCP_PORT, MCP_API_KEY, { + registeredGroups: () => registeredGroups, + }); +} +``` + +In the `shutdown()` function, add before `queue.shutdown(...)`: + +```typescript +(await mcpServer)?.close(); +``` + +### Validate + +```bash +npm run build +``` + +Build must succeed before proceeding. + +## Phase 3: Setup + +### Generate API Key + +```bash +openssl rand -base64 32 +``` + +### Configure environment + +Add to `.env`: + +``` +MCP_API_KEY= +MCP_PORT=3002 +``` + +### Rebuild and restart + +```bash +npm run build +``` + +Then restart the service: + +```bash +# Linux (systemd) +sudo systemctl restart nanoclaw + +# macOS (launchd) +launchctl kickstart -k gui/$(id -u)/com.nanoclaw +``` + +### Verify + +```bash +# Check MCP server is listening +ss -tlnp | grep 3002 # Linux +lsof -i :3002 # macOS + +# Check logs +grep "MCP server listening" logs/nanoclaw.log | tail -1 +grep "MCP channel connected" logs/nanoclaw.log | tail -1 +``` + +Both log lines should be present. + +### Test + +```bash +export MCP_API_KEY="" + +curl -s -X POST http://localhost:3002/mcp \ + -H "Content-Type: application/json" \ + -H "Accept: application/json, text/event-stream" \ + -H "Authorization: Bearer $MCP_API_KEY" \ + -d '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}' +``` + +Should return `chat` and `list_groups` tools. + +## Connecting External Agents + +The MCP server binds to `127.0.0.1` only (not publicly accessible). External clients connect via SSH tunnel: + +```bash +ssh -L 3002:localhost:3002 user@your-server -N +``` + +Then configure the MCP client: + +```json +{ + "mcpServers": { + "nanoclaw": { + "type": "streamable-http", + "url": "http://localhost:3002/mcp", + "headers": { + "Authorization": "Bearer " + } + } + } +} +``` + +See the [pi.dev extension](../index.ts) in this repo for a working client implementation. + +## Architecture + +- Registers as a NanoClaw **Channel** (like Telegram or WhatsApp) +- Uses `mcp:` JID namespace — isolated from all other channels +- Same container, workspace, tools, and CLAUDE.md as the target group +- Container reuse via GroupQueue (cold start ~2-3 min, warm ~5-10s) +- Responses debounced (3s) for multi-part agent output +- Bearer token auth + SSH tunnel for security + +## Troubleshooting + +**MCP server not starting:** Check `MCP_API_KEY` is set in `.env` and NanoClaw was rebuilt. + +**401 Unauthorized:** Bearer token doesn't match `MCP_API_KEY`. + +**Connection refused:** SSH tunnel not running, or NanoClaw not running. + +**Slow first response:** Expected — cold start boots a container (~2-3 min). Subsequent calls reuse it. + +**"(no response)":** Container timed out or crashed. Check `docker logs `. diff --git a/nanoclaw-skill/modify/src/channels/index.ts b/nanoclaw-skill/modify/src/channels/index.ts new file mode 100644 index 0000000..b130adb --- /dev/null +++ b/nanoclaw-skill/modify/src/channels/index.ts @@ -0,0 +1,2 @@ +// mcp +import '../mcp-server.js'; diff --git a/nanoclaw-skill/modify/src/channels/index.ts.intent.md b/nanoclaw-skill/modify/src/channels/index.ts.intent.md new file mode 100644 index 0000000..fcbf812 --- /dev/null +++ b/nanoclaw-skill/modify/src/channels/index.ts.intent.md @@ -0,0 +1,7 @@ +# Intent: Add MCP server import + +Add `import '../mcp-server.js';` to the channel barrel file so the MCP +server self-registers as a channel with the channel registry on startup. + +This is an append-only change — existing import lines for other channels +must be preserved. diff --git a/nanoclaw-skill/src/mcp-server.ts b/nanoclaw-skill/src/mcp-server.ts new file mode 100644 index 0000000..e25fb10 --- /dev/null +++ b/nanoclaw-skill/src/mcp-server.ts @@ -0,0 +1,329 @@ +import http from 'http'; +import crypto from 'crypto'; + +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'; +import { z } from 'zod'; + +import { ASSISTANT_NAME, MCP_API_KEY } from './config.js'; +import { logger } from './logger.js'; +import { registerChannel, ChannelOpts } from './channels/registry.js'; +import type { Channel, RegisteredGroup } from './types.js'; + +// Pending MCP requests waiting for Carson's response +interface PendingRequest { + resolve: (text: string) => void; + reject: (err: Error) => void; + timer: ReturnType; + chunks: string[]; + debounce: ReturnType | null; +} + +const RESPONSE_DEBOUNCE_MS = 3000; +const REQUEST_TIMEOUT_MS = 600000; // 10 minutes max + +const pendingRequests = new Map(); + +// The MCP channel — receives responses from the container agent +class McpChannel implements Channel { + name = 'mcp'; + private connected = false; + private opts: ChannelOpts; + + constructor(opts: ChannelOpts) { + this.opts = opts; + } + + async connect(): Promise { + this.connected = true; + logger.info('MCP channel connected'); + } + + async disconnect(): Promise { + this.connected = false; + for (const [id, req] of pendingRequests) { + clearTimeout(req.timer); + if (req.debounce) clearTimeout(req.debounce); + req.reject(new Error('MCP server shutting down')); + pendingRequests.delete(id); + } + } + + isConnected(): boolean { + return this.connected; + } + + ownsJid(jid: string): boolean { + return jid.startsWith('mcp:'); + } + + async sendMessage(jid: string, text: string): Promise { + // Find the pending request for this JID + const requestId = jid.replace('mcp:', ''); + const pending = pendingRequests.get(requestId); + if (!pending) { + logger.debug( + { jid }, + 'MCP sendMessage: no pending request (likely already resolved)', + ); + return; + } + + // Accumulate chunks with debounce — agent may send multiple messages + pending.chunks.push(text); + + if (pending.debounce) clearTimeout(pending.debounce); + pending.debounce = setTimeout(() => { + clearTimeout(pending.timer); + const fullResponse = pending.chunks.join('\n\n'); + pending.resolve(fullResponse); + pendingRequests.delete(requestId); + }, RESPONSE_DEBOUNCE_MS); + } + + // Inject a message and wait for the response + async chat( + message: string, + groupJid: string, + group: RegisteredGroup, + ): Promise<{ text: string; requestId: string }> { + const requestId = crypto.randomUUID(); + const mcpJid = `mcp:${requestId}`; + + // Register the MCP JID as a temporary group (same folder as target group) + const mcpGroup: RegisteredGroup = { + ...group, + name: `${group.name} (MCP)`, + requiresTrigger: false, + isMain: group.isMain, + }; + + // Temporarily register this JID so the queue can find the group + const groups = this.opts.registeredGroups(); + groups[mcpJid] = mcpGroup; + + const responsePromise = new Promise((resolve, reject) => { + const timer = setTimeout(() => { + pendingRequests.delete(requestId); + delete groups[mcpJid]; + reject(new Error('MCP request timed out')); + }, REQUEST_TIMEOUT_MS); + + pendingRequests.set(requestId, { + resolve: (text) => { + delete groups[mcpJid]; + resolve(text); + }, + reject: (err) => { + delete groups[mcpJid]; + reject(err); + }, + timer, + chunks: [], + debounce: null, + }); + }); + + // Register chat metadata so the foreign key constraint is satisfied + const now = new Date().toISOString(); + this.opts.onChatMetadata(mcpJid, now, `MCP: ${group.name}`, 'mcp', false); + + // Inject as inbound message — this triggers the normal message flow + this.opts.onMessage(mcpJid, { + id: `mcp-${requestId}`, + chat_jid: mcpJid, + sender: 'external-agent', + sender_name: 'External Agent', + content: message, + timestamp: now, + is_from_me: false, + }); + + const text = await responsePromise; + return { text, requestId }; + } +} + +let mcpChannelInstance: McpChannel | null = null; + +// Register as a NanoClaw channel +registerChannel('mcp', (opts) => { + if (!MCP_API_KEY) return null; + + mcpChannelInstance = new McpChannel(opts); + return mcpChannelInstance; +}); + +// HTTP server with MCP protocol +export async function startMcpServer( + port: number, + apiKey: string, + deps: { registeredGroups: () => Record }, +): Promise { + const server = http.createServer(async (req, res) => { + if (req.method === 'OPTIONS') { + res.writeHead(204, { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Methods': 'POST, DELETE, OPTIONS', + 'Access-Control-Allow-Headers': 'Content-Type, Authorization', + }); + res.end(); + return; + } + + const authHeader = req.headers.authorization; + if (!authHeader || authHeader !== `Bearer ${apiKey}`) { + res.writeHead(401, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Unauthorized' })); + return; + } + + if (req.url !== '/mcp') { + res.writeHead(404); + res.end(); + return; + } + + const mcp = createMcpServer(deps); + const transport = new StreamableHTTPServerTransport({ + sessionIdGenerator: undefined, // stateless + }); + await mcp.connect(transport); + await transport.handleRequest(req, res); + await transport.close(); + await mcp.close(); + }); + + return new Promise((resolve) => { + server.listen(port, '127.0.0.1', () => { + logger.info({ port }, 'MCP server listening'); + resolve(server); + }); + }); +} + +function createMcpServer(deps: { + registeredGroups: () => Record; +}): McpServer { + const mcp = new McpServer({ + name: 'nanoclaw', + version: '1.0.0', + }); + + mcp.tool( + 'chat', + `Send a message to ${ASSISTANT_NAME} and receive a response.`, + { + message: z.string().describe('The message to send'), + group: z + .string() + .optional() + .describe('Group name to target (default: main group)'), + }, + async ({ message, group: groupName }) => { + if (!mcpChannelInstance) { + return { + content: [ + { type: 'text' as const, text: 'MCP channel not initialized.' }, + ], + isError: true, + }; + } + + const groups = deps.registeredGroups(); + + let targetJid: string | undefined; + let targetGroup: RegisteredGroup | undefined; + + if (groupName) { + for (const [jid, g] of Object.entries(groups)) { + if ( + !jid.startsWith('mcp:') && + (g.name.toLowerCase() === groupName.toLowerCase() || + g.folder === groupName) + ) { + targetJid = jid; + targetGroup = g; + break; + } + } + } else { + for (const [jid, g] of Object.entries(groups)) { + if (!jid.startsWith('mcp:') && g.isMain) { + targetJid = jid; + targetGroup = g; + break; + } + } + } + + if (!targetGroup || !targetJid) { + const msg = groupName + ? `Group "${groupName}" not found. Use list_groups to see available groups.` + : 'No main group configured.'; + return { + content: [{ type: 'text' as const, text: msg }], + isError: true, + }; + } + + logger.info( + { group: targetGroup.name, messageLength: message.length }, + 'MCP chat request', + ); + + try { + const { text, requestId } = await mcpChannelInstance.chat( + message, + targetJid, + targetGroup, + ); + + const cleanResponse = text + .replace(/[\s\S]*?<\/internal>/g, '') + .trim(); + + logger.info( + { group: targetGroup.name, responseLength: cleanResponse.length }, + 'MCP chat response', + ); + + return { + content: [ + { type: 'text' as const, text: cleanResponse || '(no response)' }, + ], + }; + } catch (err) { + const errorMsg = err instanceof Error ? err.message : String(err); + logger.error({ err, group: targetGroup.name }, 'MCP chat error'); + return { + content: [{ type: 'text' as const, text: `Error: ${errorMsg}` }], + isError: true, + }; + } + }, + ); + + mcp.tool( + 'list_groups', + 'List available groups that can be targeted with the chat tool.', + {}, + async () => { + const groups = deps.registeredGroups(); + const list = Object.entries(groups) + .filter(([jid]) => !jid.startsWith('mcp:')) + .map(([, g]) => ({ + name: g.name, + folder: g.folder, + isMain: g.isMain || false, + })); + return { + content: [ + { type: 'text' as const, text: JSON.stringify(list, null, 2) }, + ], + }; + }, + ); + + return mcp; +}