In the last lesson you built a tiny server with one tool and called it by hand. Now you will build something useful for a resume product, and then connect it to Claude so that the model decides when to call it. This is the real payoff of MCP: you write the capability once, and any AI app that speaks MCP can use it.
Our server is called resume-tools. It has two tools, a keyword finder and a bullet checker, and one resource, a short list of resume rules. You will learn how to design good tools, how to report errors so the model can recover, and how to translate MCP tools into the format that Claude expects.
Designing good tools
A tool is an interface for a model, and a model is a reader that cannot ask you questions. So the quality of the description decides how well the tool works. Follow these rules.
- One job per tool.
job_keywordsfinds keywords.check_bulletschecks bullets. Do not make one tool calledresume_helperthat does five things. - A description that says when to use it. “Use it when the user pastes a job post and wants keywords for a resume” tells the model the right moment.
- A strict input schema. Use types, minimum and maximum values and defaults.
max_keywordsis an integer from 1 to 20 with a default of 8. The schema rejects nonsense before your code runs. - Small, structured results. Return the useful data, not a whole database dump. Large results waste tokens and distract the model.
- Honest errors. Say what went wrong and how to fix it.
import { McpServer, ResourceTemplate } from '@modelcontextprotocol/sdk/server/mcp.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import { z } from 'zod';
const server = new McpServer({ name: 'resume-tools', version: '1.0.0' });
const STOP = new Set('the a an and or of to in for with on at by is are be as we you our your will can from this that it their they them have has not but if'.split(' '));
const text = (value) => ({ content: [{ type: 'text', text: typeof value === 'string' ? value : JSON.stringify(value) }] });
// Tool 1: keywords from a job post.
server.registerTool(
'job_keywords',
{
title: 'Job keywords',
description: 'Find the most frequent skills and keywords in a job description. Use it when the user pastes a job post and wants keywords for a resume.',
inputSchema: {
job_text: z.string().min(20).max(20000).describe('The full job description'),
max_keywords: z.number().int().min(1).max(20).default(8).describe('How many keywords to return'),
},
},
async ({ job_text, max_keywords }) => {
const counts = new Map();
for (const w of job_text.toLowerCase().match(/[a-z][a-z+#.]{2,}/g) || []) if (!STOP.has(w)) counts.set(w, (counts.get(w) || 0) + 1);
const top = [...counts].sort((a, b) => b[1] - a[1]).slice(0, max_keywords);
return text({ keywords: top.map(([word, times]) => ({ word, times })) });
},
);
// Tool 2: check bullet points. Errors the model can fix are returned with isError: true,
// not thrown, so the model reads them and tries again.
server.registerTool(
'check_bullets',
{
title: 'Check resume bullets',
description: 'Check resume bullet points. Reports bullets that are too long, do not start with an action verb or contain no number.',
inputSchema: { bullets: z.array(z.string().max(400)).min(1).max(20) },
},
async ({ bullets }) => {
const verbs = /^(managed|built|led|created|reduced|increased|improved|developed|handled|delivered|trained|designed|launched|organised|organized|operated|repaired|installed|sold|served|wrote|planned|supported|maintained|cut|saved|grew|drove)\b/i;
const report = bullets.map((b, i) => ({
bullet: i + 1,
tooLong: b.split(/\s+/).length > 28,
startsWithActionVerb: verbs.test(b.trim()),
hasNumber: /\d/.test(b),
}));
return text({ report, allGood: report.every((r) => !r.tooLong && r.startsWithActionVerb && r.hasNumber) });
},
);
// A resource = read-only data the host can load as context (no model decision involved).
server.registerResource(
'resume-rules',
'resume://rules',
{ title: 'Resume rules', description: 'Short house rules for good resume bullets', mimeType: 'text/plain' },
async (uri) => ({ contents: [{ uri: uri.href, text: 'Start with an action verb. Keep each bullet under 28 words. Include one number. Be honest.' }] }),
);
console.error('[resume-tools] ready');
await server.connect(new StdioServerTransport());
Two kinds of failure
There are two different kinds of error in MCP, and mixing them up causes bugs.
- Protocol errors, for example an unknown tool name or arguments that do not match the schema. The SDK handles these for you.
- Tool errors, where the tool ran but the work failed, such as “file not found” or “the text is too short”. You return these as a normal result with
isError: trueand a clear message.
The reason to return tool errors as results is that the model can read them. If the tool says “the text must be at least 20 characters”, the model can fix its next call. If you throw an exception, the model sees nothing useful. In our agent code in lesson 17 we did the same thing for the same reason.
Resources: data without a model decision
The server also registers a resource named resume://rules. A resource is read-only data with an address. Unlike a tool, the model does not decide to call it. The host (your app) decides to read it and add it to the context, or lets the user choose it. Use a resource for reference material: documentation, a schema, a style guide. Use a tool when the model needs to do something or look something up on demand.
Connecting MCP to Claude
Here is the part that surprises people because it is so small. Claude's tool format and MCP's tool format are almost the same. A tool has a name, a description and a JSON schema for the input. So to let Claude use an MCP server, the host does a simple translation:
- Ask each MCP server for its tools (
tools/list). - Convert each tool into Claude's format. The name becomes
server__tool, so two servers can have a tool with the same name without clashing. The description stays. MCP'sinputSchemabecomes Claude'sinput_schema. - Send the converted tools to Claude with the user's message.
- When Claude asks for a tool, split the name back into server and tool, call it through the MCP client and send the result back as a
tool_result. - Repeat until Claude gives a text answer, with a step limit as before.
import { client as claude, MODEL, textOf } from '../claude.js';
import { withClient, listServers } from './client.js';
// Claude's tool format is almost the same as MCP's. Only the names differ.
// We prefix each name with the server, so two servers can both have a tool called "search".
const toClaudeTool = (server, t) => ({
name: `${server}__${t.name}`,
description: t.description || t.title || t.name,
input_schema: t.inputSchema,
});
const textFromResult = (result) =>
(result.content || []).filter((c) => c.type === 'text').map((c) => c.text).join('\n') || JSON.stringify(result);
// Ask Claude a question and let it use every tool from the MCP servers on the allow-list.
export async function askWithMcp(message, { servers: allowed, maxTurns = 5 } = {}) {
const available = (await listServers()).filter((s) => !allowed || allowed.includes(s.name));
const tools = available.flatMap((s) => s.tools.map((t) => toClaudeTool(s.name, t)));
const messages = [{ role: 'user', content: message }];
const trace = [];
for (let turn = 1; turn <= maxTurns; turn++) {
const reply = await claude.messages.create({ model: MODEL, max_tokens: 700, tools, messages });
messages.push({ role: 'assistant', content: reply.content });
if (reply.stop_reason !== 'tool_use') return { text: textOf(reply), trace, tools: tools.map((t) => t.name) };
const results = [];
for (const block of reply.content.filter((b) => b.type === 'tool_use')) {
const [server, ...rest] = block.name.split('__');
const tool = rest.join('__');
let content;
let isError = false;
try {
if (!tools.some((t) => t.name === block.name)) throw new Error('That tool is not allowed.');
const r = await withClient(server, (c) => c.callTool({ name: tool, arguments: block.input }));
content = textFromResult(r);
isError = Boolean(r.isError);
} catch (e) {
content = `Error: ${e.message}`;
isError = true;
}
trace.push({ tool: block.name, input: block.input, output: content, isError });
results.push({ type: 'tool_result', tool_use_id: block.id, content: content.slice(0, 6000), ...(isError ? { is_error: true } : {}) });
}
messages.push({ role: 'user', content: results });
}
return { text: 'Stopped: too many steps.', trace, tools: tools.map((t) => t.name) };
}
Look at the guard before the call: tools.some((t) => t.name === block.name). The host only runs tools that it offered. Never run a tool name just because the model wrote it. We also use an allow-list of servers: the route gives Claude only the resume-tools server, not every server in the configuration.
import { Router } from 'express';
import { askWithMcp } from '../mcp/bridge.js';
const router = Router();
// POST /api/mcp/ask { message } -> { text, trace, tools }
router.post('/mcp/ask', async (req, res) => {
const message = String(req.body?.message || '').trim().slice(0, 6000);
if (!message) return res.status(400).json({ error: { message: 'Please type a message.' } });
res.json(await askWithMcp(message, { servers: ['resume-tools'] }));
});
export default router;
{
"text-tools": { "command": "node", "args": ["mcp/text-tools-server.js"] },
"resume-tools": { "command": "node", "args": ["mcp/resume-tools-server.js"] }
}
What this gives you
Once the bridge works, adding a new capability is just adding a server to servers.json. The Angular page, the route and the loop do not change. And the same resume-tools server can be used by another AI app that speaks MCP without any change at all. That is the benefit of a standard.
Your task: build the resume-tools server and use it from Claude
Build an MCP server with two tools and one resource, connect it to Claude, and show every MCP call in the page.
What you will build
- server/mcp/resume-tools-server.js with the tools job_keywords and check_bullets and the resource resume://rules.
- server/mcp/bridge.js that converts MCP tools to Claude tools and runs the loop with a step limit.
- A route POST /api/mcp/ask that allows only the resume-tools server.
- An Angular page that shows the answer, the tools offered to Claude and every MCP call with its input and output.
Steps
- Write job_keywords. Count the words in the job text, remove tiny common words, and return the top words with their counts. Use a zod schema with a minimum length for the text and a limit for max_keywords.
- Write check_bullets. For every bullet report if it is too long (more than 28 words), if it starts with an action verb and if it has a number. Return a report and a flag called allGood.
- Register the resource resume://rules with a short text.
- Add the server to servers.json. Run npm run mcp:test and check that both servers are listed.
- Write bridge.js. Convert the tools, call Claude, run the requested tools through the MCP client and send results back with is_error when needed. Stop after five turns.
- Write the route and the page. Test with a request to check two bullets, a request to find keywords in a pasted job post, and a request that needs no tool.
Hints
- Prefix tool names with the server name and a double underscore, and split on the first double underscore when you call.
- Cut every tool result to a safe size before returning it to Claude.
- In the Inspector, test your server alone first. Then test through Claude.
You know it works when Claude picks check_bullets for a bullet question without being told to, the trace shows the exact arguments, bad arguments produce a readable error that Claude can correct, and tools of other servers are not offered.
Solution
You have read the server, the bridge, the route and the server list above. This is the Angular page:
import { Component, inject, signal } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { HttpClient } from '@angular/common/http';
import { firstValueFrom } from 'rxjs';
import { errorText } from '../util';
type Step = { tool: string; input: unknown; output: string; isError: boolean };
@Component({
selector: 'app-mcp-ask',
imports: [FormsModule],
template: `
<h1>Claude using an MCP server</h1>
<p class="hint">The tools come from our own resume-tools MCP server. Claude picks a tool, our host calls it over MCP and returns the result.</p>
<div class="card">
<label for="m">Your request</label>
<textarea id="m" [(ngModel)]="message" style="min-height:140px"></textarea>
<button (click)="run()" [disabled]="busy() || !message.trim()">{{ busy() ? 'Working...' : 'Run' }}</button>
@if (error()) { <div class="err">{{ error() }}</div> }
</div>
@if (text()) {
<div class="card">
<h2>Answer</h2>
<div class="answer">{{ text() }}</div>
<p class="muted">Tools offered to Claude: {{ offered().join(', ') }}</p>
<h2>MCP calls ({{ trace().length }})</h2>
@for (s of trace(); track $index) {
<p><strong>{{ s.tool }}</strong> <span [class]="s.isError ? 'bad' : 'ok'">{{ s.isError ? 'error' : 'ok' }}</span></p>
<pre class="out">in: {{ json(s.input) }}
out: {{ s.output }}</pre>
}
</div>
}
`,
})
export class McpAskPage {
private http = inject(HttpClient);
message = 'Check these resume bullets and tell me what to fix:\n1. Responsible for customer calls\n2. Handled 40 billing tickets a day and cut waiting time by 15%';
busy = signal(false);
error = signal('');
text = signal('');
trace = signal<Step[]>([]);
offered = signal<string[]>([]);
json(v: unknown) {
return JSON.stringify(v);
}
async run() {
this.busy.set(true);
this.error.set('');
this.text.set('');
try {
const r = await firstValueFrom(this.http.post<{ text: string; trace: Step[]; tools: string[] }>('/api/mcp/ask', { message: this.message }));
this.text.set(r.text);
this.trace.set(r.trace);
this.offered.set(r.tools);
} catch (e) {
this.error.set(errorText(e));
} finally {
this.busy.set(false);
}
}
}
What you learned
- Good tools do one job, have clear descriptions and strict input schemas, and return small results.
- Tool errors are returned as results with isError so that the model can read and fix them.
- Resources give read-only context. Tools perform actions on demand.
- Claude's tool format and MCP's are nearly the same, so a small bridge connects them. The host only runs tools that it offered.
In the next lesson you will learn the part that decides whether MCP is safe: permissions, approvals and prompt injection.