Building the Issue Tracker MCP Server
A real-world MCP server with 16 tools backed by SQLite — registering tools, querying the database, and structuring tool responses.
I built a complete issue tracker as my practice project — a real-world MCP server that lets Claude manage software issues via 16 tools backed by a SQLite database. The full source is at github.com/btholt/mcp-issue-tracker.
It covers the full lifecycle: creating issues, updating them, adding tags, assigning users, filtering by status, and bulk operations. It's small enough to understand completely, complex enough to be representative.
Database Setup
The server uses better-sqlite3 — a synchronous SQLite driver for Node.js. Synchronous is simpler here because MCP tool handlers are already async at the transport level, and SQLite operations are fast enough that blocking the event loop isn't a concern.
import Database from 'better-sqlite3';
export const db = new Database('./issues.db');
db.exec(`
CREATE TABLE IF NOT EXISTS issues (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
description TEXT,
status TEXT DEFAULT 'open',
assignee TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS tags (
id INTEGER PRIMARY KEY AUTOINCREMENT,
issue_id INTEGER REFERENCES issues(id),
name TEXT NOT NULL
);
`);Registering Multiple Tools
With 16 tools, you want a consistent pattern. I register each in its own function and call them all at startup:
// tools/issues.ts
export function registerIssueTool(server: McpServer) {
server.tool(
"create_issue",
"Create a new issue in the tracker. Use when the user wants to log a bug, feature request, or task.",
{
title: z.string().describe("Issue title"),
description: z.string().optional().describe("Detailed description"),
assignee: z.string().optional().describe("Username to assign the issue to"),
},
async ({ title, description, assignee }) => {
const stmt = db.prepare(
"INSERT INTO issues (title, description, assignee) VALUES (?, ?, ?)"
);
const result = stmt.run(title, description ?? null, assignee ?? null);
return {
content: [{
type: "text",
text: `Issue created with ID ${result.lastInsertRowid}: "${title}"`,
}],
};
}
);
}// index.ts
registerIssueTool(server);
registerTagTool(server);
registerSearchTool(server);
// ... etcA Complete Tool: List Issues
server.tool(
"list_issues",
"List issues from the tracker. Optionally filter by status (open, closed, in-progress) or assignee.",
{
status: z.enum(["open", "closed", "in-progress", "all"]).optional().default("all"),
assignee: z.string().optional().describe("Filter by username"),
limit: z.number().optional().default(20).describe("Max results to return"),
},
async ({ status, assignee, limit }) => {
let query = "SELECT * FROM issues WHERE 1=1";
const params: (string | number)[] = [];
if (status !== "all") {
query += " AND status = ?";
params.push(status);
}
if (assignee) {
query += " AND assignee = ?";
params.push(assignee);
}
query += " ORDER BY created_at DESC LIMIT ?";
params.push(limit);
const issues = db.prepare(query).all(...params) as Issue[];
if (!issues.length) {
return { content: [{ type: "text", text: "No issues found." }] };
}
const formatted = issues
.map(i => `[${i.id}] ${i.status.toUpperCase()} — ${i.title}${i.assignee ? ` (@${i.assignee})` : ''}`)
.join('\n');
return { content: [{ type: "text", text: formatted }] };
}
);Formatting Responses for the LLM
Tool responses return content arrays with typed items. For most tools, plain text is sufficient:
return { content: [{ type: "text", text: "Issue #42 updated to closed." }] };For data the LLM needs to reason over, structure it clearly:
- Use short, scannable formats rather than verbose JSON
- Include the ID so the LLM can reference issues in follow-up calls
- Keep it terse — the LLM is reading this, not a human
For errors, throw normally:
if (!issue) throw new Error(`Issue #${id} not found`);The MCP SDK catches thrown errors and converts them to proper JSON-RPC error responses.
The Tag Tool Challenge
Tags have a many-to-one relationship with issues. A common mistake I see is exposing separate add_tag / remove_tag / list_tags tools. This works but is chatty — the LLM has to make multiple calls for basic operations.
A better approach: a single manage_tags tool that accepts an operation type:
{
issue_id: z.number(),
operation: z.enum(["add", "remove", "list"]),
tags: z.array(z.string()).optional(),
}One tool call, clear intent, less back-and-forth.
Further Reading
Enjoyed this? Get more like it.
Deep dives on system design, React, web development, and personal finance — straight to your inbox. Free, always.