mirror of
https://github.com/github/awesome-copilot.git
synced 2026-08-01 23:12:29 +00:00
Add the WebMCPify agent skill 🤖🤖🤖 (#2400)
* feat(skills): add webmcpify skill * style(skills): quote webmcpify description
This commit is contained in:
+59
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* React JSX typings for the declarative WebMCP attributes — vendored from
|
||||
* https://github.com/TueJon/webmcpify
|
||||
*
|
||||
* MIT License
|
||||
* Copyright (c) 2026 Jonas Tüchler
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
* of this software and associated documentation files (the "Software"), to deal
|
||||
* in the Software without restriction, including without limitation the rights
|
||||
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
* copies of the Software, and to permit persons to whom the Software is
|
||||
* furnished to do so, subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in
|
||||
* all copies or substantial portions of the Software — keep this header when
|
||||
* copying this file into your project.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
* SOFTWARE.
|
||||
*
|
||||
* Full text: https://github.com/TueJon/webmcpify/blob/main/LICENSE
|
||||
*
|
||||
* NOTE: this file is a MODULE (`declare module 'react'` requires the `import`)
|
||||
* — keep it SEPARATE from webmcp.d.ts. Merging it there would add an import to
|
||||
* that file, turning it into a module and un-globalizing its ambient interfaces
|
||||
* (empirically verified). Vendor both files side by side in React TSX projects.
|
||||
*/
|
||||
|
||||
import 'react';
|
||||
|
||||
declare module 'react' {
|
||||
interface FormHTMLAttributes<T> {
|
||||
toolname?: string;
|
||||
tooldescription?: string;
|
||||
/**
|
||||
* Boolean attribute — write `toolautosubmit=""` in TSX. ONLY on pure read
|
||||
* forms (search/filter/availability); never on state-changing forms.
|
||||
*/
|
||||
toolautosubmit?: string;
|
||||
}
|
||||
interface InputHTMLAttributes<T> {
|
||||
toolparamdescription?: string;
|
||||
}
|
||||
interface SelectHTMLAttributes<T> {
|
||||
toolparamdescription?: string;
|
||||
}
|
||||
interface TextareaHTMLAttributes<T> {
|
||||
toolparamdescription?: string;
|
||||
}
|
||||
interface FieldsetHTMLAttributes<T> {
|
||||
toolparamdescription?: string;
|
||||
}
|
||||
}
|
||||
+110
@@ -0,0 +1,110 @@
|
||||
/**
|
||||
* Ambient types for the WebMCP API — vendored from https://github.com/TueJon/webmcpify
|
||||
*
|
||||
* MIT License
|
||||
* Copyright (c) 2026 Jonas Tüchler
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
* of this software and associated documentation files (the "Software"), to deal
|
||||
* in the Software without restriction, including without limitation the rights
|
||||
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
* copies of the Software, and to permit persons to whom the Software is
|
||||
* furnished to do so, subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in
|
||||
* all copies or substantial portions of the Software — keep this header when
|
||||
* copying this file into your project.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
* SOFTWARE.
|
||||
*
|
||||
* Full text: https://github.com/TueJon/webmcpify/blob/main/LICENSE
|
||||
*
|
||||
* registerTool/ontoolchange/annotations/getTools follow the CG draft
|
||||
* (https://webmachinelearning.github.io/webmcp/); executeTool is a Chrome-only
|
||||
* extension not yet in the draft. This API is in flux — re-check against the
|
||||
* draft and https://developer.chrome.com/docs/ai/webmcp when updating.
|
||||
*
|
||||
* This is a GLOBAL script file — no imports (an import would turn it into a
|
||||
* module and un-globalize every interface). React JSX typings for the declarative
|
||||
* attributes live in the separate webmcp-jsx.d.ts template.
|
||||
*/
|
||||
|
||||
interface ModelContextToolAnnotations {
|
||||
readOnlyHint?: boolean;
|
||||
untrustedContentHint?: boolean;
|
||||
}
|
||||
|
||||
interface ModelContext extends EventTarget {
|
||||
registerTool(
|
||||
tool: ModelContextTool,
|
||||
options?: { signal?: AbortSignal; exposedTo?: string[] },
|
||||
): Promise<void>;
|
||||
ontoolchange: ((this: ModelContext, ev: Event) => unknown) | null;
|
||||
/**
|
||||
* Enumerates tools exposed to this document. Added to the CG draft in 2026-07;
|
||||
* intended for in-page agents and test harnesses, not application logic.
|
||||
*/
|
||||
getTools(options?: { fromOrigins?: string[] }): Promise<RegisteredTool[]>;
|
||||
/**
|
||||
* Chrome-only execution surface (2026-07+; not yet in the CG draft); replaced
|
||||
* the removed navigator.modelContextTesting API.
|
||||
*/
|
||||
executeTool?(
|
||||
tool: RegisteredTool,
|
||||
inputJson: string,
|
||||
options?: { signal?: AbortSignal },
|
||||
): Promise<string | null>;
|
||||
}
|
||||
|
||||
interface ModelContextTool {
|
||||
/** [a-zA-Z0-9_.-]; spec allows up to 128 chars, Google recommends ≤30 */
|
||||
name: string;
|
||||
/** Optional display label */
|
||||
title?: string;
|
||||
/** Natural-language capability statement, ≤500 chars recommended */
|
||||
description: string;
|
||||
/** JSON Schema for the tool's input */
|
||||
inputSchema?: object;
|
||||
/**
|
||||
* Only `input` is passed — there is no client/session argument in the IDL.
|
||||
* IDL: `Promise<any>` — WebIDL auto-wraps synchronous returns (and throws) in
|
||||
* a promise, so a sync implementation still fulfills this type at runtime;
|
||||
* declare it async for type fidelity.
|
||||
*/
|
||||
execute(input: Record<string, unknown>): Promise<unknown>;
|
||||
annotations?: ModelContextToolAnnotations;
|
||||
}
|
||||
|
||||
/** Shape returned by getTools(). NOTE inputSchema is a STRINGIFIED JSON Schema. */
|
||||
interface RegisteredTool {
|
||||
name: string;
|
||||
title?: string;
|
||||
description: string;
|
||||
inputSchema?: string;
|
||||
annotations?: ModelContextToolAnnotations;
|
||||
/** Registering origin (secure origins only). */
|
||||
origin: string;
|
||||
/** Owning window (cross-document enumeration). */
|
||||
window: Window;
|
||||
}
|
||||
|
||||
interface Document {
|
||||
readonly modelContext?: ModelContext;
|
||||
}
|
||||
|
||||
interface Navigator {
|
||||
/** Deprecated Chrome 149 origin-trial surface; prefer document.modelContext. */
|
||||
readonly modelContext?: ModelContext;
|
||||
}
|
||||
|
||||
/** Declarative form submissions: agent-invoked flag + result bridge. */
|
||||
interface SubmitEvent {
|
||||
readonly agentInvoked?: boolean;
|
||||
respondWith?(result: Promise<unknown>): void;
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
/**
|
||||
* webmcpify verification template — vendored from https://github.com/TueJon/webmcpify
|
||||
*
|
||||
* MIT License
|
||||
* Copyright (c) 2026 Jonas Tüchler
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
* of this software and associated documentation files (the "Software"), to deal
|
||||
* in the Software without restriction, including without limitation the rights
|
||||
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
* copies of the Software, and to permit persons to whom the Software is
|
||||
* furnished to do so, subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in
|
||||
* all copies or substantial portions of the Software — keep this header when
|
||||
* copying this file into your project.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
* SOFTWARE.
|
||||
*
|
||||
* Full text: https://github.com/TueJon/webmcpify/blob/main/LICENSE
|
||||
*
|
||||
* The webmcpify skill instantiates one describe-block per manifest tool, filling
|
||||
* route/auth/examples/expect from .webmcpify/manifest.json. The example blocks
|
||||
* below show the complete patterns with REAL assertions — generated blocks must
|
||||
* assert, never comment out.
|
||||
*
|
||||
* Requirements: real Chrome, HEADED (WebMCP needs a visible tab — headless will
|
||||
* never work; use xvfb-run in CI). Enumeration/execution uses the production
|
||||
* document.modelContext.getTools()/executeTool() surface (Chrome 2026-07+), with a
|
||||
* probe fallback to the removed navigator.modelContextTesting for older builds.
|
||||
* Alternative harness: Puppeteer's first-class WebMCP API (pptr.dev/guides/webmcp).
|
||||
*/
|
||||
import { chromium, expect, test } from '@playwright/test';
|
||||
import type { BrowserContext, Page } from '@playwright/test';
|
||||
|
||||
const BASE_URL = process.env.WEBMCP_BASE_URL ?? 'http://localhost:5173';
|
||||
|
||||
let context: BrowserContext;
|
||||
let page: Page;
|
||||
|
||||
test.beforeAll(async () => {
|
||||
context = await chromium.launchPersistentContext('', {
|
||||
channel: 'chrome',
|
||||
headless: false,
|
||||
args: ['--enable-features=WebMCP,WebMCPTesting'],
|
||||
});
|
||||
page = await context.newPage();
|
||||
});
|
||||
|
||||
test.afterAll(async () => {
|
||||
await context.close();
|
||||
});
|
||||
|
||||
/** Enumerate registered tools; inputSchema comes back as a STRING (JSON Schema). */
|
||||
async function listTools(p: Page): Promise<
|
||||
Array<{
|
||||
name: string;
|
||||
inputSchema?: string;
|
||||
annotations?: { readOnlyHint?: boolean; untrustedContentHint?: boolean };
|
||||
}>
|
||||
> {
|
||||
return p.evaluate(async () => {
|
||||
const mc = (document as any).modelContext ?? (navigator as any).modelContext;
|
||||
if (mc?.getTools) return mc.getTools();
|
||||
const legacy = (navigator as any).modelContextTesting; // removed 2026-07; older builds only
|
||||
if (legacy?.listTools) return legacy.listTools();
|
||||
throw new Error('No WebMCP enumeration surface — wrong Chrome build or flags');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a tool. Contract (Chrome): resolves to a string result, or null when the
|
||||
* execution navigated; execution/validation failures REJECT — assert with
|
||||
* expect(...).rejects where a failure is the expected outcome.
|
||||
*/
|
||||
async function executeTool(p: Page, name: string, args: object): Promise<string | null> {
|
||||
return p.evaluate(
|
||||
async ({ name, args }) => {
|
||||
const mc = (document as any).modelContext ?? (navigator as any).modelContext;
|
||||
if (mc?.getTools && mc?.executeTool) {
|
||||
const tools = await mc.getTools();
|
||||
const tool = tools.find((t: { name: string }) => t.name === name);
|
||||
if (!tool) throw new Error(`tool ${name} is not registered`);
|
||||
return mc.executeTool(tool, JSON.stringify(args));
|
||||
}
|
||||
const legacy = (navigator as any).modelContextTesting;
|
||||
if (legacy?.executeTool) return legacy.executeTool(name, JSON.stringify(args));
|
||||
throw new Error('No WebMCP execution surface — wrong Chrome build or flags');
|
||||
},
|
||||
{ name, args },
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* registerTool is ASYNC — a tool is not enumerable the instant the page loads.
|
||||
* Poll (or await a `toolchange` event) instead of asserting immediately.
|
||||
*/
|
||||
async function waitForTool(p: Page, name: string, timeoutMs = 5000): Promise<boolean> {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
for (;;) {
|
||||
const tools = await listTools(p);
|
||||
if (tools.some((t) => t.name === name)) return true;
|
||||
if (Date.now() >= deadline) return false;
|
||||
await p.waitForTimeout(100);
|
||||
}
|
||||
}
|
||||
|
||||
/** The modern surface exposes annotations; the legacy fallback does not. */
|
||||
async function hasModernSurface(p: Page): Promise<boolean> {
|
||||
return p.evaluate(() => {
|
||||
const mc = (document as any).modelContext ?? (navigator as any).modelContext;
|
||||
return !!mc?.getTools;
|
||||
});
|
||||
}
|
||||
|
||||
test('WebMCP is available in the test environment', async () => {
|
||||
await page.goto(BASE_URL);
|
||||
const available = await page.evaluate(
|
||||
() => !!(document as any).modelContext || !!(navigator as any).modelContext,
|
||||
);
|
||||
expect(available, 'Enable chrome://flags/#enable-webmcp-testing and use current Chrome').toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
// ── Generated per manifest tool ──────────────────────────────────────────────
|
||||
// Complete example for a read-only imperative tool. Fill route/examples/expect
|
||||
// from the manifest entry; for `auth != none`, sign in with the recorded
|
||||
// app.authFixtures fixture before the tool tests — once per role listed in `auth`.
|
||||
|
||||
test.describe('search_tickets', () => {
|
||||
test.beforeEach(async () => {
|
||||
// Navigate per TEST, not per describe — an earlier test may have navigated
|
||||
// away (executeTool returning null means exactly that).
|
||||
await page.goto(`${BASE_URL}/projects/demo/tickets`); // manifest: route
|
||||
});
|
||||
|
||||
test('is registered with the expected schema and annotations', async () => {
|
||||
expect(await waitForTool(page, 'search_tickets')).toBe(true); // async registration — poll
|
||||
const tools = await listTools(page);
|
||||
const tool = tools.find((t) => t.name === 'search_tickets')!;
|
||||
const schema = JSON.parse(tool.inputSchema ?? '{}'); // stringified → parse first
|
||||
expect(schema.required).toContain('query'); // manifest: inputSchema
|
||||
if (await hasModernSurface(page)) {
|
||||
// manifest: annotations — assert exactly what the manifest recorded
|
||||
expect(tool.annotations?.readOnlyHint).toBe(true);
|
||||
expect(tool.annotations?.untrustedContentHint).toBe(true);
|
||||
} else {
|
||||
// Legacy modelContextTesting fallback cannot enumerate annotations —
|
||||
// skip the assertion and record the gap in the report.
|
||||
test.info().annotations.push({
|
||||
type: 'webmcpify',
|
||||
description: 'annotations not enumerable on this Chrome build — assertion skipped',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test('executes the valid example and changes the UI', async () => {
|
||||
expect(await waitForTool(page, 'search_tickets')).toBe(true);
|
||||
// Capture the relevant UI state BEFORE executing — success must be a DELTA,
|
||||
// not mere visibility of something that was already on screen.
|
||||
const before = await page.getByRole('list', { name: 'Tickets' }).innerText();
|
||||
const out = await executeTool(page, 'search_tickets', { query: 'test' }); // manifest: examples.valid
|
||||
expect(out).not.toBeNull(); // null would mean "navigated" — not expected for this tool
|
||||
expect(out).not.toMatch(/^ERROR:/);
|
||||
await expect(page.getByRole('list', { name: 'Tickets' })).toBeVisible(); // manifest: expect.ui
|
||||
const after = await page.getByRole('list', { name: 'Tickets' }).innerText();
|
||||
expect(after).not.toBe(before); // the UI actually changed
|
||||
});
|
||||
|
||||
test('rejects the invalid example with a self-correcting message', async () => {
|
||||
// Prove the tool is PRESENT first — otherwise this test can "pass" on a
|
||||
// rejection that merely means the tool never registered.
|
||||
expect(await waitForTool(page, 'search_tickets')).toBe(true);
|
||||
const out = await executeTool(page, 'search_tickets', {}); // manifest: examples.invalid
|
||||
expect(out).toMatch(/^ERROR:/); // imperative convention: resolves with "ERROR: ..."
|
||||
// Declarative tools instead REJECT on schema/validation failures — for those,
|
||||
// generate: await expect(executeTool(page, '<tool>', {})).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
// Complete example for a MUTATING DECLARATIVE form tool. Chrome fills the form,
|
||||
// then PAUSES the execution until a real submit interaction happens — awaiting
|
||||
// executeTool alone deadlocks. Start it unawaited, wait for the agent-filled
|
||||
// value, click submit, then await the result.
|
||||
|
||||
test.describe('send_contact_message', () => {
|
||||
test.beforeEach(async () => {
|
||||
// Navigate per test — a submit-navigating execution leaves the route.
|
||||
await page.goto(`${BASE_URL}/contact`); // manifest: route
|
||||
});
|
||||
|
||||
test('executes via the concurrent submit-click pattern', async () => {
|
||||
expect(await waitForTool(page, 'send_contact_message')).toBe(true);
|
||||
// 1. Start the execution WITHOUT awaiting it (Chrome pauses it at the form).
|
||||
const pending = executeTool(page, 'send_contact_message', {
|
||||
email: 'qa@example.test', // manifest: examples.valid
|
||||
message: '[webmcpify verification] harness test message',
|
||||
});
|
||||
// 2. Wait until the agent-filled value is visible in the form.
|
||||
await expect(page.getByLabel('Email')).toHaveValue('qa@example.test');
|
||||
// 3. Perform the real submit interaction that resumes the paused execution.
|
||||
await page.getByRole('button', { name: 'Send' }).click();
|
||||
// 4. Now the promise settles.
|
||||
const out = await pending;
|
||||
if (out === null) {
|
||||
// null = the execution navigated (submit-navigating form) — assert the
|
||||
// destination instead of the return value. beforeEach restores the route.
|
||||
await expect(page).toHaveURL(/thank-you/); // manifest: expect.navigation
|
||||
} else {
|
||||
expect(out).not.toMatch(/^ERROR:/);
|
||||
expect(out).toContain('received'); // manifest: expect.result
|
||||
}
|
||||
// manifest: cleanup — mutating:"server" tools MUST undo the side effect here
|
||||
// (e.g. delete the test message via the UI's own admin path).
|
||||
});
|
||||
});
|
||||
|
||||
// Complete example for a ZERO-PARAM READ tool with `examples.invalid` following
|
||||
// the zero-param convention ({"unexpected": true}). Dual-outcome: rejecting the
|
||||
// unexpected key OR resolving benignly (accept-and-ignore) are BOTH passes —
|
||||
// what must never pass is a missing tool or a missing WebMCP surface.
|
||||
|
||||
test.describe('get_page_summary', () => {
|
||||
test.beforeEach(async () => {
|
||||
await page.goto(BASE_URL); // manifest: route
|
||||
});
|
||||
|
||||
test('handles unexpected input without side effects (dual-outcome)', async () => {
|
||||
expect(await waitForTool(page, 'get_page_summary')).toBe(true); // presence FIRST
|
||||
try {
|
||||
const out = await executeTool(page, 'get_page_summary', { unexpected: true }); // manifest: examples.invalid
|
||||
// Resolved: must be benign — a normal result (readOnlyHint tool: no side
|
||||
// effect possible) or a self-correcting "ERROR: ..." string.
|
||||
expect(out).not.toBeNull();
|
||||
} catch (err) {
|
||||
// Rejected: acceptable only as a validation rejection — a missing surface
|
||||
// or unregistered tool is a real failure, not a pass.
|
||||
expect(String(err)).not.toMatch(/No WebMCP|is not registered/);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,251 @@
|
||||
/**
|
||||
* webmcpify runtime (JavaScript variant) — vendored from https://github.com/TueJon/webmcpify
|
||||
*
|
||||
* MIT License
|
||||
* Copyright (c) 2026 Jonas Tüchler
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
* of this software and associated documentation files (the "Software"), to deal
|
||||
* in the Software without restriction, including without limitation the rights
|
||||
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
* copies of the Software, and to permit persons to whom the Software is
|
||||
* furnished to do so, subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in
|
||||
* all copies or substantial portions of the Software — keep this header when
|
||||
* copying this file into your project.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
* SOFTWARE.
|
||||
*
|
||||
* Full text: https://github.com/TueJon/webmcpify/blob/main/LICENSE
|
||||
*
|
||||
* Spec-shaped helper around the WebMCP API (document.modelContext). Vendor this
|
||||
* file; do not add it as a dependency. Everything is feature-detected: in browsers
|
||||
* without WebMCP every function is a safe no-op. This file is an ES module
|
||||
* (`export`) — load it via a bundler or `<script type="module">`; for
|
||||
* CommonJS/classic-script projects, transpile or vendor the TS variant instead.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {(() => void) & { ready: Promise<boolean> }} ToolScopeHandle
|
||||
* Callable dispose handle: call it to dispose (aborts the registration signal,
|
||||
* frees the key). `ready` resolves true when all registrations committed and the
|
||||
* scope is still active; false on no WebMCP / key already active / registration
|
||||
* failure (rolled back) / disposed first. Never rejects — failures go to onError.
|
||||
*/
|
||||
|
||||
/** The ONLY place the raw API is referenced — spec churn is a one-file fix. */
|
||||
export function getModelContext() {
|
||||
if (typeof document !== 'undefined' && document.modelContext) return document.modelContext;
|
||||
// Deprecated surface used by the Chrome 149 origin trial; remove when obsolete.
|
||||
if (typeof navigator !== 'undefined' && navigator.modelContext) return navigator.modelContext;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function isWebMCPAvailable() {
|
||||
return getModelContext() !== undefined;
|
||||
}
|
||||
|
||||
const scopes = new Map();
|
||||
|
||||
/**
|
||||
* @param {() => void} dispose
|
||||
* @param {Promise<boolean>} ready
|
||||
* @returns {ToolScopeHandle}
|
||||
*/
|
||||
function makeHandle(dispose, ready) {
|
||||
return Object.assign(dispose, { ready });
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a set of tools under one scope key. Returns a callable dispose handle
|
||||
* carrying `ready: Promise<boolean>` (see ToolScopeHandle).
|
||||
*
|
||||
* - AbortSignal is the spec's only unregistration mechanism — dispose aborts it.
|
||||
* - Validation runs BEFORE any registration, so a bad contract never leaves a
|
||||
* half-registered scope.
|
||||
* - Registration failures — rejections AND synchronously throwing registerTool
|
||||
* implementations (pre-2026-07 Chromium) — roll back the entire scope, resolve
|
||||
* `ready` to false, and are reported via `onError` (default: console.error);
|
||||
* the key is never stranded. `onError` is not called when the scope is
|
||||
* disposed before registration settles.
|
||||
* - Disposing before registration settles (e.g. React StrictMode unmount) rolls
|
||||
* back silently: `ready` resolves false and `onError` is NOT called.
|
||||
* - Calling with a key that is already active returns a no-op handle
|
||||
* (`ready` → false) and leaves the existing scope untouched.
|
||||
*
|
||||
* @param {string} key
|
||||
* @param {Array<object>} tools
|
||||
* @param {{ exposedTo?: string[], validate?: boolean, onError?: (e: unknown) => void }} [options]
|
||||
* @returns {ToolScopeHandle}
|
||||
*/
|
||||
export function createToolScope(key, tools, options) {
|
||||
const mc = getModelContext();
|
||||
if (!mc) return makeHandle(() => {}, Promise.resolve(false));
|
||||
if (scopes.has(key)) return makeHandle(() => {}, Promise.resolve(false));
|
||||
|
||||
if (shouldValidate(options)) for (const tool of tools) validateTool(tool);
|
||||
|
||||
const controller = new AbortController();
|
||||
scopes.set(key, controller);
|
||||
|
||||
let disposed = false;
|
||||
const rollback = () => {
|
||||
if (scopes.get(key) === controller) {
|
||||
controller.abort();
|
||||
scopes.delete(key);
|
||||
}
|
||||
};
|
||||
|
||||
const registerOptions = { signal: controller.signal };
|
||||
if (options?.exposedTo) registerOptions.exposedTo = options.exposedTo;
|
||||
|
||||
let registrations;
|
||||
try {
|
||||
// Legacy registerTool implementations throw synchronously instead of
|
||||
// rejecting — normalize so the rollback path below covers both.
|
||||
registrations = Promise.all(tools.map((tool) => mc.registerTool(tool, registerOptions)));
|
||||
} catch (error) {
|
||||
registrations = Promise.reject(error);
|
||||
}
|
||||
|
||||
const ready = registrations.then(
|
||||
() => scopes.get(key) === controller, // false when disposed before settling
|
||||
(error) => {
|
||||
rollback();
|
||||
if (!disposed) {
|
||||
const report =
|
||||
options?.onError ??
|
||||
((e) => console.error(`webmcpify: registration failed for scope "${key}"`, e));
|
||||
report(error);
|
||||
}
|
||||
return false;
|
||||
},
|
||||
);
|
||||
|
||||
return makeHandle(() => {
|
||||
disposed = true;
|
||||
rollback();
|
||||
}, ready);
|
||||
}
|
||||
|
||||
/**
|
||||
* Bridge execute() to the app's own event/state flow. The dispatched detail
|
||||
* carries `{ ...detail, requestId, signal }` — `signal` is an AbortSignal aborted
|
||||
* on timeout; handlers should pass it to fetch() and skip state commits and the
|
||||
* completion dispatch once aborted. Resolves only after the component confirms
|
||||
* the outcome by dispatching `tool-completion-<requestId>` with
|
||||
* `detail: { ok: boolean, message?: string, error?: string }` — and it must do so
|
||||
* AFTER the async work truly finished (awaited fetch/state commit/render),
|
||||
* because agents plan from what is on screen.
|
||||
*
|
||||
* The completion contract fails closed: `ok === true` resolves the message,
|
||||
* `ok === false` resolves `"ERROR: <error>"`, and anything else (missing or
|
||||
* non-boolean `ok`, no detail) resolves an ERROR string reporting an unknown
|
||||
* outcome. Timeouts resolve an ERROR string too. Never rejects — the model can
|
||||
* self-correct without unhandled rejections inside execute().
|
||||
*
|
||||
* @param {string} eventName
|
||||
* @param {Record<string, unknown>} [detail]
|
||||
* @param {number} [timeoutMs]
|
||||
* @returns {Promise<string>}
|
||||
*/
|
||||
export function dispatchAndWait(eventName, detail = {}, timeoutMs = 10000) {
|
||||
return new Promise((resolve) => {
|
||||
const requestId = Math.random().toString(36).slice(2, 12);
|
||||
const completionEvent = `tool-completion-${requestId}`;
|
||||
const abort = new AbortController();
|
||||
const cleanup = () => {
|
||||
clearTimeout(timer);
|
||||
window.removeEventListener(completionEvent, onDone);
|
||||
};
|
||||
const timer = setTimeout(() => {
|
||||
cleanup();
|
||||
abort.abort();
|
||||
resolve(
|
||||
'ERROR: The interface did not confirm this action in time. The request was signalled to cancel but may still be processing — check the current page state before retrying.',
|
||||
);
|
||||
}, timeoutMs);
|
||||
const onDone = (event) => {
|
||||
cleanup();
|
||||
const result = event.detail ?? {};
|
||||
if (result.ok === true) {
|
||||
resolve(result.message ?? 'Action completed successfully.');
|
||||
} else if (result.ok === false) {
|
||||
resolve(`ERROR: ${result.error ?? 'The action failed.'}`);
|
||||
} else {
|
||||
resolve(
|
||||
'ERROR: The interface sent a completion without a boolean `ok` — the outcome is unknown. Check the current page state before retrying.',
|
||||
);
|
||||
}
|
||||
};
|
||||
window.addEventListener(completionEvent, onDone);
|
||||
window.dispatchEvent(
|
||||
new CustomEvent(eventName, { detail: { ...detail, requestId, signal: abort.signal } }),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Serialize a tool's execute(): while one call is in flight, further calls
|
||||
* resolve immediately to a busy ERROR string instead of racing shared UI state.
|
||||
*
|
||||
* ```js
|
||||
* execute: singleFlight(async (input) => dispatchAndWait('webmcp:save', input)),
|
||||
* ```
|
||||
*
|
||||
* @template {unknown[]} A
|
||||
* @param {(...args: A) => string | Promise<string>} fn
|
||||
* @param {string} [busyMessage]
|
||||
* @returns {(...args: A) => Promise<string>}
|
||||
*/
|
||||
export function singleFlight(
|
||||
fn,
|
||||
busyMessage = 'ERROR: A previous invocation of this tool is still in progress. Wait for it to finish, then check the current page state before retrying.',
|
||||
) {
|
||||
let inFlight = false;
|
||||
return async (...args) => {
|
||||
if (inFlight) return busyMessage;
|
||||
inFlight = true;
|
||||
try {
|
||||
return await fn(...args);
|
||||
} finally {
|
||||
inFlight = false;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {{ validate?: boolean }} [options]
|
||||
* Default: enabled when the bundler substitutes `process.env.NODE_ENV`
|
||||
* (Vite/webpack automatic; esbuild via `--define`) and it isn't `'production'`;
|
||||
* unbundled projects default to false — pass `validate: true` during development.
|
||||
*/
|
||||
function shouldValidate(options) {
|
||||
if (options?.validate !== undefined) return options.validate;
|
||||
// Bundlers substitute the literal; unbundled, the bare reference throws → false.
|
||||
try {
|
||||
return process.env.NODE_ENV !== 'production';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Contract-quality checks (Google's recommended budgets). */
|
||||
function validateTool(tool) {
|
||||
const problems = [];
|
||||
if (!/^[a-zA-Z0-9_.-]{1,30}$/.test(tool.name)) {
|
||||
problems.push(`name "${tool.name}" should be 1-30 chars of [a-zA-Z0-9_.-]`);
|
||||
}
|
||||
if (!tool.description) problems.push(`tool "${tool.name}" is missing a description`);
|
||||
else if (tool.description.length > 500) {
|
||||
problems.push(`tool "${tool.name}" description exceeds 500 chars`);
|
||||
}
|
||||
if (problems.length) throw new Error(`webmcpify: ${problems.join('; ')}`);
|
||||
}
|
||||
@@ -0,0 +1,266 @@
|
||||
/**
|
||||
* webmcpify runtime — vendored from https://github.com/TueJon/webmcpify
|
||||
*
|
||||
* MIT License
|
||||
* Copyright (c) 2026 Jonas Tüchler
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
* of this software and associated documentation files (the "Software"), to deal
|
||||
* in the Software without restriction, including without limitation the rights
|
||||
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
* copies of the Software, and to permit persons to whom the Software is
|
||||
* furnished to do so, subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in
|
||||
* all copies or substantial portions of the Software — keep this header when
|
||||
* copying this file into your project.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
* SOFTWARE.
|
||||
*
|
||||
* Full text: https://github.com/TueJon/webmcpify/blob/main/LICENSE
|
||||
*
|
||||
* Spec-shaped helper around the WebMCP API (document.modelContext, W3C Web Machine
|
||||
* Learning CG draft / Chrome origin trial). Vendor this file; do not add it as a
|
||||
* dependency. Everything is feature-detected: in browsers without WebMCP every
|
||||
* function is a safe no-op and the app behaves identically.
|
||||
*/
|
||||
|
||||
/**
|
||||
* The bundler substitutes `process.env.NODE_ENV` with a literal (Vite/webpack do
|
||||
* this automatically; esbuild via `--define`). This declaration only satisfies the
|
||||
* typechecker — no Node ambient types are required or wanted in a browser file.
|
||||
*/
|
||||
declare const process: { env: { NODE_ENV?: string } };
|
||||
|
||||
export interface ToolScopeOptions {
|
||||
exposedTo?: string[];
|
||||
/**
|
||||
* Contract validation (name/description budgets). Default: enabled when the
|
||||
* bundler substitutes `process.env.NODE_ENV` (Vite/webpack automatic; esbuild
|
||||
* via `--define`) and it isn't `'production'`; unbundled projects default to
|
||||
* false — pass `validate: true` during development.
|
||||
*/
|
||||
validate?: boolean;
|
||||
/**
|
||||
* Called if any registration in the scope fails (the scope is rolled back).
|
||||
* Not called when the scope is disposed before registration settles.
|
||||
*/
|
||||
onError?: (error: unknown) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Callable dispose handle returned by `createToolScope` — call it to dispose
|
||||
* (backwards-compatible with `const dispose = createToolScope(...)`).
|
||||
*/
|
||||
export interface ToolScopeHandle {
|
||||
/** Dispose: aborts the registration signal, frees the key. */
|
||||
(): void;
|
||||
/**
|
||||
* true = all registrations committed and the scope is still active;
|
||||
* false = no WebMCP / key already active / registration failed (rolled back) /
|
||||
* disposed first. Never rejects — failures go to `onError`.
|
||||
*/
|
||||
ready: Promise<boolean>;
|
||||
}
|
||||
|
||||
/** The ONLY place the raw API is referenced — spec churn is a one-file fix. */
|
||||
export function getModelContext(): ModelContext | undefined {
|
||||
if (typeof document !== 'undefined' && document.modelContext) return document.modelContext;
|
||||
// Deprecated surface used by the Chrome 149 origin trial; remove when obsolete.
|
||||
if (typeof navigator !== 'undefined' && navigator.modelContext) return navigator.modelContext;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function isWebMCPAvailable(): boolean {
|
||||
return getModelContext() !== undefined;
|
||||
}
|
||||
|
||||
const scopes = new Map<string, AbortController>();
|
||||
|
||||
function makeHandle(dispose: () => void, ready: Promise<boolean>): ToolScopeHandle {
|
||||
const handle = dispose as ToolScopeHandle;
|
||||
handle.ready = ready;
|
||||
return handle;
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a set of tools under one scope key. Returns a callable dispose handle
|
||||
* carrying `ready: Promise<boolean>` (see ToolScopeHandle).
|
||||
*
|
||||
* - AbortSignal is the spec's only unregistration mechanism — dispose aborts it.
|
||||
* - Validation runs BEFORE any registration, so a bad contract never leaves a
|
||||
* half-registered scope.
|
||||
* - Registration failures — rejections AND synchronously throwing registerTool
|
||||
* implementations (pre-2026-07 Chromium) — roll back the entire scope, resolve
|
||||
* `ready` to false, and are reported via `onError` (default: console.error);
|
||||
* the key is never stranded.
|
||||
* - Disposing before registration settles (e.g. React StrictMode unmount) rolls
|
||||
* back silently: `ready` resolves false and `onError` is NOT called.
|
||||
* - Calling with a key that is already active returns a no-op handle
|
||||
* (`ready` → false) and leaves the existing scope untouched.
|
||||
*/
|
||||
export function createToolScope(
|
||||
key: string,
|
||||
tools: ModelContextTool[],
|
||||
options?: ToolScopeOptions,
|
||||
): ToolScopeHandle {
|
||||
const mc = getModelContext();
|
||||
if (!mc) return makeHandle(() => {}, Promise.resolve(false));
|
||||
if (scopes.has(key)) return makeHandle(() => {}, Promise.resolve(false));
|
||||
|
||||
if (shouldValidate(options)) for (const tool of tools) validateTool(tool);
|
||||
|
||||
const controller = new AbortController();
|
||||
scopes.set(key, controller);
|
||||
|
||||
let disposed = false;
|
||||
const rollback = () => {
|
||||
if (scopes.get(key) === controller) {
|
||||
controller.abort();
|
||||
scopes.delete(key);
|
||||
}
|
||||
};
|
||||
|
||||
const registerOptions: { signal: AbortSignal; exposedTo?: string[] } = {
|
||||
signal: controller.signal,
|
||||
};
|
||||
if (options?.exposedTo) registerOptions.exposedTo = options.exposedTo;
|
||||
|
||||
let registrations: Promise<unknown>;
|
||||
try {
|
||||
// Legacy registerTool implementations throw synchronously instead of
|
||||
// rejecting — normalize so the rollback path below covers both.
|
||||
registrations = Promise.all(tools.map((tool) => mc.registerTool(tool, registerOptions)));
|
||||
} catch (error) {
|
||||
registrations = Promise.reject(error);
|
||||
}
|
||||
|
||||
const ready = registrations.then(
|
||||
() => scopes.get(key) === controller, // false when disposed before settling
|
||||
(error) => {
|
||||
rollback();
|
||||
if (!disposed) {
|
||||
const report =
|
||||
options?.onError ??
|
||||
((e: unknown) => console.error(`webmcpify: registration failed for scope "${key}"`, e));
|
||||
report(error);
|
||||
}
|
||||
return false;
|
||||
},
|
||||
);
|
||||
|
||||
return makeHandle(() => {
|
||||
disposed = true;
|
||||
rollback();
|
||||
}, ready);
|
||||
}
|
||||
|
||||
/**
|
||||
* Bridge execute() to the app's own event/state flow. The dispatched detail
|
||||
* carries `{ ...detail, requestId, signal }` — `signal` is an AbortSignal aborted
|
||||
* on timeout; handlers should pass it to fetch() and skip state commits and the
|
||||
* completion dispatch once aborted. Resolves only after the component confirms
|
||||
* the outcome by dispatching `tool-completion-<requestId>` with
|
||||
* `detail: { ok: boolean, message?: string, error?: string }` — and it must do so
|
||||
* AFTER the async work truly finished (awaited fetch/state commit/render),
|
||||
* because agents plan from what is on screen.
|
||||
*
|
||||
* The completion contract fails closed: `ok === true` resolves the message,
|
||||
* `ok === false` resolves `"ERROR: <error>"`, and anything else (missing or
|
||||
* non-boolean `ok`, no detail) resolves an ERROR string reporting an unknown
|
||||
* outcome. Timeouts resolve an ERROR string too. Never rejects — the model can
|
||||
* self-correct without unhandled rejections inside execute().
|
||||
*/
|
||||
export function dispatchAndWait(
|
||||
eventName: string,
|
||||
detail: Record<string, unknown> = {},
|
||||
timeoutMs = 10000,
|
||||
): Promise<string> {
|
||||
return new Promise((resolve) => {
|
||||
const requestId = Math.random().toString(36).slice(2, 12);
|
||||
const completionEvent = `tool-completion-${requestId}`;
|
||||
const abort = new AbortController();
|
||||
const cleanup = () => {
|
||||
clearTimeout(timer);
|
||||
window.removeEventListener(completionEvent, onDone as EventListener);
|
||||
};
|
||||
const timer = setTimeout(() => {
|
||||
cleanup();
|
||||
abort.abort();
|
||||
resolve(
|
||||
'ERROR: The interface did not confirm this action in time. The request was signalled to cancel but may still be processing — check the current page state before retrying.',
|
||||
);
|
||||
}, timeoutMs);
|
||||
const onDone = (event: Event) => {
|
||||
cleanup();
|
||||
const result =
|
||||
(event as CustomEvent<{ ok?: unknown; message?: string; error?: string }>).detail ?? {};
|
||||
if (result.ok === true) {
|
||||
resolve(result.message ?? 'Action completed successfully.');
|
||||
} else if (result.ok === false) {
|
||||
resolve(`ERROR: ${result.error ?? 'The action failed.'}`);
|
||||
} else {
|
||||
resolve(
|
||||
'ERROR: The interface sent a completion without a boolean `ok` — the outcome is unknown. Check the current page state before retrying.',
|
||||
);
|
||||
}
|
||||
};
|
||||
window.addEventListener(completionEvent, onDone as EventListener);
|
||||
window.dispatchEvent(
|
||||
new CustomEvent(eventName, { detail: { ...detail, requestId, signal: abort.signal } }),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Serialize a tool's execute(): while one call is in flight, further calls
|
||||
* resolve immediately to a busy ERROR string instead of racing shared UI state.
|
||||
*
|
||||
* ```ts
|
||||
* execute: singleFlight(async (input) => dispatchAndWait('webmcp:save', input)),
|
||||
* ```
|
||||
*/
|
||||
export function singleFlight<A extends unknown[]>(
|
||||
fn: (...args: A) => string | Promise<string>,
|
||||
busyMessage = 'ERROR: A previous invocation of this tool is still in progress. Wait for it to finish, then check the current page state before retrying.',
|
||||
): (...args: A) => Promise<string> {
|
||||
let inFlight = false;
|
||||
return async (...args: A) => {
|
||||
if (inFlight) return busyMessage;
|
||||
inFlight = true;
|
||||
try {
|
||||
return await fn(...args);
|
||||
} finally {
|
||||
inFlight = false;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function shouldValidate(options?: ToolScopeOptions): boolean {
|
||||
if (options?.validate !== undefined) return options.validate;
|
||||
// Bundlers substitute the literal; unbundled, the bare reference throws → false.
|
||||
try {
|
||||
return process.env.NODE_ENV !== 'production';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Contract-quality checks (Google's recommended budgets). */
|
||||
function validateTool(tool: ModelContextTool): void {
|
||||
const problems: string[] = [];
|
||||
if (!/^[a-zA-Z0-9_.-]{1,30}$/.test(tool.name)) {
|
||||
problems.push(`name "${tool.name}" should be 1-30 chars of [a-zA-Z0-9_.-]`);
|
||||
}
|
||||
if (!tool.description) problems.push(`tool "${tool.name}" is missing a description`);
|
||||
else if (tool.description.length > 500) {
|
||||
problems.push(`tool "${tool.name}" description exceeds 500 chars`);
|
||||
}
|
||||
if (problems.length) throw new Error(`webmcpify: ${problems.join('; ')}`);
|
||||
}
|
||||
Reference in New Issue
Block a user