refactor(skill/atomic-chat-tool): ship MCP file in skill folder, revert src edits
The initial /add-atomic-chat-tool merge added src edits directly to main. That conflicts with the utility-skill pattern used elsewhere (e.g. /claw): the skill folder should ship the file and SKILL.md should instruct copy + idempotent edits at install time, not a git merge that carries src diffs. - Move container/agent-runner/src/atomic-chat-mcp-stdio.ts → .claude/skills/add-atomic-chat-tool/atomic-chat-mcp-stdio.ts - Revert the atomic_chat mcpServers entry in agent-runner index.ts - Revert mcp__atomic_chat__* from TOOL_ALLOWLIST in providers/claude.ts - Revert ATOMIC_CHAT_* env forwarding and [ATOMIC] log elevation in src/container-runner.ts - Empty .env.example back out - Rewrite SKILL.md: copy the shipped file, then apply deterministic Edits (index.ts, providers/claude.ts, container-runner.ts, .env.example) with exact before/after snippets the installer agent can match. Main is now back to its pre-PR state for the tool; /add-atomic-chat-tool re-applies everything at install time. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -13,6 +13,8 @@ Tools exposed:
|
||||
|
||||
Model management (download, delete) is done through the **Atomic Chat desktop UI** — the app is a fork of Jan and manages its own model library.
|
||||
|
||||
The skill ships the MCP server source in this folder and copies it into the agent-runner tree at install time, then wires it up with small edits to `index.ts`, `providers/claude.ts`, and `container-runner.ts`. No branch merge — all edits are additive and idempotent.
|
||||
|
||||
## Phase 1: Pre-flight
|
||||
|
||||
### Check if already applied
|
||||
@@ -37,42 +39,128 @@ If the request fails:
|
||||
|
||||
## Phase 2: Apply Code Changes
|
||||
|
||||
### Ensure upstream remote
|
||||
### Copy the MCP server source
|
||||
|
||||
```bash
|
||||
git remote -v
|
||||
cp .claude/skills/add-atomic-chat-tool/atomic-chat-mcp-stdio.ts container/agent-runner/src/atomic-chat-mcp-stdio.ts
|
||||
```
|
||||
|
||||
If `upstream` is missing, add it:
|
||||
### Register the MCP server in the agent-runner
|
||||
|
||||
Edit `container/agent-runner/src/index.ts`. Find the `mcpServers` object that currently looks like this:
|
||||
|
||||
```ts
|
||||
const mcpServers: Record<string, { command: string; args: string[]; env: Record<string, string> }> = {
|
||||
nanoclaw: {
|
||||
command: 'bun',
|
||||
args: ['run', mcpServerPath],
|
||||
env: {},
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
Add an `atomic_chat` entry alongside `nanoclaw`:
|
||||
|
||||
```ts
|
||||
const mcpServers: Record<string, { command: string; args: string[]; env: Record<string, string> }> = {
|
||||
nanoclaw: {
|
||||
command: 'bun',
|
||||
args: ['run', mcpServerPath],
|
||||
env: {},
|
||||
},
|
||||
atomic_chat: {
|
||||
command: 'bun',
|
||||
args: ['run', path.join(__dirname, 'atomic-chat-mcp-stdio.ts')],
|
||||
env: {
|
||||
...(process.env.ATOMIC_CHAT_HOST ? { ATOMIC_CHAT_HOST: process.env.ATOMIC_CHAT_HOST } : {}),
|
||||
...(process.env.ATOMIC_CHAT_API_KEY ? { ATOMIC_CHAT_API_KEY: process.env.ATOMIC_CHAT_API_KEY } : {}),
|
||||
},
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
### Add the tool glob to the allowlist
|
||||
|
||||
Edit `container/agent-runner/src/providers/claude.ts`. Find `'mcp__nanoclaw__*',` in the `TOOL_ALLOWLIST` array and add `'mcp__atomic_chat__*',` on the following line:
|
||||
|
||||
```ts
|
||||
'mcp__nanoclaw__*',
|
||||
'mcp__atomic_chat__*',
|
||||
];
|
||||
```
|
||||
|
||||
### Forward host env vars into the container
|
||||
|
||||
Edit `src/container-runner.ts` in `buildContainerArgs`. Find the `TZ` env line:
|
||||
|
||||
```ts
|
||||
args.push('-e', `TZ=${TIMEZONE}`);
|
||||
```
|
||||
|
||||
Add ATOMIC_CHAT forwarding right after it:
|
||||
|
||||
```ts
|
||||
args.push('-e', `TZ=${TIMEZONE}`);
|
||||
|
||||
// Atomic Chat MCP tool: forward host overrides if set (default is host.docker.internal:1337).
|
||||
if (process.env.ATOMIC_CHAT_HOST) {
|
||||
args.push('-e', `ATOMIC_CHAT_HOST=${process.env.ATOMIC_CHAT_HOST}`);
|
||||
}
|
||||
if (process.env.ATOMIC_CHAT_API_KEY) {
|
||||
args.push('-e', `ATOMIC_CHAT_API_KEY=${process.env.ATOMIC_CHAT_API_KEY}`);
|
||||
}
|
||||
```
|
||||
|
||||
### Surface `[ATOMIC]` log lines at info level
|
||||
|
||||
In the same file, find the stderr logger:
|
||||
|
||||
```ts
|
||||
container.stderr?.on('data', (data) => {
|
||||
for (const line of data.toString().trim().split('\n')) {
|
||||
if (line) log.debug(line, { container: agentGroup.folder });
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
Replace it with:
|
||||
|
||||
```ts
|
||||
container.stderr?.on('data', (data) => {
|
||||
for (const line of data.toString().trim().split('\n')) {
|
||||
if (!line) continue;
|
||||
if (line.includes('[ATOMIC]')) {
|
||||
log.info(line, { container: agentGroup.folder });
|
||||
} else {
|
||||
log.debug(line, { container: agentGroup.folder });
|
||||
}
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
### Add env-var stubs to `.env.example`
|
||||
|
||||
Append to `.env.example`:
|
||||
|
||||
```bash
|
||||
git remote add upstream https://github.com/qwibitai/nanoclaw.git
|
||||
# Atomic Chat MCP tool (.claude/skills/add-atomic-chat-tool)
|
||||
# Override the host where Atomic Chat exposes its OpenAI-compatible API.
|
||||
# Default: http://host.docker.internal:1337 (with fallback to localhost)
|
||||
# ATOMIC_CHAT_HOST=http://host.docker.internal:1337
|
||||
|
||||
# Optional API key. Leave unset for a local Atomic Chat install — it does not require auth.
|
||||
# ATOMIC_CHAT_API_KEY=
|
||||
```
|
||||
|
||||
### Merge the skill branch
|
||||
|
||||
```bash
|
||||
git fetch upstream skill/atomic-chat-tool
|
||||
git merge upstream/skill/atomic-chat-tool
|
||||
```
|
||||
|
||||
This merges in:
|
||||
- `container/agent-runner/src/atomic-chat-mcp-stdio.ts` (Atomic Chat MCP server, run directly via `bun`)
|
||||
- Atomic Chat MCP registration in `container/agent-runner/src/index.ts` (`mcpServers.atomic_chat`)
|
||||
- `mcp__atomic_chat__*` added to `TOOL_ALLOWLIST` in `container/agent-runner/src/providers/claude.ts`
|
||||
- `[ATOMIC]` log surfacing and `ATOMIC_CHAT_HOST` / `ATOMIC_CHAT_API_KEY` forwarding in `src/container-runner.ts`
|
||||
- `ATOMIC_CHAT_HOST` / `ATOMIC_CHAT_API_KEY` stubs in `.env.example`
|
||||
|
||||
If the merge reports conflicts, resolve them by reading the conflicted files and understanding the intent of both sides.
|
||||
|
||||
### Validate code changes
|
||||
|
||||
```bash
|
||||
pnpm run build
|
||||
pnpm exec tsc -p container/agent-runner/tsconfig.json --noEmit
|
||||
./container/build.sh
|
||||
```
|
||||
|
||||
Build must be clean before proceeding.
|
||||
All three must be clean before proceeding.
|
||||
|
||||
## Phase 3: Configure
|
||||
|
||||
@@ -126,9 +214,10 @@ Look for:
|
||||
### Agent says "Atomic Chat is not installed" or tries to run a CLI
|
||||
|
||||
The agent is looking for a CLI that doesn't exist instead of using the MCP tools. This means:
|
||||
1. The MCP server wasn't registered — check `container/agent-runner/src/index.ts` has the `atomic_chat` entry in `mcpServers`
|
||||
2. The allowlist wasn't updated — check `container/agent-runner/src/providers/claude.ts` includes `mcp__atomic_chat__*` in `TOOL_ALLOWLIST`
|
||||
3. The container wasn't rebuilt — run `./container/build.sh`
|
||||
1. The MCP server wasn't copied — check `container/agent-runner/src/atomic-chat-mcp-stdio.ts` exists
|
||||
2. The MCP server wasn't registered — check `container/agent-runner/src/index.ts` has the `atomic_chat` entry in `mcpServers`
|
||||
3. The allowlist wasn't updated — check `container/agent-runner/src/providers/claude.ts` includes `mcp__atomic_chat__*` in `TOOL_ALLOWLIST`
|
||||
4. The container wasn't rebuilt — run `./container/build.sh`
|
||||
|
||||
### "Failed to connect to Atomic Chat"
|
||||
|
||||
|
||||
229
.claude/skills/add-atomic-chat-tool/atomic-chat-mcp-stdio.ts
Normal file
229
.claude/skills/add-atomic-chat-tool/atomic-chat-mcp-stdio.ts
Normal file
@@ -0,0 +1,229 @@
|
||||
/**
|
||||
* Atomic Chat MCP Server for NanoClaw
|
||||
* Exposes local Atomic Chat models (OpenAI-compatible, /v1) as tools for the container agent.
|
||||
* Uses host.docker.internal to reach the host's Atomic Chat desktop app from Docker.
|
||||
*/
|
||||
|
||||
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
||||
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
||||
import { z } from 'zod';
|
||||
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
const ATOMIC_CHAT_HOST =
|
||||
process.env.ATOMIC_CHAT_HOST || 'http://host.docker.internal:1337';
|
||||
const ATOMIC_CHAT_API_KEY = process.env.ATOMIC_CHAT_API_KEY || '';
|
||||
const ATOMIC_CHAT_STATUS_FILE = '/workspace/ipc/atomic_chat_status.json';
|
||||
|
||||
function log(msg: string): void {
|
||||
console.error(`[ATOMIC] ${msg}`);
|
||||
}
|
||||
|
||||
function writeStatus(status: string, detail?: string): void {
|
||||
try {
|
||||
const data = { status, detail, timestamp: new Date().toISOString() };
|
||||
const tmpPath = `${ATOMIC_CHAT_STATUS_FILE}.tmp`;
|
||||
fs.mkdirSync(path.dirname(ATOMIC_CHAT_STATUS_FILE), { recursive: true });
|
||||
fs.writeFileSync(tmpPath, JSON.stringify(data));
|
||||
fs.renameSync(tmpPath, ATOMIC_CHAT_STATUS_FILE);
|
||||
} catch {
|
||||
/* best-effort */
|
||||
}
|
||||
}
|
||||
|
||||
async function atomicFetch(
|
||||
apiPath: string,
|
||||
options?: RequestInit,
|
||||
): Promise<Response> {
|
||||
const url = `${ATOMIC_CHAT_HOST}${apiPath}`;
|
||||
const headers: Record<string, string> = {
|
||||
...((options?.headers as Record<string, string>) || {}),
|
||||
};
|
||||
if (ATOMIC_CHAT_API_KEY) {
|
||||
headers.Authorization = `Bearer ${ATOMIC_CHAT_API_KEY}`;
|
||||
}
|
||||
const finalOptions: RequestInit = { ...options, headers };
|
||||
try {
|
||||
return await fetch(url, finalOptions);
|
||||
} catch (err) {
|
||||
// Fallback to localhost if host.docker.internal fails
|
||||
if (ATOMIC_CHAT_HOST.includes('host.docker.internal')) {
|
||||
const fallbackUrl = url.replace('host.docker.internal', 'localhost');
|
||||
return await fetch(fallbackUrl, finalOptions);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
const server = new McpServer({
|
||||
name: 'atomic_chat',
|
||||
version: '1.0.0',
|
||||
});
|
||||
|
||||
server.tool(
|
||||
'atomic_chat_list_models',
|
||||
'List all models available in the local Atomic Chat desktop app. Use this to see which models are loaded before calling atomic_chat_generate.',
|
||||
{},
|
||||
async () => {
|
||||
log('Listing models...');
|
||||
writeStatus('listing', 'Listing available models');
|
||||
try {
|
||||
const res = await atomicFetch('/v1/models');
|
||||
if (!res.ok) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text' as const,
|
||||
text: `Atomic Chat API error: ${res.status} ${res.statusText}`,
|
||||
},
|
||||
],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
|
||||
const data = (await res.json()) as {
|
||||
data?: Array<{ id: string; owned_by?: string }>;
|
||||
};
|
||||
const models = data.data || [];
|
||||
|
||||
if (models.length === 0) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text' as const,
|
||||
text: 'No models available. Open Atomic Chat on the host and download a model from the Hub.',
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
const list = models
|
||||
.map((m) => `- ${m.id}${m.owned_by ? ` (${m.owned_by})` : ''}`)
|
||||
.join('\n');
|
||||
|
||||
log(`Found ${models.length} models`);
|
||||
return {
|
||||
content: [
|
||||
{ type: 'text' as const, text: `Available models:\n${list}` },
|
||||
],
|
||||
};
|
||||
} catch (err) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text' as const,
|
||||
text: `Failed to connect to Atomic Chat at ${ATOMIC_CHAT_HOST}: ${err instanceof Error ? err.message : String(err)}`,
|
||||
},
|
||||
],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
server.tool(
|
||||
'atomic_chat_generate',
|
||||
'Send a prompt to a local Atomic Chat model and get a response. Good for cheaper/faster tasks like summarization, translation, or general queries. Use atomic_chat_list_models first to see available models.',
|
||||
{
|
||||
model: z
|
||||
.string()
|
||||
.describe(
|
||||
'The model ID as returned by atomic_chat_list_models (e.g. "llama3.2-3b-instruct")',
|
||||
),
|
||||
prompt: z.string().describe('The prompt to send to the model'),
|
||||
system: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('Optional system prompt to set model behavior'),
|
||||
temperature: z
|
||||
.number()
|
||||
.optional()
|
||||
.describe('Sampling temperature (0.0–2.0). Defaults to model default.'),
|
||||
max_tokens: z
|
||||
.number()
|
||||
.optional()
|
||||
.describe('Maximum number of tokens to generate in the response.'),
|
||||
},
|
||||
async (args) => {
|
||||
log(`>>> Generating with ${args.model} (${args.prompt.length} chars)...`);
|
||||
writeStatus('generating', `Generating with ${args.model}`);
|
||||
try {
|
||||
const messages: Array<{ role: string; content: string }> = [];
|
||||
if (args.system) {
|
||||
messages.push({ role: 'system', content: args.system });
|
||||
}
|
||||
messages.push({ role: 'user', content: args.prompt });
|
||||
|
||||
const body: Record<string, unknown> = {
|
||||
model: args.model,
|
||||
messages,
|
||||
stream: false,
|
||||
};
|
||||
if (args.temperature !== undefined) body.temperature = args.temperature;
|
||||
if (args.max_tokens !== undefined) body.max_tokens = args.max_tokens;
|
||||
|
||||
const startedAt = Date.now();
|
||||
const res = await atomicFetch('/v1/chat/completions', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const errorText = await res.text();
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text' as const,
|
||||
text: `Atomic Chat error (${res.status}): ${errorText}`,
|
||||
},
|
||||
],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
|
||||
const data = (await res.json()) as {
|
||||
choices?: Array<{ message?: { content?: string } }>;
|
||||
usage?: {
|
||||
prompt_tokens?: number;
|
||||
completion_tokens?: number;
|
||||
total_tokens?: number;
|
||||
};
|
||||
};
|
||||
|
||||
const response = data.choices?.[0]?.message?.content ?? '';
|
||||
const elapsedSec = ((Date.now() - startedAt) / 1000).toFixed(1);
|
||||
const completionTokens = data.usage?.completion_tokens;
|
||||
|
||||
const meta = `\n\n[${args.model} | ${elapsedSec}s${
|
||||
completionTokens !== undefined ? ` | ${completionTokens} tokens` : ''
|
||||
}]`;
|
||||
|
||||
log(
|
||||
`<<< Done: ${args.model} | ${elapsedSec}s | ${
|
||||
completionTokens ?? '?'
|
||||
} tokens | ${response.length} chars`,
|
||||
);
|
||||
writeStatus(
|
||||
'done',
|
||||
`${args.model} | ${elapsedSec}s | ${completionTokens ?? '?'} tokens`,
|
||||
);
|
||||
|
||||
return { content: [{ type: 'text' as const, text: response + meta }] };
|
||||
} catch (err) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text' as const,
|
||||
text: `Failed to call Atomic Chat: ${err instanceof Error ? err.message : String(err)}`,
|
||||
},
|
||||
],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
const transport = new StdioServerTransport();
|
||||
await server.connect(transport);
|
||||
Reference in New Issue
Block a user