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.

April 1, 202610 min read2 / 2

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:

  1. Call users-list to get all users
  2. Call some me endpoint or infer from context who the current user is
  3. Store that user's ID
  4. Call issue-create with 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%.

Diagram

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?
Diagram

The LLM makes one tool call. Your code handles the orchestration deterministically.


Building Jobs-Based Tools

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

Diagram

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

JavaScript · Live Editor
Loading editor...
✅ The insight: Going from 12 API-mapped tools to 4 jobs reduces LLM decision complexity, improves reliability, and hides API complexity from the agent. Every job succeeds deterministically because your code owns the orchestration.

Lab 2 -- Spot What Makes a Good Job

JavaScript · Live Editor
Loading editor...

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.