Think about the charger problem. Years ago every phone had a different charger. If you built a power bank, you needed a different cable for every brand. Then USB-C arrived, and now one kind of plug works with many devices. The Model Context Protocol, or MCP, is trying to be USB-C for AI apps.
Here is the problem it solves. Suppose you build an AI app, and you want it to read files, search a database, check a calendar and send messages. Without a standard, you write a custom connection for each tool, for each AI app. If there are 5 apps and 10 tools, that is up to 50 separate connections to build and maintain. With a standard, each tool is wrapped once as an MCP server, and every app that speaks MCP can use it.
MCP was introduced by Anthropic as an open protocol, and it is now supported by many AI products and tools. In this lesson you will learn how it fits together and build the client side in your AI Lab.
The three roles: host, client, server
- The host is the AI application that the user sees: a chat app, a code editor, or your own Angular and Node app. The host decides which servers it may connect to, and it controls what the user allows.
- The client is a piece of code inside the host. Each client keeps one connection to one server. The host can run many clients.
- The server is a small program that offers capabilities in the MCP format. It might wrap a database, a folder of files, or an online service.
In our lab, your Node server is the host. It starts MCP servers as child processes and talks to each one through a client object. The Angular page never talks to MCP directly. It talks to your Node server, which keeps the control.
What a server can offer
An MCP server can offer three kinds of things, called primitives:
- Tools are actions the model can choose to call, like “search_files” or “create_ticket”. They have a name, a description and an input schema. This is the most used primitive, and it is what you used in the agent lessons, now in a standard format.
- Resources are data that the host can read and add as context: a file, a database row, a document. The application usually decides when to load them, not the model.
- Prompts are reusable prompt templates that the user can pick, like “summarise this repository”.
In this lesson we focus on tools. In the next lesson you will also build a resource.
How the messages travel
MCP messages use a simple format called JSON-RPC 2.0: a request has a method name and parameters, and the response has a result or an error. The typical conversation is:
- The client connects and says hello. The two sides agree on what they support (this is called initialisation).
- The client asks the server: “what tools do you have?” (
tools/list). - The server replies with each tool's name, description and input schema.
- Later, the client says: “run this tool with these arguments” (
tools/call). - The server runs it and returns the result.
You almost never write these messages by hand. The official SDK does it. But knowing the shape helps you understand error messages.
There are two common ways to carry the messages, called transports:
- stdio. The host starts the server as a child process and they talk through standard input and output. It is simple and runs on one machine. This is what we use.
- Streamable HTTP. The server runs as a web service, and clients connect over the network. It is used for remote and shared servers and needs proper authentication.
A tiny server
This is a complete MCP server with one tool. The tool has a name, a description that the model will read, an input schema written with zod, and a handler that returns content:
// A very small MCP server. It speaks the MCP protocol over stdin/stdout (the "stdio" transport).
// A host (our Node app) starts this file as a child process and talks to it.
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import { z } from 'zod';
const server = new McpServer({ name: 'text-tools', version: '1.0.0' });
// A tool = a name, a description the model reads, an input schema and a handler.
server.registerTool(
'count_words',
{
title: 'Count words',
description: 'Count the words and characters in a piece of text.',
inputSchema: { text: z.string().max(20000).describe('The text to count') },
},
async ({ text }) => {
const words = text.trim().split(/\s+/).filter(Boolean).length;
return { content: [{ type: 'text', text: JSON.stringify({ words, characters: text.length }) }] };
},
);
// IMPORTANT: never use console.log in a stdio server. stdout carries the protocol itself.
// Write logs to stderr instead.
console.error('[text-tools] ready');
await server.connect(new StdioServerTransport());
Read the description. The model chooses tools by reading it, so a good description is part of your product. Also look at the input schema: z.string().max(20000) means the server rejects numbers and huge texts before the handler runs. A schema is both documentation and a safety fence.
The host side: starting a server and calling a tool
On the host, the code must do four things: start the server process, connect a client, do the work, and always close the connection. Look at withClient. It uses a finally block so that the child process is stopped even when something fails. A forgotten child process is a quiet memory leak.
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
const here = path.dirname(fileURLToPath(import.meta.url));
const serverDir = path.join(here, '..');
// The host decides which servers exist. Users and the model cannot add new ones.
export const servers = () => JSON.parse(fs.readFileSync(path.join(here, 'servers.json'), 'utf8'));
// Start a server, connect, run `fn(client)`, then always shut the server down.
// (Starting a process per call is simple and fine for a lab. A production host keeps connections open.)
export async function withClient(name, fn) {
const cfg = servers()[name];
if (!cfg) {
const err = new Error(`Unknown MCP server: ${name}`);
err.status = 404;
err.userMessage = 'That MCP server is not configured.';
throw err;
}
const transport = new StdioClientTransport({ command: cfg.command, args: cfg.args, cwd: serverDir, stderr: 'ignore' });
const client = new Client({ name: 'ai-lab', version: '1.0.0' });
await client.connect(transport);
try {
return await fn(client);
} finally {
await client.close();
}
}
export async function listServers() {
const out = [];
for (const name of Object.keys(servers())) {
const tools = await withClient(name, async (c) => (await c.listTools()).tools);
out.push({ name, tools: tools.map((t) => ({ name: t.name, description: t.description, inputSchema: t.inputSchema })) });
}
return out;
}
export const callTool = (server, tool, args) => withClient(server, (c) => c.callTool({ name: tool, arguments: args }));
Notice that the list of servers lives in a file that you control, servers.json. The browser and the model cannot add servers. A host must decide which servers exist, because a server runs code on your machine.
For simplicity this lab starts a new process for every call. That is easy to understand and fine for learning. A production host keeps connections open and reuses them, and restarts them when they fail.
import { Router } from 'express';
import { listServers, callTool } from '../mcp/client.js';
const router = Router();
// GET /api/mcp/servers -> { servers: [{ name, tools: [...] }] }
router.get('/mcp/servers', async (req, res) => {
res.json({ servers: await listServers() });
});
// POST /api/mcp/call { server, tool, args } -> { result }
router.post('/mcp/call', async (req, res) => {
const { server, tool, args } = req.body || {};
if (typeof server !== 'string' || typeof tool !== 'string') {
return res.status(400).json({ error: { message: 'server and tool are required.' } });
}
const result = await callTool(server, tool, args && typeof args === 'object' ? args : {});
res.json({ result });
});
export default router;
The Inspector: a debugger for MCP servers
The MCP project provides a tool called the MCP Inspector. You run it with npx @modelcontextprotocol/inspector node server/mcp/text-tools-server.js, and it opens a web page where you can list tools, fill in arguments, call them and see the raw messages. Use it whenever a server does not behave. If it works in the Inspector but not in your app, the problem is in your host code. If it fails in the Inspector too, the problem is in the server.
Your task: connect to an MCP server from your AI Lab
Write a small MCP server, connect to it from Node, and show its tools in a new Angular page where you can call a tool.
What you will build
- server/mcp/text-tools-server.js, an MCP server with a count_words tool.
- server/mcp/servers.json and server/mcp/client.js, a host helper that starts a server, runs a function with a connected client and closes it.
- Routes: GET /api/mcp/servers (lists servers and their tools) and POST /api/mcp/call (calls a tool).
- An Angular MCP page that shows each tool, its schema, a box for JSON arguments and the result.
Steps
- Add the dependency: run npm install @modelcontextprotocol/sdk. The SDK has the McpServer, StdioServerTransport, Client and StdioClientTransport classes.
- Write the server with McpServer and registerTool. Give the tool a clear description and a zod input schema. Connect it with a StdioServerTransport. Write logs with console.error only.
- Write client.js. Read servers.json, start the process with StdioClientTransport, connect a Client, run your function and close the client in a finally block. Return a 404 error for an unknown server name.
- Add the two routes. In POST /api/mcp/call, check that server and tool are strings.
- Build the Angular page. Load the servers when the page opens. For each tool, show the schema, a textarea with {} as the default, a button and the result. Show a friendly message when the JSON is not valid.
- Test: call count_words with some text, call it with a number instead of text, and ask for a server that does not exist. Each case should end in a clear message, not a crash.
Hints
- The schema check happens in the server. A wrong argument comes back as a result with isError set to true, not as a crash.
- Run
npm run mcp:test(the lab includes this script) to list all tools from the terminal without the browser. - If the page hangs, check that nothing in your server writes to standard output.
You know it works when the page lists the count_words tool with its schema, calling it returns the word and character counts, wrong arguments give a readable error, and an unknown server gives a 404 message.
Solution
You have read the server, the client helper and the routes above. Here is the test script and the Angular page.
// Run with: npm run mcp:test
// Connects to every server in servers.json, lists its tools and prints them.
import { listServers } from './client.js';
for (const s of await listServers()) {
console.log(`\n${s.name}`);
for (const t of s.tools) console.log(` - ${t.name}: ${t.description}`);
}
import { Component, inject, signal, OnInit } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { HttpClient } from '@angular/common/http';
import { firstValueFrom } from 'rxjs';
import { errorText } from '../util';
type Tool = { name: string; description: string; inputSchema: unknown };
type Srv = { name: string; tools: Tool[] };
@Component({
selector: 'app-mcp',
imports: [FormsModule],
template: `
<h1>MCP servers and tools</h1>
<p class="hint">Our Node server starts each MCP server as a child process, asks what tools it offers and can call them.</p>
@if (error()) { <div class="err">{{ error() }}</div> }
@for (s of servers(); track s.name) {
<div class="card">
<h2>Server: {{ s.name }}</h2>
@for (t of s.tools; track t.name) {
<p><strong>{{ t.name }}</strong><br /><span class="muted">{{ t.description }}</span></p>
<pre class="out">{{ pretty(t.inputSchema) }}</pre>
<label [attr.for]="'a-' + t.name">Arguments (JSON)</label>
<textarea [id]="'a-' + t.name" [(ngModel)]="args[t.name]" style="min-height:70px"></textarea>
<button (click)="call(s.name, t.name)" [disabled]="busy()">Call {{ t.name }}</button>
@if (results[t.name]) { <pre class="out">{{ results[t.name] }}</pre> }
}
</div>
} @empty {
<p class="muted">{{ busy() ? 'Starting MCP servers...' : 'No servers found.' }}</p>
}
`,
})
export class McpPage implements OnInit {
private http = inject(HttpClient);
busy = signal(false);
error = signal('');
servers = signal<Srv[]>([]);
args: Record<string, string> = {};
results: Record<string, string> = {};
pretty(v: unknown) {
return JSON.stringify(v, null, 2);
}
async ngOnInit() {
this.busy.set(true);
try {
const r = await firstValueFrom(this.http.get<{ servers: Srv[] }>('/api/mcp/servers'));
for (const s of r.servers) for (const t of s.tools) this.args[t.name] ??= '{}';
this.servers.set(r.servers);
} catch (e) {
this.error.set(errorText(e));
} finally {
this.busy.set(false);
}
}
async call(server: string, tool: string) {
this.busy.set(true);
this.error.set('');
try {
const parsed = JSON.parse(this.args[tool] || '{}');
const r = await firstValueFrom(this.http.post<{ result: unknown }>('/api/mcp/call', { server, tool, args: parsed }));
this.results[tool] = JSON.stringify(r.result, null, 2);
} catch (e) {
this.error.set(e instanceof SyntaxError ? 'The arguments are not valid JSON.' : errorText(e));
} finally {
this.busy.set(false);
}
}
}
What you learned
- MCP is an open standard that lets AI apps connect to tools and data through one shared protocol.
- Host, client and server are the three roles. A server offers tools, resources and prompts.
- Messages use JSON-RPC over stdio (local) or HTTP (remote).
- The host decides which servers exist and must always close its connections.
In the next lesson you will build a more useful server for the resume app and connect it to Claude, so that Claude can call your MCP tools by itself.