Extend the Angular MCP Server with a Companion MCP Server [2026]

Link copied
Extend the Angular MCP Server with a Companion MCP Server [2026]

Extend the Angular MCP Server with a Companion MCP Server [2026]

The Angular CLI's MCP server knows Angular. It doesn't know your team: that components in features/ must never call HttpClient directly, that you banned @Input() a year ago, or which old components nobody uses any more. The obvious move would be adding your own tools to ng mcp, but there's no public extension API for it. The approach that works today is simpler anyway: run a small companion MCP server of your own next to it, and let the assistant use both.

This is lesson 12.4 of the Angular Tutorial. Lesson 12.3 compared the Angular server with generic servers and ended on this gap. In this lesson you'll build a companion server in about 120 lines of Node with two project-specific tools, connect it alongside ng mcp, test it, and decide how to share it with your team. If you've never built an MCP server, build your first MCP server in Node.js covers the basics this lesson builds on.

The companion pattern #

Server Knows Examples
ng mcp (Angular CLI) Angular itself: docs, official best practices, angular.json, builds search_documentation, get_best_practices, run_target
Your companion server Your codebase and team rules find_unused_components, check_conventions
The agent's built-in tools Files and the shell Read, edit, run commands

MCP clients happily connect to several servers at once and show the model every tool together. So the "extension" is just a second entry in your MCP config. Each server stays small and focused, and you never depend on the Angular server's internals.

What to put in a companion server #

Good candidates are questions the model can't answer reliably by reading a few files:

  • Whole-codebase analysis: unused components, circular imports, which features use a service.
  • Team conventions stricter than Angular's guide: "no HttpClient in components", "every route lazy-loaded".
  • Project knowledge that lives outside the code: feature flags, API environments, design-system tokens.

Bad candidates: anything the agent already does well (editing files, running ng build), and anything that writes. Start with read-only analysis tools; they're safe to run without approval and still save the model dozens of file reads.

Set up the project #

Keep the server in the repo so it versions with the code:

mkdir -p tools/team-mcp && cd tools/team-mcp
npm init -y
npm install @modelcontextprotocol/sdk zod

In tools/team-mcp/package.json, set "type": "module" so the file can use import. The code below was tested with @modelcontextprotocol/sdk 1.31 and zod 4, on Node 22.

The server skeleton #

#!/usr/bin/env node
// tools/team-mcp/server.js
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import { readFile, readdir } from 'node:fs/promises';
import { join, relative, resolve, sep } from 'node:path';
import { z } from 'zod';

// The client starts the server from the workspace root.
const ROOT = resolve(process.env.WORKSPACE_ROOT ?? process.cwd());

const server = new McpServer({ name: 'team', version: '1.0.0' });

// Refuse anything outside the workspace.
function inWorkspace(folder) {
  const dir = resolve(ROOT, folder);
  if (dir !== ROOT && !dir.startsWith(ROOT + sep)) throw new Error(`"${folder}" is outside the workspace`);
  return dir;
}

// All .ts and .html source files under a folder, skipping tests and node_modules.
async function sourceFiles(dir) {
  const entries = await readdir(dir, { recursive: true, withFileTypes: true });
  return entries
    .filter(e => e.isFile() && /\.(ts|html)$/.test(e.name) && !e.name.endsWith('.spec.ts'))
    .map(e => join(e.parentPath ?? e.path, e.name))
    .filter(f => !f.includes(`${sep}node_modules${sep}`));
}

async function readAll(files) {
  return new Map(await Promise.all(files.map(async f => [f, await readFile(f, 'utf8')])));
}

// ...tools go here...

await server.connect(new StdioServerTransport());

Two details matter. ROOT comes from the working directory the client starts the server in, which is your workspace when the config lives in the repo. And inWorkspace() guards every path the model passes in, so a confused or manipulated request can't read files elsewhere on the machine.

Tool 1: find_unused_components #

server.registerTool(
  'find_unused_components',
  {
    title: 'Find unused components',
    description:
      'List Angular components whose class is never imported and whose selector is never used ' +
      'anywhere else in the workspace. Use before deleting or refactoring UI code.',
    inputSchema: {
      folder: z.string().default('src').describe('Folder to scan, relative to the workspace root'),
    },
  },
  async ({ folder }) => {
    const texts = await readAll(await sourceFiles(inWorkspace(folder)));

    const components = [];
    for (const [file, text] of texts) {
      const cls = text.match(/@Component\(\{[\s\S]*?\}\)\s*export class (\w+)/);
      if (!file.endsWith('.ts') || !cls) continue;
      const selector = text.match(/selector:\s*['"`]([\w-]+)['"`]/)?.[1];
      components.push({ file, name: cls[1], selector });
    }

    const unused = components.filter(({ file, name, selector }) =>
      ![...texts].some(([other, text]) =>
        other !== file &&
        (new RegExp(`\\b${name}\\b`).test(text) || (selector && text.includes(`<${selector}`)))));

    const text = unused.length
      ? `Possibly unused components (${unused.length} of ${components.length}):\n` +
        unused.map(c => `- ${c.name} (${relative(ROOT, c.file)})`).join('\n')
      : `All ${components.length} components are referenced somewhere.`;
    return { content: [{ type: 'text', text }] };
  },
);

It's deliberately simple: a component counts as used if another file mentions its class name (imports, route loadComponent, dynamic creation) or its selector (templates). That catches the common cases without a TypeScript compiler dependency; your root component counts as used because main.ts passes it to bootstrapApplication(). The word possibly in the output matters: it tells the model to double-check before deleting anything, which is exactly how you want it to behave.

The description is the most important line in the tool. The model decides when to call a tool from its description alone, so say what it returns and when to use it.

Tool 2: check_conventions #

const RULES = [
  { id: 'standalone',      pattern: /@NgModule\(/,              message: 'Use standalone components instead of NgModules' },
  { id: 'signal-inputs',   pattern: /@Input\(/,                 message: 'Use input() instead of @Input()' },
  { id: 'signal-outputs',  pattern: /@Output\(/,                message: 'Use output() instead of @Output()' },
  { id: 'control-flow',    pattern: /\*ng(If|For|Switch)\b/,    message: 'Use @if / @for / @switch' },
  { id: 'inject-function', pattern: /constructor\([^)]*\b(private|public|protected|readonly)\s+\w+\s*:/,
                                                                message: 'Prefer inject() over constructor injection' },
  // Team rule, stricter than Angular's guide: components talk to services, not HTTP.
  { id: 'no-http-in-components', pattern: /\bHttpClient\b/, onlyIn: /@Component\(/,
                                                                message: 'Components must not use HttpClient; call a service' },
];

server.registerTool(
  'check_conventions',
  {
    title: 'Check team conventions',
    description:
      "Check source files against this team's Angular conventions (standalone, signal inputs/outputs, " +
      'new control flow, inject(), no HttpClient in components). Returns violations as file:line. ' +
      'Use when reviewing or before finishing a change.',
    inputSchema: {
      folder: z.string().default('src').describe('Folder to scan, relative to the workspace root'),
      rules: z.array(z.string()).optional().describe(`Rule ids to run (default: all): ${RULES.map(r => r.id).join(', ')}`),
    },
  },
  async ({ folder, rules }) => {
    const active = rules?.length ? RULES.filter(r => rules.includes(r.id)) : RULES;
    const texts = await readAll(await sourceFiles(inWorkspace(folder)));
    const findings = [];

    for (const [file, text] of texts) {
      for (const rule of active) {
        if (rule.onlyIn && !rule.onlyIn.test(text)) continue;
        text.split('\n').forEach((line, i) => {
          if (rule.pattern.test(line)) findings.push(`${relative(ROOT, file)}:${i + 1}  [${rule.id}] ${rule.message}`);
        });
      }
    }

    const text = findings.length
      ? `${findings.length} convention violation(s):\n${findings.slice(0, 200).join('\n')}` +
        (findings.length > 200 ? `\n…and ${findings.length - 200} more` : '')
      : 'No convention violations found.';
    return { content: [{ type: 'text', text }] };
  },
);

This is where the companion server earns its keep. get_best_practices tells the model what Angular recommends; check_conventions tells it what your team enforces, with exact locations. The no-http-in-components rule is the kind of thing no generic tool can know. Output is capped at 200 lines because every line lands in the model's context.

The server.registerTool(name, config, handler) form is the SDK's current API. Older tutorials, including some on this site, use server.tool(name, description, schema, handler), which still works.

Test it before you connect it #

The MCP Inspector runs your server and gives you a UI to call its tools by hand:

npx @modelcontextprotocol/inspector node tools/team-mcp/server.js

Open the URL it prints, list the tools, and call both with the default folder. Check three things: the descriptions read well, the output is short and useful, and a bad folder like ../../ returns the "outside the workspace" error instead of results.

Connect both servers #

Add the companion to the same project config as ng mcp. For Claude Code and Cursor (.mcp.json or .cursor/mcp.json):

{
  "mcpServers": {
    "angular-cli": {
      "command": "npx",
      "args": ["-y", "@angular/cli", "mcp", "--read-only"]
    },
    "team": {
      "command": "node",
      "args": ["tools/team-mcp/server.js"]
    }
  }
}

For VS Code (.vscode/mcp.json), the same two entries go under servers. Relative paths resolve from the workspace root in these project-level configs; if your client complains, use an absolute path or set WORKSPACE_ROOT in an env block.

Now a single request uses both servers:

"Review the orders feature against our conventions and Angular best practices, and list anything unused."

The model calls check_conventions and find_unused_components from your server, get_best_practices from the Angular server, then edits files with its built-in tools.

Sharing it with the team #

Option How Best when
In the repo (this lesson) tools/team-mcp/ + config entry One repo; rules evolve with the code
Private npm package Add a bin entry, publish, run with npx -y @your-org/team-mcp Several repos share the same conventions
Monorepo package A workspace package every app references Nx or npm-workspace monorepos

Whichever you choose, add the rules to code review too: a tool the assistant uses is great, but a lint rule in CI is what actually enforces a convention. Many teams turn their most useful check_conventions rules into ESLint rules once they've settled.

Gotchas #

Symptom Cause Fix
Server fails to start Missing "type": "module" or dependencies not installed in tools/team-mcp Add the field; run npm install in that folder
Tools never called Vague descriptions Say what it returns and when to use it; name the tool in your project instructions
Scans the wrong folder Client started the server outside the workspace Use a project-level config, or set WORKSPACE_ROOT
Huge, slow responses Scanning dist/ or generated code Keep the default src, exclude build output, cap result lines
Output on stdout breaks the protocol console.log in a stdio server Log with console.error; stdout is reserved for MCP messages
Two servers answer the same question Overlapping tools Keep the companion to team-specific knowledge; leave Angular questions to ng mcp

What's next #

Lesson 12.5 closes Module 12 with an end-to-end AI-assisted workflow: how the Angular server, your companion server and the agent's built-in tools fit into a real feature from ticket to pull request, where AI assistance helps most, where it doesn't, and how to keep it fast and cheap.

Try it yourself #

Build the server, point the Inspector at it, then add one rule that's specific to your codebase, such as a banned import or a required file-naming pattern. Connect it next to ng mcp and ask your assistant to review a feature folder against both.

YouCheck the src/app/orders folder against our team conventions and tell me what to fix first.
Claude · used check_conventions7 violations in src/app/orders. Fix first: order-list.ts:14 [no-http-in-components], where the component injects HttpClient directly; move that call into OrdersService, which already has a getOrders() method. Then the mechanical ones: 3× [signal-inputs] in order-card.ts and order-filter.ts, 2× [control-flow] (*ngFor) in order-list.html, and 1× [inject-function] in order-detail.ts. The signal-input and control-flow fixes have official migrations (ng generate @angular/core:signal-input-migration and @angular/core:control-flow), so I’d run those rather than editing by hand.

Up next in Angular

More from this topic

View all Angular articles →

Enjoyed this article?

Get new Angular tutorials delivered. No spam — just code-first articles when they ship.

Leave a Comment

Your email stays private. Required fields are marked *

Leave a Comment

Your email stays private. Required fields are marked *