Update memory: Camie Tagger v2 upgrade results

This commit is contained in:
root
2026-03-30 12:27:20 +02:00
commit c3033db6a7
1082 changed files with 649864 additions and 0 deletions

View File

@@ -0,0 +1,721 @@
#!/usr/bin/env node
/**
* Claude Code Skill for ClawdBot
* Provides CLI access to Claude Code via MCP protocol
*/
import { Command } from 'commander';
const SASHA_DOCTOR_URL = process.env.SASHA_DOCTOR_URL || 'http://127.0.0.1:18795';
const PREFIX = '/sasha-doctor/claude-code';
interface ApiResponse {
ok: boolean;
error?: string;
[key: string]: unknown;
}
async function apiCall(endpoint: string, method: string = 'GET', body?: object): Promise<ApiResponse> {
const url = `${SASHA_DOCTOR_URL}${PREFIX}${endpoint}`;
const options: RequestInit = {
method,
headers: { 'Content-Type': 'application/json' },
};
if (body) {
options.body = JSON.stringify(body);
}
try {
const response = await fetch(url, options);
return await response.json() as ApiResponse;
} catch (error) {
return { ok: false, error: (error as Error).message };
}
}
const program = new Command();
program
.name('claude-code-skill')
.description('Control Claude Code via MCP protocol')
.version('1.0.0');
// Connect command
program
.command('connect')
.description('Connect to Claude Code MCP server')
.action(async () => {
console.log('Connecting to Claude Code...');
const result = await apiCall('/connect', 'POST');
if (result.ok) {
console.log(`Connected! Status: ${result.status}`);
console.log(`Server: ${JSON.stringify(result.server)}`);
console.log(`Available tools: ${result.tools}`);
} else {
console.error(`Failed: ${result.error}`);
process.exit(1);
}
});
// Disconnect command
program
.command('disconnect')
.description('Disconnect from Claude Code MCP server')
.action(async () => {
const result = await apiCall('/disconnect', 'POST');
if (result.ok) {
console.log('Disconnected from Claude Code');
} else {
console.error(`Failed: ${result.error}`);
}
});
// List tools command
program
.command('tools')
.description('List available Claude Code tools')
.action(async () => {
const result = await apiCall('/tools');
if (result.ok && Array.isArray(result.tools)) {
console.log('Available tools:');
for (const tool of result.tools) {
const t = tool as { name: string; description: string };
console.log(` - ${t.name}: ${t.description}`);
}
} else {
console.error(`Failed: ${result.error || 'Not connected'}`);
console.log('Tip: Run "claude-code-skill connect" first');
}
});
// Bash command
program
.command('bash <command>')
.description('Execute a bash command via Claude Code')
.option('-d, --description <desc>', 'Description of what the command does')
.action(async (command: string, options: { description?: string }) => {
const result = await apiCall('/bash', 'POST', {
command,
description: options.description || ''
});
if (result.ok) {
const r = result.result as { stdout?: string; stderr?: string };
if (r.stdout) console.log(r.stdout);
if (r.stderr) console.error(r.stderr);
} else {
console.error(`Failed: ${result.error}`);
}
});
// Read file command
program
.command('read <file>')
.description('Read a file via Claude Code')
.action(async (filePath: string) => {
const result = await apiCall('/read', 'POST', { file_path: filePath });
if (result.ok) {
const r = result.result as { type?: string; file?: { content?: string } };
if (r.file?.content) {
console.log(r.file.content);
} else {
console.log(JSON.stringify(result.result, null, 2));
}
} else {
console.error(`Failed: ${result.error}`);
}
});
// Call any tool
program
.command('call <tool>')
.description('Call any Claude Code tool with JSON args')
.option('-a, --args <json>', 'JSON arguments for the tool', '{}')
.action(async (tool: string, options: { args: string }) => {
let args: object;
try {
args = JSON.parse(options.args);
} catch {
console.error('Invalid JSON args');
process.exit(1);
}
const result = await apiCall('/call', 'POST', { tool, args });
if (result.ok) {
console.log(JSON.stringify(result.result, null, 2));
} else {
console.error(`Failed: ${result.error}`);
}
});
// Glob search
program
.command('glob <pattern>')
.description('Search for files by pattern')
.option('-p, --path <dir>', 'Directory to search in')
.action(async (pattern: string, options: { path?: string }) => {
const args: { pattern: string; path?: string } = { pattern };
if (options.path) args.path = options.path;
const result = await apiCall('/call', 'POST', { tool: 'Glob', args });
if (result.ok) {
const r = result.result as { filenames?: string[] };
if (r.filenames) {
for (const f of r.filenames) console.log(f);
} else {
console.log(JSON.stringify(result.result, null, 2));
}
} else {
console.error(`Failed: ${result.error}`);
}
});
// Grep search
program
.command('grep <pattern>')
.description('Search file contents')
.option('-p, --path <dir>', 'Directory to search in')
.option('-g, --glob <pattern>', 'File pattern to filter')
.option('-c, --content', 'Show matching content')
.action(async (pattern: string, options: { path?: string; glob?: string; content?: boolean }) => {
const args: Record<string, unknown> = { pattern };
if (options.path) args.path = options.path;
if (options.glob) args.glob = options.glob;
if (options.content) args.output_mode = 'content';
const result = await apiCall('/call', 'POST', { tool: 'Grep', args });
if (result.ok) {
const r = result.result as { filenames?: string[]; content?: string };
if (r.content) {
console.log(r.content);
} else if (r.filenames) {
for (const f of r.filenames) console.log(f);
} else {
console.log(JSON.stringify(result.result, null, 2));
}
} else {
console.error(`Failed: ${result.error}`);
}
});
// Status command
program
.command('status')
.description('Check connection status')
.action(async () => {
// Try to get tools - if it works, we're connected
const result = await apiCall('/tools');
if (result.ok) {
console.log('Status: Connected');
console.log(`Tools available: ${(result.tools as unknown[]).length}`);
} else {
console.log('Status: Not connected');
console.log('Run "claude-code-skill connect" to connect');
}
});
// List sessions command
program
.command('sessions')
.description('List all Claude Code sessions')
.option('-n, --limit <n>', 'Limit number of sessions', '10')
.action(async (options: { limit: string }) => {
const result = await apiCall('/sessions');
if (result.ok && Array.isArray(result.sessions)) {
const sessions = result.sessions.slice(0, parseInt(options.limit));
console.log('Recent Claude Code sessions:\n');
for (const s of sessions) {
const sess = s as {
sessionId: string;
summary?: string;
projectPath?: string;
modified?: string;
messageCount?: number;
};
console.log(` ${sess.sessionId}`);
console.log(` Summary: ${sess.summary || 'N/A'}`);
console.log(` Project: ${sess.projectPath || 'N/A'}`);
console.log(` Modified: ${sess.modified || 'N/A'}`);
console.log(` Messages: ${sess.messageCount || 0}`);
console.log('');
}
} else {
console.error(`Failed: ${result.error || 'Unknown error'}`);
}
});
// Resume session command
program
.command('resume <sessionId> <prompt>')
.description('Resume a Claude Code session with a prompt')
.option('-d, --cwd <dir>', 'Working directory')
.action(async (sessionId: string, prompt: string, options: { cwd?: string }) => {
console.log(`Resuming session ${sessionId}...`);
const result = await apiCall('/resume', 'POST', {
sessionId,
prompt,
cwd: options.cwd
});
if (result.ok) {
console.log(result.output as string);
if (result.stderr) console.error(result.stderr as string);
} else {
console.error(`Failed: ${result.error}`);
}
});
// Continue command
program
.command('continue <prompt>')
.description('Continue the most recent session in a directory')
.option('-d, --cwd <dir>', 'Working directory', process.cwd())
.action(async (prompt: string, options: { cwd: string }) => {
console.log(`Continuing session in ${options.cwd}...`);
const result = await apiCall('/continue', 'POST', {
prompt,
cwd: options.cwd
});
if (result.ok) {
console.log(result.output as string);
if (result.stderr) console.error(result.stderr as string);
} else {
console.error(`Failed: ${result.error}`);
}
});
// === Persistent Session Commands ===
// Start a persistent session
program
.command('session-start [name]')
.description('Start a persistent Claude Code session')
.option('-d, --cwd <dir>', 'Working directory')
.option('-r, --resume <sessionId>', 'Resume an existing Claude session')
.option('-m, --model <model>', 'Model to use')
.option('-b, --base-url <url>', 'Custom API endpoint (for Gemini/GPT proxy)')
.option('--permission-mode <mode>', 'Permission mode: acceptEdits, bypassPermissions, default, delegate, dontAsk, plan', 'acceptEdits')
.option('--fork-session', 'Create a new session ID instead of reusing (use with --resume)')
.option('--allowed-tools <tools>', 'Comma-separated list of tools to auto-approve (e.g. Bash,Read,Edit)')
.option('--disallowed-tools <tools>', 'Comma-separated list of tools to deny')
.option('--tools <tools>', 'Limit available tools (use "" to disable all, "default" for all)')
.option('--max-turns <n>', 'Maximum agent loop turns')
.option('--max-budget <usd>', 'Maximum API spend in USD')
.option('--system-prompt <prompt>', 'Replace system prompt completely')
.option('--append-system-prompt <prompt>', 'Append to system prompt')
.option('--skip-permissions', 'Skip all permission checks (dangerous!)')
.option('--agents <json>', 'Custom sub-agents JSON (e.g. \'{"reviewer": {"prompt": "You review code"}}\')')
.option('--agent <name>', 'Default agent to use')
.option('--session-id <uuid>', 'Use a specific session ID (must be valid UUID)')
.option('--add-dir <dirs>', 'Additional directories to allow tool access (comma-separated)')
.action(async (name: string | undefined, options: {
cwd?: string;
resume?: string;
model?: string;
baseUrl?: string;
permissionMode?: string;
forkSession?: boolean;
allowedTools?: string;
disallowedTools?: string;
tools?: string;
maxTurns?: string;
maxBudget?: string;
systemPrompt?: string;
appendSystemPrompt?: string;
skipPermissions?: boolean;
agents?: string;
agent?: string;
sessionId?: string;
addDir?: string;
}) => {
const sessionName = name || `session-${Date.now()}`;
console.log(`Starting persistent session: ${sessionName}...`);
const body: Record<string, unknown> = {
name: sessionName,
cwd: options.cwd,
sessionId: options.resume,
model: options.model,
baseUrl: options.baseUrl,
permissionMode: options.permissionMode || 'acceptEdits',
forkSession: options.forkSession
};
// New flags
if (options.allowedTools) {
body.allowedTools = options.allowedTools.split(',').map(t => t.trim());
}
if (options.disallowedTools) {
body.disallowedTools = options.disallowedTools.split(',').map(t => t.trim());
}
if (options.tools) {
body.tools = options.tools === '""' ? [] : options.tools.split(',').map(t => t.trim());
}
if (options.maxTurns) {
body.maxTurns = parseInt(options.maxTurns);
}
if (options.maxBudget) {
body.maxBudgetUsd = parseFloat(options.maxBudget);
}
if (options.systemPrompt) {
body.systemPrompt = options.systemPrompt;
}
if (options.appendSystemPrompt) {
body.appendSystemPrompt = options.appendSystemPrompt;
}
if (options.skipPermissions) {
body.dangerouslySkipPermissions = true;
}
if (options.agents) {
try {
body.agents = JSON.parse(options.agents);
} catch {
console.error('Invalid --agents JSON');
process.exit(1);
}
}
if (options.agent) {
body.agent = options.agent;
}
if (options.sessionId) {
body.customSessionId = options.sessionId;
}
if (options.addDir) {
body.addDir = options.addDir.split(',').map(d => d.trim());
}
const result = await apiCall('/session/start', 'POST', body);
if (result.ok) {
console.log(`Session '${sessionName}' started!`);
if (result.claudeSessionId) {
console.log(`Claude Session ID: ${result.claudeSessionId}`);
}
// Show active options
if (options.model) console.log(`Model: ${options.model}`);
if (options.baseUrl) console.log(`Base URL: ${options.baseUrl}`);
console.log(`Permission mode: ${options.permissionMode || 'acceptEdits'}`);
if (options.allowedTools) console.log(`Allowed tools: ${options.allowedTools}`);
if (options.disallowedTools) console.log(`Disallowed tools: ${options.disallowedTools}`);
if (options.tools) console.log(`Available tools: ${options.tools}`);
if (options.maxTurns) console.log(`Max turns: ${options.maxTurns}`);
if (options.maxBudget) console.log(`Max budget: $${options.maxBudget}`);
if (options.forkSession) console.log(`Fork mode: enabled`);
} else {
console.error(`Failed: ${result.error}`);
}
});
// Send message to a persistent session
program
.command('session-send <name> <message>')
.description('Send a message to a persistent session')
.option('-t, --timeout <ms>', 'Timeout in milliseconds', '120000')
.option('-s, --stream', 'Stream output in real-time')
.action(async (name: string, message: string, options: { timeout: string; stream?: boolean }) => {
console.log(`Sending to session '${name}'...`);
if (options.stream) {
// Use SSE streaming endpoint
const url = `${SASHA_DOCTOR_URL}${PREFIX}/session/send-stream`;
try {
const response = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, message, timeout: parseInt(options.timeout) })
});
if (!response.ok || !response.body) {
console.error('Failed to connect to stream');
return;
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop() || '';
for (const line of lines) {
if (line.startsWith('data: ')) {
try {
const data = JSON.parse(line.slice(6)) as { type: string; text?: string; tool?: string; error?: string };
if (data.type === 'text') {
process.stdout.write(data.text || '');
} else if (data.type === 'tool_use') {
console.log(`\n🔧 [Tool: ${data.tool}]`);
} else if (data.type === 'tool_result') {
console.log('✓ Tool completed');
} else if (data.type === 'done') {
console.log('\n--- Done ---');
} else if (data.type === 'error') {
console.error(`\nError: ${data.error}`);
}
} catch {
// Ignore parse errors
}
}
}
}
} catch (error) {
console.error(`Stream error: ${(error as Error).message}`);
}
} else {
// Non-streaming mode
const result = await apiCall('/session/send', 'POST', {
name,
message,
timeout: parseInt(options.timeout)
});
if (result.ok) {
console.log(result.response as string);
} else {
console.error(`Failed: ${result.error}`);
}
}
});
// List active sessions
program
.command('session-list')
.description('List active persistent sessions')
.action(async () => {
const result = await apiCall('/session/list');
if (result.ok && Array.isArray(result.sessions)) {
if (result.sessions.length === 0) {
console.log('No active persistent sessions.');
} else {
console.log('Active persistent sessions:\n');
for (const s of result.sessions) {
const sess = s as {
name: string;
cwd?: string;
created?: string;
isReady?: boolean;
};
console.log(` ${sess.name}`);
console.log(` CWD: ${sess.cwd || 'N/A'}`);
console.log(` Created: ${sess.created || 'N/A'}`);
console.log(` Ready: ${sess.isReady ? 'Yes' : 'No'}`);
console.log('');
}
}
} else {
console.error(`Failed: ${result.error || 'Unknown error'}`);
}
});
// Stop a persistent session
program
.command('session-stop <name>')
.description('Stop a persistent session')
.action(async (name: string) => {
const result = await apiCall('/session/stop', 'POST', { name });
if (result.ok) {
console.log(`Session '${name}' stopped.`);
} else {
console.error(`Failed: ${result.error}`);
}
});
// Get session status
program
.command('session-status <name>')
.description('Get detailed status of a persistent session')
.action(async (name: string) => {
const result = await apiCall('/session/status', 'POST', { name });
if (result.ok) {
console.log(`Session: ${name}`);
console.log(` Claude ID: ${result.claudeSessionId || 'N/A'}`);
console.log(` CWD: ${result.cwd}`);
console.log(` Created: ${result.created}`);
const stats = result.stats as {
turns?: number;
toolCalls?: number;
tokensIn?: number;
tokensOut?: number;
uptime?: number;
lastActivity?: string;
isReady?: boolean;
};
console.log('\nStatistics:');
console.log(` Ready: ${stats.isReady ? 'Yes' : 'No'}`);
console.log(` Turns: ${stats.turns || 0}`);
console.log(` Tool Calls: ${stats.toolCalls || 0}`);
console.log(` Tokens In: ${stats.tokensIn || 0}`);
console.log(` Tokens Out: ${stats.tokensOut || 0}`);
console.log(` Uptime: ${stats.uptime || 0}s`);
console.log(` Last Activity: ${stats.lastActivity || 'N/A'}`);
} else {
console.error(`Failed: ${result.error}`);
}
});
// Get session history
program
.command('session-history <name>')
.description('Get conversation history of a persistent session')
.option('-n, --limit <n>', 'Number of events to show', '20')
.action(async (name: string, options: { limit: string }) => {
const result = await apiCall('/session/history', 'POST', {
name,
limit: parseInt(options.limit)
});
if (result.ok) {
console.log(`Session '${name}' history (${result.count} events):\n`);
const history = result.history as Array<{
time: string;
type: string;
event: { message?: { content?: Array<{ type: string; text?: string; name?: string }> } };
}>;
for (const entry of history) {
const time = new Date(entry.time).toLocaleTimeString();
const type = entry.type.padEnd(12);
let content = '';
if (entry.event.message?.content) {
for (const c of entry.event.message.content) {
if (c.type === 'text' && c.text) {
content = c.text.substring(0, 60).replace(/\n/g, ' ');
if (c.text.length > 60) content += '...';
} else if (c.type === 'tool_use' && c.name) {
content = `[Tool: ${c.name}]`;
}
}
}
console.log(`[${time}] ${type} ${content}`);
}
} else {
console.error(`Failed: ${result.error}`);
}
});
// Pause a persistent session
program
.command('session-pause <name>')
.description('Pause a persistent session (saves state)')
.action(async (name: string) => {
const result = await apiCall('/session/pause', 'POST', { name });
if (result.ok) {
console.log(`Session '${name}' paused.`);
console.log(`Resume with: claude-code-skill session-resume ${name}`);
} else {
console.error(`Failed: ${result.error}`);
}
});
// Resume a paused session
program
.command('session-resume-paused <name>')
.description('Resume a paused persistent session')
.action(async (name: string) => {
const result = await apiCall('/session/resume', 'POST', { name });
if (result.ok) {
console.log(`Session '${name}' resumed.`);
} else {
console.error(`Failed: ${result.error}`);
}
});
// Fork a session
program
.command('session-fork <name> <newName>')
.description('Fork an existing session to create a branch')
.action(async (name: string, newName: string) => {
const result = await apiCall('/session/fork', 'POST', { name, newName });
if (result.ok) {
console.log(`Session '${name}' forked to '${newName}'`);
if (result.claudeSessionId) {
console.log(`New Claude Session ID: ${result.claudeSessionId}`);
}
} else {
console.error(`Failed: ${result.error}`);
}
});
// Search sessions
program
.command('session-search [query]')
.description('Search sessions by project path, summary, or time')
.option('-p, --project <path>', 'Filter by project path')
.option('-s, --since <time>', 'Show sessions since (e.g., "1h", "2d", "2024-01-01")')
.option('-n, --limit <n>', 'Limit results', '20')
.action(async (query: string | undefined, options: { project?: string; since?: string; limit: string }) => {
const result = await apiCall('/session/search', 'POST', {
query,
project: options.project,
since: options.since,
limit: parseInt(options.limit)
});
if (result.ok && Array.isArray(result.sessions)) {
if (result.sessions.length === 0) {
console.log('No sessions found.');
} else {
console.log(`Found ${result.sessions.length} session(s):\n`);
for (const s of result.sessions) {
const sess = s as { name: string; cwd?: string; created?: string; summary?: string };
console.log(` ${sess.name}`);
console.log(` CWD: ${sess.cwd || 'N/A'}`);
console.log(` Created: ${sess.created || 'N/A'}`);
if (sess.summary) console.log(` Summary: ${sess.summary}`);
console.log('');
}
}
} else {
console.error(`Failed: ${result.error || 'Unknown error'}`);
}
});
// Batch read files
program
.command('batch-read <patterns...>')
.description('Read multiple files at once by glob patterns')
.option('-p, --path <dir>', 'Base directory')
.action(async (patterns: string[], options: { path?: string }) => {
const result = await apiCall('/batch-read', 'POST', {
patterns,
basePath: options.path
});
if (result.ok && result.files) {
const files = result.files as Array<{ path: string; content: string; error?: string }>;
console.log(`Read ${files.length} file(s):\n`);
for (const file of files) {
console.log(`=== ${file.path} ===`);
if (file.error) {
console.error(`Error: ${file.error}`);
} else {
console.log(file.content);
}
console.log('');
}
} else {
console.error(`Failed: ${result.error}`);
}
});
// Restart a failed session
program
.command('session-restart <name>')
.description('Restart a failed or stopped session')
.action(async (name: string) => {
const result = await apiCall('/session/restart', 'POST', { name });
if (result.ok) {
console.log(`Session '${name}' restarted.`);
} else {
console.error(`Failed: ${result.error}`);
}
});
program.parse();

View File

@@ -0,0 +1,365 @@
/**
* MCP Actions - Server-side actions for managing MCP clients
*/
import {
createClient,
executeRequest,
listTools,
removeClient,
} from "./client";
import { MCPClientLogger } from "./logger";
import {
DEFAULT_MCP_CONFIG,
McpClientData,
McpConfigData,
McpRequestMessage,
ServerConfig,
ServerStatusResponse,
} from "./types";
import fs from "fs/promises";
import path from "path";
const logger = new MCPClientLogger("MCP Actions");
// Default config path - can be overridden
let CONFIG_PATH = path.join(process.cwd(), "mcp_config.json");
const clientsMap = new Map<string, McpClientData>();
// Set custom config path
export function setConfigPath(configPath: string) {
CONFIG_PATH = configPath;
}
// Get clients status
export async function getClientsStatus(): Promise<
Record<string, ServerStatusResponse>
> {
const config = await getMcpConfigFromFile();
const result: Record<string, ServerStatusResponse> = {};
for (const clientId of Object.keys(config.mcpServers)) {
const status = clientsMap.get(clientId);
const serverConfig = config.mcpServers[clientId];
if (!serverConfig) {
result[clientId] = { status: "undefined", errorMsg: null };
continue;
}
if (serverConfig.status === "paused") {
result[clientId] = { status: "paused", errorMsg: null };
continue;
}
if (!status) {
result[clientId] = { status: "undefined", errorMsg: null };
continue;
}
if (
status.client === null &&
status.tools === null &&
status.errorMsg === null
) {
result[clientId] = { status: "initializing", errorMsg: null };
continue;
}
if (status.errorMsg) {
result[clientId] = { status: "error", errorMsg: status.errorMsg };
continue;
}
if (status.client) {
result[clientId] = { status: "active", errorMsg: null };
continue;
}
result[clientId] = { status: "error", errorMsg: "Client not found" };
}
return result;
}
// Get client tools
export async function getClientTools(clientId: string) {
return clientsMap.get(clientId)?.tools ?? null;
}
// Get available clients count
export async function getAvailableClientsCount() {
let count = 0;
clientsMap.forEach((map) => !map.errorMsg && count++);
return count;
}
// Get all tools from all clients
export async function getAllTools() {
const result = [];
for (const [clientId, status] of clientsMap.entries()) {
result.push({
clientId,
tools: status.tools,
});
}
return result;
}
// Initialize single client
async function initializeSingleClient(
clientId: string,
serverConfig: ServerConfig,
) {
if (serverConfig.status === "paused") {
logger.info(`Skipping initialization for paused client [${clientId}]`);
return;
}
logger.info(`Initializing client [${clientId}]...`);
clientsMap.set(clientId, {
client: null,
tools: null,
errorMsg: null,
});
createClient(clientId, serverConfig)
.then(async (client) => {
const tools = await listTools(client);
logger.info(
`Supported tools for [${clientId}]: ${JSON.stringify(tools, null, 2)}`,
);
clientsMap.set(clientId, { client, tools, errorMsg: null });
logger.success(`Client [${clientId}] initialized successfully`);
})
.catch((error) => {
clientsMap.set(clientId, {
client: null,
tools: null,
errorMsg: error instanceof Error ? error.message : String(error),
});
logger.error(`Failed to initialize client [${clientId}]: ${error}`);
});
}
// Initialize MCP system
export async function initializeMcpSystem() {
logger.info("MCP Actions starting...");
try {
if (clientsMap.size > 0) {
logger.info("MCP system already initialized, skipping...");
return;
}
const config = await getMcpConfigFromFile();
for (const [clientId, serverConfig] of Object.entries(config.mcpServers)) {
await initializeSingleClient(clientId, serverConfig);
}
return config;
} catch (error) {
logger.error(`Failed to initialize MCP system: ${error}`);
throw error;
}
}
// Add MCP server
export async function addMcpServer(clientId: string, config: ServerConfig) {
try {
const currentConfig = await getMcpConfigFromFile();
const isNewServer = !(clientId in currentConfig.mcpServers);
if (isNewServer && !config.status) {
config.status = "active";
}
const newConfig = {
...currentConfig,
mcpServers: {
...currentConfig.mcpServers,
[clientId]: config,
},
};
await updateMcpConfig(newConfig);
if (isNewServer || config.status === "active") {
await initializeSingleClient(clientId, config);
}
return newConfig;
} catch (error) {
logger.error(`Failed to add server [${clientId}]: ${error}`);
throw error;
}
}
// Pause MCP server
export async function pauseMcpServer(clientId: string) {
try {
const currentConfig = await getMcpConfigFromFile();
const serverConfig = currentConfig.mcpServers[clientId];
if (!serverConfig) {
throw new Error(`Server ${clientId} not found`);
}
const newConfig: McpConfigData = {
...currentConfig,
mcpServers: {
...currentConfig.mcpServers,
[clientId]: {
...serverConfig,
status: "paused",
},
},
};
await updateMcpConfig(newConfig);
const client = clientsMap.get(clientId);
if (client?.client) {
await removeClient(client.client);
}
clientsMap.delete(clientId);
return newConfig;
} catch (error) {
logger.error(`Failed to pause server [${clientId}]: ${error}`);
throw error;
}
}
// Resume MCP server
export async function resumeMcpServer(clientId: string): Promise<void> {
try {
const currentConfig = await getMcpConfigFromFile();
const serverConfig = currentConfig.mcpServers[clientId];
if (!serverConfig) {
throw new Error(`Server ${clientId} not found`);
}
logger.info(`Trying to initialize client [${clientId}]...`);
try {
const client = await createClient(clientId, serverConfig);
const tools = await listTools(client);
clientsMap.set(clientId, { client, tools, errorMsg: null });
logger.success(`Client [${clientId}] initialized successfully`);
const newConfig: McpConfigData = {
...currentConfig,
mcpServers: {
...currentConfig.mcpServers,
[clientId]: {
...serverConfig,
status: "active" as const,
},
},
};
await updateMcpConfig(newConfig);
} catch (error) {
const currentConfig = await getMcpConfigFromFile();
const serverConfig = currentConfig.mcpServers[clientId];
if (serverConfig) {
serverConfig.status = "error";
await updateMcpConfig(currentConfig);
}
clientsMap.set(clientId, {
client: null,
tools: null,
errorMsg: error instanceof Error ? error.message : String(error),
});
logger.error(`Failed to initialize client [${clientId}]: ${error}`);
throw error;
}
} catch (error) {
logger.error(`Failed to resume server [${clientId}]: ${error}`);
throw error;
}
}
// Remove MCP server
export async function removeMcpServer(clientId: string) {
try {
const currentConfig = await getMcpConfigFromFile();
const { [clientId]: _, ...rest } = currentConfig.mcpServers;
const newConfig = {
...currentConfig,
mcpServers: rest,
};
await updateMcpConfig(newConfig);
const client = clientsMap.get(clientId);
if (client?.client) {
await removeClient(client.client);
}
clientsMap.delete(clientId);
return newConfig;
} catch (error) {
logger.error(`Failed to remove server [${clientId}]: ${error}`);
throw error;
}
}
// Restart all clients
export async function restartAllClients() {
logger.info("Restarting all clients...");
try {
for (const client of clientsMap.values()) {
if (client.client) {
await removeClient(client.client);
}
}
clientsMap.clear();
const config = await getMcpConfigFromFile();
for (const [clientId, serverConfig] of Object.entries(config.mcpServers)) {
await initializeSingleClient(clientId, serverConfig);
}
return config;
} catch (error) {
logger.error(`Failed to restart clients: ${error}`);
throw error;
}
}
// Execute MCP action
export async function executeMcpAction(
clientId: string,
request: McpRequestMessage,
) {
try {
const client = clientsMap.get(clientId);
if (!client?.client) {
throw new Error(`Client ${clientId} not found`);
}
logger.info(`Executing request for [${clientId}]`);
return await executeRequest(client.client, request);
} catch (error) {
logger.error(`Failed to execute request for [${clientId}]: ${error}`);
throw error;
}
}
// Get MCP config from file
export async function getMcpConfigFromFile(): Promise<McpConfigData> {
try {
const configStr = await fs.readFile(CONFIG_PATH, "utf-8");
return JSON.parse(configStr);
} catch (error) {
logger.error(`Failed to load MCP config, using default config: ${error}`);
return DEFAULT_MCP_CONFIG;
}
}
// Update MCP config file
async function updateMcpConfig(config: McpConfigData): Promise<void> {
try {
await fs.mkdir(path.dirname(CONFIG_PATH), { recursive: true });
await fs.writeFile(CONFIG_PATH, JSON.stringify(config, null, 2));
} catch (error) {
throw error;
}
}

View File

@@ -0,0 +1,59 @@
/**
* MCP Client - Creates and manages MCP client connections
*/
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
import { MCPClientLogger } from "./logger";
import { ListToolsResponse, McpRequestMessage, ServerConfig } from "./types";
import { z } from "zod";
const logger = new MCPClientLogger();
export async function createClient(
id: string,
config: ServerConfig,
): Promise<Client> {
logger.info(`Creating client for ${id}...`);
const transport = new StdioClientTransport({
command: config.command,
args: config.args,
env: {
...Object.fromEntries(
Object.entries(process.env)
.filter(([_, v]) => v !== undefined)
.map(([k, v]) => [k, v as string]),
),
...(config.env || {}),
},
});
const client = new Client(
{
name: `openclaw-mcp-client-${id}`,
version: "1.0.0",
},
{
capabilities: {},
},
);
await client.connect(transport);
return client;
}
export async function removeClient(client: Client) {
logger.info(`Removing client...`);
await client.close();
}
export async function listTools(client: Client): Promise<ListToolsResponse> {
return client.listTools();
}
export async function executeRequest(
client: Client,
request: McpRequestMessage,
) {
return client.request(request, z.any());
}

View File

@@ -0,0 +1,9 @@
/**
* MCP Module - Model Context Protocol integration
*/
export * from "./types";
export * from "./logger";
export * from "./client";
export * from "./actions";
export * from "./utils";

View File

@@ -0,0 +1,60 @@
/**
* MCP Client Logger with ANSI color support
*/
const colors = {
reset: "\x1b[0m",
bright: "\x1b[1m",
dim: "\x1b[2m",
green: "\x1b[32m",
yellow: "\x1b[33m",
red: "\x1b[31m",
blue: "\x1b[34m",
};
export class MCPClientLogger {
private readonly prefix: string;
private readonly debugMode: boolean;
constructor(
prefix: string = "OpenClaw MCP Client",
debugMode: boolean = false,
) {
this.prefix = prefix;
this.debugMode = debugMode;
}
info(message: any) {
this.print(colors.blue, message);
}
success(message: any) {
this.print(colors.green, message);
}
error(message: any) {
this.print(colors.red, message);
}
warn(message: any) {
this.print(colors.yellow, message);
}
debug(message: any) {
if (this.debugMode) {
this.print(colors.dim, message);
}
}
private formatMessage(message: any): string {
return typeof message === "object"
? JSON.stringify(message, null, 2)
: message;
}
private print(color: string, message: any) {
const formattedMessage = this.formatMessage(message);
const logMessage = `${color}${colors.bright}[${this.prefix}]${colors.reset} ${formattedMessage}`;
console.log(logMessage);
}
}

View File

@@ -0,0 +1,162 @@
/**
* MCP (Model Context Protocol) Type Definitions
* Reference: https://spec.modelcontextprotocol.io/specification/basic/messages/
*/
import { z } from "zod";
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
// MCP Request Message
export interface McpRequestMessage {
jsonrpc?: "2.0";
id?: string | number;
method: "tools/call" | string;
params?: {
[key: string]: unknown;
};
}
export const McpRequestMessageSchema: z.ZodType<McpRequestMessage> = z.object({
jsonrpc: z.literal("2.0").optional(),
id: z.union([z.string(), z.number()]).optional(),
method: z.string(),
params: z.record(z.unknown()).optional(),
});
// MCP Response Message
export interface McpResponseMessage {
jsonrpc?: "2.0";
id?: string | number;
result?: {
[key: string]: unknown;
};
error?: {
code: number;
message: string;
data?: unknown;
};
}
export const McpResponseMessageSchema: z.ZodType<McpResponseMessage> = z.object(
{
jsonrpc: z.literal("2.0").optional(),
id: z.union([z.string(), z.number()]).optional(),
result: z.record(z.unknown()).optional(),
error: z
.object({
code: z.number(),
message: z.string(),
data: z.unknown().optional(),
})
.optional(),
},
);
// MCP Notifications
export interface McpNotifications {
jsonrpc?: "2.0";
method: string;
params?: {
[key: string]: unknown;
};
}
export const McpNotificationsSchema: z.ZodType<McpNotifications> = z.object({
jsonrpc: z.literal("2.0").optional(),
method: z.string(),
params: z.record(z.unknown()).optional(),
});
// List Tools Response
export interface ListToolsResponse {
tools: {
name?: string;
description?: string;
inputSchema?: object;
[key: string]: any;
};
}
// MCP Client Data States
export type McpClientData =
| McpActiveClient
| McpErrorClient
| McpInitializingClient;
interface McpInitializingClient {
client: null;
tools: null;
errorMsg: null;
}
interface McpActiveClient {
client: Client;
tools: ListToolsResponse;
errorMsg: null;
}
interface McpErrorClient {
client: null;
tools: null;
errorMsg: string;
}
// Server Status Types
export type ServerStatus =
| "undefined"
| "active"
| "paused"
| "error"
| "initializing";
export interface ServerStatusResponse {
status: ServerStatus;
errorMsg: string | null;
}
// MCP Server Configuration
export interface ServerConfig {
command: string;
args: string[];
env?: Record<string, string>;
status?: "active" | "paused" | "error";
}
export interface McpConfigData {
mcpServers: Record<string, ServerConfig>;
}
export const DEFAULT_MCP_CONFIG: McpConfigData = {
mcpServers: {},
};
// Arguments Mapping for Preset Servers
export interface ArgsMapping {
type: "spread" | "single" | "env";
position?: number;
key?: string;
}
// Preset Server Configuration
export interface PresetServer {
id: string;
name: string;
description: string;
repo: string;
tags: string[];
command: string;
baseArgs: string[];
configurable: boolean;
configSchema?: {
properties: Record<
string,
{
type: string;
description?: string;
required?: boolean;
minItems?: number;
}
>;
};
argsMapping?: Record<string, ArgsMapping>;
}

View File

@@ -0,0 +1,21 @@
/**
* MCP Utility Functions
*/
/**
* Check if content contains MCP JSON format
*/
export function isMcpJson(content: string) {
return content.match(/```json:mcp:([^{\s]+)([\s\S]*?)```/);
}
/**
* Extract MCP JSON from content
*/
export function extractMcpJson(content: string) {
const match = content.match(/```json:mcp:([^{\s]+)([\s\S]*?)```/);
if (match && match.length === 3) {
return { clientId: match[1], mcp: JSON.parse(match[2]) };
}
return null;
}

View File

@@ -0,0 +1,7 @@
/**
* Store Module - State management and persistence
*/
export * from "./persist";
export * from "./indexeddb-storage";
export * from "./sync";

View File

@@ -0,0 +1,71 @@
/**
* IndexedDB Storage - Persistent storage with localStorage fallback
*/
import { StateStorage } from "zustand/middleware";
import { get, set, del, clear } from "idb-keyval";
/**
* Safe localStorage wrapper that handles SSR
*/
function safeLocalStorage(): Storage {
if (typeof window !== "undefined" && window.localStorage) {
return window.localStorage;
}
// Return a mock storage for SSR
return {
length: 0,
clear: () => {},
getItem: () => null,
key: () => null,
removeItem: () => {},
setItem: () => {},
};
}
const localStorage = safeLocalStorage();
/**
* IndexedDB Storage implementation with localStorage fallback
*/
class IndexedDBStorage implements StateStorage {
public async getItem(name: string): Promise<string | null> {
try {
const value = (await get(name)) || localStorage.getItem(name);
return value;
} catch (error) {
return localStorage.getItem(name);
}
}
public async setItem(name: string, value: string): Promise<void> {
try {
const _value = JSON.parse(value);
if (!_value?.state?._hasHydrated) {
console.warn("skip setItem", name);
return;
}
await set(name, value);
} catch (error) {
localStorage.setItem(name, value);
}
}
public async removeItem(name: string): Promise<void> {
try {
await del(name);
} catch (error) {
localStorage.removeItem(name);
}
}
public async clear(): Promise<void> {
try {
await clear();
} catch (error) {
localStorage.clear();
}
}
}
export const indexedDBStorage = new IndexedDBStorage();

View File

@@ -0,0 +1,93 @@
/**
* Persistent Store Factory - Creates Zustand stores with persistence
*/
import { create } from "zustand";
import { combine, persist, createJSONStorage, StateStorage } from "zustand/middleware";
type SecondParam<T> = T extends (
_f: infer _F,
_s: infer S,
...args: infer _U
) => any
? S
: never;
type MakeUpdater<T> = {
lastUpdateTime: number;
_hasHydrated: boolean;
markUpdate: () => void;
update: (updater: (state: T) => void) => void;
setHasHydrated: (state: boolean) => void;
};
type SetStoreState<T> = (
partial: T | Partial<T> | ((state: T) => T | Partial<T>),
replace?: boolean | undefined,
) => void;
/**
* Deep clone utility
*/
function deepClone<T>(obj: T): T {
return JSON.parse(JSON.stringify(obj));
}
/**
* Create a persistent store with automatic hydration tracking
*/
export function createPersistStore<T extends object, M>(
state: T,
methods: (
set: SetStoreState<T & MakeUpdater<T>>,
get: () => T & MakeUpdater<T>,
) => M,
persistOptions: SecondParam<typeof persist<T & M & MakeUpdater<T>>>,
storage?: StateStorage,
) {
if (storage) {
persistOptions.storage = createJSONStorage(() => storage);
}
const oldOnRehydrateStorage = persistOptions?.onRehydrateStorage;
persistOptions.onRehydrateStorage = (state) => {
oldOnRehydrateStorage?.(state);
return () => state.setHasHydrated(true);
};
return create(
persist(
combine(
{
...state,
lastUpdateTime: 0,
_hasHydrated: false,
},
(set, get) => {
return {
...methods(set, get as any),
markUpdate() {
set({ lastUpdateTime: Date.now() } as Partial<
T & M & MakeUpdater<T>
>);
},
update(updater: (state: T) => void) {
const state = deepClone(get());
updater(state);
set({
...state,
lastUpdateTime: Date.now(),
});
},
setHasHydrated: (state: boolean) => {
set({ _hasHydrated: state } as Partial<T & M & MakeUpdater<T>>);
},
} as M & MakeUpdater<T>;
},
),
persistOptions as any,
),
);
}

View File

@@ -0,0 +1,128 @@
/**
* State Sync Utilities - Merge local and remote states
*/
/**
* Deep merge utility
*/
export function merge<T extends object>(target: T, source: Partial<T>): T {
for (const key of Object.keys(source) as (keyof T)[]) {
const sourceValue = source[key];
const targetValue = target[key];
if (
sourceValue !== undefined &&
typeof sourceValue === "object" &&
sourceValue !== null &&
!Array.isArray(sourceValue)
) {
if (typeof targetValue !== "object" || targetValue === null) {
(target as any)[key] = {};
}
merge(target[key] as object, sourceValue as object);
} else if (sourceValue !== undefined) {
(target as any)[key] = sourceValue;
}
}
return target;
}
/**
* Get non-function fields from an object
*/
export function getNonFunctionFields<T extends object>(obj: T) {
const ret: any = {};
Object.entries(obj).map(([k, v]) => {
if (typeof v !== "function") {
ret[k] = v;
}
});
return ret;
}
/**
* Merge state with lastUpdateTime - older state will be overridden
*/
export function mergeWithUpdate<T extends { lastUpdateTime?: number }>(
localState: T,
remoteState: T,
): T {
const localUpdateTime = localState.lastUpdateTime ?? 0;
const remoteUpdateTime = remoteState.lastUpdateTime ?? 1;
if (localUpdateTime < remoteUpdateTime) {
merge(remoteState, localState);
return { ...remoteState };
} else {
merge(localState, remoteState);
return { ...localState };
}
}
/**
* Session-like data structure for chat history
*/
export interface Session {
id: string;
messages: Array<{ id: string; date: string | Date; [key: string]: any }>;
lastUpdate: string | Date;
[key: string]: any;
}
/**
* Merge sessions - combines messages from local and remote
*/
export function mergeSessions(
localSessions: Session[],
remoteSessions: Session[],
): Session[] {
const localSessionMap: Record<string, Session> = {};
localSessions.forEach((s) => (localSessionMap[s.id] = s));
remoteSessions.forEach((remoteSession) => {
// Skip empty chats
if (remoteSession.messages.length === 0) return;
const localSession = localSessionMap[remoteSession.id];
if (!localSession) {
// Remote session is new, add it
localSessions.push(remoteSession);
} else {
// Both have same session id, merge messages
const localMessageIds = new Set(localSession.messages.map((v) => v.id));
remoteSession.messages.forEach((m) => {
if (!localMessageIds.has(m.id)) {
localSession.messages.push(m);
}
});
// Sort messages by date ascending
localSession.messages.sort(
(a, b) => new Date(a.date).getTime() - new Date(b.date).getTime(),
);
}
});
// Sort sessions by lastUpdate descending
localSessions.sort(
(a, b) =>
new Date(b.lastUpdate).getTime() - new Date(a.lastUpdate).getTime(),
);
return localSessions;
}
/**
* Merge key-value stores - remote values first, then local overrides
*/
export function mergeKeyValueStore<T extends Record<string, any>>(
localStore: T,
remoteStore: T,
): T {
return {
...remoteStore,
...localStore,
};
}