Build WebMCP tools with Vue.js using navigator.modelContext

Kashish Hora

Kashish Hora

Co-founder of MCPcat

Try out 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.modelContext API (see registering your first WebMCP tool)
  • A Chromium-based browser with chrome://flags/#enable-webmcp-testing enabled, 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:

  1. npx webmcp-server is running in a terminal
  2. The Chrome extension is activated on the tab (click icon, choose "Until Reload" or "Always On")
  3. 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.