All posts

WebMCP in Angular — make your app agent-callable

Aug 21, 2026 7 min read
Share

When I wrote up Angular v22, Signal Forms took the headline. The quieter arrival in that release is WebMCP support, and it points somewhere genuinely new. Every MCP post I’ve written so far has been about your editor talking to Angular while you code. WebMCP flips the direction: the app you ship registers tools that an AI agent running inside the user’s browser can call directly.

Both the WebMCP spec and Angular’s support for it are experimental. The browser API has already moved once — navigator.modelContext is deprecated in favour of document.modelContext — and every Angular symbol below carries Experimental in its name for a reason. Build with it, don’t bet a release on it.

Tools instead of clicks

Picture an agent asked to register a user on your site. Without WebMCP it has to do what a person does: find the form, read the labels, type into each field, click submit, and hope nothing re-rendered mid-way. That’s slow, brittle, and it breaks the day you restyle the wizard.

WebMCP lets the page skip all of it. Your app declares a tool — a name, a description, a JSON Schema for the arguments, and a function to run — and the agent calls it like an API. No DOM scraping, no guessing which button is the real submit.

It’s worth separating this from the thing it sounds like, because the names collide badly:

Angular CLI MCP serverWebMCP
RunsOn your machine, at development timeIn the browser, in production
Who calls itYour coding assistantThe end user’s browser agent
What it exposesDocs search, migrations, project infoYour app’s own capabilities
SetupEditor configApplication providers

Same protocol family, opposite ends of the lifecycle. If you’ve already wired up the Angular CLI MCP server, none of that carries over here — this is app code.

Prerequisites

  • Angular v22. The APIs live in @angular/core and @angular/forms/signals. There is no @angular/web-mcp package to install, and no schematic to run.
  • A browser that speaks WebMCP. In Chrome that means enabling chrome://flags/#enable-webmcp-testing, or registering your origin for the WebMCP origin trial.
  • A polyfill for everything else. @mcp-b/webmcp-polyfill installs the API where it’s missing, which is also how you’ll test.

If you ever need to check for the raw platform API — you won’t, from Angular — the feature detect has to cover both spellings while the rename lands:

const modelContext = document.modelContext || navigator.modelContext;

Everything after this point uses Angular’s wrappers instead, which is the point of them.

Step 1 — Register a tool for the whole app

The simplest scope is the application itself. provideExperimentalWebMcpTools takes an array of tool descriptors, registers them when the app initializes, and unregisters them when it’s destroyed:

import { Service, inject, provideExperimentalWebMcpTools } from '@angular/core';
import { bootstrapApplication } from '@angular/platform-browser';
import { AppRoot } from './app-root';

@Service()
class Greeter {
	sayHello(): string {
		return 'Hello agent!';
	}
}

bootstrapApplication(AppRoot, {
	providers: [
		provideExperimentalWebMcpTools([
			{
				name: 'greet',
				description: 'Greets the agent.',
				inputSchema: { type: 'object', properties: {} },
				execute: () => {
					const greeter = inject(Greeter);

					return { content: [{ type: 'text', text: greeter.sayHello() }] };
				}
			}
		])
	]
});

Three things to notice. inputSchema is required even when the tool takes nothing — an empty properties object is the “no arguments” spelling. execute returns a content array rather than a bare value, which is the MCP result shape. And that inject(Greeter) call inside execute is not a mistake: the callback runs in the injection context of the associated injector, so tools reach services exactly the way the rest of your app does.

The description is not decoration. It’s the only thing the agent reads when deciding whether this tool answers the request in front of it. Write it like an API doc, not a label.

Step 2 — Give the tool parameters

Arguments are described with plain JSON Schema, and Angular infers the parameter types of execute from it:

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 }) => {
			// Type of `query` is inferred as `string`.
			// Type of `maxResults` is inferred as `number | undefined`.

			// Consider validating this at runtime, since inputs may not be validated to match the schema.
			if (typeof query !== 'string') throw new Error(`Bad query: ${query}`);
			if (typeof maxResults !== 'number' && maxResults !== undefined)
				throw new Error(`Bad maxResults: ${maxResults}`);

			const limit = maxResults ?? 5;
			return {
				content: [{ type: 'text', text: `Returning up to ${limit} results for "${query}".` }]
			};
		}
	}
]);

Two schema keys do real work for the types. required: ['query'] strips undefined from that parameter, and additionalProperties: false narrows the argument object to exactly the declared keys.

Read that middle comment twice, because it’s the security story in one line: the schema types your callback, it does not enforce anything at runtime. Arguments arrive from an agent that is itself driven by text from who-knows-where. Validate them the way you’d validate a request body.

Step 3 — Tools that live in a service

Providers are fine for static tools, but the interesting tools need state. declareExperimentalWebMcpTool registers one from inside any injection context and tears it down automatically when that context is destroyed:

import { Service, declareExperimentalWebMcpTool, signal } from '@angular/core';

@Service()
export class Counter {
	readonly count = signal(0);

	constructor() {
		declareExperimentalWebMcpTool({
			name: 'getCounter',
			description: 'Reads the global counter.',
			inputSchema: { type: 'object', properties: {} },
			execute: () => ({
				content: [{ type: 'text', text: `The count is: ${this.count()}.` }]
			})
		});
	}
}

Because execute reads the signal at call time, the agent always sees current state — no subscription, no manual refresh. This is the shape I’d reach for most: a root service that owns some state and exposes a couple of tools over it.

Step 4 — Tools scoped to a route

A tool that only makes sense on one screen belongs in that route’s providers:

import { provideExperimentalWebMcpTools } from '@angular/core';
import { Routes } from '@angular/router';

export const routes: Routes = [
	{
		path: 'dashboard',
		loadComponent: () => import('./dashboard').then((m) => m.Dashboard),
		providers: [
			provideExperimentalWebMcpTools([
				{
					name: 'exportDashboardReports',
					description: 'Exports the current dashboard analytics.',
					inputSchema: { type: 'object', properties: {} },
					execute: () => ({
						content: [{ type: 'text', text: 'Dashboard export successfully triggered.' }]
					})
				}
			])
		]
	}
];

This one has a catch worth its own paragraph. Route-scoped tools stay registered after you navigate away unless the router is told to clean up route injectors. So opt in:

import { ApplicationConfig } from '@angular/core';
import { provideRouter, withExperimentalAutoCleanupInjectors } from '@angular/router';
import { routes } from './routes';

export const appConfig: ApplicationConfig = {
	providers: [provideRouter(routes, withExperimentalAutoCleanupInjectors())]
};

Skip it and your dashboard export tool is still advertised to the agent from the settings page — which either fails confusingly or, worse, quietly works.

Step 5 — Signal Forms give you a tool for free

Here’s the part that made me sit up. If you’re already on Signal Forms, you can turn an existing form into an agent tool with one option and no hand-written schema.

Enable the feature at the root:

import { bootstrapApplication } from '@angular/platform-browser';
import { provideExperimentalWebMcpForms } from '@angular/forms/signals';
import { AppRoot } from './app-root';

bootstrapApplication(AppRoot, {
	providers: [provideExperimentalWebMcpForms()]
});

Then name the tool on the form itself:

import { Component, signal } from '@angular/core';
import { form, required } from '@angular/forms/signals';

@Component({
	selector: 'app-user-registration',
	templateUrl: './user-registration.html'
})
export class UserRegistration {
	private readonly model = signal({
		firstName: '',
		lastName: '',
		age: 0,
		hobbies: ['Web Development']
	});

	readonly userForm = form(
		this.model,
		(f) => {
			required(f.firstName, { message: 'First name is mandatory.' });
			required(f.lastName, { message: 'Last name is mandatory.' });
		},
		{
			// Implicitly registers a WebMCP tool named `registerUser` with parameters derived from `model`.
			experimentalWebMcpTool: {
				name: 'registerUser',
				description: 'Registers a new user.'
			},
			submission: {
				action: async (formValue) => {
					console.log('Submitting user:', formValue);
					// ...
				}
			}
		}
	);
}

Angular derives the entire input schema from the form. firstName, lastName, age and hobbies become parameters, typed from the model’s initial values. The required() validators become required schema fields. hobbies becomes an array of strings, so the agent can pass as many as it likes.

That inference detail has teeth: the schema comes from initial values, so age: 0 is what makes age a number, and hobbies: ['Web Development'] is what makes it a string array. An empty array or a null placeholder gives the agent nothing to infer from. Seed the model with real, concrete values.

submission.action isn’t optional here either — it’s what the tool actually invokes. The form your users see and the tool the agent calls run the same code path, which is the strongest argument for this over hand-writing a parallel tool that drifts.

Which scope should you use

ScopeAPILives as long asReach for it when
ApplicationprovideExperimentalWebMcpTools in bootstrapApplicationThe appThe tool is always available
ServicedeclareExperimentalWebMcpTool in a @Service()Its injectorThe tool reads or writes app state
RouteprovideExperimentalWebMcpTools in route providersThe route, with auto-cleanupThe tool only makes sense on one screen
FormexperimentalWebMcpTool on form()The componentAn existing Signal Form is the action

Pitfalls

  • Names must be unique, and duplicates throw. Registering the same tool name twice is a runtime error, so anything registered from a component breaks the moment two instances render at once. Prefer application providers, route providers, or root services. If a tool must live on a component — including an implicit Signal Forms tool — guarantee that component renders at most once at a time.
  • Nothing validates the arguments for you. Covered above, and worth repeating because the type inference makes it feel handled. It isn’t.
  • Tool input is untrusted input. An agent’s arguments can originate in a web page, an email, or a document the user never read closely. Treat a tool call like an unauthenticated POST — check permissions inside execute, and don’t assume the agent asked the user first.
  • Cross-origin iframes need permission. Chrome gates the API behind a tools Permissions Policy that defaults to self, so an embedded frame needs an explicit allow="tools".
  • The spec is moving. Tool unregistration used to be a method and is now an AbortSignal; provideContext() came and went; the entry point moved from navigator to document. Angular’s wrappers absorb most of that churn, which is the best reason to use them over the raw API.

Testing

You don’t need a flagged browser in CI. Install @mcp-b/webmcp-polyfill and let it stand in as a mock implementation, then assert on your execute callbacks like any other unit under test — the interesting logic is a plain function that takes arguments and returns content.

Writing those tests also drags the descriptions into better shape. If you can’t state in one sentence what a tool does and when to call it, an agent can’t pick it either.

Wrap-up

Four scopes, one mental model: a tool is a described function, and Angular ties its lifetime to an injector you already understand. Start with a root service exposing one honest read-only tool, add provideExperimentalWebMcpForms() if you’re already on Signal Forms, and don’t skip withExperimentalAutoCleanupInjectors() the day you scope something to a route. The official guide is at angular.dev/ai/webmcp, and the spec is being incubated in the open — both move fast enough to be worth a re-read before you ship.