Building a small MCP server to learn the protocol hands-on

6 min readmcp, typescript, node

After writing about what MCP actually is, the obvious next step was building one. payments-toolkit-mcp is a small MCP server that exposes payment-data validation as MCP tools: Luhn checksum validation, card network detection, and IBAN validation. It also exposes a static resource listing supported card networks and their prefix ranges.

Composition, not configuration

The whole server is built from the official @modelcontextprotocol/sdk, and src/index.ts is just composition. It creates a server, registers each capability, and connects a transport:

const server = new McpServer({
name: 'payments-toolkit-mcp',
version: '1.0.0',
});
registerValidateCardNumberTool(server);
registerDetectCardTypeTool(server);
registerValidateIbanTool(server);
registerCardNetworksResource(server);
const transport = new StdioServerTransport();
await server.connect(transport);

Each tool lives in its own file under src/tools/, and each one only knows how to register itself onto a server it’s handed. It doesn’t own the server or the transport. The validation logic itself (isValidLuhn, the IBAN mod-97 check, the IIN/BIN prefix table) lives in src/lib/, completely unaware that MCP exists. That split made the MCP-specific code trivial to write, since it’s never more than a schema and a thin wrapper around a function that already works and is easy to test on its own.

A tool is a schema plus a handler

Registering a tool means giving it a name, a Zod input/output schema, and a handler:

server.registerTool(
'validate_card_number',
{
title: 'Validate Card Number',
description:
'Checks whether a card number passes the Luhn checksum algorithm. ' +
'Accepts digits only (spaces/dashes should be stripped by the caller).',
inputSchema: { cardNumber: cardNumberSchema },
outputSchema: { valid: z.boolean() },
},
async ({ cardNumber }) => {
const valid = isValidLuhn(cardNumber);
return {
content: [{ type: 'text', text: JSON.stringify({ valid }) }],
structuredContent: { valid },
};
},
);

The model reads description to decide whether to call the tool. The input schema does its own filtering: cardNumberSchema enforces digits-only and a sane length range, so malformed input never reaches isValidLuhn in the first place.

The two-shape response (content and structuredContent) surprised me initially. content is the older, universal shape: a list of blocks a client can render regardless of what it understands. structuredContent is the newer, typed one, matched against outputSchema so a client can consume it programmatically instead of parsing text. Returning both is redundant on paper, but it’s what keeps the tool usable by clients that only implement one side.

Resources are the read-only counterpart

Tools are actions; resources are just data a client can fetch by URI. The one resource here, card_networks, is a static JSON table of card networks and their prefix ranges. It’s the same table detect_card_type matches against internally, exposed separately so a client can read it directly instead of inferring it from tool calls:

server.registerResource(
'card_networks',
'payments-toolkit://card-networks',
{
title: 'Card Networks',
description:
'Supported card networks and the IIN/BIN prefix ranges used to identify them.',
mimeType: 'application/json',
},
async (uri) => ({
contents: [
{
uri: uri.href,
mimeType: 'application/json',
text: JSON.stringify(data, null, 2),
},
],
}),
);

Custom URI scheme (payments-toolkit://...), a MIME type, and a handler that returns contents. It’s structurally almost identical to a tool registration, which made resources feel like a small addition once tools already clicked rather than a separate concept to learn.

Prompts are the user-invoked primitive

Tools and resources cover two of MCP’s three primitives. The third is a prompt: a reusable template the server hands the client instead of the client writing it per integration. The one prompt here, check_payment_details, takes optional cardNumber/iban arguments and returns a message telling the model which tools to call and how to format the summary. The server never calls the tools itself, it just hands back text:

server.registerPrompt(
'check_payment_details',
{
title: 'Check Payment Details',
description:
'Validates a card number and/or IBAN using the available tools and ' +
'reports the results in a standard summary format.',
argsSchema: {
cardNumber: z.string().optional().describe('Card number to validate'),
iban: z.string().optional().describe('IBAN to validate'),
},
},
({ cardNumber, iban }) => ({
messages: [{ role: 'user', content: { type: 'text', text: /* ... */ } }],
}),
);

argsSchema is a flat shape of strings only, not arbitrary Zod like a tool’s inputSchema. The MCP spec has clients render prompt arguments as plain text fields. That restriction lines up with how prompts get invoked: not chosen by the model like a tool, but triggered explicitly by the user, surfaced as a slash command in Claude Code (/mcp__payments-toolkit-mcp__check_payment_details).

The gotcha: stdout is not yours

This is the one mistake the README specifically calls out, and it’s the kind of thing you’d only discover by hitting it: over the stdio transport, stdout is the JSON-RPC wire. A stray console.log doesn’t just clutter logs; it injects malformed data into the protocol stream and breaks the client’s parser. Every bit of debug output in this server goes through console.error instead, since stderr is left alone. This explains why MCP server boilerplate almost always reaches for a logger or console.error by convention rather than console.log.

Inspecting and connecting

@modelcontextprotocol/inspector gives a local web UI to call tools and read resources directly, without wiring up a client first. That’s useful for confirming schemas and responses actually round-trip before pointing a real host at the server. Once that works, connecting it to Claude Code is a single command:

claude mcp add payments-toolkit-mcp -- node /path/to/payments-toolkit-mcp/dist/index.js

/mcp inside a session then confirms the connection: seeing payments-toolkit-mcp · ✔ connected means the handshake worked and the tools were discovered. That registration defaults to local scope, tied to the project directory claude mcp add was run from, so a different project needs its own claude mcp add (or --scope user to make the server available everywhere).

Adding the HTTP transport

Stdio only works because Claude Code spawns the server itself and owns the subprocess. A server meant to be shared across multiple clients needs to run over HTTP instead, via StreamableHTTPServerTransport. Supporting both meant splitting index.ts: tool/resource registration moved into a createServer() factory, and each transport got its own module, picked at startup by a --http flag.

The part that isn’t obvious going in: an McpServer can only be connect()-ed to one transport, so serving multiple HTTP clients from one long-running process means a fresh McpServer + StreamableHTTPServerTransport pair per session, created on the initialize request and keyed by the Mcp-Session-Id header on every request after that:

if (!transport) {
if (!isInitializeRequest(req.body)) {
res.status(400).json({/* ... */});
return;
}
transport = new StreamableHTTPServerTransport({
sessionIdGenerator: () => randomUUID(),
onsessioninitialized: (newSessionId) => {
transports.set(newSessionId, transport!);
},
});
const server = createServer();
await server.connect(transport);
}

Requests without a session ID that aren’t initialize get a 400.