Jobs-Based MCP Tool Design
The most important MCP design insight -- don't map tools to API endpoints. Map them to user jobs. Fewer tools, more reliable results, less LLM chaos.
You can build an MCP server that maps perfectly to your API -- one tool per endpoint, 16 tools total. This is everyone's first instinct.
It's a bad instinct.
The Problem with API-Based Tools
Here's what Brian discovered when he first built the issue tracker MCP server:
He sent this prompt:
"Create a new issue and assign it to me."
The LLM's response: "I don't know who 'me' is."
Which is true -- the LLM has no idea who's logged in. It would need to:
- Call
users-listto get all users - Call some
meendpoint or infer from context who the current user is - Store that user's ID
- Call
issue-createwith that ID
That's a 3-step dependency chain. Claude Desktop with Sonnet handled it. The Qwen 0.6B model did not.
The more complex the orchestration required, the less reliable smaller models are. And even with powerful models, reliability isn't 100%.
The Jobs-Based Approach
Instead of mapping tools to API endpoints, map tools to user jobs -- the outcomes users actually want to accomplish.
Think backwards from what the user needs:
- What are they trying to accomplish?
- What's the complete sequence of operations to do it right?
- Can I wrap that entire sequence in one tool call?
The LLM makes one tool call. Your code handles the orchestration deterministically.
Building Jobs-Based Tools
// jobs-based-tools.js
import { z } from "zod";
export default function registerJobsTools(server) {
// Job 1: Create a bug report
server.registerTool("create-bug", {
title: "Create Bug Report",
description: "Create a new bug report with high priority and bug tag. Use when user reports a bug or error.",
inputSchema: {
title: z.string().describe("Bug title -- what is broken?"),
description: z.string().optional().describe("Steps to reproduce and expected vs actual behavior"),
api_key: z.string().describe("API key for authentication"),
}
}, async ({ title, description, api_key }) => {
// Step 1: Get or create the bug tag
const tagsResult = await makeRequest("GET", `${API_BASE_URL}/tags`, null, {
headers: { "x-api-key": api_key }
});
let bugTagId = tagsResult.data.find(t => t.name === "bug")?.id;
if (!bugTagId) {
const newTag = await makeRequest("POST", `${API_BASE_URL}/tags`,
{ name: "bug", color: "#ef4444" },
{ headers: { "x-api-key": api_key } }
);
bugTagId = newTag.data.id;
}
// Step 2: Create the issue with correct defaults
const result = await makeRequest("POST", `${API_BASE_URL}/issues`, {
title,
description,
priority: "high", // bugs are always high priority
status: "not-started",
tag_ids: [bugTagId],
}, { headers: { "x-api-key": api_key } });
return {
content: [{ type: "text", text: JSON.stringify(result, null, 2) }]
};
});
// Job 2: Create a feature request
server.registerTool("create-feature-request", {
title: "Create Feature Request",
description: "Create a new feature request with low priority. Use when user wants to suggest a new feature or enhancement.",
inputSchema: {
title: z.string().describe("Feature name"),
description: z.string().optional().describe("Feature description and use case"),
api_key: z.string().describe("API key"),
}
}, async ({ title, description, api_key }) => {
const result = await makeRequest("POST", `${API_BASE_URL}/issues`, {
title,
description,
priority: "low", // feature requests are always low priority
status: "not-started",
}, { headers: { "x-api-key": api_key } });
return {
content: [{ type: "text", text: JSON.stringify(result, null, 2) }]
};
});
// Job 3: Update ticket status
server.registerTool("update-ticket-status", {
title: "Update Ticket Status",
description: "Change the status of an existing ticket. Use when marking work as done, in-progress, or not started.",
inputSchema: {
id: z.number().describe("Issue ID to update"),
status: z.enum(["not-started", "in-progress", "done"]).describe("New status"),
api_key: z.string().describe("API key"),
}
}, async ({ id, status, api_key }) => {
const result = await makeRequest("PATCH", `${API_BASE_URL}/issues/${id}`,
{ status },
{ headers: { "x-api-key": api_key } }
);
return {
content: [{ type: "text", text: JSON.stringify(result, null, 2) }]
};
});
}Why This Produces Better Results
With API-based tools, Claude has to:
- Know the correct sequence of calls
- Remember IDs across multiple turns
- Handle race conditions if steps fail
- Make decisions about defaults (what priority? what tags?)
With jobs-based tools, your code handles all of that. The LLM only needs to understand the user's intent and pick the right job.
Brian's result: "The success rate went from sometimes working with API-based tools to working every single time with jobs-based tools."
The Decision: When to Use Each
Use API-based tools when:
- You're building a developer-facing MCP server for exploration
- You don't yet know what users will do with it
- You want maximum flexibility
Use jobs-based tools when:
- You know the specific outcomes users want
- Reliability matters (production, teams, customers)
- You're supporting less powerful models (Qwen 0.6B, etc.)
- The workflow involves multiple sequential API calls
The "Jobs to Be Done" Framework
This is where the name comes from. Jobs to Be Done is a product management framework: instead of asking "what features do users want?", ask "what jobs are users hiring the product to do?"
Applied to MCP:
| ❌ API-based thinking | ✅ Jobs-based thinking |
|---|---|
| "User needs to POST /issues" | "User needs to report a bug" |
| "User needs to GET /tags" | (handled internally -- user doesn't care) |
| "User needs to PATCH /issues/:id" | "User needs to mark a ticket as done" |
The LLM doesn't care about your API structure. It cares about what the user wants to accomplish.
Lab -- Design Jobs for a Customer Support System
Lab 2 -- Spot What Makes a Good Job
Key Takeaways
- Map tools to user jobs, not API endpoints -- this is the single most impactful MCP design insight
- Your code owns the orchestration -- don't make the LLM manage API call sequences
- Fewer tools = better results -- each job reduces LLM decision complexity
- Hardcode sensible defaults -- bugs are high priority, feature requests are low
- Jobs-based tools = 100% reliability where API tools sometimes fail
- Work backwards from outcomes -- what does the user want to accomplish?
What's Next
Your MCP server runs locally via stdio. For production and team use, you need a server that runs remotely. The transport layer: stdio, SSE, and Streamable HTTP.
Keep reading