feat: add /add-ollama skill for local model inference (#712)
* feat: add /add-ollama skill for local model inference Adds a skill that integrates Ollama as an MCP server, allowing the container agent to offload tasks to local models (summarization, translation, general queries) while keeping Claude as orchestrator. Skill contents: - ollama-mcp-stdio.ts: stdio MCP server with ollama_list_models and ollama_generate tools - ollama-watch.sh: macOS notification watcher for Ollama activity - Modifications to index.ts (MCP config) and container-runner.ts (log surfacing) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * chore: rename skill from /add-ollama to /add-ollama-tool Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> Co-authored-by: gavrielc <gabicohen22@yahoo.com>
This commit is contained in:
@@ -0,0 +1,147 @@
|
||||
/**
|
||||
* Ollama MCP Server for NanoClaw
|
||||
* Exposes local Ollama models as tools for the container agent.
|
||||
* Uses host.docker.internal to reach the host's Ollama instance 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 OLLAMA_HOST = process.env.OLLAMA_HOST || 'http://host.docker.internal:11434';
|
||||
const OLLAMA_STATUS_FILE = '/workspace/ipc/ollama_status.json';
|
||||
|
||||
function log(msg: string): void {
|
||||
console.error(`[OLLAMA] ${msg}`);
|
||||
}
|
||||
|
||||
function writeStatus(status: string, detail?: string): void {
|
||||
try {
|
||||
const data = { status, detail, timestamp: new Date().toISOString() };
|
||||
const tmpPath = `${OLLAMA_STATUS_FILE}.tmp`;
|
||||
fs.mkdirSync(path.dirname(OLLAMA_STATUS_FILE), { recursive: true });
|
||||
fs.writeFileSync(tmpPath, JSON.stringify(data));
|
||||
fs.renameSync(tmpPath, OLLAMA_STATUS_FILE);
|
||||
} catch { /* best-effort */ }
|
||||
}
|
||||
|
||||
async function ollamaFetch(path: string, options?: RequestInit): Promise<Response> {
|
||||
const url = `${OLLAMA_HOST}${path}`;
|
||||
try {
|
||||
return await fetch(url, options);
|
||||
} catch (err) {
|
||||
// Fallback to localhost if host.docker.internal fails
|
||||
if (OLLAMA_HOST.includes('host.docker.internal')) {
|
||||
const fallbackUrl = url.replace('host.docker.internal', 'localhost');
|
||||
return await fetch(fallbackUrl, options);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
const server = new McpServer({
|
||||
name: 'ollama',
|
||||
version: '1.0.0',
|
||||
});
|
||||
|
||||
server.tool(
|
||||
'ollama_list_models',
|
||||
'List all locally installed Ollama models. Use this to see which models are available before calling ollama_generate.',
|
||||
{},
|
||||
async () => {
|
||||
log('Listing models...');
|
||||
writeStatus('listing', 'Listing available models');
|
||||
try {
|
||||
const res = await ollamaFetch('/api/tags');
|
||||
if (!res.ok) {
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: `Ollama API error: ${res.status} ${res.statusText}` }],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
|
||||
const data = await res.json() as { models?: Array<{ name: string; size: number; modified_at: string }> };
|
||||
const models = data.models || [];
|
||||
|
||||
if (models.length === 0) {
|
||||
return { content: [{ type: 'text' as const, text: 'No models installed. Run `ollama pull <model>` on the host to install one.' }] };
|
||||
}
|
||||
|
||||
const list = models
|
||||
.map(m => `- ${m.name} (${(m.size / 1e9).toFixed(1)}GB)`)
|
||||
.join('\n');
|
||||
|
||||
log(`Found ${models.length} models`);
|
||||
return { content: [{ type: 'text' as const, text: `Installed models:\n${list}` }] };
|
||||
} catch (err) {
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: `Failed to connect to Ollama at ${OLLAMA_HOST}: ${err instanceof Error ? err.message : String(err)}` }],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
server.tool(
|
||||
'ollama_generate',
|
||||
'Send a prompt to a local Ollama model and get a response. Good for cheaper/faster tasks like summarization, translation, or general queries. Use ollama_list_models first to see available models.',
|
||||
{
|
||||
model: z.string().describe('The model name (e.g., "llama3.2", "mistral", "gemma2")'),
|
||||
prompt: z.string().describe('The prompt to send to the model'),
|
||||
system: z.string().optional().describe('Optional system prompt to set model behavior'),
|
||||
},
|
||||
async (args) => {
|
||||
log(`>>> Generating with ${args.model} (${args.prompt.length} chars)...`);
|
||||
writeStatus('generating', `Generating with ${args.model}`);
|
||||
try {
|
||||
const body: Record<string, unknown> = {
|
||||
model: args.model,
|
||||
prompt: args.prompt,
|
||||
stream: false,
|
||||
};
|
||||
if (args.system) {
|
||||
body.system = args.system;
|
||||
}
|
||||
|
||||
const res = await ollamaFetch('/api/generate', {
|
||||
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: `Ollama error (${res.status}): ${errorText}` }],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
|
||||
const data = await res.json() as { response: string; total_duration?: number; eval_count?: number };
|
||||
|
||||
let meta = '';
|
||||
if (data.total_duration) {
|
||||
const secs = (data.total_duration / 1e9).toFixed(1);
|
||||
meta = `\n\n[${args.model} | ${secs}s${data.eval_count ? ` | ${data.eval_count} tokens` : ''}]`;
|
||||
log(`<<< Done: ${args.model} | ${secs}s | ${data.eval_count || '?'} tokens | ${data.response.length} chars`);
|
||||
writeStatus('done', `${args.model} | ${secs}s | ${data.eval_count || '?'} tokens`);
|
||||
} else {
|
||||
log(`<<< Done: ${args.model} | ${data.response.length} chars`);
|
||||
writeStatus('done', `${args.model} | ${data.response.length} chars`);
|
||||
}
|
||||
|
||||
return { content: [{ type: 'text' as const, text: data.response + meta }] };
|
||||
} catch (err) {
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: `Failed to call Ollama: ${err instanceof Error ? err.message : String(err)}` }],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
const transport = new StdioServerTransport();
|
||||
await server.connect(transport);
|
||||
41
.claude/skills/add-ollama-tool/add/scripts/ollama-watch.sh
Executable file
41
.claude/skills/add-ollama-tool/add/scripts/ollama-watch.sh
Executable file
@@ -0,0 +1,41 @@
|
||||
#!/bin/bash
|
||||
# Watch NanoClaw IPC for Ollama activity and show macOS notifications
|
||||
# Usage: ./scripts/ollama-watch.sh
|
||||
|
||||
cd "$(dirname "$0")/.." || exit 1
|
||||
|
||||
echo "Watching for Ollama activity..."
|
||||
echo "Press Ctrl+C to stop"
|
||||
echo ""
|
||||
|
||||
LAST_TIMESTAMP=""
|
||||
|
||||
while true; do
|
||||
# Check all group IPC dirs for ollama_status.json
|
||||
for status_file in data/ipc/*/ollama_status.json; do
|
||||
[ -f "$status_file" ] || continue
|
||||
|
||||
TIMESTAMP=$(python3 -c "import json; print(json.load(open('$status_file'))['timestamp'])" 2>/dev/null)
|
||||
[ -z "$TIMESTAMP" ] && continue
|
||||
[ "$TIMESTAMP" = "$LAST_TIMESTAMP" ] && continue
|
||||
|
||||
LAST_TIMESTAMP="$TIMESTAMP"
|
||||
STATUS=$(python3 -c "import json; d=json.load(open('$status_file')); print(d['status'])" 2>/dev/null)
|
||||
DETAIL=$(python3 -c "import json; d=json.load(open('$status_file')); print(d.get('detail',''))" 2>/dev/null)
|
||||
|
||||
case "$STATUS" in
|
||||
generating)
|
||||
osascript -e "display notification \"$DETAIL\" with title \"NanoClaw → Ollama\" sound name \"Submarine\"" 2>/dev/null
|
||||
echo "$(date +%H:%M:%S) 🔄 $DETAIL"
|
||||
;;
|
||||
done)
|
||||
osascript -e "display notification \"$DETAIL\" with title \"NanoClaw ← Ollama ✓\" sound name \"Glass\"" 2>/dev/null
|
||||
echo "$(date +%H:%M:%S) ✅ $DETAIL"
|
||||
;;
|
||||
listing)
|
||||
echo "$(date +%H:%M:%S) 📋 Listing models..."
|
||||
;;
|
||||
esac
|
||||
done
|
||||
sleep 0.5
|
||||
done
|
||||
Reference in New Issue
Block a user