Building a small MCP server to learn the protocol hands-on
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.
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).
Next steps
Two things this server doesn’t cover yet, left for a follow-up:
- Swap
StdioServerTransportforStreamableHTTPServerTransport. 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. - Add a prompt. Tools and resources are two of MCP’s three primitives; a
prompt is the third, a reusable template the server hands the client
instead of the client writing it per integration. A
validate-payment-detailsprompt that checks card number, expiry, and CVV together would round out the toolkit.