The difference between an agent that works and one that flails is almost never the model. It's the tools. Give a capable model forty CRUD tools that mirror your REST API and it will pick the wrong one, mis-format the arguments, and drown in raw rows. Give it a dozen task-shaped tools with sharp descriptions and it just works. This part is the craft of designing tools an agent uses right.
This is Part 5 of a 15-part deep dive on Model Context Protocol (MCP). Parts 3 and 4 built the server and the client — the machinery. This part is about the design of the tools themselves, and every rule comes with the before that confused the model and the after that didn't.
TL;DR
| Aspect | REST-mirror tools (before) | Task-shaped tools (after) |
|---|---|---|
| Granularity | 1 tool per endpoint (~40) | ~12 intent-shaped tools |
| Naming |
proc1 / vague |
verb_noun, specific |
| Description | "processes data" | what + when + returns |
| Input | free-form strings | enums, formats, required |
| Output | raw rows / giant JSON | compact, answer-shaped |
| Side effects | invisible | annotated (read/write/destructive) |
| Errors | "500" | actionable guidance |
The one mental shift: a tool surface is not your API — it's a menu you're writing for a reader who decides in one shot and never asks a clarifying question.
1. Design around intents, not endpoints
The first toolset was a 1:1 mirror of the REST API — one tiny tool per endpoint. The agent had to orchestrate five calls to answer one question, and frequently picked the wrong one.
// BEFORE: mirror the REST API. Five calls to answer one question.
[McpServerTool(Name = "get_campaign")] public Task<Campaign> GetCampaign(string id, ...);
[McpServerTool(Name = "get_campaign_budget")] public Task<Budget> GetBudget(string id, ...);
[McpServerTool(Name = "get_campaign_events")] public Task<Events> GetEvents(string id, ...);
[McpServerTool(Name = "get_campaign_ctr")] public Task<double> GetCtr(string id, ...);
[McpServerTool(Name = "get_campaign_spend")] public Task<decimal> GetSpend(string id, ...);
// AFTER: a tool shaped like an intent — "assess this campaign's health."
[McpServerTool(Name = "get_campaign_kpis")]
[Description("Return a campaign's KPI snapshot (CTR, spend, budget pacing, conversions) for a range.")]
public async Task<CampaignKpis> GetCampaignKpis(string campaignId, string range, CancellationToken ct)
=> await campaigns.GetKpisAsync(principal.TenantId, campaignId, DateRange.Parse(range), ct);
Your REST API is designed for programmers who read docs and compose calls. A tool surface is a menu for a model that reasons in one shot. Collapsing ~40 endpoint-mirroring tools into ~12 intent-shaped ones cut wrong-tool selection dramatically and is part of why agentic p95 dropped to 1.8s.
2. The description is the prompt
The model chooses tools by reading their descriptions — the description is a prompt injected into its decision.
// BEFORE
[McpServerTool(Name = "proc")]
[Description("Processes campaign data.")] // the model has no idea when to reach for this
// AFTER
[McpServerTool(Name = "get_campaign_kpis")]
[Description("""
Return a campaign's KPI snapshot: CTR, spend, budget pacing, and conversions for a date range.
Use this to assess how a campaign is performing or to diagnose a metric drop.
Returns aggregate numbers only — for the raw event stream, use query_events instead.
""")]
public Task<CampaignKpis> GetCampaignKpis(string campaignId, string range, CancellationToken ct);
A good description states the purpose, the trigger ("use this to..."), the return shape, and the boundary with adjacent tools so the model doesn't confuse get_campaign_kpis with query_events. Sharpening descriptions was the cheapest accuracy win we made.
3. Constrain the input schema
Every constraint you encode is a mistake the model cannot make.
// BEFORE: what values does "type" accept? "format"? The model invents them.
public Task<Report> CreateReport(string type, string format, string range, CancellationToken ct);
// AFTER: enums, required fields, described formats -> JSON Schema the model must satisfy.
public sealed record CreateReportArgs(
[property: Description("The report to generate.")] ReportKind Kind, // Performance | Attribution | Spend
[property: Description("Output format.")] ReportFormat Format, // Pdf | Csv
[property: Description("ISO-8601 range, max 90 days, e.g. 2026-06-01/2026-06-30.")] string Range,
[property: Description("Email to notify on completion (optional).")] string? NotifyEmail);
An enum beats a free string, a required field beats optional-and-guess, and a described format beats hoping the model matches yours. Typed, constrained inputs are the main reason the tool-call error rate sits at 0.8%.
4. Shape the output for reasoning
A tool result lands in the model's context, gets reasoned over, and gets paid for in tokens.
// BEFORE: 500 rows x 30 columns dumped into the model's context.
return await db.QueryAsync("SELECT * FROM campaign_events WHERE campaign_id = @id", ct);
// AFTER: answer-shaped output. Small, relevant, cheap to reason over.
return new CampaignKpis(
Ctr: 0.021, CtrDelta: -0.006, // the drop the user is asking about
Spend: 4_120m, BudgetPacing: 0.82,
Conversions: 318,
Window: range,
Note: "CTR down 22% vs the prior period; spend is on pace."); // a nudge, not raw data
Return the handful of numbers that answer the question plus a one-line interpretation — not 500 rows the model has to summarize and you pay to send. Raw drill-downs belong in a separate, paged tool. Answer-shaped outputs are a big part of holding context at 3.5k tokens (down from 14k).
5. Annotate side effects
A read and a write look identical to the model — and to the host's approval layer.
[McpServerTool(Name = "get_campaign_kpis")]
[McpToolAnnotations(ReadOnly = true)] // safe: the host may call freely
public Task<CampaignKpis> GetCampaignKpis(...);
[McpServerTool(Name = "create_report")]
[McpToolAnnotations(ReadOnly = false, Idempotent = false)] // has an effect: enqueues work
public Task<ReportQueued> CreateReport(...);
[McpServerTool(Name = "delete_audience")]
[McpToolAnnotations(ReadOnly = false, Destructive = true)] // dangerous: host requires confirmation
public Task<Deleted> DeleteAudience(...);
Annotations let a destructive tool require confirmation while a read-only tool runs freely. (These are hints; real enforcement is authorization — Part 7 — never trust an annotation as your security boundary.) They're the reason an over-eager agent has never dropped an audience in production.
6. Errors that teach
An error message is another prompt.
// BEFORE
throw new Exception("Query failed"); // the agent gives up or invents a workaround
// AFTER
if (DateRange.Parse(range).Days > 90)
return ToolResults.Error("range_too_wide",
"Date range exceeds the 90-day maximum. Narrow the range, or use get_campaign_kpis for a summary.");
"range_too_wide: max 90 days, narrow it" gets a corrected retry on the next turn; "error 500" gets a give-up or a confidently wrong workaround. Actionable errors turn most failed calls into a successful retry within the same agent run.
Curate the menu
More tools is not more capability — past a point it's less, because tool selection degrades as the menu grows. Curate the toolset to the intents an agent actually has, and expose different toolsets to different agents rather than one giant catalog to all of them.
The numbers, in one place
| Metric | REST-mirror tools (before) | Task-shaped tools (after) |
|---|---|---|
| Tool count | ~40 (endpoint mirror) | ~12 (intent-shaped) |
| Wrong-tool selection | frequent | rare |
| Tool-call error rate | 6% | 0.8% |
| Context tokens / call | ~14,000 | ~3,500 |
| Agentic p95 | 4.2s | 1.8s |
| Destructive-tool accidents | possible | gated by annotation + approval |
The model to carry forward
Design the tool for the reader who decides in one shot. Name it so it's found, describe it so it's chosen, type it so it's called right, shape its output so it's cheap to reason over, annotate it so it's safe to run, and word its errors so it's recoverable. A good MCP tool is a good prompt with a typed signature — and getting the tools right does more for agent reliability than any model upgrade.
- Shape tools to intents, not endpoints. One tool per question the agent asks.
- Treat the description as a prompt and the schema as a guardrail.
- Curate ruthlessly. Fewer, sharper tools beat a complete API surface every time.
Originally published on PrepStack. Designing an agent's toolset and want a second pair of eyes on the granularity or schemas? Reach me at randhir.jassal[at]gmail.com.
Top comments (0)