From e398fa23aea30f6cb7ca25af511207016d9fe859 Mon Sep 17 00:00:00 2001 From: Mathieu Perreault Date: Mon, 26 Jan 2026 23:10:59 -0500 Subject: [PATCH] Adapt to the latest WebMCP spec --- examples/AGENTS.md | 138 ++++++++- examples/shopify_storefront.js | 12 +- package-lock.json | 4 +- package.json | 2 +- src/content-scripts/page-bridge.js | 68 ++++- src/content-scripts/webmcp-polyfill.js | 323 +++++++++++++++----- src/lib/ajv-csp-safe.js | 138 +++++++++ src/lib/webmcp/lifecycle.ts | 16 +- tests/webmcp-polyfill.test.ts | 396 ++++++++++++++++++++++++- vite.config.ts | 5 +- 10 files changed, 1001 insertions(+), 101 deletions(-) create mode 100644 src/lib/ajv-csp-safe.js diff --git a/examples/AGENTS.md b/examples/AGENTS.md index 3d99378..6d9a543 100644 --- a/examples/AGENTS.md +++ b/examples/AGENTS.md @@ -2,6 +2,8 @@ This guide covers how to create custom WebMCP tools that run in the browser context and are available to the LLM for execution. +> **Spec Alignment**: This implementation aligns with the [WebMCP proposed spec](https://github.com/webmachinelearning/webmcp/blob/main/docs/proposal.md). + ## Basic Structure Every WebMCP tool is a JavaScript file with two required exports: @@ -24,8 +26,10 @@ export const metadata = { }, }; -export async function execute(args = {}) { +// Per WebMCP spec: execute receives (args, agent) where agent provides requestUserInteraction() +export async function execute(args = {}, agent) { // Tool implementation - has full DOM/window access + // Use agent.requestUserInteraction() for user confirmation flows // Return result object } ``` @@ -129,8 +133,13 @@ inputSchema: { ## The `execute` Function +Per the WebMCP spec, execute receives two arguments: + +- `args` - The tool arguments from the LLM +- `agent` - An agent context object with `requestUserInteraction()` for user confirmation flows + ```javascript -export async function execute(args = {}) { +export async function execute(args = {}, agent) { // Destructure with defaults const { limit = 100, format = 'full' } = args; @@ -141,6 +150,36 @@ export async function execute(args = {}) { } ``` +### Requesting User Interaction + +For tools that perform sensitive actions (purchases, deletions, etc.), use `agent.requestUserInteraction()`: + +```javascript +export async function execute(args = {}, agent) { + const { productId } = args; + + // Request user confirmation before sensitive action + const confirmed = await agent.requestUserInteraction(async () => { + return confirm(`Purchase product ${productId}?\nClick OK to confirm.`); + }); + + if (!confirmed) { + throw new Error('Purchase cancelled by user.'); + } + + // Proceed with action... + await executePurchase(productId); + return { success: true, productId }; +} +``` + +The `requestUserInteraction` API: + +- Takes an async function that performs the UI interaction +- Returns the result of that function +- Allows tools to prompt for confirmation, input, or any other user interaction +- The agent (browser) handles pausing execution while waiting for user input + ### Available in `execute`: - Full DOM access: `document`, `window` @@ -414,6 +453,50 @@ Single-page apps may not update `window.location` when navigating. Your tool may - Detect context from DOM state, not just URL - Handle cases where URL shows one view but DOM shows another +## Programmatic Tool Registration + +For advanced use cases (like dynamically discovering tools), you can register tools programmatically: + +```javascript +// Per WebMCP spec: navigator.modelContext is the primary API +// window.agent is also available as a backward-compatible alias + +if ('modelContext' in navigator) { + // Register a single tool + navigator.modelContext.registerTool({ + name: 'my_tool', + description: 'Does something useful', + inputSchema: { type: 'object', properties: {} }, + execute: async (args, agent) => { + return { result: 'success' }; + } + }); + + // Unregister a tool + navigator.modelContext.unregisterTool('my_tool'); + + // Clear all tools + navigator.modelContext.clearContext(); + + // Replace all tools at once + navigator.modelContext.provideContext({ + tools: [ + { name: 'tool1', description: '...', inputSchema: {...}, execute: async (args, agent) => {...} }, + { name: 'tool2', description: '...', inputSchema: {...}, execute: async (args, agent) => {...} } + ] + }); +} +``` + +**API methods:** + +- `provideContext({ tools })` - Replace entire tool set (clears existing) +- `registerTool(tool)` - Add/replace a single tool +- `unregisterTool(name)` - Remove a tool by name +- `clearContext()` - Remove all tools +- `listTools()` - Get current tool definitions (without execute functions) +- `callTool(name, args)` - Invoke a tool (used by the agent, not typically by tools) + ## Testing Your Tool 1. Open browser DevTools console on the target site @@ -447,7 +530,8 @@ export const metadata = { }, }; -export async function execute() { +// agent param is optional if not using requestUserInteraction +export async function execute(args = {}, agent) { const docId = getDocId(); if (!docId) { throw new Error('Could not extract document ID from URL.'); @@ -473,6 +557,52 @@ function getDocId() { } ``` +## Example: Tool with User Interaction + +```javascript +'use webmcp-tool v1'; + +export const metadata = { + name: 'delete_item', + namespace: 'myapp', + version: '1.0.0', + description: 'Delete an item after user confirmation.', + match: 'https://app.example.com/*', + inputSchema: { + type: 'object', + properties: { + itemId: { type: 'string', description: 'ID of item to delete' }, + }, + required: ['itemId'], + additionalProperties: false, + }, +}; + +export async function execute(args = {}, agent) { + const { itemId } = args; + + // Request user confirmation before destructive action + const confirmed = await agent.requestUserInteraction(async () => { + return confirm(`Are you sure you want to delete item ${itemId}?`); + }); + + if (!confirmed) { + return { cancelled: true, message: 'Deletion cancelled by user.' }; + } + + const response = await fetch(`https://app.example.com/api/items/${itemId}`, { + method: 'DELETE', + credentials: 'include', + }); + + if (!response.ok) { + throw new Error(`Delete failed: ${response.status}`); + } + + return { success: true, itemId }; +} +``` + ## Example: Tool with API Calls and Entity Resolution ```javascript @@ -493,7 +623,7 @@ export const metadata = { }, }; -export async function execute(args = {}) { +export async function execute(args = {}, agent) { const { limit = 50 } = args; const threadId = getThreadId(); diff --git a/examples/shopify_storefront.js b/examples/shopify_storefront.js index 1763308..a036d3a 100644 --- a/examples/shopify_storefront.js +++ b/examples/shopify_storefront.js @@ -146,15 +146,19 @@ function registerShopifyTool(toolSchema, mcpEndpoint) { inputSchema: toolSchema.inputSchema || { type: 'object', properties: {} }, // Create an execute function that proxies to MCP + // Per WebMCP spec: execute receives (args, agent) execute: createExecutor(toolSchema.name, mcpEndpoint) }; - // Register with the agent - if (window.agent && typeof window.agent.registerTool === 'function') { - window.agent.registerTool(tool); + // Register with modelContext (per WebMCP spec) + // Falls back to window.agent for backward compat + const modelContext = ('modelContext' in navigator) ? navigator.modelContext : window.agent; + + if (modelContext && typeof modelContext.registerTool === 'function') { + modelContext.registerTool(tool); console.log(`[Shopify Bootstrap] ✅ Successfully registered: ${toolName}`); } else { - throw new Error('window.agent.registerTool not available'); + throw new Error('navigator.modelContext.registerTool not available'); } } diff --git a/package-lock.json b/package-lock.json index 93a8cb6..b9f781d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "ai-sidebar-extension", - "version": "0.4.7", + "version": "0.6.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "ai-sidebar-extension", - "version": "0.4.7", + "version": "0.6.0", "hasInstallScript": true, "dependencies": { "@ai-sdk/anthropic": "^2.0.15", diff --git a/package.json b/package.json index d7cb8ea..7bcaf4e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "ai-sidebar-extension", - "version": "0.5.0", + "version": "0.6.0", "description": "Chrome extension AI sidebar with LLM providers and MCP support", "private": true, "type": "module", diff --git a/src/content-scripts/page-bridge.js b/src/content-scripts/page-bridge.js index c99d708..e10da01 100644 --- a/src/content-scripts/page-bridge.js +++ b/src/content-scripts/page-bridge.js @@ -1,7 +1,10 @@ /** * WebMCP Page Bridge (MAIN World) - * Bridges window.agent events to the extension via postMessage + * Bridges navigator.modelContext events to the extension via postMessage * Executes in MAIN world with full access to page runtime + * + * Aligns with WebMCP proposed spec: + * https://github.com/webmachinelearning/webmcp/blob/main/docs/proposal.md */ (function () { 'use strict'; @@ -24,18 +27,52 @@ } /** - * Initialize bridge if window.agent exists + * Get the agent-side API for discovering and executing tools + * + * Chrome's WebMCP implementation: + * - navigator.modelContextTesting = agent-side (listTools, executeTool, registerToolsChangedCallback) + * - navigator.modelContext = page-side (registerTool, unregisterTool, provideContext) + * + * Our polyfill provides the same separation. + */ + function getAgentAPI() { + // Use modelContextTesting (agent-side API) - either native Chrome or our polyfill + if ('modelContextTesting' in navigator) { + return { + native: !Object.prototype.hasOwnProperty.call(navigator.modelContextTesting, 'errors'), // Native won't have our errors property + listTools: () => navigator.modelContextTesting.listTools(), + // executeTool expects args as JSON string + executeTool: (name, args) => navigator.modelContextTesting.executeTool(name, JSON.stringify(args)), + registerToolsChangedCallback: (callback) => navigator.modelContextTesting.registerToolsChangedCallback(callback) + }; + } + // Legacy fallback: window.agent (backward compat API) + if ('agent' in window) { + return { + native: false, + listTools: () => window.agent.listTools(), + executeTool: (name, args) => window.agent.callTool(name, args), + registerToolsChangedCallback: (callback) => window.agent.addEventListener('tools/listChanged', callback) + }; + } + return null; + } + + /** + * Initialize bridge using the agent-side API */ function initBridge() { - if (!window.agent || typeof window.agent.addEventListener !== 'function') { - console.warn('[WebMCP Bridge] window.agent not found or invalid'); + const agentAPI = getAgentAPI(); + + if (!agentAPI) { + console.warn('[WebMCP Bridge] No WebMCP API found (modelContextTesting, modelContext, or agent)'); return; } - console.log('[WebMCP Bridge] Initializing in MAIN world'); + console.log('[WebMCP Bridge] Initializing in MAIN world', agentAPI.native ? '(native Chrome API)' : '(polyfill)'); // Get current tools immediately - in case any were registered before we got here - const currentTools = window.agent.listTools(); + const currentTools = agentAPI.listTools(); console.log('[WebMCP Bridge] Current tools on init:', currentTools); // Send initial snapshot if there are already tools @@ -54,9 +91,9 @@ } // Subscribe to tool registry changes - window.agent.addEventListener('tools/listChanged', () => { + agentAPI.registerToolsChangedCallback(() => { try { - const tools = window.agent.listTools(); + const tools = agentAPI.listTools(); postToExtension({ jsonrpc: JSONRPC, method: 'tools/listChanged', @@ -91,7 +128,7 @@ console.log('[WebMCP Bridge] Received tools/list request'); try { - const tools = window.agent.listTools(); + const tools = agentAPI.listTools(); // Send tools via notification (not a response to preserve protocol semantics) postToExtension({ @@ -128,8 +165,8 @@ const { name, arguments: args } = msg.params || {}; try { - // Delegate to window.agent - const result = await window.agent.callTool(name, args || {}); + // Delegate to agent API + const result = await agentAPI.executeTool(name, args || {}); // Send success response postToExtension({ @@ -165,12 +202,13 @@ console.log('[WebMCP Bridge] Ready and listening'); } - // Initialize immediately if window.agent exists - if (window.agent) { + // Initialize immediately if any WebMCP API exists + const agentAPI = getAgentAPI(); + if (agentAPI) { initBridge(); } else { - // If window.agent doesn't exist yet, it might be loaded later + // If no API exists yet, it might be loaded later // This shouldn't happen if polyfill is injected at document_start - console.warn('[WebMCP Bridge] window.agent not found at injection time'); + console.warn('[WebMCP Bridge] No WebMCP API found at injection time'); } })(); diff --git a/src/content-scripts/webmcp-polyfill.js b/src/content-scripts/webmcp-polyfill.js index 411faea..a85c690 100644 --- a/src/content-scripts/webmcp-polyfill.js +++ b/src/content-scripts/webmcp-polyfill.js @@ -2,12 +2,31 @@ * WebMCP Polyfill - Complete implementation of WebMCP API * Provides tool registration and invocation capabilities for web agents * - * Usage: Include this script before any code that uses window.agent + * Aligns with WebMCP proposed spec: + * https://github.com/webmachinelearning/webmcp/blob/main/docs/proposal.md + * + * API: window.navigator.modelContext + * Backward compat: window.agent (alias) + * + * Chrome Canary native support: + * - navigator.modelContextTesting (agent-side: listTools, executeTool, registerToolsChangedCallback) + * - navigator.modelContext (page-side: provideContext, registerTool) - when available */ (function () { 'use strict'; - if ('agent' in window) { + // Guard: already initialized (either by us or native browser support) + if ('modelContext' in navigator) { + console.log('[WebMCP] Native navigator.modelContext detected, skipping polyfill'); + // Still set up window.agent alias for backward compat if not present + if (!('agent' in window)) { + Object.defineProperty(window, 'agent', { + value: navigator.modelContext, + writable: false, + configurable: false, + enumerable: true + }); + } return; } @@ -189,93 +208,251 @@ }; } - function createAgent() { - const tools = new Map(); - const events = createEventTarget(); + /** + * Create an agent context object to pass to tool execute functions + * Per WebMCP spec, this provides requestUserInteraction() API + */ + function createAgentContext() { + return { + /** + * Request user interaction during tool execution + * Allows tools to prompt for confirmation, input, etc. + * + * @param {Function} interactionFn - Async function that performs UI interaction + * @returns {Promise} - Result of the interaction function + * + * Example: + * const confirmed = await agent.requestUserInteraction(async () => { + * return confirm('Proceed with purchase?'); + * }); + */ + async requestUserInteraction(interactionFn) { + if (typeof interactionFn !== 'function') { + throw new ValidationError('requestUserInteraction requires a function'); + } + // Execute the interaction function - it handles its own UI + return await interactionFn(); + } + }; + } - function validateAndNormalizeTool(tool) { - if (!tool || typeof tool !== 'object') { - throw new ValidationError('Tool must be an object'); + // Shared state between page-side (modelContext) and agent-side (modelContextTesting) APIs + const tools = new Map(); + const events = createEventTarget(); + const toolsChangedCallbacks = []; + + // Forward internal events to toolsChangedCallbacks + events.addEventListener('tools/listChanged', () => { + for (const callback of toolsChangedCallbacks) { + try { + callback(); + } catch (err) { + console.error('[WebMCP] Error in toolsChangedCallback:', err); } - const { name, description, inputSchema, execute } = tool; - if (!name || typeof name !== 'string') throw new ValidationError('Tool must have a string name'); - if (!description || typeof description !== 'string') throw new ValidationError('Tool must have a string description'); - if (typeof execute !== 'function') throw new ValidationError('Tool must have an execute function'); - return { - name, - description, - inputSchema: inputSchema || { type: 'object', properties: {} }, - execute - }; } + }); - const agent = { - // Replace entire tool set - provideContext(context) { - if (!context || !context.tools || !Array.isArray(context.tools)) { - throw new ValidationError('Context must have a tools array'); - } - tools.clear(); - for (const raw of context.tools) { - const tool = validateAndNormalizeTool(raw); - if (tools.has(tool.name)) { - throw new ValidationError(`Duplicate tool name: ${tool.name}`); - } - tools.set(tool.name, tool); - } - // Notify listeners - queueMicrotask(() => events.dispatchEvent({ type: 'tools/listChanged' })); - }, + function validateAndNormalizeTool(tool) { + if (!tool || typeof tool !== 'object') { + throw new ValidationError('Tool must be an object'); + } + const { name, description, inputSchema, execute } = tool; + if (!name || typeof name !== 'string') throw new ValidationError('Tool must have a string name'); + if (!description || typeof description !== 'string') throw new ValidationError('Tool must have a string description'); + if (typeof execute !== 'function') throw new ValidationError('Tool must have an execute function'); + return { + name, + description, + inputSchema: inputSchema || { type: 'object', properties: {} }, + execute + }; + } - // Append a single tool - registerTool(rawTool) { - const tool = validateAndNormalizeTool(rawTool); + /** + * Internal function to execute a tool + * Used by both modelContext.callTool (legacy) and modelContextTesting.executeTool + */ + async function executeToolInternal(toolName, params = {}) { + if (!tools.has(toolName)) { + throw new ToolNotFoundError(toolName); + } + const tool = tools.get(toolName); + try { + validateParams(params, tool.inputSchema); + // Create agent context for this tool execution + const agentContext = createAgentContext(); + // Per WebMCP spec: execute(params, agent) + return await Promise.resolve(tool.execute(params, agentContext)); + } catch (err) { + if (err instanceof ValidationError || err instanceof ExecutionError) throw err; + throw new ExecutionError(`Tool '${toolName}' execution failed: ${err && err.message ? err.message : String(err)}`, err); + } + } + + /** + * Internal function to list tools + * Used by both APIs + */ + function listToolsInternal() { + return Array.from(tools.values()).map(({ name, description, inputSchema }) => ({ + name, description, inputSchema + })); + } + + /** + * PAGE-SIDE API: navigator.modelContext + * For pages to register tools with the agent + */ + const modelContext = { + /** + * Replace entire tool set (per WebMCP spec) + * Clears any pre-existing tools before registering new ones + */ + provideContext(context) { + if (!context || !context.tools || !Array.isArray(context.tools)) { + throw new ValidationError('Context must have a tools array'); + } + tools.clear(); + for (const raw of context.tools) { + const tool = validateAndNormalizeTool(raw); if (tools.has(tool.name)) { - // Allow replacement for hot reload - console.warn(`[WebMCP] Replacing existing tool: ${tool.name}`); + throw new ValidationError(`Duplicate tool name: ${tool.name}`); } tools.set(tool.name, tool); + } + // Notify listeners + queueMicrotask(() => events.dispatchEvent({ type: 'tools/listChanged' })); + }, + + /** + * Register a single tool (per WebMCP spec) + * Adds to existing tools without clearing + */ + registerTool(rawTool) { + const tool = validateAndNormalizeTool(rawTool); + if (tools.has(tool.name)) { + // Allow replacement for hot reload + console.warn(`[WebMCP] Replacing existing tool: ${tool.name}`); + } + tools.set(tool.name, tool); + queueMicrotask(() => events.dispatchEvent({ type: 'tools/listChanged' })); + }, + + /** + * Unregister a tool by name (per WebMCP spec) + */ + unregisterTool(toolName) { + if (typeof toolName !== 'string') { + throw new ValidationError('Tool name must be a string'); + } + const existed = tools.delete(toolName); + if (existed) { queueMicrotask(() => events.dispatchEvent({ type: 'tools/listChanged' })); - }, - - // Invoke a tool - async callTool(toolName, params = {}) { - if (!tools.has(toolName)) { - throw new ToolNotFoundError(toolName); - } - const tool = tools.get(toolName); - try { - validateParams(params, tool.inputSchema); - return await Promise.resolve(tool.execute(params)); - } catch (err) { - if (err instanceof ValidationError || err instanceof ExecutionError) throw err; - throw new ExecutionError(`Tool '${toolName}' execution failed: ${err && err.message ? err.message : String(err)}`, err); - } - }, + } + return existed; + }, + + /** + * Clear all tools (per Chrome's WebMCP implementation) + */ + clearContext() { + const hadTools = tools.size > 0; + tools.clear(); + if (hadTools) { + queueMicrotask(() => events.dispatchEvent({ type: 'tools/listChanged' })); + } + } + }; - // Discover tools - listTools() { - return Array.from(tools.values()).map(({ name, description, inputSchema }) => ({ - name, description, inputSchema - })); - }, + /** + * AGENT-SIDE API: navigator.modelContextTesting + * For agents to discover and call tools registered by pages + */ + const modelContextTesting = { + /** + * List all registered tools (agent-side) + * Returns tool descriptors without execute functions + */ + listTools() { + return listToolsInternal(); + }, + + /** + * Execute a tool by name (agent-side) + * Chrome's native API expects args as JSON string + */ + async executeTool(name, args = '{}') { + // Parse args if it's a JSON string (Chrome's native format) + const parsedArgs = typeof args === 'string' ? JSON.parse(args) : args; + return executeToolInternal(name, parsedArgs); + }, + + /** + * Register a callback for when tools change (agent-side) + * Chrome's native API uses this pattern instead of addEventListener + */ + registerToolsChangedCallback(callback) { + if (typeof callback !== 'function') { + throw new TypeError('Callback must be a function'); + } + toolsChangedCallbacks.push(callback); + } + }; - // Events - addEventListener: events.addEventListener.bind(events), - removeEventListener: events.removeEventListener.bind(events) - }; + // Make modelContextTesting look like Chrome's native implementation + Object.defineProperty(modelContextTesting, Symbol.toStringTag, { + value: 'ModelContextTesting', + configurable: true + }); + + // Define navigator.modelContext (page-side API) + Object.defineProperty(navigator, 'modelContext', { + value: modelContext, + writable: false, + configurable: false, + enumerable: true + }); + + // Define navigator.modelContextTesting (agent-side API) + Object.defineProperty(navigator, 'modelContextTesting', { + value: modelContextTesting, + writable: false, + configurable: false, + enumerable: true + }); + + // Backward compatibility: window.agent combines both APIs for legacy scripts + const agentCompat = { + // Page-side methods + provideContext: modelContext.provideContext.bind(modelContext), + registerTool: modelContext.registerTool.bind(modelContext), + unregisterTool: modelContext.unregisterTool.bind(modelContext), + clearContext: modelContext.clearContext.bind(modelContext), + // Agent-side methods (legacy support) + listTools: modelContextTesting.listTools.bind(modelContextTesting), + callTool: (name, args) => executeToolInternal(name, args), // Direct call, not JSON string + // Legacy event API + addEventListener: events.addEventListener.bind(events), + removeEventListener: events.removeEventListener.bind(events) + }; - return agent; - } + Object.defineProperty(window, 'agent', { + value: agentCompat, + writable: false, + configurable: false, + enumerable: true + }); - // Initialize the agent - window.agent = createAgent(); - window.agent.errors = { + // Expose error classes on all APIs for consumers + const errorClasses = { WebMCPError, ToolNotFoundError, ValidationError, ExecutionError }; + modelContext.errors = errorClasses; + modelContextTesting.errors = errorClasses; + window.agent.errors = errorClasses; // Create Trusted Types policy for user script injection if (typeof trustedTypes !== 'undefined') { @@ -297,4 +474,6 @@ console.warn('[WebMCP] User scripts may not work on this site due to Trusted Types'); } } -})(); \ No newline at end of file + + console.log('[WebMCP] Polyfill ready: navigator.modelContext, navigator.modelContextTesting (also: window.agent)'); +})(); diff --git a/src/lib/ajv-csp-safe.js b/src/lib/ajv-csp-safe.js new file mode 100644 index 0000000..93263da --- /dev/null +++ b/src/lib/ajv-csp-safe.js @@ -0,0 +1,138 @@ +/** + * CSP-safe Ajv shim for Chrome extensions + * + * Ajv uses new Function() for performance, which violates Chrome extension CSP. + * This shim provides a minimal implementation that validates without eval. + * + * Trade-off: Less thorough validation, but works in extension context. + */ + +class AjvCSPSafe { + constructor(options = {}) { + this.schemas = new Map(); + this.options = options; + } + + compile(schema) { + // Return a validator function that does basic type checking + // without using eval/Function + return (data) => { + const errors = []; + + if (!this._validate(schema, data, '', errors)) { + const validator = () => false; + validator.errors = errors; + return false; + } + + return true; + }; + } + + _validate(schema, data, path, errors) { + if (!schema || typeof schema !== 'object') { + return true; // No schema = valid + } + + // Handle type checking + if (schema.type) { + const types = Array.isArray(schema.type) ? schema.type : [schema.type]; + const actualType = this._getType(data); + + if (!types.includes(actualType) && !(actualType === 'integer' && types.includes('number'))) { + errors.push({ + instancePath: path, + schemaPath: `${path}/type`, + keyword: 'type', + params: { type: schema.type }, + message: `must be ${schema.type}` + }); + return false; + } + } + + // Handle required properties + if (schema.required && Array.isArray(schema.required) && typeof data === 'object' && data !== null) { + for (const prop of schema.required) { + if (!(prop in data)) { + errors.push({ + instancePath: path, + schemaPath: `${path}/required`, + keyword: 'required', + params: { missingProperty: prop }, + message: `must have required property '${prop}'` + }); + return false; + } + } + } + + // Handle properties (recursive) + if (schema.properties && typeof data === 'object' && data !== null) { + for (const [key, propSchema] of Object.entries(schema.properties)) { + if (key in data) { + if (!this._validate(propSchema, data[key], `${path}/${key}`, errors)) { + return false; + } + } + } + } + + // Handle items (arrays) + if (schema.items && Array.isArray(data)) { + for (let i = 0; i < data.length; i++) { + if (!this._validate(schema.items, data[i], `${path}/${i}`, errors)) { + return false; + } + } + } + + // Handle enum + if (schema.enum && !schema.enum.includes(data)) { + errors.push({ + instancePath: path, + schemaPath: `${path}/enum`, + keyword: 'enum', + params: { allowedValues: schema.enum }, + message: `must be one of: ${schema.enum.join(', ')}` + }); + return false; + } + + return true; + } + + _getType(value) { + if (value === null) return 'null'; + if (Array.isArray(value)) return 'array'; + if (typeof value === 'number') { + return Number.isInteger(value) ? 'integer' : 'number'; + } + return typeof value; + } + + addSchema(schema, key) { + this.schemas.set(key || schema.$id, schema); + return this; + } + + getSchema(key) { + const schema = this.schemas.get(key); + return schema ? this.compile(schema) : undefined; + } + + validate(schemaOrRef, data) { + const schema = typeof schemaOrRef === 'string' + ? this.schemas.get(schemaOrRef) + : schemaOrRef; + + if (!schema) return true; + + const validator = this.compile(schema); + return validator(data); + } +} + +// Export as default (matching Ajv's export) +export default AjvCSPSafe; + diff --git a/src/lib/webmcp/lifecycle.ts b/src/lib/webmcp/lifecycle.ts index 6cef4f9..30a9c94 100644 --- a/src/lib/webmcp/lifecycle.ts +++ b/src/lib/webmcp/lifecycle.ts @@ -226,8 +226,22 @@ export class TabManager { * Update tool registry for a tab */ private updateToolRegistry(tabId: number, params: ToolsListChangedParams): void { + // Parse inputSchema if it's a JSON string (Chrome's native API returns it as string) + const normalizedTools = (params.tools || []).map((tool) => { + let inputSchema = tool.inputSchema; + if (typeof inputSchema === 'string') { + try { + inputSchema = JSON.parse(inputSchema); + log.debug(`[WebMCP Lifecycle] Parsed inputSchema for tool "${tool.name}"`); + } catch (e) { + log.warn(`[WebMCP Lifecycle] Failed to parse inputSchema for tool "${tool.name}":`, e); + } + } + return { ...tool, inputSchema }; + }); + const registry: ToolRegistry = { - tools: params.tools || [], + tools: normalizedTools, origin: params.origin || '', timestamp: params.timestamp ?? Date.now(), }; diff --git a/tests/webmcp-polyfill.test.ts b/tests/webmcp-polyfill.test.ts index a4a6a06..94b1646 100644 --- a/tests/webmcp-polyfill.test.ts +++ b/tests/webmcp-polyfill.test.ts @@ -1,5 +1,9 @@ /** - * Tests for WebMCP Polyfill (window.agent API) + * Tests for WebMCP Polyfill + * + * Per WebMCP spec: https://github.com/webmachinelearning/webmcp/blob/main/docs/proposal.md + * - Primary API: navigator.modelContext + * - Backward compat: window.agent (alias) */ import { describe, it, expect, beforeEach, vi } from 'vitest'; @@ -8,6 +12,395 @@ import { JSDOM } from 'jsdom'; import fs from 'fs'; import path from 'path'; +describe('WebMCP Polyfill - API Location', () => { + let dom: JSDOM; + let window: Window & typeof globalThis; + + beforeEach(() => { + dom = new JSDOM('', { + url: 'https://example.com', + runScripts: 'dangerously', + }); + + window = dom.window as any; + global.window = window as any; + + const polyfillCode = fs.readFileSync( + path.join(__dirname, '../src/content-scripts/webmcp-polyfill.js'), + 'utf8' + ); + + const script = dom.window.document.createElement('script'); + script.textContent = polyfillCode; + dom.window.document.body.appendChild(script); + }); + + it('should expose navigator.modelContext (page-side API per WebMCP spec)', () => { + expect((window as any).navigator.modelContext).toBeDefined(); + // Page-side methods only + expect(typeof (window as any).navigator.modelContext.provideContext).toBe('function'); + expect(typeof (window as any).navigator.modelContext.registerTool).toBe('function'); + expect(typeof (window as any).navigator.modelContext.unregisterTool).toBe('function'); + expect(typeof (window as any).navigator.modelContext.clearContext).toBe('function'); + // Agent-side methods should NOT be on modelContext + expect((window as any).navigator.modelContext.callTool).toBeUndefined(); + expect((window as any).navigator.modelContext.listTools).toBeUndefined(); + }); + + it('should expose navigator.modelContextTesting (agent-side API)', () => { + expect((window as any).navigator.modelContextTesting).toBeDefined(); + expect(typeof (window as any).navigator.modelContextTesting.listTools).toBe('function'); + expect(typeof (window as any).navigator.modelContextTesting.executeTool).toBe('function'); + expect(typeof (window as any).navigator.modelContextTesting.registerToolsChangedCallback).toBe( + 'function' + ); + }); + + it('should expose window.agent as backward-compat combined API', () => { + expect((window as any).agent).toBeDefined(); + // Should have both page-side and agent-side methods for legacy compat + expect(typeof (window as any).agent.registerTool).toBe('function'); + expect(typeof (window as any).agent.listTools).toBe('function'); + expect(typeof (window as any).agent.callTool).toBe('function'); + }); +}); + +describe('WebMCP Polyfill - unregisterTool', () => { + let dom: JSDOM; + let window: Window & typeof globalThis; + let modelContext: any; + let modelContextTesting: any; + + beforeEach(() => { + dom = new JSDOM('', { + url: 'https://example.com', + runScripts: 'dangerously', + }); + + window = dom.window as any; + global.window = window as any; + + const polyfillCode = fs.readFileSync( + path.join(__dirname, '../src/content-scripts/webmcp-polyfill.js'), + 'utf8' + ); + + const script = dom.window.document.createElement('script'); + script.textContent = polyfillCode; + dom.window.document.body.appendChild(script); + + modelContext = (window as any).navigator.modelContext; + modelContextTesting = (window as any).navigator.modelContextTesting; + }); + + it('should unregister a tool by name', () => { + modelContext.registerTool({ + name: 'test_tool', + description: 'Test tool', + inputSchema: { type: 'object', properties: {} }, + execute: vi.fn(), + }); + + expect(modelContextTesting.listTools().length).toBe(1); + + const removed = modelContext.unregisterTool('test_tool'); + expect(removed).toBe(true); + expect(modelContextTesting.listTools().length).toBe(0); + }); + + it('should return false when unregistering non-existent tool', () => { + const removed = modelContext.unregisterTool('non_existent'); + expect(removed).toBe(false); + }); +}); + +describe('WebMCP Polyfill - clearContext', () => { + let dom: JSDOM; + let window: Window & typeof globalThis; + let modelContext: any; + let modelContextTesting: any; + + beforeEach(() => { + dom = new JSDOM('', { + url: 'https://example.com', + runScripts: 'dangerously', + }); + + window = dom.window as any; + global.window = window as any; + + const polyfillCode = fs.readFileSync( + path.join(__dirname, '../src/content-scripts/webmcp-polyfill.js'), + 'utf8' + ); + + const script = dom.window.document.createElement('script'); + script.textContent = polyfillCode; + dom.window.document.body.appendChild(script); + + modelContext = (window as any).navigator.modelContext; + modelContextTesting = (window as any).navigator.modelContextTesting; + }); + + it('should clear all tools', () => { + modelContext.registerTool({ + name: 'tool1', + description: 'Tool 1', + inputSchema: { type: 'object', properties: {} }, + execute: vi.fn(), + }); + modelContext.registerTool({ + name: 'tool2', + description: 'Tool 2', + inputSchema: { type: 'object', properties: {} }, + execute: vi.fn(), + }); + + expect(modelContextTesting.listTools().length).toBe(2); + + modelContext.clearContext(); + + expect(modelContextTesting.listTools().length).toBe(0); + }); + + it('should trigger toolsChangedCallback', async () => { + const callback = vi.fn(); + modelContextTesting.registerToolsChangedCallback(callback); + + modelContext.registerTool({ + name: 'test_tool', + description: 'Test tool', + inputSchema: { type: 'object', properties: {} }, + execute: vi.fn(), + }); + + // Wait for microtask + await new Promise((resolve) => setTimeout(resolve, 0)); + callback.mockClear(); + + modelContext.clearContext(); + + // Wait for microtask + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(callback).toHaveBeenCalled(); + }); +}); + +describe('WebMCP Polyfill - modelContextTesting (agent-side API)', () => { + let dom: JSDOM; + let window: Window & typeof globalThis; + let modelContext: any; + let modelContextTesting: any; + + beforeEach(() => { + dom = new JSDOM('', { + url: 'https://example.com', + runScripts: 'dangerously', + }); + + window = dom.window as any; + global.window = window as any; + + const polyfillCode = fs.readFileSync( + path.join(__dirname, '../src/content-scripts/webmcp-polyfill.js'), + 'utf8' + ); + + const script = dom.window.document.createElement('script'); + script.textContent = polyfillCode; + dom.window.document.body.appendChild(script); + + modelContext = (window as any).navigator.modelContext; + modelContextTesting = (window as any).navigator.modelContextTesting; + }); + + it('should expose navigator.modelContextTesting', () => { + expect(modelContextTesting).toBeDefined(); + expect(typeof modelContextTesting.listTools).toBe('function'); + expect(typeof modelContextTesting.executeTool).toBe('function'); + expect(typeof modelContextTesting.registerToolsChangedCallback).toBe('function'); + }); + + it('should have ModelContextTesting as Symbol.toStringTag', () => { + expect(modelContextTesting[Symbol.toStringTag]).toBe('ModelContextTesting'); + }); + + it('listTools() should return tools registered via modelContext', () => { + modelContext.registerTool({ + name: 'test_tool', + description: 'Test tool', + inputSchema: { type: 'object', properties: {} }, + execute: vi.fn(), + }); + + const tools = modelContextTesting.listTools(); + expect(tools.length).toBe(1); + expect(tools[0].name).toBe('test_tool'); + }); + + it('executeTool() should call tools with JSON string args (Chrome native format)', async () => { + const executeFn = vi.fn().mockResolvedValue('result'); + modelContext.registerTool({ + name: 'test_tool', + description: 'Test tool', + inputSchema: { type: 'object', properties: { input: { type: 'string' } } }, + execute: executeFn, + }); + + // Chrome's native API passes args as JSON string + const result = await modelContextTesting.executeTool('test_tool', '{"input":"hello"}'); + expect(result).toBe('result'); + expect(executeFn).toHaveBeenCalledWith({ input: 'hello' }, expect.any(Object)); + }); + + it('executeTool() should also accept object args for convenience', async () => { + const executeFn = vi.fn().mockResolvedValue('result'); + modelContext.registerTool({ + name: 'test_tool', + description: 'Test tool', + inputSchema: { type: 'object', properties: { input: { type: 'string' } } }, + execute: executeFn, + }); + + // Also support object for backward compat + const result = await modelContextTesting.executeTool('test_tool', { input: 'hello' }); + expect(result).toBe('result'); + expect(executeFn).toHaveBeenCalledWith({ input: 'hello' }, expect.any(Object)); + }); + + it('registerToolsChangedCallback() should be called when tools change', async () => { + const callback = vi.fn(); + modelContextTesting.registerToolsChangedCallback(callback); + + modelContext.registerTool({ + name: 'test_tool', + description: 'Test tool', + inputSchema: { type: 'object', properties: {} }, + execute: vi.fn(), + }); + + // Wait for microtask + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(callback).toHaveBeenCalled(); + }); + + it('registerToolsChangedCallback() should be called on unregisterTool', async () => { + modelContext.registerTool({ + name: 'test_tool', + description: 'Test tool', + inputSchema: { type: 'object', properties: {} }, + execute: vi.fn(), + }); + + // Wait for initial registration callback + await new Promise((resolve) => setTimeout(resolve, 0)); + + const callback = vi.fn(); + modelContextTesting.registerToolsChangedCallback(callback); + + modelContext.unregisterTool('test_tool'); + + // Wait for microtask + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(callback).toHaveBeenCalled(); + }); + + it('registerToolsChangedCallback() should be called on clearContext', async () => { + modelContext.registerTool({ + name: 'test_tool', + description: 'Test tool', + inputSchema: { type: 'object', properties: {} }, + execute: vi.fn(), + }); + + // Wait for initial registration callback + await new Promise((resolve) => setTimeout(resolve, 0)); + + const callback = vi.fn(); + modelContextTesting.registerToolsChangedCallback(callback); + + modelContext.clearContext(); + + // Wait for microtask + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(callback).toHaveBeenCalled(); + }); +}); + +describe('WebMCP Polyfill - agent context in execute', () => { + let dom: JSDOM; + let window: Window & typeof globalThis; + let modelContext: any; + let modelContextTesting: any; + + beforeEach(() => { + dom = new JSDOM('', { + url: 'https://example.com', + runScripts: 'dangerously', + }); + + window = dom.window as any; + global.window = window as any; + + const polyfillCode = fs.readFileSync( + path.join(__dirname, '../src/content-scripts/webmcp-polyfill.js'), + 'utf8' + ); + + const script = dom.window.document.createElement('script'); + script.textContent = polyfillCode; + dom.window.document.body.appendChild(script); + + modelContext = (window as any).navigator.modelContext; + modelContextTesting = (window as any).navigator.modelContextTesting; + }); + + it('should pass agent context with requestUserInteraction to execute', async () => { + let receivedAgent: any = null; + + modelContext.registerTool({ + name: 'test_tool', + description: 'Test tool', + inputSchema: { type: 'object', properties: {} }, + execute: (_args: any, agent: any) => { + receivedAgent = agent; + return { success: true }; + }, + }); + + await modelContextTesting.executeTool('test_tool', '{}'); + + expect(receivedAgent).toBeDefined(); + expect(typeof receivedAgent.requestUserInteraction).toBe('function'); + }); + + it('should execute requestUserInteraction callback', async () => { + let interactionCalled = false; + + modelContext.registerTool({ + name: 'test_tool', + description: 'Test tool', + inputSchema: { type: 'object', properties: {} }, + execute: async (_args: any, agent: any) => { + const result = await agent.requestUserInteraction(async () => { + interactionCalled = true; + return 'user_confirmed'; + }); + return { result }; + }, + }); + + const result = await modelContextTesting.executeTool('test_tool', '{}'); + + expect(interactionCalled).toBe(true); + expect(result.result).toBe('user_confirmed'); + }); +}); + describe('WebMCP Polyfill - JSON Schema Validation', () => { let dom: JSDOM; let window: Window & typeof globalThis; @@ -32,6 +425,7 @@ describe('WebMCP Polyfill - JSON Schema Validation', () => { script.textContent = polyfillCode; dom.window.document.body.appendChild(script); + // Use window.agent (backward compat API with both page-side and agent-side methods) agent = (window as any).agent; }); diff --git a/vite.config.ts b/vite.config.ts index 25559ff..396d60e 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -21,6 +21,9 @@ export default defineConfig({ '@': path.resolve(__dirname, './src'), '@lib': path.resolve(__dirname, './src/lib'), '@types': path.resolve(__dirname, './src/types'), + // Use CSP-safe Ajv shim - real Ajv uses new Function() which violates extension CSP + // Also fixes ESM import issue (ajv doesn't have default export in ESM) + ajv: path.resolve(__dirname, 'src/lib/ajv-csp-safe.js'), }, }, build: { @@ -72,6 +75,6 @@ export default defineConfig({ }, // Chrome extension specific optimizations optimizeDeps: { - exclude: ['@modelcontextprotocol/sdk'], + // With our CSP-safe ajv shim, we can pre-bundle normally }, });