Skip to content

Commit bcf4258

Browse files
nickdirienzoclaude
andcommitted
feat: MCP-native skill architecture (nonnaclaw)
Replace NanoClaw's codemods-based skill model with MCP-native skills that add capabilities through community MCP servers and scoped authorization instead of code generation. - Add host-side MCP bridge (src/mcp-bridge.ts) that spawns MCP servers as child processes and exposes them via HTTP endpoints - Add container-side MCP proxy with HTTP upstream transport and scopeTemplate enforcement (tool allowlists + param pinning) - Extend SkillManifest with pollTool, pollIntervalMs, pollTimestampArg - Fix authorized_skills column read/write in DB accessors - Convert WhatsApp skill to use lharries/whatsapp-mcp community server - Remove old custom Baileys WhatsApp implementation - Add /install skill for generic skill installation - Update CLAUDE.md files for channel-agnostic, MCP-aware agents - Rewrite README for nonnaclaw philosophy and architecture Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 0278510 commit bcf4258

25 files changed

Lines changed: 2218 additions & 4384 deletions

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,9 @@ groups/global/*
1818
!groups/main/CLAUDE.md
1919
!groups/global/CLAUDE.md
2020

21+
# Skills (cloned per-installation from external repos)
22+
skills/
23+
2124
# Secrets
2225
*.keys.json
2326
.env

README.md

Lines changed: 130 additions & 130 deletions
Large diffs are not rendered by default.

container/agent-runner/src/ipc-mcp-stdio.ts

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@ import { CronExpressionParser } from 'cron-parser';
1414
const IPC_DIR = '/workspace/ipc';
1515
const MESSAGES_DIR = path.join(IPC_DIR, 'messages');
1616
const TASKS_DIR = path.join(IPC_DIR, 'tasks');
17+
const STATE_DIR = path.join(IPC_DIR, 'state');
18+
const STATE_SNAPSHOT = path.join(IPC_DIR, 'current_state.json');
1719

1820
// Context from environment variables (set by the agent runner)
1921
const chatJid = process.env.NANOCLAW_CHAT_JID!;
@@ -280,6 +282,92 @@ Use available_groups.json to find the JID for a group. The folder name should be
280282
},
281283
);
282284

285+
// --- Structured memory (KV store) ---
286+
287+
server.tool(
288+
'save_state',
289+
`Save a key-value pair that persists across agent invocations. Use this for remembering preferences, cursors, thread IDs, or any structured data that should survive between conversations.
290+
291+
Values are scoped to this group — other groups cannot read or modify them.`,
292+
{
293+
key: z.string().describe('The key to save (e.g., "user_preference", "last_cursor")'),
294+
value: z.string().describe('The value to save (will be stored as a string — use JSON.stringify for structured data)'),
295+
},
296+
async (args) => {
297+
const data = {
298+
type: 'save_state',
299+
groupFolder,
300+
key: args.key,
301+
value: args.value,
302+
timestamp: new Date().toISOString(),
303+
};
304+
305+
writeIpcFile(STATE_DIR, data);
306+
307+
return { content: [{ type: 'text' as const, text: `State saved: ${args.key}` }] };
308+
},
309+
);
310+
311+
server.tool(
312+
'get_state',
313+
`Read previously saved state for this group. Returns all saved key-value pairs, or a specific key's value.`,
314+
{
315+
key: z.string().optional().describe('Specific key to read. If omitted, returns all saved state.'),
316+
},
317+
async (args) => {
318+
try {
319+
if (!fs.existsSync(STATE_SNAPSHOT)) {
320+
return { content: [{ type: 'text' as const, text: args.key ? 'Key not found.' : 'No saved state.' }] };
321+
}
322+
323+
const allState: Record<string, string> = JSON.parse(
324+
fs.readFileSync(STATE_SNAPSHOT, 'utf-8'),
325+
);
326+
327+
if (args.key) {
328+
const value = allState[args.key];
329+
if (value === undefined) {
330+
return { content: [{ type: 'text' as const, text: `Key "${args.key}" not found.` }] };
331+
}
332+
return { content: [{ type: 'text' as const, text: value }] };
333+
}
334+
335+
if (Object.keys(allState).length === 0) {
336+
return { content: [{ type: 'text' as const, text: 'No saved state.' }] };
337+
}
338+
339+
const formatted = Object.entries(allState)
340+
.map(([k, v]) => `${k}: ${v}`)
341+
.join('\n');
342+
return { content: [{ type: 'text' as const, text: formatted }] };
343+
} catch (err) {
344+
return {
345+
content: [{ type: 'text' as const, text: `Error reading state: ${err instanceof Error ? err.message : String(err)}` }],
346+
};
347+
}
348+
},
349+
);
350+
351+
server.tool(
352+
'delete_state',
353+
'Delete a previously saved key-value pair.',
354+
{
355+
key: z.string().describe('The key to delete'),
356+
},
357+
async (args) => {
358+
const data = {
359+
type: 'delete_state',
360+
groupFolder,
361+
key: args.key,
362+
timestamp: new Date().toISOString(),
363+
};
364+
365+
writeIpcFile(STATE_DIR, data);
366+
367+
return { content: [{ type: 'text' as const, text: `State deleted: ${args.key}` }] };
368+
},
369+
);
370+
283371
// Start the stdio transport
284372
const transport = new StdioServerTransport();
285373
await server.connect(transport);
Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
/**
2+
* MCP Proxy — sits between agent and upstream MCP server.
3+
* Enforces tool allowlists and param pinning per ProxyConfig.
4+
*
5+
* Reads config from MCP_PROXY_CONFIG env var (JSON).
6+
* Starts as stdio MCP server (for agent SDK) and connects
7+
* to the upstream MCP server via stdio (spawn) or HTTP (bridge).
8+
*
9+
* Security model:
10+
* - Only tools with `allow: true` in rules are visible to the agent
11+
* - Pinned params are injected on every call — agent can't override them
12+
* - Pinned params are hidden from tool schemas so agent doesn't see them
13+
* - Tools not in rules are blocked (secure by default)
14+
*/
15+
16+
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
17+
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
18+
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
19+
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
20+
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
21+
import {
22+
CallToolRequestSchema,
23+
ListToolsRequestSchema,
24+
} from '@modelcontextprotocol/sdk/types.js';
25+
26+
interface ToolRule {
27+
allow: boolean;
28+
pinnedParams?: Record<string, string>;
29+
}
30+
31+
interface ProxyConfig {
32+
upstream: {
33+
command?: string;
34+
args?: string[];
35+
env?: Record<string, string>;
36+
/** HTTP URL for connecting to a host-side MCP bridge */
37+
url?: string;
38+
};
39+
rules: Record<string, ToolRule>;
40+
}
41+
42+
// --- Parse config ---
43+
44+
const configJson = process.env.MCP_PROXY_CONFIG;
45+
if (!configJson) {
46+
process.stderr.write('MCP_PROXY_CONFIG env var is required\n');
47+
process.exit(1);
48+
}
49+
50+
const config: ProxyConfig = JSON.parse(configJson);
51+
52+
// --- Connect to upstream MCP server ---
53+
54+
const upstream = new Client({ name: 'nonnaclaw-mcp-proxy', version: '1.0.0' });
55+
56+
if (config.upstream.url) {
57+
// HTTP mode: connect to host-side MCP bridge
58+
const httpTransport = new StreamableHTTPClientTransport(
59+
new URL(config.upstream.url),
60+
);
61+
await upstream.connect(httpTransport);
62+
} else if (config.upstream.command) {
63+
// Stdio mode: spawn upstream as child process
64+
const stdioTransport = new StdioClientTransport({
65+
command: config.upstream.command,
66+
args: config.upstream.args,
67+
env: { ...process.env, ...config.upstream.env } as Record<string, string>,
68+
});
69+
await upstream.connect(stdioTransport);
70+
} else {
71+
process.stderr.write('ProxyConfig.upstream must have either url or command\n');
72+
process.exit(1);
73+
}
74+
75+
// Fetch upstream tool list once at startup
76+
const { tools: upstreamTools } = await upstream.listTools();
77+
78+
// --- Create proxy server ---
79+
80+
const proxy = new Server(
81+
{ name: 'nanoclaw-mcp-proxy', version: '1.0.0' },
82+
{ capabilities: { tools: {} } },
83+
);
84+
85+
// tools/list — return only allowed tools, with pinned params hidden from schemas
86+
proxy.setRequestHandler(ListToolsRequestSchema, async () => {
87+
const tools = upstreamTools
88+
.filter((tool) => config.rules[tool.name]?.allow === true)
89+
.map((tool) => {
90+
const pinnedKeys = new Set(
91+
Object.keys(config.rules[tool.name]?.pinnedParams || {}),
92+
);
93+
if (pinnedKeys.size === 0) return tool;
94+
95+
// Remove pinned params from the schema so agent doesn't see them
96+
const schema = {
97+
...(tool.inputSchema || { type: 'object' as const }),
98+
} as {
99+
type: string;
100+
properties?: Record<string, unknown>;
101+
required?: string[];
102+
};
103+
104+
if (schema.properties) {
105+
const props = { ...schema.properties };
106+
for (const key of pinnedKeys) delete props[key];
107+
schema.properties = props;
108+
}
109+
110+
if (schema.required) {
111+
schema.required = schema.required.filter((r) => !pinnedKeys.has(r));
112+
}
113+
114+
return { ...tool, inputSchema: schema };
115+
});
116+
117+
return { tools };
118+
});
119+
120+
// tools/call — check allowlist, inject pinned params, forward to upstream
121+
proxy.setRequestHandler(CallToolRequestSchema, async (request) => {
122+
const { name, arguments: args } = request.params;
123+
const rule = config.rules[name];
124+
125+
if (!rule?.allow) {
126+
return {
127+
content: [
128+
{ type: 'text' as const, text: `Tool "${name}" is not allowed.` },
129+
],
130+
isError: true,
131+
};
132+
}
133+
134+
// Pinned params override anything the agent provides
135+
const mergedArgs = {
136+
...(args || {}),
137+
...(rule.pinnedParams || {}),
138+
};
139+
140+
return await upstream.callTool({ name, arguments: mergedArgs });
141+
});
142+
143+
// --- Start stdio transport (facing agent SDK) ---
144+
145+
const transport = new StdioServerTransport();
146+
await proxy.connect(transport);

groups/global/CLAUDE.md

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,11 @@ You are Andy, a personal assistant. You help with tasks, answer questions, and c
1010
- Read and write files in your workspace
1111
- Run bash commands in your sandbox
1212
- Schedule tasks to run later or on a recurring basis
13-
- Send messages back to the chat
13+
- Use any MCP tools provided by installed skills (messaging, contacts, media, etc.)
1414

1515
## Communication
1616

17-
Your output is sent to the user or group.
17+
Your output is sent to the user or group via whichever messaging channel they use.
1818

1919
You also have `mcp__nanoclaw__send_message` which sends a message immediately while you're still working. This is useful when you want to acknowledge a request before starting longer work.
2020

@@ -34,6 +34,10 @@ Text inside `<internal>` tags is logged but not sent to the user. If you've alre
3434

3535
When working as a sub-agent or teammate, only use `send_message` if instructed to by the main agent.
3636

37+
## MCP Tools
38+
39+
You may have additional MCP tools from installed skills (e.g., WhatsApp, Telegram). These are auto-discovered — check your available tools. Skill tools are scoped per-group: some parameters may be pre-filled to restrict which chats or resources you can access.
40+
3741
## Your Workspace
3842

3943
Files you create are saved in `/workspace/group/`. Use this for notes, research, or anything that should persist.
@@ -49,7 +53,7 @@ When you learn something important:
4953

5054
## Message Formatting
5155

52-
NEVER use markdown. Only use WhatsApp/Telegram formatting:
56+
NEVER use markdown. Only use messaging-app formatting:
5357
- *single asterisks* for bold (NEVER **double asterisks**)
5458
- _underscores_ for italic
5559
- • bullet points

0 commit comments

Comments
 (0)