Build an MCP Server in Node.js and Connect It to Claude

RAG, AI Agents & MCP · Model Context Protocol (MCP) · 14 min read · Updated

Your first MCP server

  1. Install the MCP SDK and write a server that registers a tool with a name, a description and an input schema.
  2. Test it with the MCP inspector before connecting any AI app.
  3. The host app starts your server and talks to it over a local connection.
  4. The client lists your tools, so the model knows they exist.
  5. When the model chooses your tool, the client sends the call to your server and returns the result.
  6. Good practice: clear descriptions, validated inputs and small tools that do one job.

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_keywords finds keywords. check_bullets checks bullets. Do not make one tool called resume_helper that 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_keywords is 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: true and 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:

  1. Ask each MCP server for its tools (tools/list).
  2. 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's inputSchema becomes Claude's input_schema.
  3. Send the converted tools to Claude with the user's message.
  4. 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.
  5. 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

  1. 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.
  2. 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.
  3. Register the resource resume://rules with a short text.
  4. Add the server to servers.json. Run npm run mcp:test and check that both servers are listed.
  5. 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.
  6. 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.

Download the solution (ai-lab-lesson-20.zip)

Frequently asked questions

How do I connect an MCP server to Claude?

Your app (the host) lists the server's tools, converts them to Claude's tool format, sends them with the user message, and runs the tool calls that Claude requests through the MCP client. Some Claude products also support MCP servers directly in their settings.

What is the difference between a tool and a resource in MCP?

A tool is an action that the model decides to call. A resource is read-only data that the application decides to load as context.

Should I throw an error or return isError from a tool?

For failures of the work itself, return a normal result with isError set to true and a clear message, so the model can read it and try again. Protocol problems are handled by the SDK.

How do I test an MCP server?

Use the MCP Inspector to list and call tools by hand and to see the raw messages. Then test through your host application.