Sitemap
Vasanthan K

Vasanthan K

Senior Full Stack Engineer | Scalable Enterprise Systems | Node.js • GraphQL • React/Vue • Mongodb | Azure Cloud | 10+ Year | Gen AI enthusiast

Angular’s Built-in MCP Server: Letting AI Agents Drive Zoneless Migrations Themselves

Why this matters

19 min readOct 7, 2026

--

Angular now ships its own MCP server inside the CLI, and one of its tools, onpush_zoneless_migration, lets an AI agent plan and drive your move off Zone.js one safe step at a time.

For most of Angular’s life, a library called Zone.js quietly watched every click, timer and HTTP call in your app. Whenever something happened, it told Angular “go check the screen.” That magic made Angular easy to learn. It also cost bundle size, startup time, and confusing stack traces.

Modern Angular can run without Zone.js. This is called zoneless change detection. New projects are zoneless by default, but millions of existing apps still depend on Zone.js. Migrating them by hand is tedious: you have to find every component that silently relied on Zone.js and fix it.

This is exactly the kind of job an AI agent is good at, if it has the right tools. In this article you will learn:

  • What Zone.js, change detection, OnPush and zoneless actually mean (no prior knowledge needed).
  • What the Model Context Protocol (MCP) is, and what Angular’s built-in MCP server exposes.
  • How to connect it to VS Code, Cursor or Claude Code in two minutes.
  • A full, worked migration of a small app, with before/after code and the reasoning behind every change.
  • The guardrails that keep an agent from breaking your app.

Every diagram in this post was drawn fresh for this article.

Part 1: The basics, explained from zero

Before an agent can migrate anything, you need to know what it is migrating. Four ideas carry the whole article.

1.1 Change detection: keeping the screen in sync with your data

Every Angular component has a TypeScript class (the data) and a template (the HTML). When the data changes, the HTML must be refreshed. The process that compares old and new values and patches the DOM is called change detection.

The hard question is not how to refresh the screen. It is when. JavaScript has no built-in event that says “a variable just changed.” Angular needs some way to find out.

1.2 Zone.js: the classic answer to “when?”

Zone.js answers the “when” question by monkey-patching the browser. Monkey-patching means replacing a built-in function with a wrapped version. Zone.js wraps addEventListener, setTimeout, Promise.then, fetch, XMLHttpRequest and dozens more.

When any wrapped callback finishes, Zone.js tells Angular: “something async just happened, maybe data changed.” Angular then calls ApplicationRef.tick() and checks the component tree.

// What Zone.js effectively does (heavily simplified, for intuition only)
const originalSetTimeout = window.setTimeout;

window.setTimeout = function (callback, delay) {
return originalSetTimeout(() => {
callback(); // 1. run YOUR code
angularApp.tick(); // 2. then re-check the whole UI
}, delay);
};

How to read this snippet. Zone.js keeps a reference to the real setTimeout, then installs its own version. Your callback still runs exactly as before. The only extra step is line 2: after your code finishes, Angular is asked to re-check the screen. Now imagine the same trick applied to every click, every HTTP response and every promise. That is why classic Angular "just works" even if you mutate a plain variable inside a timer.

This convenience has three costs:

  1. Wasted work. Angular re-checks even when the callback changed nothing (for example, an analytics ping).
  2. Bundle size and startup. Zone.js must be downloaded and must patch dozens of APIs before your app boots.
  3. Debuggability. Stack traces fill up with Zone.js frames, and some modern APIs (like native async/await) cannot be patched cleanly.
Press enter or click to view image in full size

With Zone.js, every async event triggers a full check. Zoneless Angular only reacts to explicit signals of change.

1.3 OnPush: telling Angular to check less

By default, Angular checks every component on every tick. The ChangeDetectionStrategy.OnPush setting tells Angular: "skip this component unless one of these things happened":

  • One of its @Input() (or input() signal) references changed.
  • An event handler in its own template fired.
  • A signal read in its template changed.
  • An async pipe in its template received a new value.
  • Someone called ChangeDetectorRef.markForCheck() on it.
import { ChangeDetectionStrategy, Component } from '@angular/core';

@Component({
selector: 'app-cart',
template: `<p>Items: {{ count }}</p>`,
changeDetection: ChangeDetectionStrategy.OnPush, // <- opt into "check only when told"
})
export class CartComponent {
count = 0;
}

What changed here? Only one line: changeDetection: ChangeDetectionStrategy.OnPush. The component looks the same, but Angular now treats it as "clean until proven dirty." If some unrelated timer mutates count, the screen will not update. That is a feature: it forces you to make state changes explicit.

Press enter or click to view image in full size

When Cart is clicked, Default checks all six components; OnPush checks only the path from the root to Cart.

1.4 Zoneless: removing Zone.js completely

Zoneless Angular goes one step further. It removes Zone.js entirely, so there is no automatic “something happened” signal at all. Angular only schedules change detection when it is explicitly notified: a signal is set, a template event fires, an async pipe emits, or markForCheck() is called.

Notice that this is the same list OnPush already relies on. That is the key insight of the whole migration:

A component that works correctly with OnPush is almost always ready for zoneless.

This is why Angular’s tool is called onpush_zoneless_migration. It uses OnPush as the stepping stone. Once every component behaves correctly under OnPush, removing Zone.js becomes a small, low-risk final switch.

Part 2: MCP and Angular’s built-in server

2.1 What is the Model Context Protocol?

A large language model (LLM) on its own can only produce text. It cannot read your angular.json, run your tests, or look up the latest Angular docs. The Model Context Protocol (MCP) is an open standard that fixes this. It lets an AI application (the host, such as VS Code, Cursor or Claude Code) talk to small programs called MCP servers, each of which exposes tools.

Think of it as USB for AI. The host is the laptop. Each MCP server is a device you plug in. The protocol is the same no matter which device or which laptop you use.

A tool is just a named function with a description and a typed input. The LLM reads the description, decides when to call it, and the host sends a request like this over standard input/output (stdio):

{
"jsonrpc": "2.0",
"id": 7,
"method": "tools/call",
"params": {
"name": "onpush_zoneless_migration",
"arguments": {
"fileOrDirPath": "/home/dev/shop/src/app/cart"
}
}
}

Reading this message. jsonrpc and id are plumbing that pairs a request with its reply. method: "tools/call" means "run a tool." params.name picks the tool, and params.arguments carries its input, here an absolute path to a folder. You never write this JSON yourself; the host builds it when the model decides to use the tool. The argument name shown is illustrative; the agent reads the real input schema from the server at start-up.

2.2 Angular’s own MCP server

The Angular CLI ships an MCP server out of the box. You start it with ng mcp (or npx @angular/cli mcp). Because it is written by the Angular team and lives inside the CLI, its tools understand your Angular version, your workspace layout and your test runner. The agent no longer guesses from stale training data.

Press enter or click to view image in full size

You talk to the host, the host’s LLM calls Angular’s tools over MCP, and both the server and the host act on your workspace.

The default tool set, as listed in the official docs at the time of writing (Angular v22):

Press enter or click to view image in full size

Two server flags are worth knowing. --read-only registers only tools that do not modify the project. --local-only registers only tools that work without an internet connection. Some newer tools are experimental and must be switched on with -E <tool> (--experimental-tool).

2.3 Why onpush_zoneless_migration is different

Most migration tools are schematics: you run them once and they rewrite many files in one go. That is fast, but when something breaks you get a giant diff and no idea which change caused it.

onpush_zoneless_migration is designed for agents. Each call returns one actionable change plus an explanation. The agent applies it, verifies it, and calls the tool again. The loop ends when the tool reports that nothing is left. Small steps make every failure easy to trace and easy to undo.

Part 3: Connecting the server in two minutes

You do not install anything new. The server is part of @angular/cli, and npx downloads it on demand. You only tell your AI host how to start it.

VS Code (GitHub Copilot agent mode) — create .vscode/mcp.json in the project root:

{
"servers": {
"angular-cli": {
"command": "npx",
"args": ["-y", "@angular/cli", "mcp"]
}
}
}

Cursor — create .cursor/mcp.json (or ~/.cursor/mcp.json for all projects):

{
"mcpServers": {
"angular-cli": {
"command": "npx",
"args": ["-y", "@angular/cli", "mcp"]
}
}
}

Claude Code — one command from the project root:

claude mcp add angular-cli -- npx -y @angular/cli mcp

What each piece means. command is the program the host launches. args are its arguments: -y tells npx to skip the "install this package?" prompt, @angular/cli is the package, and mcp is the CLI sub-command that starts the server. The host runs this as a child process and talks to it over stdio, so no ports or network setup are involved. Note the small difference in key names: VS Code uses servers, Cursor uses mcpServers.

A safe first run. If you want the agent to explore and plan before it is allowed to run anything that changes your project, add --read-only to the arguments. Only tools that do not modify the project are registered, such as list_projects and search_documentation. Remove the flag when you are ready for the full loop.

"args": ["-y", "@angular/cli", "mcp", "--read-only"]

Check that it worked. Restart your editor, open the agent chat, and ask: “Use the angular-cli MCP server to list the projects in this workspace.” If the agent replies with your app names from angular.json, you are connected.

Part 4: The demo app that secretly depends on Zone.js

To see the migration in action, we use a tiny “Order Tracker” app with three components. Each one hides a classic Zone.js dependency, the kind you will find in almost every real codebase. All three work today. All three would silently stop updating the screen the moment Zone.js is removed.

4.1 The starting app.config.ts

// src/app/app.config.ts  (BEFORE)
import { ApplicationConfig, provideZoneChangeDetection } from '@angular/core';
import { provideHttpClient } from '@angular/common/http';

export const appConfig: ApplicationConfig = {
providers: [
provideZoneChangeDetection({ eventCoalescing: true }),
provideHttpClient(),
],
};

provideZoneChangeDetection is the line that wires Zone.js into the app. eventCoalescing: true is a small optimisation that merges several events into one change-detection pass. angular.json also lists "polyfills": ["zone.js"], which loads the patching library before the app starts.

4.2 Problem 1: a timer that mutates a plain field

// src/app/clock/clock.component.ts  (BEFORE)
@Component({
selector: 'app-clock',
template: `<span>Last refresh: {{ now | date: 'mediumTime' }}</span>`,
imports: [DatePipe],
})
export class ClockComponent implements OnInit, OnDestroy {
now = new Date();
private intervalId?: ReturnType<typeof setInterval>;

ngOnInit() {
this.intervalId = setInterval(() => {
this.now = new Date(); // plain assignment, nobody is told
}, 1000);
}
ngOnDestroy() {
clearInterval(this.intervalId);
}
}

Why it works today. setInterval is patched by Zone.js. Every second, after the callback assigns a new date, Zone.js triggers a full tick, and the template re-reads now.

Get Vasanthan K’s stories in your inbox

Join Medium for free to get updates from this writer.

Why it breaks without Zone.js. Nothing patches setInterval any more. The field changes in memory, but Angular is never notified, so the clock freezes on its first value.

4.3 Problem 2: an HTTP result stored in a field

// src/app/orders/order-list.component.ts  (BEFORE)
@Component({
selector: 'app-order-list',
template: `
@if (loading) { <p>Loading…</p> }
@for (o of orders; track o.id) {
<app-order-card [order]="o" />
}
`,
imports: [OrderCardComponent],
})
export class OrderListComponent implements OnInit {
private http = inject(HttpClient);
orders: Order[] = [];
loading = true;

ngOnInit() {
this.http.get<Order[]>('/api/orders').subscribe((data) => {
this.orders = data; // mutated inside an async callback
this.loading = false;
});
}
}

The hidden dependency. The subscribe callback runs when the network response arrives, long after ngOnInit returned. Zone.js noticed the XHR completing and refreshed the view. Without it, the spinner says "Loading…" forever.

4.4 Problem 3: a child that mutates its input object

// src/app/orders/order-card.component.ts  (BEFORE)
@Component({
selector: 'app-order-card',
template: `
<h3>#{{ order.id }} — {{ order.status }}</h3>
<button (click)="advance()">Advance status</button>
`,
})
export class OrderCardComponent {
@Input({ required: true }) order!: Order;

advance() {
this.order.status = nextStatus(this.order.status); // mutates the parent's object
}
}

The subtle trap. This one actually keeps working in its own template, because a template click is an explicit notification. The danger is elsewhere: it mutates an object owned by the parent. Under OnPush, any other component that displays the same order (a summary badge, a totals bar) will not know the object changed, because its input reference is still the same. This is exactly the sort of issue a human reviewer misses and a dedicated analysis tool catches.

Part 5: Letting the agent drive

The official Angular docs describe a six-step workflow for this exact job. Figure 4 shows it as a loop, because the heart of it repeats until the tool says you are done.

Press enter or click to view image in full size

Discover, modernize, then repeat: ask for one change, apply it, test it. Exit only when the tool has nothing left to suggest.

5.1 The prompt

A good prompt names the server, the goal, and the rules of the loop. Vague prompts (“make my app zoneless”) invite the model to improvise.

Use the angular-cli MCP server to migrate this workspace to zoneless change detection.

Rules:
1. Start with list_projects to find the app and its test runner.
2. Run any signal-input / signal-query migrations first with ng generate.
3. Then call onpush_zoneless_migration on src/app (absolute path).
4. Apply ONLY the single change it returns. Do not batch changes.
5. After each change, call run_target with target "test". If tests fail,
fix or revert that one change before continuing.
6. Call onpush_zoneless_migration again. Repeat until it reports no more actions.
7. Show me a one-line summary after every iteration.

Why these rules matter. Rule 4 is the most important: one change at a time keeps every diff small enough to review. Rule 5 turns your unit tests into a safety net. Rule 7 keeps you in the loop, so you can stop the agent the moment something looks wrong.

5.2 Step by step: what happens under the hood

  1. Discover the workspace. The agent calls list_projects. The tool reads angular.json and returns the project name (order-tracker), its source root (src), its builder and its test runner (for example Vitest or Karma). The agent now knows where code lives and how to test it, without guessing.
  2. Modernize first. Zoneless works best with signals. The agent runs the official schematics, such as ng generate @angular/core:signal-input-migration and ng generate @angular/core:signal-queries-migration. These convert @Input() and @ViewChild() into their signal equivalents across the codebase in one deterministic pass.
  3. Ask for a plan. The agent calls onpush_zoneless_migration with the absolute path of src/app. The tool analyzes the files and returns the next actionable change, with a short explanation of why it is needed.
  4. Apply the change. The agent edits exactly the file the tool pointed to. Nothing else.
  5. Verify. The agent calls run_target with "test". The CLI runs your real test suite and returns pass/fail output.
  6. Repeat. On green, the agent goes back to step 3. On red, it fixes or reverts that one change first.

5.3 What a session looks like

The log below is a condensed, illustrative session for our demo app. The exact wording of the tool’s responses will differ from version to version, but the rhythm is the same.

> list_projects
order-tracker (application) root: src test: vitest

> ng generate @angular/core:signal-input-migration
Migrated 1 input in order-card.component.ts

> onpush_zoneless_migration { path: "/home/dev/order-tracker/src/app" }
Next step: clock.component.ts mutates `now` inside setInterval.
This update is invisible without Zone.js. Store it in a signal.

(agent edits clock.component.ts)
> run_target { target: "test" } ✔ 14 passed

> onpush_zoneless_migration { ... }
Next step: order-list.component.ts assigns HTTP results to plain fields
inside subscribe(). Use signals or toSignal().

(agent edits order-list.component.ts)
> run_target { target: "test" } ✔ 14 passed

> onpush_zoneless_migration { ... }
Next step: order-card.component.ts mutates its input object.
Emit an event and let the parent produce a new object.

(agent edits order-card + order-list)
> run_target { target: "test" } ✘ 1 failed (order-card.spec.ts)
(agent updates the spec to expect the emitted event)
> run_target { target: "test" } ✔ 14 passed

> onpush_zoneless_migration { ... }
Next step: all components are OnPush-compatible. Add
ChangeDetectionStrategy.OnPush, then switch to zoneless.

Notice the failed test. It is not a problem; it is the system working. The agent changed a public behaviour of OrderCardComponent (it now emits an event instead of mutating), the old spec caught it, and the agent fixed the spec before moving on. With a one-shot schematic, that failure would be buried in a diff touching dozens of files.

Part 6: The fixes, explained line by line

Here is what the agent produced for each problem, and the theory that makes each fix correct. Read these even if you plan to let an agent do the work. You are still the reviewer.

6.1 Fix 1: the clock becomes a signal

// src/app/clock/clock.component.ts  (AFTER)
import {
ChangeDetectionStrategy, Component, DestroyRef, inject, signal,
} from '@angular/core';
import { DatePipe } from '@angular/common';

@Component({
selector: 'app-clock',
template: `<span>Last refresh: {{ now() | date: 'mediumTime' }}</span>`,
imports: [DatePipe],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class ClockComponent {
readonly now = signal(new Date());
constructor() {
const id = setInterval(() => this.now.set(new Date()), 1000);
inject(DestroyRef).onDestroy(() => clearInterval(id));
}
}

The theory. A signal is a box that holds a value and keeps a list of everyone who reads it. When the template calls now(), Angular records "this view depends on now." When the timer calls now.set(...), the signal marks every dependent view as dirty and asks the scheduler for a change-detection pass. That is the explicit notification zoneless Angular needs.

Line by line.

  • signal(new Date()) creates the box with an initial value.
  • In the template, now() with parentheses reads the signal and registers the dependency. Forgetting the () is the most common beginner mistake; you would print the function itself.
  • this.now.set(new Date()) replaces the value and triggers the update.
  • inject(DestroyRef).onDestroy(...) replaces ngOnDestroy. It registers clean-up right next to the code that needs it, which is easier to read and impossible to forget.
  • ChangeDetectionStrategy.OnPush is now safe, because the only state change goes through a signal.

6.2 Fix 2: the HTTP list uses toSignal

// src/app/orders/order-list.component.ts  (AFTER)
import { ChangeDetectionStrategy, Component, computed, inject, signal } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { toSignal } from '@angular/core/rxjs-interop';
import { map } from 'rxjs';

@Component({
selector: 'app-order-list',
template: `
@if (orders() === undefined) { <p>Loading…</p> }
@for (o of orders() ?? []; track o.id) {
<app-order-card [order]="o" (advanced)="onAdvanced($event)" />
}
`,
imports: [OrderCardComponent],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class OrderListComponent {
private http = inject(HttpClient);
private overrides = signal<Record<number, OrderStatus>>({});
private serverOrders = toSignal(this.http.get<Order[]>('/api/orders'));
readonly orders = computed(() => {
const list = this.serverOrders();
const o = this.overrides();
return list?.map((x) => (o[x.id] ? { ...x, status: o[x.id] } : x));
});
onAdvanced(e: { id: number; status: OrderStatus }) {
this.overrides.update((o) => ({ ...o, [e.id]: e.status }));
}
}

The theory. toSignal() subscribes to an Observable and exposes its latest value as a signal. Before the first response it holds undefined, which we use as the loading state. When the HTTP response arrives, the signal updates and notifies the view. No manual subscribe, no forgotten unsubscribe, no Zone.js.

Line by line.

  • toSignal(this.http.get(...)) starts the request when the component is created and unsubscribes automatically when it is destroyed.
  • overrides is a small signal that stores status changes made in the UI, keyed by order id.
  • computed(...) builds a derived signal. It re-runs only when serverOrders or overrides change, and it returns new objects for changed orders. New object references are what OnPush children need to see a change.
  • onAdvanced uses update, which receives the current value and returns a new one. We spread (...o) instead of mutating, for the same reason.
  • In the template, orders() ?? [] handles the loading state safely before data arrives.

6.3 Fix 3: the card stops mutating and starts emitting

// src/app/orders/order-card.component.ts  (AFTER)
import { ChangeDetectionStrategy, Component, input, output } from '@angular/core';

@Component({
selector: 'app-order-card',
template: `
<h3>#{{ order().id }} - {{ order().status }}</h3>
<button (click)="advance()">Advance status</button>
`,
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class OrderCardComponent {
readonly order = input.required<Order>();
readonly advanced = output<{ id: number; status: OrderStatus }>();
advance() {
const o = this.order();
this.advanced.emit({ id: o.id, status: nextStatus(o.status) });
}
}

The theory: one-way data flow. In Angular, data should flow down through inputs and events should flow up through outputs. The old card broke this rule by reaching into the parent’s object and changing it. Now the card only announces “please advance order 42.” The parent, which owns the data, creates a new object. Every component that shows that order receives a new reference and refreshes correctly under OnPush.

Line by line.

  • input.required<Order>() is the signal-based replacement for @Input({ required: true }). The signal-input schematic from step 2 of the loop produced it. Read it with order().
  • output<...>() is the modern replacement for @Output() new EventEmitter().
  • advance() reads the current order and emits a small, typed event. It no longer touches shared state.

6.4 Fix 4: the spec that caught it

// src/app/orders/order-card.component.spec.ts  (AFTER)
it('emits the next status instead of mutating the order', async () => {
const fixture = TestBed.createComponent(OrderCardComponent);
const order: Order = { id: 42, status: 'PLACED' };
fixture.componentRef.setInput('order', order);

let emitted: { id: number; status: OrderStatus } | undefined;
fixture.componentInstance.advanced.subscribe((e) => (emitted = e));
fixture.nativeElement.querySelector('button').click();
await fixture.whenStable();
expect(emitted).toEqual({ id: 42, status: 'PACKED' });
expect(order.status).toBe('PLACED'); // the original object is untouched
});

Why whenStable() instead of detectChanges(). In a zoneless test, you let Angular's own scheduler decide when to render, exactly as it will in production. await fixture.whenStable() waits for that scheduled pass. Calling detectChanges() manually can hide bugs, because it forces a refresh that the real app would never get. The last assertion proves the core of the fix: the input object was not mutated.

Part 7: Flipping the switch

When onpush_zoneless_migration reports that every component is ready, three small edits finish the job. The agent can make them, but this is the step where a human should read every line.

7.1 Swap the provider

// src/app/app.config.ts  (AFTER)
import { ApplicationConfig, provideZonelessChangeDetection } from '@angular/core';
import { provideHttpClient } from '@angular/common/http';

export const appConfig: ApplicationConfig = {
providers: [
provideZonelessChangeDetection(),
provideHttpClient(),
],
};

What this does. provideZonelessChangeDetection() replaces the Zone.js-based scheduler with one that listens only for explicit notifications: signal writes, template events, async pipe emissions and markForCheck(). In current Angular versions, newly generated projects are zoneless by default, so this line simply makes an older app match what ng new would produce today. If an agent suggests provideExperimentalZonelessChangeDetection, it is reading old docs: that was the name while the API was in developer preview.

7.2 Remove Zone.js from the build

// angular.json  → projects.order-tracker.architect.build.options
- "polyfills": ["zone.js"],

// angular.json → projects.order-tracker.architect.test.options
- "polyfills": ["zone.js", "zone.js/testing"],
npm uninstall zone.js

Why both places. The build entry stops Zone.js from loading in the browser. The test entry stops it from loading in unit tests. If you only remove the first, your tests still run with Zone.js and can pass while the real app is broken. Uninstalling the package last makes any forgotten import fail loudly at build time.

7.3 Make tests zoneless too

// src/test-setup.ts (or each spec's TestBed configuration)
import { provideZonelessChangeDetection } from '@angular/core';

beforeEach(() => {
TestBed.configureTestingModule({
providers: [provideZonelessChangeDetection()],
});
});

The theory. Tests should run under the same rules as production. With this provider, fixture.whenStable() waits for Angular's real scheduler, and any component that still relies on Zone.js will fail its test instead of failing in front of a user.

7.4 The final verification

Ask the agent to run one last loop:

  1. run_target with "test" — every spec must pass.
  2. run_target with "build" — the production build must succeed and should be smaller now that Zone.js is gone.
  3. devserver.start, then devserver.wait_for_build — the agent confirms the app compiles and serves without errors.
  4. You open the app and click through the main flows yourself. The clock should tick, the order list should load, and advancing an order should update every place it appears.

Then devserver.stop, commit, and open a pull request with one commit per migration step. That history is your audit trail.

Part 8: Guardrails, pitfalls and when not to trust the agent

An agent with real tools is powerful, and power needs limits. These are the rules I would put in place before letting any agent touch a production codebase.

8.1 Guardrails to set up first

  • Work on a branch, always. Every agent session starts from a clean branch so you can throw away a bad run in one command.
  • Have tests before you migrate. The loop is only as safe as your test suite. If a component has no spec, ask the agent to write a characterization test for its current behaviour before migrating it.
  • Start with --read-only. Let the agent explore and plan on day one. Grant write tools once you trust its plan.
  • One change per commit. Ask the agent to commit after each green test run. Reviewing ten small commits is far easier than one huge diff.
  • Migrate by folder. Point onpush_zoneless_migration at one feature folder at a time, not the whole src/app, on large codebases.

8.2 Pitfalls the tool cannot see for you

Press enter or click to view image in full size

8.3 When to override the agent

The tool tells the agent what to fix. The agent decides how. Review its “how” closely in three situations:

  1. When it reaches for ChangeDetectorRef.markForCheck() everywhere. It works, but it is a patch, not a design. Signals are almost always the cleaner fix.
  2. When it changes a component’s public API (inputs, outputs, method names). That may ripple into other teams’ code.
  3. When it edits or deletes a test to make it pass. A test change is fine when behaviour intentionally changed, as with our order card. It is a red flag when the test was simply inconvenient.

The golden rule: the agent does the typing, you keep the judgment.

Conclusion: the framework now teaches the agent

The big shift here is not zoneless itself. It is who knows how to do the migration. Until now, an AI assistant had to rely on whatever Angular knowledge it absorbed during training, which is often a version or two behind. With the built-in MCP server, the framework itself hands the agent the right tools, the right docs and a step-by-step plan.

The recipe is short:

  1. Connect npx @angular/cli mcp to your AI host.
  2. Let the agent discover the workspace and run the signal migrations.
  3. Loop: onpush_zoneless_migration → apply one change → run_target "test".
  4. When the tool says you are done, switch to provideZonelessChangeDetection() and remove Zone.js.
  5. Review every commit like it came from a new teammate, because it did.

If you try this on a real codebase, I would love to hear how many iterations your app needed and which pitfall surprised you most. Drop it in the comments.

--

--

Vasanthan K
Vasanthan K

Written by Vasanthan K

Senior Full Stack Engineer | Scalable Enterprise Systems | Node.js • GraphQL • React/Vue • Mongodb | Azure Cloud | 10+ Year | Gen AI enthusiast