Install the runtime
- @mcp-b/global (recommended)
- @mcp-b/webmcp-polyfill (strict core)
The full runtime: polyfill, MCP server, transports, prompts, resources, sampling, and elicitation.
npm install @mcp-b/global
pnpm add @mcp-b/global
<script src="https://unpkg.com/@mcp-b/global@latest/dist/index.iife.js"></script>
@mcp-b/global accesses browser APIs on import. If you use SSR, see Handle SSR for required guards.Strict-core only: installs The polyfill checks for a browser environment internally and is SSR-safe. No client guards needed.For details on when to use the polyfill vs the full runtime, see Choose a Runtime.
navigator.modelContext with registerTool and unregisterTool. No MCP extensions, no transports.npm install @mcp-b/webmcp-polyfill
React users also need a hook package. Install
@mcp-b/react-webmcp (recommended) or usewebmcp alongside your chosen runtime. See Choose a hook package below.Initialize at your entry point
Import@mcp-b/global once before any component mounts. The import is a side effect that installs navigator.modelContext.
- React
- Vue
- Svelte
- Angular
- Next.js
- Astro
- Vanilla JS
import '@mcp-b/global';
import { createRoot } from 'react-dom/client';
import { App } from './App';
createRoot(document.getElementById('root')!).render(<App />);
import '@mcp-b/global';
import { createApp } from 'vue';
import App from './App.vue';
createApp(App).mount('#app');
import '@mcp-b/global';
import App from './App.svelte';
const app = new App({ target: document.getElementById('app')! });
export default app;
import '@mcp-b/global';
import { bootstrapApplication } from '@angular/platform-browser';
import { AppComponent } from './app/app.component';
bootstrapApplication(AppComponent);
Next.js App Router defaults to Server Components. Import The polyfill is idempotent. If tools live in multiple sections, import it in each feature layout.
@mcp-b/global in a Client Component layout, not the root layout.'use client';
import '@mcp-b/global';
export default function DashboardLayout({ children }) {
return <>{children}</>;
}
Do not make your root layout a Client Component. This disables SSR for your entire application.
Import inside a Alternatively, load the IIFE in your layout’s
<script> tag. Astro processes these through its bundler and runs them on the client.<script>
import '@mcp-b/global';
</script>
<head>:<head>
<script src="https://unpkg.com/@mcp-b/global@latest/dist/index.iife.js"></script>
</head>
import '@mcp-b/global';
<script src="https://unpkg.com/@mcp-b/global@latest/dist/index.iife.js"></script>
Register a tool
Each framework has its own lifecycle hooks for mount and unmount. Register tools on mount, unregister on unmount.- React
- Vue
- Svelte
- Angular
- Next.js
- Astro
- Vanilla JS
Two hook packages are available:
Both handle registration on mount and cleanup on unmount automatically.
| Package | Use when |
|---|---|
| @mcp-b/react-webmcp | You want the full MCP-B surface: Zod schemas, prompts, resources, sampling, elicitation |
| usewebmcp | You want strict-core navigator.modelContext tools only |
import { useWebMCP } from '@mcp-b/react-webmcp';
import { z } from 'zod';
export function LikeTool() {
const likeTool = useWebMCP({
name: 'posts_like',
description: 'Like a post by ID. Increments the like count.',
inputSchema: {
postId: z.string().uuid().describe('The post ID to like'),
},
annotations: {
title: 'Like Post',
readOnlyHint: false,
idempotentHint: true,
},
handler: async (input) => {
await api.posts.like(input.postId);
return { success: true, postId: input.postId };
},
});
return (
<div>
{likeTool.state.isExecuting && <p>Liking...</p>}
{likeTool.state.error && <p>Error: {likeTool.state.error.message}</p>}
</div>
);
}
Call
registerTool() in onMounted and unregisterTool() in onUnmounted. The execute function can read and write Vue reactive state through .value access.<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue';
const count = ref(0);
onMounted(() => {
navigator.modelContext.registerTool({
name: 'increment',
description: 'Increment the counter by a given amount',
inputSchema: {
type: 'object',
properties: {
amount: { type: 'number', description: 'Amount to add' },
},
},
async execute({ amount = 1 }) {
count.value += amount as number;
return {
content: [{ type: 'text', text: `Count: ${count.value}` }],
};
},
});
});
onUnmounted(() => {
navigator.modelContext.unregisterTool('increment');
});
</script>
<template>
<p>Count: {{ count }}</p>
</template>
Use
onMount to register and onDestroy to unregister. Svelte 5 runes work in the execute handler.<script lang="ts">
import { onMount, onDestroy } from 'svelte';
let count = $state(0);
onMount(() => {
navigator.modelContext.registerTool({
name: 'increment',
description: 'Increment the counter by a given amount',
inputSchema: {
type: 'object',
properties: {
amount: { type: 'number', description: 'Amount to add' },
},
},
async execute({ amount = 1 }) {
count += amount as number;
return {
content: [{ type: 'text', text: `Count: ${count}` }],
};
},
});
});
onDestroy(() => {
navigator.modelContext.unregisterTool('increment');
});
</script>
<p>Count: {count}</p>
Use
ngOnInit to register and ngOnDestroy to unregister.import { Component, OnInit, OnDestroy } from '@angular/core';
import '@mcp-b/global';
@Component({
selector: 'app-counter',
template: `<p>Count: {{ count }}</p>`,
})
export class CounterComponent implements OnInit, OnDestroy {
count = 0;
ngOnInit() {
if (!('modelContext' in navigator)) return;
navigator.modelContext.registerTool({
name: 'increment',
description: 'Increment the counter by a given amount',
inputSchema: {
type: 'object',
properties: {
amount: { type: 'number', description: 'Amount to add' },
},
},
execute: async ({ amount = 1 }) => {
this.count += amount as number;
return {
content: [{ type: 'text', text: `Count: ${this.count}` }],
};
},
});
}
ngOnDestroy() {
navigator.modelContext?.unregisterTool('increment');
}
}
Mark tool components with
'use client' and use the same React hooks.'use client';
import { useWebMCP } from '@mcp-b/react-webmcp';
import { z } from 'zod';
export function DashboardTools() {
useWebMCP({
name: 'get_metrics',
description: 'Get dashboard metrics for a date range',
inputSchema: {
startDate: z.string().describe('ISO date string'),
endDate: z.string().describe('ISO date string'),
},
handler: async ({ startDate, endDate }) => {
const res = await fetch(`/api/metrics?start=${startDate}&end=${endDate}`);
return await res.json();
},
});
return null;
}
Register tools inside a If you use View Transitions, unregister tools before navigation:
<script> tag. Astro bundles and deduplicates these scripts automatically.<script>
import '@mcp-b/global';
navigator.modelContext.registerTool({
name: 'get_page_title',
description: 'Get the current page title',
inputSchema: { type: 'object', properties: {} },
async execute() {
return {
content: [{ type: 'text', text: document.title }],
};
},
});
</script>
document.addEventListener('astro:before-preparation', () => {
navigator.modelContext.unregisterTool('get_page_title');
});
Call Unregister by name when the tool is no longer needed:
registerTool() directly after importing the runtime.import '@mcp-b/global';
navigator.modelContext.registerTool({
name: 'get-page-title',
description: 'Get the current page title',
inputSchema: { type: 'object', properties: {} },
async execute() {
return {
content: [{ type: 'text', text: document.title }],
};
},
});
navigator.modelContext.unregisterTool('get-page-title');
Create a reusable abstraction
React already has dedicated hook packages (@mcp-b/react-webmcp and usewebmcp), so no custom abstraction is needed. For other frameworks, extract the register/unregister lifecycle into a reusable pattern.
- Vue composable
- Svelte action
- Angular service
import { onMounted, onUnmounted } from 'vue';
export function useWebMCPTool(
tool: Parameters<typeof navigator.modelContext.registerTool>[0]
) {
onMounted(() => {
navigator.modelContext.registerTool(tool);
});
onUnmounted(() => {
navigator.modelContext.unregisterTool(tool.name);
});
}
<script setup lang="ts">
import { useWebMCPTool } from '@/composables/useWebMCPTool';
useWebMCPTool({
name: 'get_greeting',
description: 'Get a greeting message',
inputSchema: { type: 'object', properties: {} },
async execute() {
return { content: [{ type: 'text', text: 'Hello from Vue!' }] };
},
});
</script>
export function webmcpTool(
node: HTMLElement,
tool: Parameters<typeof navigator.modelContext.registerTool>[0]
) {
navigator.modelContext.registerTool(tool);
return {
destroy() {
navigator.modelContext.unregisterTool(tool.name);
},
};
}
<script lang="ts">
import { webmcpTool } from '$lib/actions/webmcp';
</script>
<div use:webmcpTool={{
name: 'get_greeting',
description: 'Get a greeting message',
inputSchema: { type: 'object', properties: {} },
execute: async () => ({
content: [{ type: 'text', text: 'Hello from Svelte!' }],
}),
}}>
Content here
</div>
import { Injectable, OnDestroy } from '@angular/core';
import '@mcp-b/global';
@Injectable({ providedIn: 'root' })
export class WebMCPService implements OnDestroy {
private registeredTools: string[] = [];
registerTool(tool: Parameters<typeof navigator.modelContext.registerTool>[0]) {
if (!('modelContext' in navigator)) return;
navigator.modelContext.registerTool(tool);
this.registeredTools.push(tool.name);
}
unregisterTool(name: string) {
navigator.modelContext?.unregisterTool(name);
this.registeredTools = this.registeredTools.filter((n) => n !== name);
}
ngOnDestroy() {
for (const name of this.registeredTools) {
navigator.modelContext?.unregisterTool(name);
}
this.registeredTools = [];
}
}
import { Component, OnInit, OnDestroy } from '@angular/core';
import { WebMCPService } from './webmcp.service';
@Component({
selector: 'app-greeting',
template: `<p>Greeting tool registered</p>`,
})
export class GreetingComponent implements OnInit, OnDestroy {
constructor(private webmcp: WebMCPService) {}
ngOnInit() {
this.webmcp.registerTool({
name: 'get_greeting',
description: 'Get a greeting message',
inputSchema: { type: 'object', properties: {} },
execute: async () => ({
content: [{ type: 'text', text: 'Hello from Angular!' }],
}),
});
}
ngOnDestroy() {
this.webmcp.unregisterTool('get_greeting');
}
}
Handle SSR
@mcp-b/global accesses browser APIs on import, so SSR frameworks need client-side guards. If you use @mcp-b/webmcp-polyfill instead, it is SSR-safe out of the box and these guards are not needed.
- Next.js
- Nuxt / Vue SSR
- SvelteKit
- Angular Universal
- Astro
Mark components with
'use client'. For components that access window or document directly, use dynamic imports:import dynamic from 'next/dynamic';
const BrowserOnly = dynamic(() => import('./BrowserOnly'), { ssr: false });
In Nuxt, guard with
import.meta.client:<script setup lang="ts">
import { onMounted, onUnmounted } from 'vue';
onMounted(() => {
if (!import.meta.client) return;
import('@mcp-b/global').then(() => {
navigator.modelContext.registerTool({
name: 'my_tool',
description: 'A tool that only runs on the client',
inputSchema: { type: 'object', properties: {} },
async execute() {
return { content: [{ type: 'text', text: 'Done' }] };
},
});
});
});
onUnmounted(() => {
if (import.meta.client) {
navigator.modelContext?.unregisterTool('my_tool');
}
});
</script>
Guard with To persist tools across route navigations in SvelteKit, register them in
browser from $app/environment:<script lang="ts">
import { browser } from '$app/environment';
import { onMount, onDestroy } from 'svelte';
import '@mcp-b/global';
onMount(() => {
if (!browser) return;
navigator.modelContext.registerTool({
name: 'my_tool',
description: 'A tool that only runs on the client',
inputSchema: { type: 'object', properties: {} },
async execute() {
return { content: [{ type: 'text', text: 'Done' }] };
},
});
});
onDestroy(() => {
if (browser) {
navigator.modelContext?.unregisterTool('my_tool');
}
});
</script>
+layout.svelte instead of +page.svelte.Guard with
isPlatformBrowser:import { Component, OnInit, OnDestroy, PLATFORM_ID, Inject } from '@angular/core';
import { isPlatformBrowser } from '@angular/common';
import '@mcp-b/global';
@Component({ selector: 'app-my', template: '' })
export class MyComponent implements OnInit, OnDestroy {
private isBrowser: boolean;
constructor(@Inject(PLATFORM_ID) platformId: object) {
this.isBrowser = isPlatformBrowser(platformId);
}
ngOnInit() {
if (!this.isBrowser || !('modelContext' in navigator)) return;
navigator.modelContext.registerTool({
name: 'my_tool',
description: 'A tool that only runs on the client',
inputSchema: { type: 'object', properties: {} },
execute: async () => ({
content: [{ type: 'text', text: 'Done' }],
}),
});
}
ngOnDestroy() {
if (this.isBrowser) {
navigator.modelContext?.unregisterTool('my_tool');
}
}
}
Astro renders pages as static HTML by default. Code inside
<script> tags runs on the client only, so no guard is needed for standard Astro pages. For framework islands, use client:load or client:only:---
import DashboardTools from '../components/DashboardTools';
---
<DashboardTools client:load />
Verify registration
Open the browser console and run:navigator.modelContextTesting?.listTools();
