WebMCP Tools đ§°
WebMCP is a proposed web standard that lets a web page register tools â named functions with a description and a JSON Schema â that an AI agent running in the browser can call. Instead of an agent clicking through your UI like a robot (âfind the search box, type, press Enter, scrollâŚâ), your app offers a clean searchCatalog({ query }) tool and the agent calls it directly.
Angular 22 has experimental WebMCP support: tools tied to the dependency injection lifecycle, and Signal Forms that become tools automatically.
What youâll learn
Section titled âWhat youâll learnâ- How WebMCP differs from the Angular CLI MCP server
- How to register app-wide and route-specific tools with
provideExperimentalWebMcpTools - How to declare tools from a service with
declareExperimentalWebMcpTool - How to turn a Signal Form into a tool with
provideExperimentalWebMcpForms - Tool annotations (
readOnlyHint,untrustedContentHint,consequentialHint) - Security and testing best practices
WebMCP vs. the Angular CLI MCP server
Section titled âWebMCP vs. the Angular CLI MCP serverâ| Angular CLI MCP server | WebMCP | |
|---|---|---|
| Who uses it | Your coding assistant (Copilot, Claude CodeâŚ) | An AI agent in the userâs browser |
| Runs where | Your machine, via ng mcp | Inside your running web app |
| Tools are about | Building your project (docs, build, test) | Using your app (search, add to cart, register) |
| Page | Angular MCP Server | This page |
How it works
Section titled âHow it worksâWhen the browser supports WebMCP, it exposes a modelContext object (on document or navigator). Angular calls its registerTool() for you and unregisters the tool automatically when the related injector is destroyed (app destroyed, route left, service destroyed).
If the browser has no WebMCP support, Angularâs functions simply do nothing â your app keeps working. They are also skipped during server-side rendering.
Every tool has:
| Field | Purpose |
|---|---|
name | Unique tool name, e.g. searchCatalog |
description | Tells the agent what the tool does and when to use it â write it like docs for a colleague |
inputSchema | JSON Schema of the arguments (required in the TypeScript types; use { type: 'object', properties: {} } for no arguments) |
execute(args, client) | Runs the tool in an injection context, so you can call inject(). client.signal is an AbortSignal |
annotations (optional, 22.2) | Hints about the toolâs behaviour (see Annotations) |
App-wide tools: provideExperimentalWebMcpTools
Section titled âApp-wide tools: provideExperimentalWebMcpToolsâAdd tools to your application providers. Theyâre registered when the app starts and removed when itâs destroyed.
import { ApplicationConfig, Service, inject, provideExperimentalWebMcpTools, signal,} from '@angular/core';
export interface Product { id: string; name: string; price: number;}
@Service()export class Catalog { private readonly products = signal<Product[]>([ { id: 'p1', name: 'Angular mug', price: 12 }, { id: 'p2', name: 'Signals T-shirt', price: 25 }, ]);
search(query: string, limit: number): Product[] { const q = query.toLowerCase(); return this.products() .filter((p) => p.name.toLowerCase().includes(q)) .slice(0, limit); }}
@Service()export class Cart { readonly items = signal<string[]>([]); add(productId: string) { this.items.update((items) => [...items, productId]); }}
export const appConfig: ApplicationConfig = { providers: [ provideExperimentalWebMcpTools([ { name: 'searchCatalog', description: 'Searches the store catalog for products matching a query.', inputSchema: { type: 'object', properties: { query: { type: 'string', description: 'Search keywords, e.g. "mug".' }, maxResults: { type: 'number', description: 'Maximum number of results (default 5).' }, }, required: ['query'], additionalProperties: false, }, annotations: { readOnlyHint: true }, execute: ({ query, maxResults }) => { // Agents may send anything: validate at runtime. if (typeof query !== 'string' || query.length > 100) { throw new Error('query must be a string of at most 100 characters'); } const limit = typeof maxResults === 'number' ? Math.min(maxResults, 20) : 5;
const results = inject(Catalog).search(query, limit); return { content: [{ type: 'text', text: JSON.stringify(results) }] }; }, }, { name: 'addToCart', description: 'Adds a product to the shopping cart by its id (from searchCatalog).', inputSchema: { type: 'object', properties: { productId: { type: 'string', description: 'The product id, e.g. "p1".' }, }, required: ['productId'], additionalProperties: false, }, annotations: { consequentialHint: true }, execute: ({ productId }) => { if (typeof productId !== 'string') throw new Error('productId must be a string'); inject(Cart).add(productId); return { content: [{ type: 'text', text: `Added ${productId} to the cart.` }] }; }, }, ]), ],};Things to notice:
- Typed arguments. Angular infers argument types from
inputSchema:queryisstring,maxResultsisnumber | undefined. Listing a property inrequiredremovesundefined;additionalProperties: falsestops extra keys appearing in the type. inject()works insideexecute, because it runs in the injectorâs context.- Runtime validation is still needed. Angular does not check that the agentâs arguments match the schema â the types are a promise, not a guarantee.
- The result here follows MCPâs content format (
{ content: [{ type: 'text', text }] }). Whatever you return is serialised and sent back to the agent.
Route-specific tools
Section titled âRoute-specific toolsâSome tools only make sense on one page. Put them in the routeâs providers:
import { provideExperimentalWebMcpTools } from '@angular/core';import { Routes } from '@angular/router';
export const routes: Routes = [ { path: 'dashboard', loadComponent: () => import('./dashboard/dashboard').then((m) => m.Dashboard), providers: [ provideExperimentalWebMcpTools([ { name: 'exportDashboardReport', description: 'Exports the analytics shown on the dashboard as CSV.', inputSchema: { type: 'object', properties: {} }, execute: () => ({ content: [{ type: 'text', text: 'Export started.' }] }), }, ]), ], },];Route injectors normally live on after you navigate away, so the tool would stay visible to agents. Add withAutoCleanupInjectors() (stable since 22.2) so the router destroys unused route injectors â and unregisters their tools:
import { ApplicationConfig } from '@angular/core';import { provideRouter, withAutoCleanupInjectors } from '@angular/router';import { routes } from './app.routes';
export const appConfig: ApplicationConfig = { providers: [provideRouter(routes, withAutoCleanupInjectors())],};Tools from a service: declareExperimentalWebMcpTool
Section titled âTools from a service: declareExperimentalWebMcpToolâFor dynamic cases, call declareExperimentalWebMcpTool() in any injection context (a constructor or field initializer). The tool is removed when that context is destroyed. You can also pass an Injector as the second argument to use it outside an injection context.
import { Service, declareExperimentalWebMcpTool, signal } from '@angular/core';
@Service()export class Counter { readonly count = signal(0);
constructor() { declareExperimentalWebMcpTool({ name: 'getCounter', description: 'Reads the current value of the global counter.', inputSchema: { type: 'object', properties: {} }, annotations: { readOnlyHint: true }, execute: () => ({ content: [{ type: 'text', text: `The count is ${this.count()}.` }], }), });
declareExperimentalWebMcpTool({ name: 'incrementCounter', description: 'Increments the global counter by the given amount.', inputSchema: { type: 'object', properties: { by: { type: 'number', description: 'Amount to add (1-100).' } }, required: ['by'], additionalProperties: false, }, execute: ({ by }, client) => { if (client.signal.aborted) return { content: [{ type: 'text', text: 'Cancelled.' }] }; if (typeof by !== 'number' || by < 1 || by > 100) { throw new Error('"by" must be a number between 1 and 100'); } this.count.update((c) => c + by); return { content: [{ type: 'text', text: `The count is now ${this.count()}.` }] }; }, }); }}client.signal aborts when the agent cancels the call or when the toolâs injector is destroyed â pass it to fetch() or check it in long-running work.
Signal Forms as tools
Section titled âSignal Forms as toolsâSignal Forms can become WebMCP tools with almost no code. Angular reads your form model to build the JSON Schema, wires the agentâs input into the form, runs your validators, and calls your submission action. Validation errors and submission failures are reported back to the agent so it can correct itself and retry.
1. Enable the feature
Section titled â1. Enable the featureâimport { ApplicationConfig } from '@angular/core';import { provideExperimentalWebMcpForms } from '@angular/forms/signals';
export const appConfig: ApplicationConfig = { providers: [provideExperimentalWebMcpForms()],};2. Opt a form in with experimentalWebMcpTool
Section titled â2. Opt a form in with experimentalWebMcpToolâimport { Component, signal } from '@angular/core';import { FormField, FormRoot, email, form, required } from '@angular/forms/signals';
interface Registration { firstName: string; lastName: string; email: string; age: number; newsletter: boolean; hobbies: string[];}
@Component({ selector: 'app-user-registration', imports: [FormRoot, FormField], template: ` <form [formRoot]="userForm"> <label>First name <input [formField]="userForm.firstName" /></label> <label>Last name <input [formField]="userForm.lastName" /></label> <label>Email <input type="email" [formField]="userForm.email" /></label> <label>Age <input type="number" [formField]="userForm.age" /></label> <label><input type="checkbox" [formField]="userForm.newsletter" /> Newsletter</label> <button type="submit">Register</button> </form> `,})export class UserRegistration { private readonly model = signal<Registration>({ firstName: '', lastName: '', email: '', age: 0, newsletter: false, hobbies: ['Web development'], });
protected readonly userForm = form( this.model, (f) => { required(f.firstName, { message: 'First name is required.' }); required(f.lastName, { message: 'Last name is required.' }); required(f.email); email(f.email, { message: 'Enter a valid email address.' }); }, { experimentalWebMcpTool: { name: 'registerUser', description: 'Registers a new user account. Use only when the user asks to sign up.', annotations: { consequentialHint: true }, }, submission: { action: async (field) => { const value = field().value(); console.log('Registering', value.email); }, }, }, );}From this form Angular generates a tool named registerUser whose schema:
- has
firstName,lastName,email,age,newsletterandhobbiesparameters, typed from the initial model value; - marks
firstName,lastNameandemailas required (from therequiredvalidators); - treats
hobbiesas an array of strings, so the agent can send any number; - disallows extra properties (
additionalProperties: false).
Rules for schema inference:
- Use concrete initial values (
'',0,false) â Angular canât infer a type fromnullorundefined. - Give arrays at least one item â an empty array has no element type to infer.
- Async validators donât block submission by default, so their errors arenât reported to the agent. Re-check those conditions in your submission
action.
Form tools always report readOnlyHint: false and untrustedContentHint: false (a form changes page state, and its responses are app-generated). The only annotation you can set is consequentialHint.
Annotations
Section titled âAnnotationsâSince 22.2 tools can carry annotations â hints that help agents (and browsers) decide how carefully to treat a tool:
| Annotation | Meaning | Example |
|---|---|---|
readOnlyHint: true | Doesnât change user-visible state (no navigation, DOM changes or backend writes). Agents may use such tools more freely. | searchCatalog, getCounter |
untrustedContentHint: true | The result may contain content you donât trust (user-generated text, third-party data). | readProductReviews |
consequentialHint: true | Has real-world effects: mutates data, spends money, sends messages. Agents should confirm with the user. | addToCart, placeOrder, registerUser |
Annotations are hints, not security controls. Your execute function must still enforce permissions.
Security checklist
Section titled âSecurity checklistâ- Expose only what an agent should be allowed to do. A tool is a public API for whatever agent the user runs.
- Validate every argument at runtime (types, ranges, lengths). Angular doesnât validate against the schema.
- Re-check authorization inside
executeâ the tool runs with the logged-in userâs session. - Mark consequential tools with
consequentialHint: trueand consider asking the user to confirm inside your UI before irreversible actions. - Flag untrusted output with
untrustedContentHint: trueso agents treat it as data, not instructions (prompt-injection risk). - Scope tools to routes or services so they exist only while relevant, and use
withAutoCleanupInjectors()for route tools. - Donât leak secrets in tool results (tokens, internal IDs, other usersâ data).
Testing tools
Section titled âTesting toolsâBrowsers (and jsdom) without WebMCP make the APIs no-ops, so for unit tests use a WebMCP polyfill such as @mcp-b/webmcp-polyfill, which Angularâs docs suggest. It supplies a modelContext implementation so your tools actually register and can be called from Vitest. To try tools against a real browser agent during development, Chrome DevTools for agents (chrome-devtools-mcp) has experimental list_webmcp_tools and execute_webmcp_tool tools (enable them with --categoryExperimentalWebmcp=true).
Key takeaways
Section titled âKey takeawaysâ- WebMCP lets in-browser AI agents call structured tools your app provides â itâs experimental.
provideExperimentalWebMcpTools([...])registers tools for an app or route;declareExperimentalWebMcpTool()registers one from an injection context. Both clean up automatically.provideExperimentalWebMcpForms()+experimentalWebMcpToolturns a Signal Form into a validated tool.- Always include
inputSchema, validate inputs at runtime, use annotations, and keep tool names unique.
Next steps
Section titled âNext stepsâ- Build forms that work great as tools: Signal Forms
- Inspect your running app from an agent: AI Runtime Debugging
- Learn about injectors and their lifecycle: Dependency Injection