Angular WebMCP: Turning Your App Into an AI-Ready Tool Server
Angular WebMCP AI Agents Signal Forms Tutorial

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

D. Rout

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.modelContext API 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 from null or undefined.
  • 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.action instead 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

What's next

  • Add annotations to your tools. WebMCP supports hints like readOnlyHint so 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 searchCatalog versus registerUser based purely on your description fields — 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

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.

Share

Comments (0)

Join the conversation

Sign in to leave a comment on this post.

No comments yet. to be the first!