Build WebMCP tools with Vue.js using navigator.modelContext
Kashish Hora
Co-founder of MCPcat
The Quick Answer
Build a useWebMcpTool composable that registers a tool on mount and unregisters it on unmount. No Vue-specific WebMCP library is needed — the browser API works directly with Vue's lifecycle hooks:
// composables/useWebMcpTool.ts
import { onMounted, onUnmounted } from "vue";
export function useWebMcpTool(tool: {
name: string;
description: string;
inputSchema?: object;
execute: (input: any, client: any) => Promise<any>;
}) {
onMounted(() => {
if ("modelContext" in navigator) {
navigator.modelContext.registerTool(tool);
}
});
onUnmounted(() => {
if ("modelContext" in navigator) {
navigator.modelContext.unregisterTool(tool.name);
}
});
}Vue's Composition API maps cleanly to the WebMCP lifecycle: onMounted registers the tool when the component enters the DOM, and onUnmounted removes it when the component is destroyed. Unlike React, Vue doesn't double-mount components in development, so no ownership tokens or deduplication logic is needed.
Prerequisites
- Vue 3.3+ project with TypeScript (Vite recommended)
- Understanding of Vue composables and the Composition API (
onMounted,onUnmounted,ref,watch) - Familiarity with the raw
navigator.modelContextAPI (see registering your first WebMCP tool) - A Chromium-based browser with
chrome://flags/#enable-webmcp-testingenabled, or a polyfill
Building the Composable
The minimal composable above works, but a production version should track execution state, handle errors, and support reactive tool definitions. Let's build it up piece by piece.
Type Definitions
Start by defining the types your composable will use. These mirror the W3C WebMCP spec's ModelContextTool dictionary:
// types/webmcp.ts
export interface ToolAnnotations {
readOnlyHint?: boolean;
destructiveHint?: boolean;
idempotentHint?: boolean;
}
export interface WebMcpToolConfig {
name: string;
description: string;
inputSchema?: Record<string, unknown>;
annotations?: ToolAnnotations;
execute: (input: any, client: ModelContextClient) => Promise<any>;
}
export interface ModelContextClient {
requestUserInteraction(callback: () => Promise<unknown>): Promise<unknown>;
}
export interface ToolExecutionState {
isExecuting: boolean;
lastResult: unknown | null;
error: Error | null;
executionCount: number;
}The ModelContextClient interface reflects the second argument passed to your execute callback by the browser. It provides requestUserInteraction for pausing tool execution to get user confirmation — useful for destructive actions like deleting data or submitting orders.
The Full Composable
With the types in place, here's the complete useWebMcpTool composable. It starts by setting up reactive state and a descriptor factory that wraps the raw execute callback with tracking:
// composables/useWebMcpTool.ts
import { ref, reactive, onMounted, onUnmounted } from "vue";
import type { WebMcpToolConfig, ToolExecutionState } from "@/types/webmcp";
export function useWebMcpTool(config: WebMcpToolConfig) {
const state = reactive<ToolExecutionState>({
isExecuting: false,
lastResult: null,
error: null,
executionCount: 0,
});
const isRegistered = ref(false);
function createDescriptor() {
return {
name: config.name,
description: config.description,
inputSchema: config.inputSchema,
annotations: config.annotations,
execute: async (input: any, client: any) => {
state.isExecuting = true;
state.error = null;
try {
const result = await config.execute(input, client);
state.lastResult = result;
state.executionCount++;
return result;
} catch (err) {
state.error = err instanceof Error ? err : new Error(String(err));
throw err;
} finally {
state.isExecuting = false;
}
},
};
}When an agent calls the tool, state.isExecuting flips to true and your component can react — showing a loading spinner, disabling inputs, or displaying results when the call completes. The executionCount increments on each successful invocation, which is useful for analytics or conditional UI.
The lifecycle hooks register the tool on mount and clean up on unmount:
onMounted(() => {
if (!("modelContext" in navigator)) return;
navigator.modelContext.registerTool(createDescriptor());
isRegistered.value = true;
});
onUnmounted(() => {
if (!("modelContext" in navigator) || !isRegistered.value) return;
try {
navigator.modelContext.unregisterTool(config.name);
} catch {
// Tool was already removed — safe to ignore
}
isRegistered.value = false;
});
return { state, isRegistered };
}The try/catch in onUnmounted guards against a race condition: if the page navigates away while a tool call is in flight, the browser may have already cleaned up the tool. The catch prevents an InvalidStateError from surfacing.
Why Vue Doesn't Need Ownership Tokens
If you've read the React WebMCP guide, you'll notice useMcpTool uses Symbol-based ownership tokens to handle React StrictMode's double-mounting behavior. Vue doesn't have this problem. In Vue, onMounted fires exactly once per component instance, and onUnmounted fires exactly once when the instance is destroyed. This means the composable can call registerTool and unregisterTool directly without any deduplication logic.
Using the Composable in Components
Drop useWebMcpTool into any component that should expose a tool to AI agents. The tool exists for exactly as long as the component is mounted:
<!-- FlightSearch.vue -->
<script setup lang="ts">
import { ref } from "vue";
import { useWebMcpTool } from "@/composables/useWebMcpTool";
const results = ref<Flight[]>([]);
const { state } = useWebMcpTool({
name: "search-flights",
description: "Search for available flights between two airports on a given date",
inputSchema: {
type: "object",
properties: {
origin: { type: "string", description: "Departure airport code (e.g. SFO)" },
destination: { type: "string", description: "Arrival airport code (e.g. JFK)" },
date: { type: "string", description: "Travel date in YYYY-MM-DD format" },
},
required: ["origin", "destination", "date"],
},
annotations: { readOnlyHint: true },
execute: async ({ origin, destination, date }) => {
const flights = await api.searchFlights({ origin, destination, date });
results.value = flights;
return { content: [{ type: "text", text: JSON.stringify(flights) }] };
},
});
</script>The template reacts to the composable's execution state — showing a loading message during tool calls and rendering results when complete:
<template>
<div v-if="state.isExecuting">Searching flights...</div>
<FlightResults v-else :flights="results" />
</template>The search-flights tool is registered when FlightSearch.vue mounts and removed when it unmounts. If this component only renders on a /flights route, the tool is only available to agents while the user is on that page. Navigate away and it disappears — no stale tools lingering in the background.
Vue Refs and Stale Closures
One advantage Vue has over React here is that the execute handler naturally stays current. In the example above, results.value = flights writes to a Vue ref. Because Vue refs are accessed via .value at read time, the handler always sees the latest state. In React, you'd need useRef and manual updates to avoid stale closures. With Vue, it just works.
This means you can safely reference any reactive state — Pinia stores, ref() values, computed() properties — inside your handler without worrying about capturing an outdated snapshot.
Reactive Tool Definitions with watch
Sometimes a tool's name or schema needs to change at runtime — for example, when the user switches between different data sources or feature flags toggle available capabilities. Use watch to re-register the tool when its definition changes:
// composables/useReactiveWebMcpTool.ts
import { watch, onUnmounted, ref, type Ref } from "vue";
import type { WebMcpToolConfig } from "@/types/webmcp";
export function useReactiveWebMcpTool(config: Ref<WebMcpToolConfig>) {
const currentName = ref<string | null>(null);
const stop = watch(
() => [config.value.name, config.value.description, JSON.stringify(config.value.inputSchema)],
() => {
if (!("modelContext" in navigator)) return;
// Unregister previous tool if it exists
if (currentName.value) {
try { navigator.modelContext.unregisterTool(currentName.value); } catch { /* */ }
}
navigator.modelContext.registerTool({
name: config.value.name,
description: config.value.description,
inputSchema: config.value.inputSchema,
annotations: config.value.annotations,
execute: config.value.execute,
});
currentName.value = config.value.name;
},
{ immediate: true },
);The watch with { immediate: true } fires on mount (replacing onMounted) and again whenever the watched dependencies change. The dependency array uses JSON.stringify on the schema to produce a stable comparison value — similar to how React's useMcpTool fingerprints schemas for its useEffect dependency array.
Cleanup stops the watcher and unregisters the last tool:
onUnmounted(() => {
stop();
if (!("modelContext" in navigator) || !currentName.value) return;
try { navigator.modelContext.unregisterTool(currentName.value); } catch { /* */ }
});
}For most use cases, the simpler useWebMcpTool composable is sufficient. Reach for useReactiveWebMcpTool only when tool definitions genuinely change at runtime. For patterns around conditional tool registration based on routes or user roles, see dynamically registering and unregistering WebMCP tools.
Checking WebMCP Availability
Wrap feature detection in a composable so components can conditionally render agent-specific UI:
// composables/useWebMcpStatus.ts
import { ref, onMounted } from "vue";
export function useWebMcpStatus() {
const available = ref(false);
onMounted(() => {
available.value = "modelContext" in navigator;
});
return { available };
}Use it to show a badge, gate functionality, or provide fallback behavior:
<script setup lang="ts">
import { useWebMcpStatus } from "@/composables/useWebMcpStatus";
const { available } = useWebMcpStatus();
</script>
<template>
<span v-if="available" class="badge">AI tools active</span>
</template>The check runs in onMounted to avoid SSR issues — navigator doesn't exist on the server. If you're using Nuxt, this composable is safe to use in any component since onMounted only fires on the client.
Connecting to Desktop AI Clients
Tools registered via navigator.modelContext live inside the browser tab. Desktop AI clients like Claude Code and Cursor can't see them directly. The webmcp-react package (despite the name) includes framework-agnostic infrastructure for bridging this gap: a Chrome extension and a local MCP server.
1. Install the Chrome extension
Download the extension from the webmcp-react GitHub releases and load it as an unpacked extension via chrome://extensions.
2. Start the MCP server
$npx webmcp-server
This starts a local server on port 12315 that receives tools from the Chrome extension via WebSocket and exposes them to AI clients over stdio.
3. Configure your AI client
For Claude Code:
$claude mcp add --transport stdio webmcp-server -- npx webmcp-server
For Cursor, add to .cursor/mcp.json:
{
"mcpServers": {
"webmcp-server": {
"command": "npx",
"args": ["webmcp-server"]
}
}
}4. Activate the extension
Click the extension icon on the tab running your Vue app. Choose an activation mode:
- Off — no tools exposed
- Until Reload — tools from this tab only, cleared on reload
- Always On — all pages on this origin, survives reloads and new tabs
Once active, your Vue component tools appear in the AI client namespaced as tab-{tabId}:{toolName}. The extension icon shows a green dot when connected to the MCP server.
Common Issues
InvalidStateError: A tool with this name already exists
This typically happens during Vite's hot module replacement (HMR). When a module is updated, Vite re-runs the <script setup> block without fully destroying the previous component instance, so the old tool registration may still exist.
Add an HMR cleanup handler in your composable:
onMounted(() => {
if (!("modelContext" in navigator)) return;
// Clean up stale HMR registration
try { navigator.modelContext.unregisterTool(config.name); } catch { /* */ }
navigator.modelContext.registerTool(createDescriptor());
isRegistered.value = true;
});This try/catch before registration is harmless in production (the catch fires if no tool exists) and prevents the InvalidStateError during development. An alternative is to use the useReactiveWebMcpTool variant shown above, which handles re-registration automatically via watch.
Handler doesn't see updated Pinia store state
If your execute handler reads from a Pinia store, make sure you're accessing the store inside the handler, not destructuring it at composable setup time:
// Correct: access store inside handler
execute: async (input) => {
const store = useFlightStore();
return { content: [{ type: "text", text: store.currentAirline }] };
}
// Incorrect: destructured at setup, captures snapshot
const { currentAirline } = useFlightStore();
execute: async (input) => {
return { content: [{ type: "text", text: currentAirline }] }; // stale!
}Destructuring a Pinia store extracts plain values that lose reactivity. Access the store directly inside the handler, or use storeToRefs() if you need to destructure reactive refs.
Tools not appearing in Claude Code or Cursor
Verify three things in order:
npx webmcp-serveris running in a terminal- The Chrome extension is activated on the tab (click icon, choose "Until Reload" or "Always On")
- The extension icon shows a green dot (yellow means the MCP server isn't reachable on port 12315)
If everything looks correct but tools still don't appear, open the browser console and run navigator.modelContextTesting.listTools() to confirm your tools are registered. If they show up there but not in the AI client, the issue is in the extension-to-server bridge.
Next Steps
This guide covered building WebMCP tools in Vue from scratch using navigator.modelContext directly. For a deeper understanding of the underlying API, see registering your first WebMCP tool. To add confirmation flows for destructive tools, see building a WebMCP confirmation flow with requestUserInteraction. For testing your tools before connecting them to AI clients, see testing WebMCP tools with the Model Context Inspector.
Related Guides
Register your first WebMCP tool with navigator.modelContext
How to use navigator.modelContext to register tools that AI agents can call directly from your website.
Add WebMCP tools to a React app with webmcp-react
How to use the webmcp-react library to register WebMCP tools in React with Zod schemas and connect them to desktop clients.
Build declarative WebMCP tools with HTML form attributes
Turn existing HTML forms into MCP tools using declarative attributes — no JavaScript required.