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.
document.modelContext with registerTool, getTools, and executeTool. 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 document.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 document.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 abort the registration signal 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);
let controller: AbortController | undefined;
onMounted(() => {
controller = new AbortController();
document.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}` }],
};
},
},
{ signal: controller.signal }
);
});
onUnmounted(() => {
controller?.abort();
});
</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);
let controller: AbortController | undefined;
onMount(() => {
controller = new AbortController();
document.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}` }],
};
},
},
{ signal: controller.signal }
);
});
onDestroy(() => {
controller?.abort();
});
</script>
<p>Count: {count}</p>
Use
ngOnInit to register and ngOnDestroy to abort the registration signal.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;
private toolController?: AbortController;
ngOnInit() {
if (!('modelContext' in navigator)) return;
this.toolController = new AbortController();
document.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}` }],
};
},
},
{ signal: this.toolController.signal }
);
}
ngOnDestroy() {
this.toolController?.abort();
}
}
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, abort the registration before navigation:
<script> tag. Astro bundles and deduplicates these scripts automatically.<script>
import '@mcp-b/global';
const controller = new AbortController();
document.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 }],
};
},
},
{ signal: controller.signal }
);
</script>
document.addEventListener('astro:before-preparation', () => {
controller.abort();
});
Call Abort the registration when the tool is no longer needed:
registerTool() directly after importing the runtime.import '@mcp-b/global';
const controller = new AbortController();
document.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 }],
};
},
},
{ signal: controller.signal }
);
controller.abort();
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/abort lifecycle into a reusable pattern.
- Vue composable
- Svelte action
- Angular service
import { onMounted, onUnmounted } from 'vue';
export function useWebMCPTool(
tool: Parameters<typeof document.modelContext.registerTool>[0]
) {
let controller: AbortController | undefined;
onMounted(() => {
controller = new AbortController();
document.modelContext.registerTool(tool, { signal: controller.signal });
});
onUnmounted(() => {
controller?.abort();
});
}
<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 document.modelContext.registerTool>[0]
) {
const controller = new AbortController();
document.modelContext.registerTool(tool, { signal: controller.signal });
return {
destroy() {
controller.abort();
},
};
}
<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 controllers = new Map<string, AbortController>();
registerTool(tool: Parameters<typeof document.modelContext.registerTool>[0]) {
if (!('modelContext' in navigator)) return;
this.controllers.get(tool.name)?.abort();
const controller = new AbortController();
this.controllers.set(tool.name, controller);
document.modelContext.registerTool(tool, { signal: controller.signal });
}
removeTool(name: string) {
this.controllers.get(name)?.abort();
this.controllers.delete(name);
}
ngOnDestroy() {
for (const controller of this.controllers.values()) {
controller.abort();
}
this.controllers.clear();
}
}
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.removeTool('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';
let controller: AbortController | undefined;
onMounted(() => {
if (!import.meta.client) return;
import('@mcp-b/global').then(() => {
controller = new AbortController();
document.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' }] };
},
},
{ signal: controller.signal }
);
});
});
onUnmounted(() => {
controller?.abort();
});
</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';
let controller: AbortController | undefined;
onMount(() => {
if (!browser) return;
controller = new AbortController();
document.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' }] };
},
},
{ signal: controller.signal }
);
});
onDestroy(() => {
controller?.abort();
});
</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;
private controller?: AbortController;
constructor(@Inject(PLATFORM_ID) platformId: object) {
this.isBrowser = isPlatformBrowser(platformId);
}
ngOnInit() {
if (!this.isBrowser || !('modelContext' in navigator)) return;
this.controller = new AbortController();
document.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' }],
}),
},
{ signal: this.controller.signal }
);
}
ngOnDestroy() {
this.controller?.abort();
}
}
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();
