// --------------------------------------------------------------------------- // Vendored WebMCP polyfill — forked from WebMCP-org/npm-packages // (packages/webmcp-polyfill/src/index.ts @ 3.0.0), MIT licensed. See ./LICENSE // in this directory for the upstream copyright notice. // // Why vendored: the upstream `getTools()` projection dropped tool `annotations` // entirely (it rebuilt a bare { name, description, inputSchema, title, origin, // window } object), so standard hints — readOnlyHint / destructiveHint / // idempotentHint / untrustedContentHint — never reached a consumer, even though // the published `ModelContextToolInfo` type declares `annotations?`. We carry // the annotations through in `getRegisteredToolInfos()` below (search for // "[edge-mcp fork]"). Keep this file as close to upstream as possible so it can // be re-synced; the only behavioral change is that one annotations passthrough. // --------------------------------------------------------------------------- import { type Schema, Validator } from '@cfworker/json-schema'; import type { StandardSchemaV1 } from '@standard-schema/spec'; import { debugLog, edgeWarn } from '../debug.js'; // --------------------------------------------------------------------------- // [edge-mcp fork] Inlined type definitions (from @mcp-b/webmcp-types@3.0.0, MIT) // // Upstream imported these from '@mcp-b/webmcp-types'. That package's entry // d.ts ALSO ships its own `declare global` for `Document.modelContext`, which // conflicts with the ambient declaration this package publishes for consumers // (../types.ts). The polyfill needs only the shapes below, so they are inlined // (minimally, faithful to upstream semantics) and the type dependency is // dropped — the vendored file is now fully self-contained. These types are // internal to the polyfill; the package's public types live in ../types.ts. // --------------------------------------------------------------------------- interface InputSchemaProperty { type?: string; description?: string; [key: string]: unknown; } /** JSON Schema definition for tool input parameters. */ export interface InputSchema { type?: string; properties?: Record; required?: readonly string[]; [key: string]: unknown; } type JsonObject = Record; /** The result returned from tool execution (MCP CallToolResult shape). */ interface CallToolResult { content: Array<{ type: string; [key: string]: unknown }>; structuredContent?: JsonObject; isError?: boolean; } type ToolResponse = CallToolResult; /** Per-call client provided to tool handlers. */ export interface ModelContextClient { requestUserInteraction(callback: () => Promise): Promise; } type MaybePromise = T | Promise; type ToolRawResult = unknown; type ToolExecuteResult = TResult extends CallToolResult ? TResult : CallToolResult | TResult; /** Standard WebMCP tool annotations (native accepts booleans or 'true'/'false'). */ interface ToolAnnotations { title?: string; readOnlyHint?: boolean | 'true' | 'false'; untrustedContentHint?: boolean | 'true' | 'false'; destructiveHint?: boolean | 'true' | 'false'; idempotentHint?: boolean | 'true' | 'false'; openWorldHint?: boolean | 'true' | 'false'; } /** Tool descriptor for the Web Model Context API. */ export interface ToolDescriptor< TArgs extends Record = Record, TResult = ToolRawResult, TName extends string = string, > { name: TName; title?: string; description: string; inputSchema?: InputSchema; outputSchema?: Record; annotations?: ToolAnnotations; execute: (args: TArgs, client: ModelContextClient) => MaybePromise>; } /** Tool info returned by ModelContextTesting.listTools(). */ export interface ModelContextTestingToolInfo { name: string; description: string; /** JSON Schema, serialized as a JSON STRING (Chromium's native contract). */ inputSchema?: string; } /** Tool info returned by the producer-facing ModelContext.getTools(). */ export interface ModelContextToolInfo extends ModelContextTestingToolInfo { title: string; origin: string; /** [edge-mcp fork] Standard annotations, carried through getTools(). */ annotations?: ToolAnnotations; } export interface ModelContextTestingExecuteToolOptions { signal?: AbortSignal; } /** Chromium testing API on navigator.modelContextTesting. */ export interface ModelContextTesting extends EventTarget { listTools(): ModelContextTestingToolInfo[]; executeTool( toolName: string, inputArgsJson: string, options?: ModelContextTestingExecuteToolOptions ): Promise; getCrossDocumentScriptToolResult(): Promise; ontoolchange: ((this: ModelContextTesting, ev: Event) => unknown) | null; /** @deprecated Use `addEventListener('toolchange', ...)` instead. */ registerToolsChangedCallback?(callback: () => void): void; } /** Tool identity accepted by compatibility unregister flows. */ export interface ModelContextToolReference { name: string; } export interface ModelContextRegisterToolOptions { signal?: AbortSignal; exposedTo?: string[]; } /** The standard document.modelContext surface (the members the polyfill provides). */ export interface ModelContext extends EventTarget { registerTool(tool: ToolDescriptor, options?: ModelContextRegisterToolOptions): void | Promise; getTools(): Promise; executeTool( tool: ModelContextToolInfo, inputArgsJson: string, options?: ModelContextTestingExecuteToolOptions ): Promise; } declare global { interface Navigator { /** @deprecated Legacy alias — `document.modelContext` is the canonical surface. */ modelContext?: ModelContext; /** Chromium testing API location; the polyfill's testing shim installs here. */ modelContextTesting?: ModelContextTesting; } } // ------------------------------ end inlined types -------------------------- const FAILED_TO_PARSE_INPUT_ARGUMENTS_MESSAGE = 'Failed to parse input arguments'; const TOOL_INVOCATION_FAILED_MESSAGE = 'Tool was executed but the invocation failed. For example, the script function threw an error'; const TOOL_CANCELLED_MESSAGE = 'Tool was cancelled'; const DEFAULT_INPUT_SCHEMA: InputSchema = { type: 'object', properties: {} }; const STANDARD_JSON_SCHEMA_TARGETS = ['draft-2020-12', 'draft-07'] as const; /** WebMCP §4.2 tool name: ASCII alnum, underscore, hyphen, period; 1–128 code points. */ const VALID_TOOL_NAME_RE = /^[A-Za-z0-9_\-.]{1,128}$/u; const POLYFILL_MARKER_PROPERTY = '__isWebMCPPolyfill' as const; // Fork-specific marker: identifies THIS (annotation-carrying) fork, as opposed // to the upstream @mcp-b polyfill (which shares POLYFILL_MARKER_PROPERTY but // drops annotations from getTools()) or a native browser implementation. // initializeWebMCPPolyfill() only defers to occupants carrying this marker. const EDGE_MCP_MARKER_PROPERTY = '__edgeMcpPolyfill' as const; const STANDARD_VALIDATOR_SYMBOL = Symbol('standardValidator'); export type StandardInputValidatorSchema = StandardSchemaV1< Record, Record >; export interface StandardJSONSchemaV1 { readonly '~standard': { readonly version: 1; readonly vendor: string; readonly types?: { readonly input: Input; readonly output: Output } | undefined; readonly jsonSchema: { readonly input: (options: { readonly target: 'draft-2020-12' | 'draft-07' | 'openapi-3.0' | ({} & string); readonly libraryOptions?: Record | undefined; }) => Record; readonly output: (options: { readonly target: 'draft-2020-12' | 'draft-07' | 'openapi-3.0' | ({} & string); readonly libraryOptions?: Record | undefined; }) => Record; }; }; } export type StandardInputJsonSchema = StandardJSONSchemaV1< Record, Record >; export type ToolInputSchema = InputSchema | StandardInputValidatorSchema | StandardInputJsonSchema; export type ToolOutputSchema = InputSchema | StandardInputJsonSchema; type StandardValidationResult = Awaited< ReturnType >; type StandardValidationIssue = NonNullable[number]; interface PolyfillModelContext extends ModelContext { [POLYFILL_MARKER_PROPERTY]: true; [EDGE_MCP_MARKER_PROPERTY]: true; } interface PolyfillToolDescriptor extends ToolDescriptor, unknown, string> { inputSchema: InputSchema; [STANDARD_VALIDATOR_SYMBOL]: StandardInputValidatorSchema; } interface NormalizedInputSchema { inputSchema: InputSchema; standardValidator: StandardInputValidatorSchema; } interface InstallState { installed: boolean; previousNavigatorModelContextDescriptor: PropertyDescriptor | undefined; previousNavigatorModelContextTestingDescriptor: PropertyDescriptor | undefined; previousDocumentModelContextDescriptor: PropertyDescriptor | undefined; installedNavigatorModelContext: boolean; installedNavigatorModelContextTesting: boolean; installedDocumentModelContext: boolean; /** The occupant (native browser impl or foreign polyfill) replaced at install time, if any. */ replacedModelContext: ModelContext | undefined; /** Active mirror forwarding our tools into a displaced NATIVE registry, if installed. */ mirroredNativeRegistry: NativeRegistryMirror | undefined; } const installState: InstallState = { installed: false, previousNavigatorModelContextDescriptor: undefined, previousNavigatorModelContextTestingDescriptor: undefined, previousDocumentModelContextDescriptor: undefined, installedNavigatorModelContext: false, installedNavigatorModelContextTesting: false, installedDocumentModelContext: false, replacedModelContext: undefined, mirroredNativeRegistry: undefined, }; /** * The `document.modelContext` implementation that was replaced at install time * (a native browser implementation or a foreign polyfill), if any. Retained for * diagnostics and possible future forwarding into the browser's own registry. */ export function getReplacedModelContext(): ModelContext | undefined { return installState.replacedModelContext; } export interface WebMCPPolyfillInitOptions { /** * Controls whether the polyfill auto-initializes when loaded. * Set to false to prevent auto-initialization; then call initializeWebMCPPolyfill() manually. * @default true */ autoInitialize?: boolean; /** * Controls installation of navigator.modelContextTesting when this polyfill provides modelContext. * - true or 'if-missing' (default): install only when modelContextTesting is missing. * - 'always': install even when modelContextTesting already exists. * - false: do not install. * @default 'if-missing' */ installTestingShim?: boolean | 'always' | 'if-missing'; /** * When this polyfill takes over a NATIVE browser WebMCP surface, integrate * with that displaced native registry so native-registry consumers — Chrome's * DevTools WebMCP panel and any built-in browser agent that enumerates the * native store rather than reading the JS `document.modelContext` property — * can see the tools AND the calls made to them. Without this they see an empty * registry, because our takeover routes all `registerTool` calls into our own * surface. Two things happen when it's enabled: * * 1. Every registered tool is mirrored into the native registry as a discovery * replica (the panel lists it). The native replica drops annotations * (native has no field for them). * 2. Real (non-testing) executions are routed THROUGH native (the panel logs * each call). Our surface stays the front door and validation authority: * args are validated on our side FIRST (bad input never reaches native, * keeping our input-error messages). The tool runs exactly once, inside * native's execute path; native shapes the error the calling script sees * (it substitutes a generic message — thrown-error detail reaches the * DevTools panel, not the caller). Native execution failure is surfaced with * no local retry. * * The JS surface this polyfill installs stays the PRIMARY, annotation- * preserving surface. No-op (execution stays fully local, keeping our error * formatting) when there is no native surface, when a foreign polyfill * occupied the slot, or when the native surface lacks `registerTool`. * @default true */ mirrorToNativeRegistry?: boolean; /** * Deprecated no-op kept for backward compatibility with previous wrappers. */ disableIframeTransportByDefault?: boolean; } /** * Forwards our registered tools into a displaced NATIVE `document.modelContext` * as a read replica for discovery. See the `mirrorToNativeRegistry` option for * the rationale. * * Registration uses the current spec's AbortSignal-based lifecycle: each tool is * registered with its own controller, and unregistering aborts that controller * (the April 2026 draft removed `unregisterTool` in favor of this). Every native * call is best-effort and defensively wrapped — the first failure disables the * mirror permanently so a mismatched native API can never destabilize the * primary JS surface. Execution round-trips back into the real tool closure, so * a native-initiated call (and a routed call — see `execute`) behaves like the * JS path (minus the annotations native cannot carry). */ class NativeRegistryMirror { private readonly controllers = new Map(); private disabled = false; private warned = false; // Cache of native-minted tool infos, needed to call native.executeTool (which // rejects tool objects it did not hand out). Invalidated on any tool change. private infoCache: Map | null = null; constructor(private readonly native: ModelContext) {} register(tool: PolyfillToolDescriptor): void { if (this.disabled || this.controllers.has(tool.name)) return; try { const controller = new AbortController(); const wrapper: ToolDescriptor = { name: tool.name, description: tool.description, ...(tool.title !== undefined ? { title: tool.title } : {}), ...(tool.inputSchema ? { inputSchema: tool.inputSchema } : {}), // Passed through even though native drops it — harmless if it lands. ...(tool.annotations ? { annotations: tool.annotations } : {}), // Delegate execution to the real closure so a native-initiated call (or // a routed call) does exactly what the JS surface would. We deliberately // do NOT reformat thrown errors here: native substitutes its own generic // error for the calling script regardless (our formatting would only // reach the DevTools panel, not the agent), and wrapping the throw in an // async catch leaked an uncaught rejection. Let the tool's error // propagate as-is; the caller gets whatever native surfaces. execute: (args, client) => tool.execute(args, client), }; const result = this.native.registerTool(wrapper, { signal: controller.signal }); this.controllers.set(tool.name, controller); this.infoCache = null; // Some native impls resolve registerTool asynchronously; swallow rejection. if (result && typeof (result as Promise).then === 'function') { (result as Promise).then(undefined, (error: unknown) => { this.controllers.delete(tool.name); this.fail('registerTool', error); }); } } catch (error) { this.fail('registerTool', error); } } unregister(name: string): void { const controller = this.controllers.get(name); if (!controller) return; this.controllers.delete(name); this.infoCache = null; try { controller.abort(); } catch { // Aborting a controller cannot meaningfully fail; ignore defensively. } } clear(): void { for (const name of [...this.controllers.keys()]) this.unregister(name); this.controllers.clear(); this.infoCache = null; } /** True when `name` can be routed through native (mirrored and not disabled). */ canRoute(name: string): boolean { return !this.disabled && this.controllers.has(name); } /** * Execute a mirrored tool THROUGH native, so native's invocation is observed * by the DevTools panel. Passes native's own tool info (native rejects foreign * objects). The tool runs exactly once, inside native's execute path. */ async execute( name: string, inputArgsJson: string, options?: ModelContextTestingExecuteToolOptions ): Promise { const info = await this.getNativeToolInfo(name); if (!info) { throw createUnknownError(`Tool not found in native registry: ${name}`); } return this.native.executeTool(info, inputArgsJson, options); } private async getNativeToolInfo(name: string): Promise { if (!this.infoCache) { const infos = await this.native.getTools(); this.infoCache = new Map(infos.map((info) => [info.name, info])); } return this.infoCache.get(name); } private fail(op: string, error: unknown): void { this.disabled = true; if (this.warned) return; this.warned = true; edgeWarn( `Native-registry mirror disabled after ${op}() failed against the ` + "browser's WebMCP surface. The JS document.modelContext surface is unaffected; " + 'only the native DevTools/agent view of these tools is lost:', error ); } } class StrictWebMCPContext extends EventTarget { private tools = new Map(); private testingShim: PolyfillTestingShim | null = null; private _ontoolchange: ((this: ModelContext, ev: Event) => unknown) | null = null; private unregisterToolDeprecationWarned = false; private nativeMirror: NativeRegistryMirror | null = null; /** @internal Attach a native-registry mirror; forwards subsequent tool changes. */ setNativeMirror(mirror: NativeRegistryMirror): void { this.nativeMirror = mirror; } get ontoolchange(): ((this: ModelContext, ev: Event) => unknown) | null { return this._ontoolchange; } set ontoolchange(handler: ((this: ModelContext, ev: Event) => unknown) | null) { this._ontoolchange = handler; } registerTool(tool: ToolDescriptor, options?: ModelContextRegisterToolOptions): void { const signal = options?.signal; if (signal?.aborted) { console.warn( `[WebMCPPolyfill] registerTool("${ tool?.name ?? '' }") skipped: options.signal was already aborted.` ); return; } const normalized = normalizeToolDescriptor(tool, this.tools); this.tools.set(normalized.name, normalized); this.nativeMirror?.register(normalized); debugLog(`registered tool "${normalized.name}"`); this.notifyToolsChanged(); if (signal) { signal.addEventListener( 'abort', () => { if (this.tools.delete(normalized.name)) { this.nativeMirror?.unregister(normalized.name); debugLog(`unregistered tool "${normalized.name}" (its AbortSignal fired)`); this.notifyToolsChanged(); } }, { once: true } ); } } unregisterTool(nameOrTool: string | ModelContextToolReference): void { this.warnUnregisterToolDeprecationOnce(); const name = getToolNameForUnregister(nameOrTool); const removed = this.tools.delete(name); if (removed) { this.nativeMirror?.unregister(name); debugLog(`unregistered tool "${name}"`); this.notifyToolsChanged(); } } getTools(): Promise { return Promise.resolve(this.getRegisteredToolInfos()); } executeTool( tool: ModelContextToolInfo, inputArgsJson: string, options?: ModelContextTestingExecuteToolOptions ): Promise { return this.executeToolByName(tool.name, inputArgsJson, options, false); } getTestingShim(): PolyfillTestingShim { if (!this.testingShim) { this.testingShim = new PolyfillTestingShim(this); } return this.testingShim; } /** @internal Used by PolyfillTestingShim */ getToolInfos(): ModelContextTestingToolInfo[] { return [...this.tools.values()].map((tool) => { let inputSchema: string; try { inputSchema = JSON.stringify(tool.inputSchema ?? { type: 'object' }); } catch { inputSchema = '{"type":"object"}'; } return { name: tool.name, description: tool.description, inputSchema }; }); } /** @internal Used by getTools() */ getRegisteredToolInfos(): ModelContextToolInfo[] { return this.getToolInfos().map((toolInfo) => { const tool = this.tools.get(toolInfo.name); return { ...toolInfo, title: tool?.title ?? '', origin: globalThis.location?.origin ?? '', // [edge-mcp fork] Carry standard tool annotations through to getTools(). // Upstream omits them, so readOnlyHint / destructiveHint / idempotentHint // / untrustedContentHint never reach a consumer. The published // ModelContextToolInfo type already declares `annotations?`, so this // makes the runtime match the type. ...(tool?.annotations ? { annotations: tool.annotations } : {}), // [edge-mcp fork] Upstream also attached `window: globalThis.window` // here (its iframe tool-aggregation routing). We drop it: our fork is // single-document, nothing reads it, and the circular reference made // JSON.stringify(await getTools()) throw. }; }); } /** @internal Used by PolyfillTestingShim */ async executeToolForTesting( toolName: string, inputArgsJson: string, options?: ModelContextTestingExecuteToolOptions ): Promise { return this.executeToolByName(toolName, inputArgsJson, options, true); } private async executeToolByName( toolName: string, inputArgsJson: string, options: ModelContextTestingExecuteToolOptions | undefined, normalizeResult: boolean ): Promise { if (options?.signal?.aborted) { throw createUnknownError(TOOL_CANCELLED_MESSAGE); } const tool = this.tools.get(toolName); if (!tool) { throw createUnknownError(`Tool not found: ${toolName}`); } // Validate on OUR side first — bad input never reaches native, which keeps // our input-error messages regardless of the execution path taken below. const args = parseInputArgsJson(inputArgsJson); const validationError = await validateArgsForTool(args, tool); if (validationError) { throw createUnknownError(validationError); } // Route real (non-testing) executions through the native registry whenever a // mirror is active, so the browser's DevTools WebMCP panel logs the call. // The testing path (normalizeResult) always runs locally — it backs our own // testing shim. If native execution fails, the error is surfaced; there is // deliberately no local retry. const viaNative = !normalizeResult && (this.nativeMirror?.canRoute(toolName) ?? false); debugLog(`tool "${toolName}" called (${viaNative ? 'via native registry' : 'locally'}) — args:`, args); try { const result = viaNative ? await this.nativeMirror!.execute(toolName, inputArgsJson, options) : await this.runLocalExecution(tool, args, options, normalizeResult); debugLog(`tool "${toolName}" responded:`, result); return result; } catch (error) { debugLog(`tool "${toolName}" failed:`, error); throw error; } } private async runLocalExecution( tool: PolyfillToolDescriptor, args: Record, options: ModelContextTestingExecuteToolOptions | undefined, normalizeResult: boolean ): Promise { let contextActive = true; const client: ModelContextClient = { requestUserInteraction: async (callback: () => Promise): Promise => { if (!contextActive) { throw new Error( `ModelContextClient for tool "${tool.name}" is no longer active after execute() resolved` ); } if (typeof callback !== 'function') { throw new TypeError('requestUserInteraction(callback) requires a function callback'); } return callback(); }, }; try { const execution = tool.execute(args, client); const rawResult = await withAbortSignal(Promise.resolve(execution), options?.signal); if (normalizeResult) { return toSerializedTestingResult(normalizeToolResponse(rawResult)); } const serialized = JSON.stringify(rawResult); return serialized === undefined ? null : serialized; } catch (error) { throw toFormattedToolError(error); } finally { contextActive = false; } } private notifyToolsChanged(): void { queueMicrotask(() => { const event = new Event('toolchange'); try { this._ontoolchange?.call(this as unknown as ModelContext, event); } catch (error) { console.warn('[WebMCPPolyfill] navigator.modelContext.ontoolchange handler threw:', error); } this.dispatchEvent(event); this.testingShim?.dispatchToolChange(); }); } private warnUnregisterToolDeprecationOnce(): void { if (this.unregisterToolDeprecationWarned) { return; } this.unregisterToolDeprecationWarned = true; console.warn( '[WebMCPPolyfill] navigator.modelContext.unregisterTool() is deprecated. The April 23, 2026 WebMCP draft removed it in favor of registerTool(tool, { signal }) — pass an AbortSignal and abort it to unregister.' ); } } /** * EventTarget-based testing shim matching the native Chromium ModelContextTesting surface. * * Fires `toolchange` events and supports the `ontoolchange` handler property, * matching the native Chromium 148 API. The deprecated `registerToolsChangedCallback` * is kept as a compat layer that wraps `addEventListener`. */ class PolyfillTestingShim extends EventTarget implements ModelContextTesting { private context: StrictWebMCPContext; private _ontoolchange: ((this: ModelContextTesting, ev: Event) => unknown) | null = null; constructor(context: StrictWebMCPContext) { super(); this.context = context; } listTools(): ModelContextTestingToolInfo[] { return this.context.getToolInfos(); } executeTool( toolName: string, inputArgsJson: string, options?: ModelContextTestingExecuteToolOptions ): Promise { return this.context.executeToolForTesting(toolName, inputArgsJson, options); } getCrossDocumentScriptToolResult(): Promise { return Promise.resolve('[]'); } get ontoolchange(): ((this: ModelContextTesting, ev: Event) => unknown) | null { return this._ontoolchange; } set ontoolchange(handler: ((this: ModelContextTesting, ev: Event) => unknown) | null) { this._ontoolchange = handler; } /** * @deprecated Use `addEventListener('toolchange', callback)` instead. * Kept for backward compatibility with older polyfill consumers. */ registerToolsChangedCallback(callback: () => void): void { if (typeof callback !== 'function') { throw new TypeError( "Failed to execute 'registerToolsChangedCallback' on 'ModelContextTesting': parameter 1 is not of type 'Function'." ); } this.addEventListener('toolchange', callback); } /** @internal Called by StrictWebMCPContext when tools change. */ dispatchToolChange(): void { const event = new Event('toolchange'); try { this._ontoolchange?.call(this, event); } catch (error) { console.warn('[WebMCPPolyfill] ontoolchange handler threw:', error); } this.dispatchEvent(event); // Deprecated compat: fire old event name so existing listeners keep working this.dispatchEvent(new Event('toolschanged')); } } function createUnknownError(message: string): Error { try { return new DOMException(message, 'UnknownError'); } catch { const error = new Error(message); error.name = 'UnknownError'; return error; } } /** * Format a thrown tool error our way: lead with the underlying error.message — * that's the useful part (e.g. "Your cart is empty") — and keep the generic * sentence as trailing context so the failure mode is still explicit; fall back * to it entirely when a non-Error was thrown and there's no message to surface. * Applied both on the local execution path and inside the native mirror wrapper * (at the source, before native can flatten the message). */ function toFormattedToolError(error: unknown): Error { const message = error instanceof Error ? error.message.trim() : ''; const detail = message ? `${message} (${TOOL_INVOCATION_FAILED_MESSAGE})` : TOOL_INVOCATION_FAILED_MESSAGE; return createUnknownError(detail); } function createInvalidStateError(message: string): DOMException | Error { try { return new DOMException(message, 'InvalidStateError'); } catch { const error = new Error(message); error.name = 'InvalidStateError'; return error; } } function parseInputArgsJson(inputArgsJson: string): Record { let parsed: unknown; try { parsed = JSON.parse(inputArgsJson); } catch { throw createUnknownError(FAILED_TO_PARSE_INPUT_ARGUMENTS_MESSAGE); } if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { throw createUnknownError(FAILED_TO_PARSE_INPUT_ARGUMENTS_MESSAGE); } return parsed as Record; } function getToolNameForUnregister(nameOrTool: string | ModelContextToolReference): string { if (typeof nameOrTool === 'string') { return nameOrTool; } if (isPlainObject(nameOrTool) && typeof nameOrTool.name === 'string') { return nameOrTool.name; } throw new TypeError( "Failed to execute 'unregisterTool' on 'ModelContext': parameter 1 must be a string or an object with a string name." ); } export function isPlainObject(value: unknown): value is Record { return Boolean(value) && typeof value === 'object' && !Array.isArray(value); } function getStandardProps(value: unknown): Record | null { if (!isPlainObject(value)) { return null; } const standard = value['~standard']; if (!isPlainObject(standard)) { return null; } return standard; } function isStandardInputValidatorSchema(value: unknown): value is StandardInputValidatorSchema { const standard = getStandardProps(value); return Boolean(standard && standard.version === 1 && typeof standard.validate === 'function'); } function isStandardInputJsonSchema(value: unknown): value is StandardInputJsonSchema { const standard = getStandardProps(value); if (!standard || standard.version !== 1 || !isPlainObject(standard.jsonSchema)) { return false; } return typeof standard.jsonSchema.input === 'function'; } function createStandardValidatorFromJsonSchema(schema: InputSchema): StandardInputValidatorSchema { return { '~standard': { version: 1, vendor: '@mcp-b/webmcp-polyfill-json-schema', validate(value: unknown): StandardValidationResult { if (!isPlainObject(value)) { return { issues: [{ message: 'expected object arguments' }], }; } const issue = validateArgsWithSchema(value, schema); if (issue) { return { issues: [issue], }; } return { value, }; }, }, }; } function convertStandardInputSchema(schema: StandardInputJsonSchema): InputSchema { for (const target of STANDARD_JSON_SCHEMA_TARGETS) { try { const converted = schema['~standard'].jsonSchema.input({ target }); validateInputSchema(converted); return converted; } catch (error) { console.warn( `[WebMCPPolyfill] Standard JSON Schema conversion failed for target "${target}":`, error ); } } throw new Error('Failed to convert Standard JSON Schema inputSchema to a JSON Schema object'); } function normalizeInputSchema(inputSchema: ToolInputSchema | undefined): NormalizedInputSchema { if (inputSchema === undefined) { const normalized = DEFAULT_INPUT_SCHEMA; return { inputSchema: normalized, standardValidator: createStandardValidatorFromJsonSchema(normalized), }; } if (isStandardInputJsonSchema(inputSchema)) { // Prefer JSON conversion for parity across JSON and Standard Schema inputs. const converted = convertStandardInputSchema(inputSchema); return { inputSchema: converted, standardValidator: createStandardValidatorFromJsonSchema(converted), }; } if (isStandardInputValidatorSchema(inputSchema)) { return { inputSchema: DEFAULT_INPUT_SCHEMA, standardValidator: inputSchema, }; } validateInputSchema(inputSchema); // Empty {} is valid JSON Schema but lacks type:"object" required by MCP. if (Object.keys(inputSchema as Record).length === 0) { return { inputSchema: DEFAULT_INPUT_SCHEMA, standardValidator: createStandardValidatorFromJsonSchema(DEFAULT_INPUT_SCHEMA), }; } const normalizedSchema = inputSchema.type === undefined ? ({ type: 'object', ...inputSchema } as InputSchema) : inputSchema; return { inputSchema: normalizedSchema, standardValidator: createStandardValidatorFromJsonSchema(normalizedSchema), }; } function validateInputSchema(schema: unknown): asserts schema is InputSchema { if (!isPlainObject(schema)) { throw new Error('inputSchema must be a JSON Schema object'); } validateJsonSchemaNode(schema, '$'); } function validateJsonSchemaNode(node: Record, path: string): void { const typeValue = node.type; if ( typeValue !== undefined && typeof typeValue !== 'string' && !( Array.isArray(typeValue) && typeValue.every((entry) => typeof entry === 'string' && entry.length > 0) ) ) { throw new Error(`Invalid JSON Schema at ${path}: "type" must be a string or string[]`); } const requiredValue = node.required; if ( requiredValue !== undefined && !(Array.isArray(requiredValue) && requiredValue.every((entry) => typeof entry === 'string')) ) { throw new Error(`Invalid JSON Schema at ${path}: "required" must be an array of strings`); } const propertiesValue = node.properties; if (propertiesValue !== undefined) { if (!isPlainObject(propertiesValue)) { throw new Error(`Invalid JSON Schema at ${path}: "properties" must be an object`); } for (const [key, value] of Object.entries(propertiesValue)) { if (!isPlainObject(value)) { throw new Error(`Invalid JSON Schema at ${path}.properties.${key}: expected object schema`); } validateJsonSchemaNode(value, `${path}.properties.${key}`); } } const itemsValue = node.items; if (itemsValue !== undefined) { if (Array.isArray(itemsValue)) { for (const [index, value] of itemsValue.entries()) { if (!isPlainObject(value)) { throw new Error(`Invalid JSON Schema at ${path}.items[${index}]: expected object schema`); } validateJsonSchemaNode(value, `${path}.items[${index}]`); } } else if (isPlainObject(itemsValue)) { validateJsonSchemaNode(itemsValue, `${path}.items`); } else { throw new Error(`Invalid JSON Schema at ${path}: "items" must be an object or object[]`); } } for (const keyword of ['allOf', 'anyOf', 'oneOf'] as const) { const value = node[keyword]; if (value === undefined) { continue; } if (!Array.isArray(value)) { throw new Error(`Invalid JSON Schema at ${path}: "${keyword}" must be an array`); } for (const [index, entry] of value.entries()) { if (!isPlainObject(entry)) { throw new Error( `Invalid JSON Schema at ${path}.${keyword}[${index}]: expected object schema` ); } validateJsonSchemaNode(entry, `${path}.${keyword}[${index}]`); } } const notValue = node.not; if (notValue !== undefined) { if (!isPlainObject(notValue)) { throw new Error(`Invalid JSON Schema at ${path}: "not" must be an object schema`); } validateJsonSchemaNode(notValue, `${path}.not`); } try { JSON.stringify(node); } catch { throw new Error(`Invalid JSON Schema at ${path}: schema must be JSON-serializable`); } } function normalizeToolDescriptor( tool: ToolDescriptor, existing: Map ): PolyfillToolDescriptor { if (!tool || typeof tool !== 'object') { throw new TypeError('registerTool(tool) requires a tool object'); } if (typeof tool.name !== 'string' || tool.name.length === 0) { throw createInvalidStateError('Tool "name" must be a non-empty string'); } if (!VALID_TOOL_NAME_RE.test(tool.name) || Array.from(tool.name).length > 128) { throw createInvalidStateError( 'Tool "name" must be 1–128 characters and contain only ASCII alphanumeric, underscore, hyphen, or period' ); } if (typeof tool.description !== 'string' || tool.description.length === 0) { throw createInvalidStateError('Tool "description" must be a non-empty string'); } if (typeof tool.execute !== 'function') { throw new TypeError('Tool "execute" must be a function'); } if (existing.has(tool.name)) { throw new Error(`Tool already registered: ${tool.name}`); } const normalizedInputSchema = normalizeInputSchema(tool.inputSchema); const annotations = tool.annotations; const normalizedAnnotations = annotations ? { ...annotations, ...(annotations.readOnlyHint === 'true' ? { readOnlyHint: true } : annotations.readOnlyHint === 'false' ? { readOnlyHint: false } : {}), ...(annotations.destructiveHint === 'true' ? { destructiveHint: true } : annotations.destructiveHint === 'false' ? { destructiveHint: false } : {}), ...(annotations.idempotentHint === 'true' ? { idempotentHint: true } : annotations.idempotentHint === 'false' ? { idempotentHint: false } : {}), ...(annotations.openWorldHint === 'true' ? { openWorldHint: true } : annotations.openWorldHint === 'false' ? { openWorldHint: false } : {}), } : undefined; return { ...tool, ...(normalizedAnnotations ? { annotations: normalizedAnnotations } : {}), inputSchema: normalizedInputSchema.inputSchema, [STANDARD_VALIDATOR_SYMBOL]: normalizedInputSchema.standardValidator, }; } export function validateArgsWithSchema( args: Record, schema: InputSchema ): StandardValidationIssue | null { const validator = new Validator(schema as Schema, '2020-12', true); const result = validator.validate(args); if (result.valid) { return null; } // Use the deepest (last) error for the most specific message. const error = result.errors[result.errors.length - 1]; if (!error) { return { message: 'Input validation failed' }; } return { message: error.error }; } function formatStandardIssuePath(path: StandardValidationIssue['path']) { if (!path || path.length === 0) { return null; } const segments = path .map((segment) => { if (isPlainObject(segment) && 'key' in segment) { return segment.key; } return segment; }) .map((segment) => String(segment)) .filter((segment) => segment.length > 0); if (segments.length === 0) { return null; } return segments.join('.'); } async function validateArgsWithStandardSchema( args: Record, schema: StandardInputValidatorSchema ): Promise { let result: StandardValidationResult; try { result = await Promise.resolve(schema['~standard'].validate(args)); } catch (error) { const detail = error instanceof Error ? `: ${error.message}` : ''; console.error('[WebMCPPolyfill] Standard Schema validation threw unexpectedly:', error); return `Input validation error: schema validation failed${detail}`; } if (!result.issues || result.issues.length === 0) { return null; } const firstIssue = result.issues[0]; if (!firstIssue) { return 'Input validation error'; } const path = formatStandardIssuePath(firstIssue?.path); if (path) { return `Input validation error: ${firstIssue.message} at ${path}`; } return `Input validation error: ${firstIssue.message}`; } async function validateArgsForTool( args: Record, tool: PolyfillToolDescriptor ): Promise { return validateArgsWithStandardSchema(args, tool[STANDARD_VALIDATOR_SYMBOL]); } function isCallToolResult(value: unknown): value is ToolResponse { return isPlainObject(value) && Array.isArray(value.content); } function isJsonPrimitive(value: unknown): value is string | number | boolean | null { return ( value === null || typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean' ); } function isJsonValue(value: unknown): boolean { if (isJsonPrimitive(value)) { return Number.isFinite(value as number) || typeof value !== 'number'; } if (Array.isArray(value)) { return value.every((entry) => isJsonValue(entry)); } if (!isPlainObject(value)) { return false; } return Object.values(value).every((entry) => isJsonValue(entry)); } function toStructuredContent(value: unknown): JsonObject | undefined { if (!isPlainObject(value) || !isJsonValue(value)) { return undefined; } return value as JsonObject; } function serializeTextContent(value: unknown): string { if (typeof value === 'string') { return value; } try { const candidate = JSON.stringify(value); return candidate ?? String(value); } catch { return String(value); } } function normalizeToolResponse(value: unknown): ToolResponse { if (isCallToolResult(value)) { return value; } const structuredContent = toStructuredContent(value); return { content: [ { type: 'text', text: serializeTextContent(value), }, ], ...(structuredContent ? { structuredContent } : {}), isError: false, }; } function getFirstTextBlock(result: ToolResponse): string | null { for (const block of result.content ?? []) { if (block.type === 'text' && 'text' in block && typeof block.text === 'string') { return block.text; } } return null; } function toSerializedTestingResult(result: ToolResponse): string | null { if (result.isError) { const firstText = getFirstTextBlock(result); const message = firstText?.replace(/^Error:\s*/i, '').trim() || TOOL_INVOCATION_FAILED_MESSAGE; throw createUnknownError(message); } const metadata = (result as ToolResponse & { metadata?: { willNavigate?: boolean } }).metadata; if (metadata && typeof metadata === 'object' && metadata.willNavigate) { return null; } try { return JSON.stringify(result); } catch { throw createUnknownError(TOOL_INVOCATION_FAILED_MESSAGE); } } function withAbortSignal(operation: Promise, signal?: AbortSignal): Promise { if (!signal) { return operation; } if (signal.aborted) { return Promise.reject(createUnknownError(TOOL_CANCELLED_MESSAGE)); } return new Promise((resolve, reject) => { const onAbort = () => { cleanup(); reject(createUnknownError(TOOL_CANCELLED_MESSAGE)); }; const cleanup = () => { signal.removeEventListener('abort', onAbort); }; signal.addEventListener('abort', onAbort, { once: true }); operation.then( (value) => { cleanup(); resolve(value); }, (error) => { cleanup(); reject(error); } ); }); } function getNavigator(): Navigator | null { if (typeof navigator !== 'undefined') { return navigator; } return null; } function getDocument(): Document | null { if (typeof document !== 'undefined') { return document; } return null; } function defineNavigatorProperty( target: Navigator, key: K, value: Navigator[K] ): void { Object.defineProperty(target, key, { configurable: true, enumerable: true, writable: false, value, }); } function defineDocumentModelContextProperty(target: Document, value: ModelContext): void { Object.defineProperty(target, 'modelContext', { configurable: true, enumerable: true, writable: false, value, }); } let navigatorModelContextDeprecationWarned = false; // Per webmachinelearning/webmcp#173 / PR #184, the modelContext getter moved // from Navigator to Document. We install on document.modelContext as the // primary surface and expose navigator.modelContext as a deprecated alias that // returns the same instance and logs a one-time console warning on first // access. This mirrors the deprecation behavior shipped in Chrome 150. function defineDeprecatedNavigatorModelContext(target: Navigator, value: ModelContext): void { Object.defineProperty(target, 'modelContext', { configurable: true, enumerable: true, get() { if (!navigatorModelContextDeprecationWarned) { navigatorModelContextDeprecationWarned = true; console.warn( '[WebMCPPolyfill] navigator.modelContext is deprecated. The May 27, 2026 WebMCP draft moved the modelContext getter from Navigator to Document — use document.modelContext instead. See https://github.com/webmachinelearning/webmcp/pull/184.' ); } return value; }, }); } export function initializeWebMCPPolyfill(options?: WebMCPPolyfillInitOptions): void { const nav = getNavigator(); const doc = getDocument(); if (!nav && !doc) { return; } // The ONLY occupant we defer to is our own fork (double import, HMR): // re-installing over ourselves would swap the live registry for an empty one // and silently wipe every registered tool. const existing = (doc?.modelContext ?? nav?.modelContext) as | (ModelContext & Record) | undefined; if (existing?.[EDGE_MCP_MARKER_PROPERTY]) { return; } // Any OTHER occupant — a native browser implementation or a foreign polyfill // (including upstream @mcp-b, which drops annotations) — is replaced, not // trusted. Known native defects this shields against: getTools() loses the // tool annotations (a destructive tool reads as harmless to the consumer), // and executeTool() rejects any tool object it did not hand out itself. The // replaced context is retained (getReplacedModelContext) for diagnostics and // possible future forwarding into the browser's own registry. // A genuine NATIVE occupant carries neither marker; a foreign polyfill // (upstream @mcp-b) carries POLYFILL_MARKER_PROPERTY. We only mirror into a // native registry — a foreign polyfill isn't read by the DevTools panel or a // built-in browser agent, so mirroring there gains nothing. const existingIsNative = Boolean(existing) && !existing?.[POLYFILL_MARKER_PROPERTY]; if (existing) { installState.replacedModelContext = existing as ModelContext; const willMirror = existingIsNative && options?.mirrorToNativeRegistry !== false && typeof existing?.registerTool === 'function'; debugLog( "Installed WebMCP surface — took over the browser's native " + 'document.modelContext so tool annotations are preserved (the native surface ' + 'drops them from getTools() and rejects foreign tool objects in executeTool()). ' + (willMirror ? 'Registered tools are also mirrored into the native registry, and executions ' + 'are routed through native, so the DevTools WebMCP panel and any built-in ' + 'browser agent see the tools and log each call. ' : '') + 'Expected; the replaced implementation is retained internally.' ); } if (installState.installed) { cleanupWebMCPPolyfill(); } const context = new StrictWebMCPContext(); const modelContext = context as unknown as PolyfillModelContext; modelContext[POLYFILL_MARKER_PROPERTY] = true; modelContext[EDGE_MCP_MARKER_PROPERTY] = true; // Mirror our tools into the displaced native registry (discovery replica) so // native-registry consumers see them. Only for a genuine native occupant with // a usable registerTool; opt out with { mirrorToNativeRegistry: false }. if ( existingIsNative && existing && options?.mirrorToNativeRegistry !== false && typeof existing.registerTool === 'function' ) { const mirror = new NativeRegistryMirror(existing as ModelContext); context.setNativeMirror(mirror); installState.mirroredNativeRegistry = mirror; } if (doc) { installState.previousDocumentModelContextDescriptor = Object.getOwnPropertyDescriptor( doc, 'modelContext' ); defineDocumentModelContextProperty(doc, modelContext as ModelContext); installState.installedDocumentModelContext = true; } if (nav) { installState.previousNavigatorModelContextDescriptor = Object.getOwnPropertyDescriptor( nav, 'modelContext' ); installState.previousNavigatorModelContextTestingDescriptor = Object.getOwnPropertyDescriptor( nav, 'modelContextTesting' ); // Reset the one-shot warning flag so a fresh install warns again on first access. navigatorModelContextDeprecationWarned = false; defineDeprecatedNavigatorModelContext(nav, modelContext as ModelContext); installState.installedNavigatorModelContext = true; const installTestingShim = options?.installTestingShim ?? 'if-missing'; const hasModelContextTesting = Boolean(nav.modelContextTesting); const shouldInstallTestingShim = installTestingShim === 'always' || ((installTestingShim === true || installTestingShim === 'if-missing') && !hasModelContextTesting); if (shouldInstallTestingShim) { defineNavigatorProperty( nav, 'modelContextTesting', context.getTestingShim() as Navigator['modelContextTesting'] ); installState.installedNavigatorModelContextTesting = true; } } installState.installed = true; } export function cleanupWebMCPPolyfill(): void { if (!installState.installed) { return; } const restore = ( target: Navigator | Document, key: string, previousDescriptor: PropertyDescriptor | undefined ) => { if (previousDescriptor) { Object.defineProperty(target, key, previousDescriptor); return; } delete (target as unknown as Record)[key]; }; // Remove the tools we pushed into the native registry before restoring it, so // the native surface comes back clean with no orphaned shadow tools. installState.mirroredNativeRegistry?.clear(); const nav = getNavigator(); const doc = getDocument(); if (doc && installState.installedDocumentModelContext) { restore(doc, 'modelContext', installState.previousDocumentModelContextDescriptor); } if (nav && installState.installedNavigatorModelContext) { restore(nav, 'modelContext', installState.previousNavigatorModelContextDescriptor); } if (nav && installState.installedNavigatorModelContextTesting) { restore( nav, 'modelContextTesting', installState.previousNavigatorModelContextTestingDescriptor ); } installState.installed = false; installState.previousDocumentModelContextDescriptor = undefined; installState.previousNavigatorModelContextDescriptor = undefined; installState.previousNavigatorModelContextTestingDescriptor = undefined; installState.installedDocumentModelContext = false; installState.installedNavigatorModelContext = false; installState.installedNavigatorModelContextTesting = false; installState.replacedModelContext = undefined; installState.mirroredNativeRegistry = undefined; navigatorModelContextDeprecationWarned = false; } export { initializeWebMCPPolyfill as initializeWebModelContextPolyfill }; declare global { interface Window { __webMCPPolyfillOptions?: WebMCPPolyfillInitOptions; } } if (typeof window !== 'undefined' && typeof document !== 'undefined') { const options = window.__webMCPPolyfillOptions; const shouldAutoInitialize = options?.autoInitialize !== false; if (shouldAutoInitialize) { try { initializeWebMCPPolyfill(options); } catch (error) { console.error('[WebMCPPolyfill] Auto-initialization failed:', error); } } }