vue-webmcp
v0.3.3
Published
Vue composable for registering WebMCP tools (document.modelContext) with lifecycle-managed registration and MCP result normalization.
Maintainers
Readme
vue-webmcp
A Vue composable that registers a WebMCP tool with the browser and ties its lifetime to the current component or effect scope.
The Vue counterpart to use-webmcp-tool (React, GoogleChromeLabs): agents observe identical registration behavior and results from both — the normalization matrix and re-registration rules are kept in behavioral lockstep.
Status (2026-08-27): built against the current WebMCP spec draft: the imperative API on
document.modelContext(registerTool+AbortSignalunregistration), withexecute(args, { signal })from Chrome 153. WebMCP is experimental: origin trial in Chrome (149→156; the Intent to Experiment estimates shipping in 157) and Edge (from 150), local testing viachrome://flags/#enable-webmcp-testing. ChatGPT Desktop's built-in browser consumes WebMCP tools as Site tools and Brave Leo has experimental support; the spec tracks this in its implementation status. WebKit has formally opposed the proposal; Mozilla is neutral. The composable feature-detects and degrades to a no-op everywhere the API is absent — treat it as progressive enhancement.
Install
npm install vue-webmcpRequires Vue 3.3+ as a peer dependency. Ships as ESM with TypeScript types. Its only dependency is webmcp-types, the spec's type definitions (no runtime code); importing vue-webmcp is enough to get document.modelContext typed, with no separate install. If you do install webmcp-types yourself, keep it on 0.1.x so the two copies agree.
Using Nuxt? See nuxt-webmcp for auto-imports and origin-trial token injection.
What it does
WebMCP lets a page expose JavaScript functions as "tools" that an AI agent (browser-built-in, iframe-hosted, or extension) can discover and call, instead of scraping the DOM or clicking through the UI. The raw API is imperative:
const controller = new AbortController()
document.modelContext.registerTool({
name: 'add-todo',
description: "Add a new item to the user's active todo list",
inputSchema: {
type: 'object',
properties: { text: { type: 'string', description: 'The text content of the todo item' } },
required: ['text'],
},
async execute({ text }) {
await addTodo(text)
return { content: [{ type: 'text', text: `Added todo item: "${text}" successfully.` }] }
},
}, { signal: controller.signal })
// Unregister later:
controller.abort()useWebMCPTool folds that into Vue's reactivity and lifecycle. Plain component state is all it needs:
<script setup lang="ts">
import { ref } from 'vue'
import { useWebMCPTool } from 'vue-webmcp'
const todos = ref<string[]>([])
const { isSupported, isRegistered, error } = useWebMCPTool({
name: 'add-todo',
description: "Add a new item to the user's active todo list",
inputSchema: {
type: 'object',
properties: { text: { type: 'string', description: 'The text content of the todo item' } },
required: ['text'],
},
async execute({ text }) {
todos.value.push(text)
return `Added todo item: "${text}" successfully.`
},
})
</script>
<template>
<p v-if="isSupported && isRegistered">add-todo is available to agents</p>
</template>The tool registers when the component mounts and unregisters automatically when it unmounts — the set of tools an agent sees stays in lockstep with what is actually on screen.
Tools that follow the UI
That lifecycle rule is the feature. A tool defined in a component exists only while that component is on screen, so an agent can tell where the person is from which tools are offered, and a tool never runs against a view that is not there:
<!-- NoteEditor.vue: save_note is offered only while an editor is open -->
<script setup lang="ts">
import { useWebMCPTool } from 'vue-webmcp'
const props = defineProps<{ noteId: string }>()
useWebMCPTool({
name: 'save_note',
description: 'Save the note currently being edited',
execute: () => save(props.noteId),
})
</script>Open two editors and the browser sees two registrations of save_note, so give such a tool a name that includes the instance (`save_note_${props.noteId}`) or keep one editor open at a time. For finer control than mount and unmount, enabled takes a ref or getter: enabled: () => route.name === 'notes' registers the tool only on that page.
With Pinia and vue-router
execute is an ordinary closure — no store is required, and the composable has no store integration to configure. But because execute reads reactive state live at call time, it composes naturally with one, and reactive options like enabled can follow the router:
<script setup lang="ts">
import { useRoute } from 'vue-router'
import { useWebMCPTool } from 'vue-webmcp'
import { useTodoStore } from '@/stores/todos'
const store = useTodoStore()
const route = useRoute()
const { isRegistered } = useWebMCPTool({
name: 'add-todo',
description: "Add a new item to the user's active todo list",
inputSchema: {
type: 'object',
properties: { text: { type: 'string', description: 'The text content of the todo item' } },
required: ['text'],
},
enabled: () => route.name === 'todos', // registered only while the todos page is active
async execute({ text }) {
store.add(text) // reads the live store at call time — no stale closures
return `Added todo item: "${text}" successfully.`
},
})
</script>You can also call useWebMCPTool inside a Pinia store or a bare effectScope for app-wide tools that shouldn't die with a component; teardown then runs on scope disposal instead of unmount.
Several tools from one component
One component often owns a group of tools. defineWebMCPTool() is a typed identity helper, so definitions can live in plain modules; useWebMCPTools() registers a list of them with options shared across the group:
// tools/notes.ts
import { defineWebMCPTool } from 'vue-webmcp'
import { useNotesStore } from '@/stores/notes'
export const searchNotes = defineWebMCPTool({
name: 'search_notes',
description: 'Search notes by text',
inputSchema: { type: 'object', properties: { query: { type: 'string' } }, required: ['query'] },
annotations: { readOnlyHint: true },
execute: ({ query }: { query: string }) => useNotesStore().search(query),
})<script setup lang="ts">
import { useWebMCPTools } from 'vue-webmcp'
import { useNotesStore } from '@/stores/notes'
import { addNote, getNote, searchNotes } from '@/tools/notes'
const store = useNotesStore()
const group = useWebMCPTools([searchNotes, getNote, addNote], {
enabled: () => store.loaded, // per-tool `enabled` wins over this
onError: (error, name) => console.error(name, error),
})
group.isRegistered // every enabled tool in the group is registered
group.byName.add_note.error // per-tool state is still there
</script>const { isSupported, isRegistered, error, byName } = useWebMCPTools(definitions, {
enabled, annotations, exposedTo, // shared defaults; a tool's own value wins
onError, // (error, name) => void
})Shared enabled, annotations, exposedTo and onError apply to each tool that does not set its own (a shared annotations replaces, it does not merge). Each tool still goes through useWebMCPTool, so the lifecycle, re-registration and normalization rules below apply unchanged, and one tool failing to register leaves the others registered; error holds the first failure. isRegistered counts only the tools that are enabled, so a group with its read tools on and its write tools off still reports registered. byName is keyed by each tool's name at setup time. An inline definition in the list gets args: any in execute; annotate the parameter, or write the tool with defineWebMCPTool to keep it typed.
API
const { isSupported, isRegistered, error } = useWebMCPTool({
name, // MaybeRefOrGetter<string> — tool identifier (required)
title, // MaybeRefOrGetter<string> — human-readable label for user-agent UI (optional)
description, // MaybeRefOrGetter<string> — natural-language description for the agent (required)
inputSchema, // MaybeRefOrGetter<object> — JSON Schema for the args (optional)
annotations, // MaybeRefOrGetter<{ readOnlyHint?, untrustedContentHint? }> (optional)
exposedTo, // MaybeRefOrGetter<string[]> — secure origins that may also call the tool (optional)
execute, // (args, { signal }) => result | Promise<result> (required)
enabled, // MaybeRefOrGetter<boolean> — register only while true (default true)
formatOutput, // (result, args) => any — optional shaper before MCP normalization
onError, // (error) => void — side effect when execute throws
})| return | type | meaning |
| --- | --- | --- |
| isSupported | Readonly<Ref<boolean>> | A modelContext API exists here. Flips reactively if an extension injects it late (rechecked every 500 ms for 10 s). |
| isRegistered | Readonly<Ref<boolean>> | The tool is currently registered with the browser. |
| error | Readonly<Ref<Error \| null>> | Registration failure, e.g. NotAllowedError from a tools Permissions Policy or SecurityError from an insecure exposedTo origin. |
Reactivity rules
name,title,description,inputSchema,annotations,exposedTo, andenabledaccept plain values, refs, or getters. Any change to them re-registers the tool; comparison is by content, so a rebuilt-but-identical schema object never churns.titleis a label the user agent may use when it refers to the tool in its own UI; agents work fromnameanddescription. Omit it and the user agent is free to display a value of its own. The spec recommends localizing it to the user's language.executeis not reactive input and never triggers re-registration. It reads reactive state live at call time —setup()runs once in Vue, so there is no stale-closure problem and no ref-mirroring dance.- The
Argstype parameter is unconstrained on purpose. If you wrap the composable and constrain it withArgs extends Record<string, unknown>, aninterfacewill not satisfy that constraint (interfaces have no implicit index signature) while atypealias with the same members will; declare argument shapes withtype, or leave the constraint off. - On the server (SSR) the composable is inert: no
documentaccess,isSupportedstaysfalse, registration happens after mount on the client. No hydration mismatch.
Cancellation
execute receives (args, { signal }). The browser aborts signal when the caller cancels the execution or goes away (Chrome 153+), so pass it to fetch and check it in long-running work:
async execute({ query }, { signal }) {
const response = await fetch(`/api/search?q=${encodeURIComponent(query)}`, { signal })
return response.json()
}Inside the composable an abort is a failure like any other: onError runs and execute resolves to an isError result. The caller that cancelled has already received the abort reason and does not see that result. On browsers that call execute without options, the composable supplies a signal that never aborts, so signal is always defined.
Result normalization
Whatever execute returns is normalized to an MCP tool result, identically to use-webmcp-tool:
- a string →
{ content: [{ type: "text", text }] } undefined/null→{ content: [] }(success, no payload)- a value already shaped as
{ content: [...] }→ passed through untouched - anything else (object/array/number) → JSON-serialized into a text block
- a thrown value — Error or not (
throw "not signed in",throw { code: 403 }) →{ content: [...], isError: true }, afteronError. Errors and objects with a stringmessage(an error thrown in another realm, a structured-cloned error) supply that message; strings pass through; anything else is JSON-serialized. A failure must never read as success to the agent. - a returned
Error→ treated exactly like a throw
Consuming tools: useRegisteredTools()
The other side of the API. getTools() returns the tools this document may call, executeTool() runs one in its owner's document, and the toolchange event fires when the set changes. useRegisteredTools() folds those into a reactive list, for an in-page agent, a dev panel, or an iframe-hosted agent reading a partner page's tools:
<script setup lang="ts">
import { useRegisteredTools } from 'vue-webmcp'
const { isSupported, tools, execute } = useRegisteredTools()
async function search(query: string) {
const tool = tools.value.find(t => t.name === 'search-posts')
if (!tool) return
const result = await execute(tool, { query })
// result is the tool's return value, e.g. { content: [{ type: 'text', text: '...' }] }
}
</script>
<template>
<ul v-if="isSupported">
<li v-for="tool in tools" :key="tool.name">{{ tool.name }}: {{ tool.description }}</li>
</ul>
</template>const { isSupported, tools, error, refresh, execute } = useRegisteredTools({
fromOrigins, // MaybeRefOrGetter<string[]> — secure origins whose tools to include (optional)
argumentFormat, // 'object' | 'json' — skip the detection described below (optional)
})| return | type | meaning |
| --- | --- | --- |
| isSupported | Readonly<Ref<boolean>> | getTools() and executeTool() exist here. A registration-only polyfill leaves this false. |
| tools | Readonly<ShallowRef<readonly RegisteredTool[]>> | The spec's RegisteredTool dictionaries (name, title, description, inputSchema, annotations, origin, window), sorted by name, refreshed on toolchange. |
| error | Readonly<Ref<Error \| null>> | Failure of the last getTools() call. |
| refresh | () => Promise<void> | Query again by hand. A failure lands in error. |
| execute | (tool, args?, { signal }?) => Promise<unknown> | Run a tool. Resolves with its result parsed from the JSON the browser returns; pass a signal to cancel. Rejects with the browser's DOMException when the tool or the browser fails, and with a NotSupportedError when the API is absent. |
Same lifecycle as useWebMCPTool: inert on the server, starts after mount in a component or immediately in a store or effectScope, waits up to 10 s for a late-injected API, and goes inert on scope disposal. Cross-origin tools need the other page to list your origin in exposedTo and you to list theirs in fromOrigins; the browser checks both before running anything.
Three transitional details are handled for you. Arguments go over as the JSON string Chrome shipped with, and switch to the object form the spec adopted once the browser rejects the string with a TypeError; a string handed to an object parameter fails before the tool runs, so nothing ever runs twice, and argumentFormat skips the detection. A stringified inputSchema from an older getTools() is parsed back into an object. Results come back parsed whether the browser returns JSON text (spec) or a polyfill returns the value itself; a polyfill returning a plain string that happens to be valid JSON is parsed too.
Declarative forms: useWebMCPForm()
Chrome's declarative API turns a <form toolname="…"> into a tool: the browser synthesizes the input schema from the fields, an agent fills them in and submits, and the page answers through SubmitEvent.respondWith(). useWebMCPForm() does the wiring, and the same handler serves a person clicking Submit:
<script setup lang="ts">
import { useWebMCPForm } from 'vue-webmcp'
const { attrs, isAgentActive, isSubmitting, error } = useWebMCPForm({
name: 'add_note',
description: 'Add a note to the list',
async execute({ text }) {
await addNote(String(text))
return `Added "${text}".`
},
})
</script>
<template>
<form v-bind="attrs" :class="{ 'agent-active': isAgentActive }">
<input name="text" toolparamdescription="The note" required />
<button type="submit" :disabled="isSubmitting">Add</button>
<p v-if="error">{{ error.message }}</p>
</form>
</template>attrs carries toolname, tooldescription, toolautosubmit (with autosubmit: true) and the submit handler. execute receives the fields as FormData entries (a string, a File for a file input, or an array when a name repeats, as with a checkbox group or <select multiple>) and the SubmitEvent; its return value is normalized like a useWebMCPTool result and handed to the agent when one asked, so a failure becomes an isError result rather than a rejected promise. A failure also lands in error and runs onError, so the page can show it.
The handler calls preventDefault() itself, which the spec requires before respondWith(), so the form's action never navigates: the handler is the submission, for people and agents alike. Once an agent has filled the form in, the person's click on Submit arrives with agentInvoked set and completes the tool call. The app-level call hooks see form submissions too, under the form's toolname; the character-budget checks do not apply, since the browser derives a form tool's description from its attributes.
isAgentActive follows the toolactivated / toolcancel events for this form's tool name, so you can bind a class instead of the :tool-form-active pseudo-class, which some CSS toolchains reject as unknown. For an autosubmit form Chrome submits before it dispatches toolactivated, and dispatches no toolcancel, so isAgentActive stays false there; use isSubmitting to show that call.
SubmitEvent.agentInvoked / respondWith() and the WebMCPEvent behind toolactivated / toolcancel are declared by this package, with the shapes Chromium implements, until webmcp-types covers the declarative API.
Configuration: hooks and budgets
App-level settings reach every useWebMCPTool call through Vue's provide/inject, read once when each tool is set up. Provide once, at the root:
// main.ts
import { WEBMCP_CONFIG } from 'vue-webmcp'
app.provide(WEBMCP_CONFIG, {
onToolCall: ({ name }) => track('tool_called', { name }),
onToolResult: ({ name, ok, ms }) => track('tool_result', { name, ok, ms }),
budgets: import.meta.env.MODE === 'test' ? 'error' : undefined,
})app.provide reaches everything, including tools registered from Pinia stores or under app.runWithContext. provideWebMCPConfig(config) in a component's setup() reaches that component's subtree only. Outside an injection context (a bare effectScope with no app) there is no config and the defaults apply.
| option | meaning |
| --- | --- |
| onToolCall({ name, args? }) | Runs before execute. |
| onToolResult({ name, ok, ms, response, error?, args? }) | Runs after normalization, for success and failure alike: ok is false when execute threw, returned an Error, or returned an isError result; error holds what was thrown; ms is timed from after onToolCall. Runs after the tool's own onError. |
| includeArgs | Put the call arguments in both payloads, as the same object execute receives (do not mutate it). Off by default: arguments often carry personal data that should not reach an analytics tool. |
| budgets | What happens when a name, description, parameter or result is over Chrome's character budgets: 'warn' logs (the development default); 'error' fails setup for an over-budget initial definition (a thrown error in a dev or test build; the server never validates), records a later over-budget change in error without registering, and turns an oversized result into an isError response, so a test run fails on any of them; false skips the checks (the production default). An explicit 'warn' is honoured in production too, so leave it unset outside test runs. |
A hook that throws is reported with a warning and never changes the tool's result.
Testing tools
vue-webmcp/testing ships the test double this package's own suite runs on: a stub document.modelContext that records registrations, respects the AbortSignal that unregisters, implements getTools() / executeTool() / toolchange as the spec describes, and adds names() and call() for assertions. It moves with the spec (the { signal } argument Chrome 153 added, for instance), so projects need not keep their own copy.
In vitest with jsdom or happy-dom, install a fresh stub per test:
import { installModelContextStub, uninstallModelContextStub } from 'vue-webmcp/testing'
import type { ModelContextStub } from 'vue-webmcp/testing'
let stub: ModelContextStub
beforeEach(() => {
stub = installModelContextStub()
})
afterEach(() => uninstallModelContextStub())
test('the editor offers save_note', async () => {
mount(NoteEditor)
expect(stub.names()).toEqual(['save_note'])
expect(await stub.call('save_note', { title: 'Groceries' })).toEqual({
content: [{ type: 'text', text: 'Saved "Groceries".' }],
})
})call returns what the tool's execute produced, which through useWebMCPTool is the normalized { content, isError } an agent would see.
In Playwright, the same stub as a string, installed before any page script runs. document.modelContext is typed as the browser API, so cast it to reach the helpers:
import { MODEL_CONTEXT_INIT_SCRIPT } from 'vue-webmcp/testing'
import type { ModelContextStub } from 'vue-webmcp/testing'
const stub = () => document.modelContext as unknown as ModelContextStub
await context.addInitScript(MODEL_CONTEXT_INIT_SCRIPT)
await page.goto('/notes')
expect(await page.evaluate(() => stub().names())).toEqual(['save_note'])
expect(await page.evaluate(() => stub().call('save_note', { title: 'x' }))).toEqual({
content: [{ type: 'text', text: 'Saved "x".' }],
})Puppeteer: page.evaluateOnNewDocument(MODEL_CONTEXT_INIT_SCRIPT). Import the string from Node (a fixture or setup file); a bundler that re-transforms the package with name-keeping helpers would leave them in the string.
Agent skill
The package ships a skill for coding agents at skills/vue-webmcp/SKILL.md, so it lands
in node_modules with the version you installed. It covers the composables, the testing
stub, and the mistakes models make from older training data. Agents that follow the
TanStack Intent convention pick it up with
npx @tanstack/intent install; anything else can read the file where it sits.
Security notes
Tools are an attack surface as much as an interface. Minimum hygiene:
- Mark tools that don't mutate state with
annotations: { readOnlyHint: true }; mark tools whose output embeds user- or third-party content withuntrustedContentHint: trueso agents don't follow it as instructions. - Stay within Chrome's character budgets: 500 characters per tool description, 150 per parameter description, 30 per tool or parameter name, 1.5K per tool output. Dev builds warn when you exceed any of them, and when a name is outside the spec grammar (
[a-zA-Z0-9_.-]{1,128});budgets: 'error'in the configuration makes them fail instead, which is what you want in a test run. - WebMCP requires a secure, origin-isolated context and is gated by the
toolsPermissions Policy (defaultself); denial surfaces as aNotAllowedErrorinerror. - A tool is visible to the registering page, its same-origin frames, and the browser's own agent by default.
exposedTo: ['https://agent.example']extends that to specific secure origins, for example an iframe-hosted agent, which also needsallow="tools"on its frame andgetTools({ fromOrigins })on its side. An entry that is not a potentially trustworthy origin makes registration fail:errorholds aSecurityErrorand the tool is not registered. - Registration is site-controlled: never expose an operation as a tool that you wouldn't expose as an unauthenticated-intent button — the agent acts with the signed-in user's session.
Trying it locally
- Chrome 149+ with
chrome://flags/#enable-webmcp-testingenabled (or an origin-trial token on your origin). Chrome 153+ passes{ signal }toexecute. - The Model Context Tool Inspector extension to list and invoke registered tools.
Live examples: the playground from this repo, and the Trip Splitter in webmcp-demos, which loads this package from a CDN with no build step.
Credits
The API design, result-normalization contract, and test matrix originate from use-webmcp-tool by Sarah Drasner (Google Chrome team). Portions of this package are derived from it under Apache-2.0 — see the NOTICE file.
This is an independent community project, not affiliated with or endorsed by Google.
License
Apache-2.0
