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.

April 1, 20268 min read1 / 2

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:

Bash
git clone https://github.com/btholt/mcp-issue-tracker cd mcp-issue-tracker npm install # root deps cd backend && npm install cd ../frontend && npm install

Start the app:

Bash
npm run dev # from root -- starts backend + frontend # Frontend: http://localhost:5173 # API: http://localhost:3000

Create 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:

  1. Click Copy API Token (top of the app)
  2. Keep it somewhere safe -- each copy invalidates the previous one

You'll pass this in requests via the x-api-key header.

⚠️ One API key at a time. The app intentionally only supports one valid API key at a time (Brian skipped key management to keep the course simple). Copying a new one invalidates the previous key. Don't click it twice unnecessarily.

The makeRequest Helper

Rather than writing fetch in every tool, extract a reusable helper:

JavaScript
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

JavaScript
// 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:

JavaScript
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

JavaScript
// 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:

JavaScript
// 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:

  1. Call users-list to find the admin user ID
  2. Call tags-list to see existing tags
  3. Call tags-create to create missing tags (auth, bug, feature-request)
  4. Call issue-create with all assembled data

One prompt → 4+ API calls → perfectly structured issue.


The JSON Spacing Trick

Notice the handler returns:

JavaScript
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

JavaScript · Live Editor
Loading editor...
✅ Why 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

JavaScript · Live Editor
Loading editor...

Common Debugging Issues

Cannot find module 'api-based-tools.js'

Missing the .js extension in the import:

JavaScript
// ❌ 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:

JavaScript
// ❌ Wrong { name: z.string } // ✅ Correct { name: z.string() }

process is not defined

Typo -- process has one c, not two:

JavaScript
// ❌ Wrong (common typo) const base = procccess.env.API_BASE_URL; // ✅ Correct const base = process.env.API_BASE_URL;

Key Takeaways

  • makeRequest helper 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 .js in 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.