feat(desktop): add WebMCP debugging tools for structured AI agent access
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>
This commit is contained in:
iven
2026-04-05 09:57:32 +08:00
parent de36bb0724
commit aef4e01499
3 changed files with 555 additions and 2 deletions

View File

@@ -0,0 +1,519 @@
/**
* 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];
}

View File

@@ -4,6 +4,7 @@ import App from './App';
import './index.css';
import { ToastProvider } from './components/ui/Toast';
import { GlobalErrorBoundary } from './components/ui/ErrorBoundary';
import { initWebMCPTools } from './lib/webmcp-tools';
// Global error handler for uncaught errors
const handleGlobalError = (error: Error, errorInfo: React.ErrorInfo) => {
@@ -25,6 +26,9 @@ const handleGlobalReset = () => {
sessionStorage.clear();
};
// Initialize WebMCP debugging tools (dev mode only, Chrome 146+)
initWebMCPTools();
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<GlobalErrorBoundary