DEV Community

kirandeepjassal-crypto
kirandeepjassal-crypto

Posted on • Originally published at prepstack.co.in

MCP Deep Dive, Part 5: Designing Custom MCP Tools Your Agents Actually Use Right

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, ...);
Enter fullscreen mode Exit fullscreen mode
// 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);
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode
// 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);
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode
// 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);
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode
// 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
Enter fullscreen mode Exit fullscreen mode

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(...);
Enter fullscreen mode Exit fullscreen mode

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.");
Enter fullscreen mode Exit fullscreen mode

"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)