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
|
||||
|
||||
## 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=<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
|
||||
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 <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