/** * WebMCP Tools — Structured debugging tools for AI agents via navigator.modelContext * * Chrome 146+ supports the WebMCP DevTrial: websites can register structured tools * that AI agents call directly without DOM scraping. This reduces token consumption * by ~67% compared to DevTools MCP snapshot-based debugging. * * Only registered in development mode (import.meta.env.DEV). * Requires chrome://flags/#enable-webmcp-testing enabled. * * @see https://developer.chrome.com/docs/ai/webmcp */ import { useConnectionStore } from '../store/connectionStore'; import { useSaaSStore } from '../store/saasStore'; import { useStreamStore } from '../store/chat/streamStore'; import { useConversationStore } from '../store/chat/conversationStore'; import { useMessageStore } from '../store/chat/messageStore'; import { useOfflineStore } from '../store/offlineStore'; import { createLogger } from './logger'; const log = createLogger('WebMCP'); // --------------------------------------------------------------------------- // Types // --------------------------------------------------------------------------- interface ModelContextAPI { registerTool(tool: { name: string; description: string; inputSchema: Record; handler: (input: Record) => Promise; }): void; } declare global { interface Navigator { modelContext?: ModelContextAPI; } } // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- /** Safely serialize store state, stripping private/internal fields */ function safeState>( state: T, omitKeys: string[] = [], ): Record { const result: Record = {}; for (const [key, value] of Object.entries(state)) { if (key.startsWith('_')) continue; if (omitKeys.includes(key)) continue; if (typeof value === 'function') continue; result[key] = value; } return result; } // --------------------------------------------------------------------------- // Tool definitions // --------------------------------------------------------------------------- /** All registered tool names (for logging) */ const registeredTools: string[] = []; function registerTool( name: string, description: string, inputSchema: Record, handler: (input: Record) => Promise, ): void { const ctx = navigator.modelContext; if (!ctx) return; ctx.registerTool({ name, description, inputSchema, handler }); registeredTools.push(name); } // --------------------------------------------------------------------------- // Registration // --------------------------------------------------------------------------- function registerConnectionTools(): void { // --- get_zclaw_state --- registerTool( 'get_zclaw_state', 'Get comprehensive ZCLAW application state: connection mode, SaaS login, streaming, and conversation summary', { type: 'object', properties: {} }, async () => { const conn = useConnectionStore.getState(); const saas = useSaaSStore.getState(); const stream = useStreamStore.getState(); return safeState({ connectionState: conn.connectionState, connectionMode: saas.connectionMode, saasUrl: saas.saasUrl, saasReachable: saas.saasReachable, isLoggedIn: saas.isLoggedIn, account: saas.account ? { username: saas.account.username, email: saas.account.email, role: saas.account.role } : null, isStreaming: stream.isStreaming, activeRunId: stream.activeRunId, chatMode: stream.chatMode, isLoading: stream.isLoading, availableModels: saas.availableModels.map((m) => ({ id: m.id, alias: m.alias })), }); }, ); // --- check_connection --- registerTool( 'check_connection', 'Check if ZCLAW is connected to backend and report connection details', { type: 'object', properties: {} }, async () => { const conn = useConnectionStore.getState(); const saas = useSaaSStore.getState(); const client = conn.client; return { connectionState: conn.connectionState, connectionMode: saas.connectionMode, gatewayState: client?.getState?.() ?? 'unknown', saasUrl: saas.saasUrl, saasReachable: saas.saasReachable, isLoggedIn: saas.isLoggedIn, }; }, ); } function registerChatTools(): void { // --- send_message --- registerTool( 'send_message', 'Send a chat message to the current ZCLAW agent. Returns the message ID and streaming status.', { type: 'object', properties: { content: { type: 'string', description: 'Message content to send' }, }, required: ['content'], }, async (input) => { const content = String(input.content ?? ''); if (!content.trim()) { return { error: 'Message content cannot be empty' }; } const stream = useStreamStore.getState(); if (stream.isStreaming) { return { error: 'Already streaming a message. Wait for completion.' }; } const conn = useConnectionStore.getState(); if (conn.connectionState !== 'connected') { return { error: `Not connected (state: ${conn.connectionState})` }; } // Fire and forget — streamStore handles the rest stream.sendMessage(content).catch((err: unknown) => { log.warn('sendMessage failed:', err); }); return { sent: true, content }; }, ); // --- cancel_stream --- registerTool( 'cancel_stream', 'Cancel the currently active streaming response', { type: 'object', properties: {} }, async () => { const stream = useStreamStore.getState(); if (!stream.isStreaming) { return { cancelled: false, reason: 'No active stream' }; } stream.cancelStream(); return { cancelled: true }; }, ); // --- get_streaming_state --- registerTool( 'get_streaming_state', 'Get detailed streaming state: isStreaming, activeRunId, chatMode, suggestions', { type: 'object', properties: {} }, async () => { const stream = useStreamStore.getState(); return { isStreaming: stream.isStreaming, activeRunId: stream.activeRunId, chatMode: stream.chatMode, suggestions: stream.suggestions, isLoading: stream.isLoading, }; }, ); } function registerConversationTools(): void { // --- list_conversations --- registerTool( 'list_conversations', 'List recent conversations with metadata (id, title, agent, message count, timestamps)', { type: 'object', properties: { limit: { type: 'number', description: 'Max conversations to return (default 10)' }, }, }, async (input) => { const limit = Number(input.limit ?? 10); const convStore = useConversationStore.getState(); const conversations = convStore.conversations.slice(0, limit); return conversations.map((c) => ({ id: c.id, title: c.title ?? '(untitled)', agentId: c.agentId, messageCount: c.messages?.length ?? 0, createdAt: c.createdAt, updatedAt: c.updatedAt, })); }, ); // --- get_current_conversation --- registerTool( 'get_current_conversation', 'Get the current active conversation with full message history', { type: 'object', properties: { includeContent: { type: 'boolean', description: 'Include full message content (default true)' }, maxMessages: { type: 'number', description: 'Max messages to return (default 50)' }, }, }, async (input) => { const includeContent = input.includeContent !== false; const maxMessages = Number(input.maxMessages ?? 50); const convStore = useConversationStore.getState(); const conv = convStore.conversations.find((c) => c.id === convStore.currentConversationId); if (!conv) { return { error: 'No active conversation' }; } const messages = (conv.messages ?? []).slice(-maxMessages).map((m) => { const base: Record = { id: m.id, role: m.role, timestamp: m.timestamp, streaming: m.streaming, error: m.error, }; if (includeContent) { base.content = m.content; if (m.thinkingContent) base.thinkingContent = m.thinkingContent; if (m.toolSteps) base.toolSteps = m.toolSteps; } return base; }); return { id: conv.id, title: conv.title, agentId: conv.agentId, messageCount: conv.messages?.length ?? 0, returnedMessages: messages.length, messages, }; }, ); // --- switch_conversation --- registerTool( 'switch_conversation', 'Switch to a different conversation by ID', { type: 'object', properties: { conversationId: { type: 'string', description: 'Conversation ID to switch to' }, }, required: ['conversationId'], }, async (input) => { const conversationId = String(input.conversationId ?? ''); const convStore = useConversationStore.getState(); const exists = convStore.conversations.some((c) => c.id === conversationId); if (!exists) { return { error: `Conversation ${conversationId} not found` }; } useConversationStore.setState({ currentConversationId: conversationId }); return { switched: true, conversationId }; }, ); } function registerMemoryTools(): void { // --- get_token_usage --- registerTool( 'get_token_usage', 'Get token usage statistics for the current session', { type: 'object', properties: {} }, async () => { const msgStore = useMessageStore.getState(); const totals = msgStore.getTotalTokens(); return { totalInputTokens: totals.input, totalOutputTokens: totals.output, totalTokens: totals.total, }; }, ); } function registerOfflineTools(): void { // --- get_offline_queue --- registerTool( 'get_offline_queue', 'Get the current offline message queue (messages waiting to be sent when backend reconnects)', { type: 'object', properties: {} }, async () => { const offline = useOfflineStore.getState(); return { isOffline: offline.isOffline, queueLength: offline.queuedMessages.length, messages: offline.queuedMessages.map((m) => ({ id: m.id, content: m.content.substring(0, 200), agentId: m.agentId, timestamp: m.timestamp, })), }; }, ); } function registerSaaSTools(): void { // --- get_saas_account --- registerTool( 'get_saas_account', 'Get current SaaS account details and subscription info', { type: 'object', properties: {} }, async () => { const saas = useSaaSStore.getState(); return { isLoggedIn: saas.isLoggedIn, account: saas.account, connectionMode: saas.connectionMode, saasUrl: saas.saasUrl, saasReachable: saas.saasReachable, subscription: saas.subscription, billingLoading: saas.billingLoading, billingError: saas.billingError, availableTemplates: saas.availableTemplates.map((t) => ({ id: t.id, name: t.name, category: t.category, })), assignedTemplate: saas.assignedTemplate ? { id: saas.assignedTemplate.id, name: saas.assignedTemplate.name } : null, }; }, ); // --- get_available_models --- registerTool( 'get_available_models', 'List available LLM models from the SaaS backend', { type: 'object', properties: {} }, async () => { const saas = useSaaSStore.getState(); return saas.availableModels.map((m) => ({ id: m.id, alias: m.alias, provider_id: m.provider_id, context_window: m.context_window, supports_streaming: m.supports_streaming, })); }, ); } function registerAgentTools(): void { // --- get_current_agent --- registerTool( 'get_current_agent', 'Get the currently active agent/clone details', { type: 'object', properties: {} }, async () => { const convStore = useConversationStore.getState(); const agent = convStore.currentAgent; if (!agent) { return { error: 'No agent selected' }; } return { id: agent.id, name: agent.name, icon: agent.icon, color: agent.color, lastMessage: agent.lastMessage, }; }, ); // --- list_agents --- registerTool( 'list_agents', 'List all available agents/clones', { type: 'object', properties: {} }, async () => { const convStore = useConversationStore.getState(); return convStore.agents.map((a) => ({ id: a.id, name: a.name, icon: a.icon, color: a.color, lastMessage: a.lastMessage, })); }, ); } // --------------------------------------------------------------------------- // Console error capture tool // --------------------------------------------------------------------------- function registerConsoleTools(): void { // --- get_console_errors --- registerTool( 'get_console_errors', 'Get recent console errors captured by the application logger', { type: 'object', properties: { limit: { type: 'number', description: 'Max errors to return (default 20)' }, }, }, async (input) => { const limit = Number(input.limit ?? 20); // Collect errors from recent console output // The structured logger writes to window.__zclaw_logs__ if available const logs = (window as unknown as Record).__zclaw_logs__ ?? []; const errors = logs .filter((entry): entry is Record => { const e = entry as Record; return e.level === 'error' || e.level === 'warn'; }) .slice(-limit); return { totalCaptured: logs.length, errorCount: errors.length, errors: errors.map((e) => ({ level: e.level, message: e.message, args: e.args, timestamp: e.timestamp, })), }; }, ); } // --------------------------------------------------------------------------- // Public API // --------------------------------------------------------------------------- let _initialized = false; /** * Initialize WebMCP tools (dev mode only). * Call once from app entry point. */ export function initWebMCPTools(): void { if (_initialized) return; if (!import.meta.env.DEV) return; if (!navigator.modelContext) { log.debug('WebMCP not available (enable chrome://flags/#enable-webmcp-testing)'); return; } log.info('Registering WebMCP debugging tools...'); try { registerConnectionTools(); registerChatTools(); registerConversationTools(); registerMemoryTools(); registerOfflineTools(); registerSaaSTools(); registerAgentTools(); registerConsoleTools(); log.info(`Registered ${registeredTools.length} WebMCP tools: ${registeredTools.join(', ')}`); _initialized = true; } catch (err) { log.warn('Failed to register WebMCP tools:', err); } } /** Get list of registered tool names (for diagnostics) */ export function getRegisteredWebMCPTools(): string[] { return [...registeredTools]; }