
Angular WebMCP: Turning Your App Into an AI-Ready Tool Server

D. Rout
July 4, 2026 11 min read
On this page
Today, when an AI agent "uses" your web app, it's usually doing the same thing a screen reader or a browser automation script does: reading the DOM, guessing at intent from labels and layout, then simulating clicks and keystrokes to drive a wizard it doesn't actually understand. That approach is brittle — a CSS refactor can break it — and it completely misses the business logic sitting inside your services, signals, and validators. Angular's new experimental WebMCP support takes a different approach: instead of making an agent fake being a user, your app hands it real, typed tools it can call directly.
In this tutorial we'll build a small product catalog app that registers WebMCP tools four different ways — at the application level, at the route level, from inside a service, and implicitly from a Signal Form. The full working project is on GitHub at angular-webmcp-catalog-demo if you want to clone it and follow along instead of copy-pasting.
⚠️ Before you start: this is experimental, and early. The WebMCP spec itself is a very early-stage W3C Community Group proposal and is undergoing frequent changes. Angular's support for it —
provideExperimentalWebMcpTools,declareExperimentalWebMcpTool,provideExperimentalWebMcpForms— is explicitly marked experimental, and the Angular team is upfront that these APIs "are subject to change even outside of major versions." Treat everything below as a spike you run on a low-stakes route, not something you wire into a production checkout flow this quarter.
Prerequisites
- Angular v22 or later (WebMCP support landed as part of the v22 "Build with AI" docs)
- Node.js 20.19+ or 22.12+, and npm 10+
- Angular CLI v22 (`npm install -g @angular/cli@22`)
- Familiarity with standalone components, dependency injection, and basic Signal Forms
- (Optional) Chrome 146+ if you want to test against the native
navigator.modelContextAPI behind its experimental flag - The [`@mcp-b/webmcp-polyfill`](https://www.npmjs.com/package/@mcp-b/webmcp-polyfill) npm package, so you can develop and test tools without flipping browser flags
- The WebMCP — Model Context Tool Inspector Chrome extension, to see which tools your app has registered and call them manually
Step 1: Polyfill navigator.modelContext for local development
WebMCP tools are ultimately registered on navigator.modelContext. Since native browser support is still gated behind a flag, install the polyfill and check for it before bootstrapping:
npm install @mcp-b/webmcp-polyfill --save-dev
// src/main.ts
import { bootstrapApplication } from '@angular/platform-browser';
import { App } from './app/app';
async function installPolyfillIfNeeded(): Promise<void> {
if (typeof navigator !== 'undefined' && !('modelContext' in navigator)) {
const { installWebMcpPolyfill } = await import('@mcp-b/webmcp-polyfill');
installWebMcpPolyfill();
console.info('[webmcp] navigator.modelContext polyfilled for local development.');
}
}
installPolyfillIfNeeded().then(() => {
bootstrapApplication(App, {
providers: [/* added in the next steps */],
}).catch((err) => console.error(err));
});
Now the WebMCP Inspector extension has something to talk to, whether or not your actual browser ships native support yet.
Step 2: Register an application-wide tool
provideExperimentalWebMcpTools registers tools tied to the entire application lifecycle — they're added when the app initializes and removed when it's destroyed. Here's a searchCatalog tool that lets an agent query our product catalog directly instead of scraping a rendered list:
// src/main.ts (continued)
import { inject, provideExperimentalWebMcpTools } from '@angular/core';
import { CatalogService } from './app/catalog/catalog.service';
provideExperimentalWebMcpTools([
{
name: 'searchCatalog',
description: 'Searches the store catalog for products matching a query.',
inputSchema: {
type: 'object',
properties: {
query: { type: 'string', description: 'The search keywords.' },
maxResults: { type: 'number', description: 'Maximum number of results to return.' },
},
required: ['query'],
additionalProperties: false,
},
execute: ({ query, maxResults }) => {
// Angular infers query: string and maxResults: number | undefined
// from the schema above — but validate anyway, since a misbehaving
// agent can still send garbage.
if (typeof query !== 'string') throw new Error(`Bad query: ${query}`);
if (typeof maxResults !== 'number' && maxResults !== undefined) {
throw new Error(`Bad maxResults: ${maxResults}`);
}
const catalog = inject(CatalogService);
const results = catalog.search(query).slice(0, maxResults ?? 5);
return {
content: [
{
type: 'text',
text: results.length
? `Found ${results.length} match(es) for "${query}".`
: `No products matched "${query}".`,
},
],
};
},
},
]);
Two details matter here. First, execute runs inside the injection context of the associated Injector, so you can inject() real services instead of threading dependencies through manually. Second, required and additionalProperties: false in the JSON Schema aren't just documentation — Angular uses them to narrow the TypeScript types Angular infers for your execute parameters.
Step 3: Scope a tool to a single route
Not every tool should be available everywhere. An "export this report" tool only makes sense while the user (or agent) is actually looking at that report. Register it in the route's own providers array instead of the app config:
// src/app/app.routes.ts
import { Routes } from '@angular/router';
import { provideExperimentalWebMcpTools } from '@angular/core';
export const routes: Routes = [
{
path: 'orders',
loadComponent: () => import('./orders/orders.component').then((m) => m.OrdersComponent),
providers: [
provideExperimentalWebMcpTools([
{
name: 'exportOrdersReport',
description: 'Exports a CSV summary of the orders currently shown on this page.',
inputSchema: { type: 'object', properties: {} },
execute: () => ({
content: [{ type: 'text', text: 'Orders report export triggered for the current view.' }],
}),
},
]),
],
},
];
By default, route-provided tools stay registered even after the user navigates away, which is rarely what you want. Opt the router into withExperimentalAutoCleanupInjectors() so tools are unregistered automatically when their route is torn down:
// src/app/app.config.ts
import { ApplicationConfig } from '@angular/core';
import { provideRouter, withExperimentalAutoCleanupInjectors } from '@angular/router';
import { routes } from './app.routes';
export const appConfig: ApplicationConfig = {
providers: [provideRouter(routes, withExperimentalAutoCleanupInjectors())],
};
Step 4: Register a tool from inside a service
For tools tied to a piece of shared state rather than a route, use declareExperimentalWebMcpTool directly inside a service's constructor. It registers within the current injection context and unregisters automatically when that context is destroyed:
// src/app/catalog/catalog.service.ts
import { Injectable, declareExperimentalWebMcpTool, signal } from '@angular/core';
@Injectable({ providedIn: 'root' })
export class CatalogService {
readonly viewCount = signal(0);
constructor() {
declareExperimentalWebMcpTool({
name: 'getCatalogViewCount',
description: 'Reads how many times the catalog page has been viewed this session.',
inputSchema: { type: 'object', properties: {} },
execute: () => ({
content: [{ type: 'text', text: `The catalog has been viewed ${this.viewCount()} time(s).` }],
}),
});
}
// ...search(), recordView(), etc.
}
Watch out for name collisions. WebMCP requires every tool name to be globally unique and throws at runtime if you register the same name twice. That's usually fine in a root service, which is only instantiated once — but it's a trap if you call declareExperimentalWebMcpTool inside a component constructor that could render more than once on the page at the same time. Prefer application providers, route providers, or root services, and only reach for component-level registration when you're certain the component is a singleton on the page.
Step 5: Turn a Signal Form into an implicit tool
This is the part that feels genuinely new: you can skip hand-writing a JSON Schema entirely by opting an existing Signal Form into WebMCP. Angular inspects the form's model and generates the schema, validation, and submission wiring for you.
First, enable the feature once at the root:
// src/main.ts
import { provideExperimentalWebMcpForms } from '@angular/forms/signals';
// add to your bootstrapApplication providers array
provideExperimentalWebMcpForms();
Then opt a specific form in:
// src/app/user-registration/user-registration.ts
import { Component, signal } from '@angular/core';
import { form, required } from '@angular/forms/signals';
@Component({
selector: 'app-user-registration',
standalone: true,
templateUrl: './user-registration.html',
})
export class UserRegistration {
private readonly model = signal({
firstName: '',
lastName: '',
age: 0,
hobbies: ['Trail running'],
});
readonly userForm = form(
this.model,
(f) => {
required(f.firstName, { message: 'First name is mandatory.' });
required(f.lastName, { message: 'Last name is mandatory.' });
},
{
experimentalWebMcpTool: {
name: 'registerUser',
description: 'Registers a new user for the demo store.',
},
submission: {
action: async (formValue) => {
console.log('Submitting user:', formValue);
},
},
},
);
}
From this, Angular generates a registerUser tool whose schema marks firstName and lastName as required (inferred from the required() validators) and types hobbies as an array of strings (inferred from the seeded ['Trail running'] value). If the agent's input fails validation or the submission action throws, the agent sees that failure and can self-correct and retry — it isn't just firing input into a black box.
Two constraints worth remembering:
- Angular infers types from concrete initial values (
'',0,false). It can't infer anything useful fromnullorundefined. - Arrays need at least one seed value (
['Trail running'], not[]) — Angular can't guess an empty array's element type. - Async validators are not triggered by the WebMCP integration. If a field needs async validation (like a uniqueness check), handle it inside
submission.actioninstead of relying on the form's async validator to block a bad submission.
Comparing the four approaches
| Approach | Where you call it | Lifecycle / cleanup | Best for |
|---|---|---|---|
provideExperimentalWebMcpTools (app-level) |
bootstrapApplication providers |
Registered on app init, unregistered on app destroy | Tools that should always be available, like global search |
provideExperimentalWebMcpTools (route-level) |
A route's own providers array |
Auto-unregistered on navigation away, if withExperimentalAutoCleanupInjectors() is enabled |
Tools scoped to one page or feature area, e.g. exporting the report you're currently viewing |
declareExperimentalWebMcpTool |
Inside a service (or any injection context) constructor | Unregistered when that injection context is destroyed | Tools tied to shared, injectable state — best in root services to avoid duplicate-registration errors |
experimentalWebMcpTool on a Signal Form |
The form() config, plus provideExperimentalWebMcpForms() once at root |
Follows the form's own lifecycle | Turning an existing create/update form into an agent-callable action without writing a schema by hand |
📚 Keep going
- New to MCP? Start with MCP Servers Explained: What They Are, How to Configure Them, and Best Practices for the fundamentals WebMCP builds on.
- Curious what else shipped in Angular 22? Check out Angular 22 Is Here: Signal Forms Stable, Resources, and the Signal-First Era for the bigger picture beyond WebMCP.
What's next
- Add annotations to your tools. WebMCP supports hints like
readOnlyHintso an agent (and a human reviewing tool calls) can tell a safe read from a state-changing write before it's invoked. - Wire up a real agent. Connect the demo to an MCP-capable model like Gemini through the WebMCP Inspector extension and watch it choose
searchCatalogversusregisterUserbased purely on yourdescriptionfields — a good stress test for whether your descriptions are actually clear. - Validate every tool input at runtime. Angular does not enforce that arguments an agent sends actually match your
inputSchema— that's on you, the same way you'd validate any external input. - Look at the declarative, HTML-forms flavor of WebMCP too. Chrome's AI on Chrome docs describe a second WebMCP API built on annotated HTML forms, which can work as a progressive enhancement without writing any JavaScript-level tool code — worth comparing against the imperative APIs covered here.
Further reading
- WebMCP • Angular — the official docs this tutorial is based on
- WebMCP | AI on Chrome | Chrome for Developers — Chrome's own WebMCP guide, including the declarative HTML-forms API
- webmachinelearning/webmcp — the W3C Community Group spec repository, if you want to track how the standard itself is evolving
- @mcp-b/webmcp-polyfill" target="_blank" rel="noopener">@mcp-b/webmcp-polyfill on npm — the polyfill used in Step 1
- Signal Forms • Angular — background if
form(),required(), and signal-backed models are new to you - Angular CLI MCP Server setup — a related but distinct concept: this is an MCP server that helps AI coding assistants use the Angular CLI at development time, not your shipped app exposing tools at runtime
Wrapping up
WebMCP is a genuinely interesting direction — explicit, typed, state-backed tools beat DOM-scraping for just about any agentic use case — but it's still a moving target on both the spec side and the Angular side. Spike it on an internal tool or a low-stakes route, get comfortable with tool and schema design, and hold off on anything user-facing or business-critical until the experimental label comes off. The complete, working version of everything above — app-level, route-level, service-level, and Signal Forms tools — is in the angular-webmcp-catalog-demo repo if you want to clone it, poke at it with the Inspector extension, and adapt it to your own app.
Read next
Comments (0)
Join the conversation
Sign in to leave a comment on this post.
No comments yet. to be the first!