feat: add NanoClaw server-side skill and update docs

Adds the complete server-side MCP implementation as a NanoClaw
feature skill (nanoclaw-skill/). Includes source code, modification
intents, and step-by-step SKILL.md for installation. README updated
to cover both server (NanoClaw) and client (pi.dev) setup.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
gurixs_carson
2026-04-24 22:38:56 +02:00
co-authored by Claude Opus 4.6
parent 7fa0201a6b
commit 3fa0ec8f71
5 changed files with 687 additions and 24 deletions
+139 -24
View File
@@ -1,43 +1,158 @@
# NanoClaw MCP Extension # NanoClaw MCP Extension
## Overview 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).
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.
## How it works ## 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. 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.
* `NANOCLAW_MCP_URL` **(Optional)**: The URL of the NanoClaw MCP server endpoint. Defaults to `http://localhost:3002/mcp`.
## 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=<generate with: openssl rand -base64 32>
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 ```bash
export NANOCLAW_MCP_TOKEN="your-secret-token" export NANOCLAW_MCP_TOKEN="your-secret-token"
# Optional: export NANOCLAW_MCP_URL="http://localhost:3002/mcp"
pi pi
``` ```
3. When Pi starts, you will see a notification in the UI: Pi will auto-discover and register the available tools (`chat`, `list_groups`).
`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.
## 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. ```json
{
**Example:** "mcpServers": {
Forward remote port `3002` to your local port `3002`: "nanoclaw": {
```bash "type": "streamable-http",
ssh -L 3002:localhost:3002 user@remote-nanoclaw-host "url": "http://localhost:3002/mcp",
"headers": {
"Authorization": "Bearer <MCP_API_KEY>"
}
}
}
}
``` ```
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 <container>` for errors |
| MCP server not starting | Missing config | Verify `MCP_API_KEY` in `.env`, rebuild, restart |
+210
View File
@@ -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<typeof startMcpServer> | 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=<generated-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="<your-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 <MCP_API_KEY>"
}
}
}
}
```
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 <container-name>`.
@@ -0,0 +1,2 @@
// mcp
import '../mcp-server.js';
@@ -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.
+329
View File
@@ -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<typeof setTimeout>;
chunks: string[];
debounce: ReturnType<typeof setTimeout> | null;
}
const RESPONSE_DEBOUNCE_MS = 3000;
const REQUEST_TIMEOUT_MS = 600000; // 10 minutes max
const pendingRequests = new Map<string, PendingRequest>();
// 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<void> {
this.connected = true;
logger.info('MCP channel connected');
}
async disconnect(): Promise<void> {
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<void> {
// 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<string>((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<string, RegisteredGroup> },
): Promise<http.Server> {
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<string, RegisteredGroup>;
}): 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(/<internal>[\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;
}