Building API-Based MCP Tools
Build an MCP server that wraps a real REST API -- authentication headers, a reusable makeRequest helper, and registering tools for a GitHub-style issue tracker.
The add tool was a toy. The issue tracker MCP server is real. It wraps a REST API, handles authentication, and gives an LLM the ability to create and manage GitHub-style issues.
The Issue Tracker App
Clone the course repo:
git clone https://github.com/btholt/mcp-issue-tracker
cd mcp-issue-tracker
npm install # root deps
cd backend && npm install
cd ../frontend && npm installStart the app:
npm run dev # from root -- starts backend + frontend
# Frontend: http://localhost:5173
# API: http://localhost:3000Create an account, sign in, and you'll see a GitHub-style issue tracker: statuses, priorities, tags, assignees. Your MCP server will act as an automated interface to this app.
The workflow we're building toward:
"Hey Claude, I found a bug in the login page. Create an issue, mark it urgent, assign it to me, and add a 'bug' tag."
One natural language prompt → your MCP server orchestrates 4 API calls → issue created perfectly.
Auth: Get Your API Key
The app uses a simple API key for authentication. In the UI:
- Click Copy API Token (top of the app)
- Keep it somewhere safe -- each copy invalidates the previous one
You'll pass this in requests via the x-api-key header.
The makeRequest Helper
Rather than writing fetch in every tool, extract a reusable helper:
const API_BASE_URL = process.env.API_BASE_URL ?? "http://localhost:3000";
async function makeRequest(method, url, data = null, options = {}) {
const { headers: extraHeaders, ...otherOptions } = options;
const config = {
method,
headers: {
"content-type": "application/json",
...extraHeaders,
},
...otherOptions,
};
if (data) {
config.body = JSON.stringify(data);
}
const response = await fetch(url, config);
const text = await response.text();
let jsonResult;
try {
jsonResult = JSON.parse(text);
} catch {
jsonResult = text;
}
return {
status: response.status,
data: jsonResult,
headers: Object.fromEntries(response.headers.entries()),
};
}This handles:
- JSON body serialization
- Flexible header injection (for auth keys)
- Safe response parsing (text fallback if not JSON)
- Status code passthrough
Registering the Create Issue Tool
// api-based-tools.js
import { z } from "zod";
export default function registerApiTools(server) {
server.registerTool("issue-create", {
title: "Create Issue",
description: "Create a new issue in the issue tracker",
inputSchema: {
title: z.string().describe("Issue title"),
description: z.string().optional().describe("Issue description"),
status: z.enum(["not-started", "in-progress", "done"])
.optional()
.describe("Issue status"),
priority: z.enum(["low", "medium", "high", "urgent"])
.optional()
.describe("Issue priority"),
assigned_user_id: z.string().optional().describe("ID of user to assign"),
tag_ids: z.array(z.number()).optional().describe("Array of tag IDs"),
api_key: z.string().describe("API key for authentication"),
}
}, async (params) => {
const { api_key, ...issueData } = params;
const result = await makeRequest(
"POST",
`${API_BASE_URL}/issues`,
issueData,
{ headers: { "x-api-key": api_key } }
);
return {
content: [{ type: "text", text: JSON.stringify(result, null, 2) }]
};
});
}Notice the destructuring pattern:
const { api_key, ...issueData } = params;Pull the API key out, pass everything else as the issue body. Clean separation of auth from data.
Importing and Using the Tools Module
// main.js
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import registerApiTools from "./api-based-tools.js";
const server = new McpServer({
name: "issue-server",
version: "1.0.0",
});
registerApiTools(server);
const transport = new StdioServerTransport();
await server.connect(transport);The module pattern -- passing server into a registration function -- keeps each domain of tools in its own file. As the server grows, this keeps things manageable.
The Full Tool Set
Brian copied the rest of the 16 tools from the solution file rather than hand-coding each one. Here's a representative set:
// Issue tools
server.registerTool("issue-create", { ... })
server.registerTool("issue-list", { ... })
server.registerTool("issue-get", { ... })
server.registerTool("issue-update", { ... })
server.registerTool("issue-delete", { ... })
// User tools
server.registerTool("users-list", { ... })
server.registerTool("users-get", { ... })
// Tag tools
server.registerTool("tags-list", { ... })
server.registerTool("tags-create", { ... })
server.registerTool("tags-update", { ... })
server.registerTool("tags-delete", { ... })
// Health
server.registerTool("health-check", { ... })With all 16 tools loaded, you can send a complex prompt:
"Create a new issue. Assign it to the admin user. Mark it as a bug AND a feature request AND an auth issue. Mark it as urgent. I want to add GitHub as an auth provider. Add step-by-step instructions to the description. My api key is: [key]"
Claude Desktop with Sonnet will:
- Call
users-listto find the admin user ID - Call
tags-listto see existing tags - Call
tags-createto create missing tags (auth, bug, feature-request) - Call
issue-createwith all assembled data
One prompt → 4+ API calls → perfectly structured issue.
The JSON Spacing Trick
Notice the handler returns:
return {
content: [{ type: "text", text: JSON.stringify(result, null, 2) }]
};The null, 2 in JSON.stringify adds 2-space indentation. More tokens, but:
- Easier for the LLM to parse the structure
- Less likely to misread nested keys
- Clearer error messages if something went wrong
Brian: "I've found that adding space helps agents interpret data more correctly because it doesn't have to disambiguate where to split the string. You could get away with 1, but 2 feels right."
Lab -- Build the Tags List Tool
tags-list matters: Without it, the LLM doesn't know what tag IDs exist. It either invents IDs (wrong) or asks the user to specify them (annoying). With tags-list, it can look up the correct ID automatically.Lab 2 -- Design the Tool Parameters
Common Debugging Issues
Cannot find module 'api-based-tools.js'
Missing the .js extension in the import:
// ❌ Breaks in ES modules
import registerApiTools from "./api-based-tools";
// ✅ Required
import registerApiTools from "./api-based-tools.js";Z.string is not a function
Forgot to call Zod methods as functions:
// ❌ Wrong
{ name: z.string }
// ✅ Correct
{ name: z.string() }process is not defined
Typo -- process has one c, not two:
// ❌ Wrong (common typo)
const base = procccess.env.API_BASE_URL;
// ✅ Correct
const base = process.env.API_BASE_URL;Key Takeaways
makeRequesthelper centralizes fetch config, headers, and response parsing- Destructure auth from data:
const { api_key, ...issueData } = params - Module pattern:
export default function registerTools(server)keeps tools organized JSON.stringify(result, null, 2)helps the LLM parse nested responses- With 16 tools, complex multi-step prompts work -- Claude orchestrates the sequence automatically
- Missing
.jsin imports is the most common ES module bug
What's Next
You've built API-based tools -- one tool per endpoint. That's the obvious approach. But there's a better approach that produces more reliable results, especially with smaller models. Enter: jobs-based tool design.
Keep reading