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:
co-authored by
Claude Opus 4.6
parent
7fa0201a6b
commit
3fa0ec8f71
@@ -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 |
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user