TLDR: Full walkthrough of wiring Angular signals, streaming, and MCP tool calls into a live code review assistant — typed end to end. The security boundary section is the one to read carefully: tool execution never goes directly from browser to MCP server.
I've been building MCP-aware Angular apps for a few months now, and the thing nobody mentions in most MCP coverage is how naturally the protocol maps onto Angular's reactive model — signals want to react to incremental state changes, and MCP tools produce exactly that. Most tutorials focus on server-side implementations and CLI tools. But MCP changes how you think about Angular frontends too.
Before MCP, integrating AI into a frontend meant one of two patterns: send text, get text back — or build bespoke function-calling scaffolding for every tool you wanted the model to use. MCP standardizes the second option. An MCP server exposes a set of tools over a standard protocol, and any MCP-compatible model can discover and call them.
From Angular's perspective: you're writing services that talk to an AI proxy, and the AI on the other side has structured, typed access to your data. That changes what's possible.
This post walks through building an MCP-aware Angular application: a live code review assistant that gives Claude access to your project's actual files, Git history, and linting output.
Architecture overview
Angular App → [HTTP] → AI Proxy / MCP Host → [MCP] → MCP Servers → Your Data
Three MCP tools power the review:
Tool 1: read_file(path) → file contents
Tool 2: git_log(path?, limit?) → recent commits
Tool 3: run_lint(files[]) → ESLint output
The user submits a file path. Claude uses the tools to read the file, check its history, lint it, and produce a structured review. The Angular app streams the result back in real time.
src/
├── app/
│ ├── services/
│ │ ├── ai-review.service.ts ← orchestrates the AI + MCP loop
│ │ └── mcp-proxy.service.ts ← talks to MCP host
│ ├── models/
│ │ └── review.model.ts
│ ├── pages/
│ │ └── review/
│ │ └── review.component.ts
│ └── shared/
│ └── review-card/
│ └── review-card.component.ts
The MCP Proxy Service
Angular services don't talk to MCP directly — that's the AI provider's job. Your Angular service sends messages to your AI backend and streams back structured results.
// mcp-proxy.service.ts
import { Injectable, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable, Subject } from 'rxjs';
export interface StreamChunk {
type: 'text_delta' | 'tool_use' | 'tool_result' | 'done';
text?: string;
tool?: { name: string; input: unknown };
result?: unknown;
}
@Injectable({ providedIn: 'root' })
export class McpProxyService {
private http = inject(HttpClient);
private baseUrl = '/api/ai';
streamReview(filePath: string): Observable<StreamChunk> {
const subject = new Subject<StreamChunk>();
fetch(`${this.baseUrl}/review`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ filePath }),
}).then(async (response) => {
if (!response.body) {
subject.error(new Error('No response body'));
return;
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
while (true) {
const { done, value } = await reader.read();
if (done) {
subject.next({ type: 'done' });
subject.complete();
break;
}
const lines = decoder.decode(value).split('\n');
for (const line of lines) {
if (!line.startsWith('data: ')) continue;
try {
const chunk: StreamChunk = JSON.parse(line.slice(6));
subject.next(chunk);
} catch {
// skip malformed chunks
}
}
}
}).catch((err) => subject.error(err));
return subject.asObservable();
}
}
The AI Review Service — signals all the way
This service wraps the stream and accumulates state into Angular signals. The template reactivity is automatic — no subscriptions to manage, no ngOnDestroy cleanup.
// ai-review.service.ts
import { Injectable, inject, signal, computed } from '@angular/core';
import { McpProxyService } from './mcp-proxy.service';
export interface ToolActivity {
tool: string;
status: 'pending' | 'running' | 'done';
summary: string;
}
export interface ReviewResult {
summary: string;
issues: ReviewIssue[];
suggestions: string[];
score: number; // 1–10
}
export interface ReviewIssue {
line?: number;
severity: 'error' | 'warning' | 'info';
message: string;
}
@Injectable({ providedIn: 'root' })
export class AiReviewService {
private proxy = inject(McpProxyService);
isReviewing = signal(false);
toolActivity = signal<ToolActivity[]>([]);
streamedText = signal('');
review = signal<ReviewResult | null>(null);
error = signal<string | null>(null);
activeTools = computed(() =>
this.toolActivity().filter((t) => t.status === 'running')
);
hasActiveTools = computed(() => this.activeTools().length > 0);
startReview(filePath: string): void {
this.isReviewing.set(true);
this.toolActivity.set([]);
this.streamedText.set('');
this.review.set(null);
this.error.set(null);
this.proxy.streamReview(filePath).subscribe({
next: (chunk) => this.handleChunk(chunk),
error: (err) => {
this.error.set(err.message ?? 'Review failed');
this.isReviewing.set(false);
},
});
}
private handleChunk(chunk: any): void {
switch (chunk.type) {
case 'text_delta':
this.streamedText.update((t) => t + chunk.text);
break;
case 'tool_use':
this.toolActivity.update((tools) => [
...tools,
{
tool: chunk.tool.name,
status: 'running',
summary: this.toolSummary(chunk.tool),
},
]);
break;
case 'tool_result':
this.toolActivity.update((tools) =>
tools.map((t) =>
t.tool === chunk.tool ? { ...t, status: 'done' } : t
)
);
break;
case 'done':
this.parseReview(this.streamedText());
this.isReviewing.set(false);
break;
}
}
private toolSummary(tool: { name: string; input: unknown }): string {
const input = tool.input as Record<string, unknown>;
switch (tool.name) {
case 'read_file': return `Reading ${input['path']}`;
case 'git_log': return `Fetching Git history`;
case 'run_lint': return `Running ESLint`;
default: return tool.name;
}
}
private parseReview(text: string): void {
try {
const json = text.match(/```json\n([\s\S]+?)\n```/)?.[1] ?? text;
this.review.set(JSON.parse(json));
} catch {
this.review.set({
summary: text,
issues: [],
suggestions: [],
score: 0,
});
}
}
}
Signal-based state is a natural fit for event-driven AI workflows. You're always reacting to incremental updates — a tool starting, a tool completing, a text delta arriving. Signals handle this without any imperative subscription management.
The Review Component
The component wires the service signals directly into the template:
// review.component.ts
import { Component, inject, signal } from '@angular/core';
import { AiReviewService } from '../../services/ai-review.service';
import { ReviewCardComponent } from '../../shared/review-card/review-card.component';
import { FormsModule } from '@angular/forms';
@Component({
selector: 'app-review',
imports: [FormsModule, ReviewCardComponent],
template: `
<main class="min-h-screen bg-zinc-950 text-zinc-100 font-mono">
<div class="max-w-3xl mx-auto px-6 py-16">
<h1 class="text-2xl font-bold text-white mb-2">AI Code Review</h1>
<p class="text-zinc-500 mb-10 font-sans text-sm">
Powered by Claude + MCP. Reads your files, checks Git history, runs ESLint.
</p>
<div class="flex gap-3 mb-8">
<div class="flex-1 flex items-center gap-2 px-4 py-2.5 rounded-lg
bg-zinc-900 border border-zinc-800 focus-within:border-teal-500/60
transition-colors">
<span class="text-teal-500 select-none">$</span>
<input
[(ngModel)]="filePath"
placeholder="src/app/services/auth.service.ts"
class="flex-1 bg-transparent text-zinc-200 placeholder-zinc-600 text-sm
focus:outline-none"
/>
</div>
<button
(click)="review()"
[disabled]="!filePath() || reviewService.isReviewing()"
class="px-5 py-2.5 rounded-lg text-sm font-semibold font-sans
bg-teal-600 hover:bg-teal-500 text-white
disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
>
Review
</button>
</div>
<!-- Tool activity feed -->
@if (reviewService.toolActivity().length > 0) {
<div class="mb-8 flex flex-col gap-2">
@for (tool of reviewService.toolActivity(); track tool.tool) {
<div class="flex items-center gap-3 text-xs">
@if (tool.status === 'running') {
<span class="w-3 h-3 rounded-full border-2 border-teal-500
border-t-transparent animate-spin flex-shrink-0"></span>
} @else {
<span class="w-3 h-3 rounded-full bg-emerald-500/20
flex items-center justify-center flex-shrink-0">
<svg width="8" height="8" viewBox="0 0 24 24" fill="none"
stroke="#10b981" stroke-width="3" stroke-linecap="round">
<polyline points="20 6 9 17 4 12"/>
</svg>
</span>
}
<span [class]="tool.status === 'running'
? 'text-teal-400'
: 'text-zinc-500 line-through'">
{{ tool.summary }}
</span>
</div>
}
</div>
}
@if (reviewService.isReviewing() && reviewService.streamedText()) {
<pre class="text-xs text-zinc-400 bg-zinc-900 rounded-lg p-4 mb-8
overflow-x-auto whitespace-pre-wrap">{{ reviewService.streamedText() }}</pre>
}
@if (reviewService.review(); as result) {
<app-review-card [review]="result" />
}
@if (reviewService.error()) {
<p class="text-red-400 text-sm mt-4">Error: {{ reviewService.error() }}</p>
}
</div>
</main>
`,
})
export class ReviewComponent {
reviewService = inject(AiReviewService);
filePath = signal('');
review() {
const path = this.filePath().trim();
if (!path) return;
this.reviewService.startReview(path);
}
}
Server-side: The MCP Host (Node.js)
Your Angular app calls your backend; your backend orchestrates Claude + MCP:
// server/routes/ai-review.ts
import Anthropic from '@anthropic-ai/sdk';
import { Request, Response } from 'express';
const client = new Anthropic();
const REVIEW_TOOLS: Anthropic.Tool[] = [
{
name: 'read_file',
description: 'Read the contents of a file by path.',
input_schema: {
type: 'object',
properties: {
path: { type: 'string', description: 'Relative file path from project root' },
},
required: ['path'],
},
},
{
name: 'git_log',
description: 'Get recent Git commit history for a file.',
input_schema: {
type: 'object',
properties: {
path: { type: 'string' },
limit: { type: 'number', default: 10 },
},
required: ['path'],
},
},
{
name: 'run_lint',
description: 'Run ESLint on specified files and return the output.',
input_schema: {
type: 'object',
properties: {
files: { type: 'array', items: { type: 'string' } },
},
required: ['files'],
},
},
];
export async function reviewHandler(req: Request, res: Response) {
const { filePath } = req.body;
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache');
const emit = (chunk: object) =>
res.write(`data: ${JSON.stringify(chunk)}\n\n`);
const messages: Anthropic.MessageParam[] = [
{ role: 'user', content: `Review this file: ${filePath}` },
];
const systemPrompt = `You are a senior engineer performing a code review.
Use the available tools to: (1) read the file, (2) check its Git history,
(3) run the linter. Then respond with a JSON code block in this exact shape:
\`\`\`json
{
"summary": "...",
"score": 8,
"issues": [{ "line": 42, "severity": "warning", "message": "..." }],
"suggestions": ["...", "..."]
}
\`\`\``;
while (true) {
const response = await client.messages.create({
model: 'claude-sonnet-4-6',
max_tokens: 4096,
system: systemPrompt,
tools: REVIEW_TOOLS,
messages,
});
if (response.stop_reason === 'tool_use') {
emit({ type: 'tool_use', tool: response.content.find(b => b.type === 'tool_use') });
messages.push({ role: 'assistant', content: response.content });
const results = await Promise.all(
response.content
.filter((b): b is Anthropic.ToolUseBlock => b.type === 'tool_use')
.map(async (block) => {
const result = await executeTool(block.name, block.input);
emit({ type: 'tool_result', tool: block.name });
return {
type: 'tool_result' as const,
tool_use_id: block.id,
content: JSON.stringify(result),
};
})
);
messages.push({ role: 'user', content: results });
continue;
}
for (const block of response.content) {
if (block.type === 'text') {
for (const char of block.text) {
emit({ type: 'text_delta', text: char });
}
}
}
break;
}
emit({ type: 'done' });
res.end();
}
Security — the trust boundary matters
Never expose MCP tool execution directly to the browser.
❌ Browser → MCP server (direct)
✅ Browser → Your backend → MCP server
Your backend is the trust boundary. Validate file paths against an allowlist, scope Git access to a specific repo, and rate-limit AI calls per user. The Angular service should treat the AI proxy like any other authenticated API endpoint.
This pattern scales well beyond code review. Any Angular feature that benefits from real-time data access — dashboard analysis, document Q&A, deployment status monitoring — can be built on the same MCP + signals foundation. The model gets structured tools. Angular gets reactive state. Both sides stay typed, testable, and composable.