Appearance
Build an MCP Server
Model Context Protocol (MCP) is an open standard that lets AI assistants call external tools through a local server. This guide walks you through building an MCP server that exposes your Holyheld card balance, card data, and top-up capability as tools that Claude can call natively.
Once installed, you can ask Claude things like:
- "What is my Holyheld card balance?"
- "Get my Holyheld card details for checkout."
- "Top up my Holyheld card with €50."
- "Check my balance. If it's below €20, top it up to €50."
Claude will handle the reasoning; the MCP server handles the API calls.
What you will build
A Node.js MCP server that registers three tools:
| Tool | Wraps | Description |
|---|---|---|
get_holyheld_balance | GET /balance | Returns the current EUR balance on the card |
get_holyheld_card_data | GET /card-data | Returns card details for active checkout flows |
topup_holyheld_card | POST /topup-request | Tops up the card and polls until the balance updates |
Prerequisites
- Node.js 18+ —
fetchis built-in from Node 18 (no extra HTTP library needed) - npm or pnpm
- Claude Desktop installed (download)
- Agent instructions for a Holyheld card; they include the Bearer token — see Authentication
Step 1 — Set up the project
bash
mkdir holyheld-mcp && cd holyheld-mcp
npm init -y
npm install @modelcontextprotocol/sdk
npm install -D typescript @types/nodeCreate tsconfig.json:
json
{
"compilerOptions": {
"target": "ES2022",
"module": "Node16",
"moduleResolution": "Node16",
"outDir": "dist",
"rootDir": "src",
"strict": true,
"esModuleInterop": true
},
"include": ["src/**/*.ts"]
}Add build scripts to package.json:
json
{
"type": "module",
"scripts": {
"build": "tsc",
"start": "node dist/index.js"
}
}Create the source directory:
bash
mkdir srcStep 2 — Implement the server
Create src/index.ts with the complete implementation below. Read the inline comments — they explain every decision.
typescript
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import {
CallToolRequestSchema,
ListToolsRequestSchema,
} from '@modelcontextprotocol/sdk/types.js';
// ── Configuration ──────────────────────────────────────────────────────────────
const BASE_URL = 'https://apicore.holyheld.com/v4/ai-agents';
const POLL_INTERVAL_MS = 30_000; // 30 seconds between balance polls
const POLL_TIMEOUT_MS = 5 * 60_000; // 5 minutes maximum wait after top-up
const token = process.env.HOLYHELD_AGENT_TOKEN;
if (!token) {
console.error('Error: HOLYHELD_AGENT_TOKEN environment variable is not set.');
process.exit(1);
}
const authHeaders = {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
};
// ── Helpers ────────────────────────────────────────────────────────────────────
async function getBalance(): Promise<{ ok: true; balance: string } | { ok: false; errorCode: string; error: string }> {
const res = await fetch(`${BASE_URL}/balance`, { headers: authHeaders });
const data = await res.json() as any;
if (data.status === 'error') return { ok: false, errorCode: data.errorCode, error: data.error };
return { ok: true, balance: data.payload.balance as string };
}
async function getCardData(): Promise<
| {
ok: true;
payload: {
cardNumber: string;
expirationDate: string;
cardholderName: string;
CVV: string;
billingAddress: string;
};
}
| { ok: false; errorCode: string; error: string }
> {
const res = await fetch(`${BASE_URL}/card-data`, { headers: authHeaders });
const data = await res.json() as any;
if (data.status === 'error') return { ok: false, errorCode: data.errorCode, error: data.error };
return { ok: true, payload: data.payload };
}
function isValidAmount(amount: string): boolean {
return /^\d+(\.\d{1,2})?$/.test(amount);
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
// ── MCP Server ─────────────────────────────────────────────────────────────────
const server = new Server(
{ name: 'holyheld-payments', version: '1.0.0' },
{ capabilities: { tools: {} } }
);
// ── Tool definitions ───────────────────────────────────────────────────────────
server.setRequestHandler(ListToolsRequestSchema, async () => ({
tools: [
{
name: 'get_holyheld_balance',
description:
'Get the current EUR balance available on the Holyheld card. ' +
'Use this before deciding whether to top up, and after a top-up to confirm the balance has updated.',
inputSchema: {
type: 'object',
properties: {},
required: [],
},
},
{
name: 'get_holyheld_card_data',
description:
'Get Holyheld card details for an active checkout flow. ' +
'This returns sensitive data, so use it only when the user is about to pay.',
inputSchema: {
type: 'object',
properties: {},
required: [],
},
},
{
name: 'topup_holyheld_card',
description:
'Top up the Holyheld card by transferring funds from the Holyheld main account to the card. ' +
'The top-up may take up to 5 minutes to be reflected in the balance. ' +
'This tool waits and polls until the balance updates or the 5-minute window expires.',
inputSchema: {
type: 'object',
properties: {
amount: {
type: 'string',
description:
'Amount in EUR to top up. Must be a positive decimal string with up to 2 decimal places. ' +
'Examples: "50", "50.00", "12.50". Do not include a currency symbol.',
},
},
required: ['amount'],
},
},
],
}));
// ── Tool handlers ──────────────────────────────────────────────────────────────
server.setRequestHandler(CallToolRequestSchema, async (request) => {
const { name, arguments: args } = request.params;
// ── get_holyheld_balance ──
if (name === 'get_holyheld_balance') {
const result = await getBalance();
if (!result.ok) {
return {
content: [{ type: 'text', text: `Failed to retrieve balance: ${result.errorCode} — ${result.error}` }],
isError: true,
};
}
return {
content: [{ type: 'text', text: `Your Holyheld card balance is €${result.balance}.` }],
};
}
// ── get_holyheld_card_data ──
if (name === 'get_holyheld_card_data') {
const result = await getCardData();
if (!result.ok) {
return {
content: [{ type: 'text', text: `Failed to retrieve card data: ${result.errorCode} — ${result.error}` }],
isError: true,
};
}
return {
content: [{
type: 'text',
text:
`Card number: ${result.payload.cardNumber}\n` +
`Expiration date: ${result.payload.expirationDate}\n` +
`Cardholder name: ${result.payload.cardholderName}\n` +
`CVV: ${result.payload.CVV}\n` +
`Billing address: ${result.payload.billingAddress}`,
}],
};
}
// ── topup_holyheld_card ──
if (name === 'topup_holyheld_card') {
const { amount } = args as { amount: string };
// Validate amount format before hitting the API
if (!isValidAmount(amount)) {
return {
content: [{
type: 'text',
text:
`Invalid amount: "${amount}". ` +
'Please provide a positive number with up to 2 decimal places (e.g. "50.00").',
}],
isError: true,
};
}
// Record balance before top-up so we can detect the change
const before = await getBalance();
if (!before.ok) {
return {
content: [{ type: 'text', text: `Could not check balance before top-up: ${before.errorCode} — ${before.error}` }],
isError: true,
};
}
const balanceBefore = parseFloat(before.balance);
// Request the top-up
const topupRes = await fetch(`${BASE_URL}/topup-request`, {
method: 'POST',
headers: authHeaders,
body: JSON.stringify({ amount }),
});
if (!topupRes.ok) {
const err = await topupRes.json() as any;
if (err.errorCode === 'AI_TOPUP_INSUFFICIENT_BALANCE') {
return {
content: [{
type: 'text',
text:
'Top-up failed: your Holyheld account does not have enough available balance. ' +
'Please add funds to your Holyheld account and try again.',
}],
isError: true,
};
}
if (err.errorCode === 'AI_TOPUP_LIMIT_EXCEEDED') {
return {
content: [{
type: 'text',
text:
'Top-up failed: your agent spending limit has been reached. ' +
'To continue, please reset the limit in your Holyheld dashboard under Settings → Agentic access.',
}],
isError: true,
};
}
return {
content: [{ type: 'text', text: `Top-up failed: ${err.errorCode} — ${err.error}` }],
isError: true,
};
}
// Top-up accepted — poll until balance increases or timeout
const deadline = Date.now() + POLL_TIMEOUT_MS;
while (Date.now() < deadline) {
await sleep(POLL_INTERVAL_MS);
const current = await getBalance();
if (!current.ok) continue; // transient error — keep polling
const balanceNow = parseFloat(current.balance);
if (balanceNow > balanceBefore) {
const added = (balanceNow - balanceBefore).toFixed(2);
return {
content: [{
type: 'text',
text:
`Top-up complete. Added €${added} to your Holyheld card. ` +
`Your balance is now €${current.balance} (was €${before.balance}).`,
}],
};
}
}
// Timeout — top-up was accepted but balance has not yet updated
return {
content: [{
type: 'text',
text:
`Your top-up of €${amount} was accepted and is being processed. ` +
'The balance has not updated within 5 minutes, which can happen during high network load. ' +
'Please check your Holyheld card balance in a few minutes.',
}],
};
}
return {
content: [{ type: 'text', text: `Unknown tool: ${name}` }],
isError: true,
};
});
// ── Entrypoint ─────────────────────────────────────────────────────────────────
async function main() {
const transport = new StdioServerTransport();
await server.connect(transport);
}
main().catch((error) => {
console.error('Server error:', error);
process.exit(1);
});Step 3 — Build the server
bash
npm run buildThis compiles TypeScript to dist/index.js. Confirm the build succeeded:
bash
node dist/index.js
# Should start silently — MCP servers communicate via stdin/stdout, not console output
# Press Ctrl+C to exitNote the absolute path to dist/index.js — you'll need it in the next step:
bash
pwd
# e.g. /Users/yourname/holyheld-mcp
# Full path: /Users/yourname/holyheld-mcp/dist/index.jsStep 4 — Configure Claude Desktop
Open the Claude Desktop configuration file in a text editor:
bash
open -e "$HOME/Library/Application Support/Claude/claude_desktop_config.json"powershell
notepad "$env:APPDATA\Claude\claude_desktop_config.json"bash
nano "$HOME/.config/Claude/claude_desktop_config.json"Add the holyheld-payments server entry. If the file is empty, create it from scratch:
json
{
"mcpServers": {
"holyheld-payments": {
"command": "node",
"args": ["/absolute/path/to/holyheld-mcp/dist/index.js"],
"env": {
"HOLYHELD_AGENT_TOKEN": "your-bearer-token-here"
}
}
}
}Replace:
/absolute/path/to/holyheld-mcp/dist/index.js→ the actual absolute path from Step 3your-bearer-token-here→ the Bearer token from the card's agent instructions
Use the absolute path
Claude Desktop launches the MCP server as a subprocess. Relative paths will not work — always use the full absolute path to dist/index.js.
Restart Claude Desktop after saving the configuration file. The MCP tools are loaded at startup.
Step 5 — Verify the connection
In Claude Desktop, look for the tools icon (🔧) in the input bar. Click it to see connected tools. You should see:
get_holyheld_balanceget_holyheld_card_datatopup_holyheld_card
If the tools do not appear, check:
- The JSON in
claude_desktop_config.jsonis valid (no trailing commas) - The path to
dist/index.jsis correct and absolute HOLYHELD_AGENT_TOKENis set to a valid token value- Claude Desktop was fully restarted (quit and reopen — not just the window)
Step 6 — Test with Claude
Try these prompts in Claude Desktop:
Check balance:
What is my Holyheld card balance?
Claude calls get_holyheld_balance and responds with something like:
Your Holyheld card balance is €42.02.
Get card details:
Get my Holyheld card details for checkout.
Claude calls get_holyheld_card_data and responds with the card number, expiry, CVV, cardholder name, and billing address. Because this is sensitive data, your MCP server should stay local and you should avoid logging tool output.
Top up a fixed amount:
Top up my Holyheld card with €30.
Claude calls topup_holyheld_card with amount: "30.00" and waits up to 5 minutes. When confirmed:
Top-up complete. Added €30.00 to your Holyheld card. Your balance is now €72.02 (was €42.02).
Conditional top-up (multi-step reasoning):
Check my Holyheld balance. If it's below €20, top it up to €50.
Claude will call get_holyheld_balance, reason about the result, and — if needed — call topup_holyheld_card with the right amount automatically.
Spending limit hit:
Top up my Holyheld card with €200.
If the spending limit is exceeded:
Top-up failed: your agent spending limit has been reached. To continue, please reset the limit in your Holyheld dashboard under Settings → Agentic access.
Security checklist
- [x] Bearer token stored in
claude_desktop_config.jsonenvfield — not hardcoded in source - [x]
dist/index.jsruns locally on your machine — the token never leaves your device - [x] Full card data from
GET /card-datais only fetched for active checkout flows and is never logged - [x] Source code does not contain the token (safe to commit
src/index.ts) - [x]
claude_desktop_config.jsonshould not be committed to version control
Next steps
See Agent Patterns for best practices on structuring agent logic around this API — including threshold-based auto top-up, safe polling, and how to handle limit-exceeded scenarios correctly.
