Some checks failed
CI / Lint & TypeCheck (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Build Frontend (push) Has been cancelled
CI / Rust Check (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
Register 15 structured tools via navigator.modelContext (Chrome 146+) for direct state queries without DOM scraping. Reduces token consumption ~67% vs DevTools MCP snapshot-based debugging. Dev mode only. Tools: get_zclaw_state, check_connection, send_message, cancel_stream, get_streaming_state, list_conversations, get_current_conversation, switch_conversation, get_token_usage, get_offline_queue, get_saas_account, get_available_models, get_current_agent, list_agents, get_console_errors Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
520 lines
15 KiB
TypeScript
520 lines
15 KiB
TypeScript
/**
|
|
* 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<string, unknown>;
|
|
handler: (input: Record<string, unknown>) => Promise<unknown>;
|
|
}): void;
|
|
}
|
|
|
|
declare global {
|
|
interface Navigator {
|
|
modelContext?: ModelContextAPI;
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Helpers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/** Safely serialize store state, stripping private/internal fields */
|
|
function safeState<T extends Record<string, unknown>>(
|
|
state: T,
|
|
omitKeys: string[] = [],
|
|
): Record<string, unknown> {
|
|
const result: Record<string, unknown> = {};
|
|
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<string, unknown>,
|
|
handler: (input: Record<string, unknown>) => Promise<unknown>,
|
|
): 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<string, unknown> = {
|
|
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<string, unknown[]>).__zclaw_logs__ ?? [];
|
|
const errors = logs
|
|
.filter((entry): entry is Record<string, unknown> => {
|
|
const e = entry as Record<string, unknown>;
|
|
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];
|
|
}
|