Search

Designing MCP search tools an agent can actually use

Why mirroring your REST API into MCP makes agents worse, and the rules that fix it: curated tool-belts, routing descriptions, compact returns, bounded output.

8 min read
On this page

The Model Context Protocol gives a model a list of tools it can call, and the obvious way to build a server is to generate one tool per API endpoint. If you have 130 routes, you get 130 tools, and you ship it in an afternoon.

It makes the agent worse. Reliably, and for reasons that are structural rather than a matter of prompt tuning.

This article is about why, and about the design rules that follow. They apply to any MCP server — search, database, internal platform. The last section covers ours, but the rules are the point.

Tool definitions are resident tokens #

The thing to internalise: every tool definition sits in the context window on every turn. Names, descriptions, full JSON Schema for every parameter — all of it, re-read on every single model call for the entire session.

So a large tool surface is charged twice. Once in tokens, on every turn, forever. And once in accuracy, because tool selection is a classification problem and you just handed the classifier 130 closely-related labels, many differing only in a word. search_google, search_google_news, search_google_light, search_bing, search_serp_advanced — a model choosing badly among those is not a model failure, it is a taxonomy failure.

Worse, the failure is quiet. The agent picks a plausible neighbour, gets a plausible-looking result, and produces a confident wrong answer. There is no exception in the logs.

Rule one: a tool-belt is curated, not generated. The right question is not "which endpoints do we have" but "which jobs does an agent actually need to do". Everything else stays in the REST API for code to call.

Design rules that hold up #

Name for the job, and prefix with the server #

dataswap_search rather than search. Prefixing is not vanity: several MCP servers can be connected at once, and a bare search colliding with another server's search produces routing errors nobody can debug from the transcript. Prefixing also gives the model a hint that these tools share a provider and a credential.

Names should be self-disambiguating in a flat list. If two tools need their descriptions read to be told apart, either the names are wrong or the tools should be one tool with a parameter.

Write descriptions that route, not descriptions that describe #

The most common weakness in MCP servers is descriptions written like API reference summaries. "Performs a web search and returns organic results" tells the model what it does and nothing about when to pick it over its neighbour.

Compare with the server-level instructions we ship:

Prefer dataswap_answer when the user wants an answer with sources, dataswap_search when you want to choose the pages yourself, and dataswap_read_url once you know which page matters.

That is three sentences that resolve the only ambiguity that actually occurs. Write descriptions comparatively: name the sibling tool and say when it wins. And keep them short — they are resident tokens too.

Return compact text, not raw JSON #

A search response envelope can run to thousands of tokens of positions, tracking parameters, sitelinks and duplicated fields. The model needs title, URL and snippet.

Return a numbered markdown list. It is dramatically cheaper, and it reads better to a model than serialised JSON, where braces and quotes are tokens that carry no meaning. Structure the tool result for the reader it has, which is a language model, not a parser.

If callers genuinely need structured output, give them a parameter that opts into it — do not make every call pay for the possibility.

Bound the output #

Every tool needs a limit parameter with a sane default and a hard ceiling. Without one, a single call against a large result set can blow the context window and end the session. "The user can ask for fewer" is not a design; the model chooses the arguments, and it will ask for 100 if 100 is allowed.

Make errors recoverable data #

An exception string dead-ends the agent. A structured, readable failure lets it fix the call:

  • invalid argument → say which field and what is valid, and the model retries correctly
  • out of credits / quota → say so explicitly, so it stops retrying and tells the user
  • rate limited → say retry-after, so it waits instead of hammering
  • upstream unavailable → say it is transient, so it retries or degrades

The difference between "Error: 402" and "Out of credits — this call needs prepaid credits; top up at …" is the difference between an agent that reports a clear problem and one that loops.

Report side effects in-band #

If a call spends money, the result should say what it cost and what is left. The model can then tell the user, and stop when the balance is gone. Anything an agent should reason about has to be in the tool result — it cannot see your billing dashboard.

Connecting a server #

Configuration is the same shape everywhere: a command, arguments, and environment.

# Claude Code
claude mcp add dataswap --env DATASWAP_API_KEY=sk_live_your_key -- npx -y @dataswap.io/mcp
{
  "mcpServers": {
    "dataswap": {
      "command": "npx",
      "args": ["-y", "@dataswap.io/mcp"],
      "env": { "DATASWAP_API_KEY": "sk_live_your_key" }
    }
  }
}

That JSON goes in .mcp.json in a project (shared with the team) or ~/.claude.json for personal use; ~/.cursor/mcp.json or .cursor/mcp.json for Cursor; claude_desktop_config.json for Claude Desktop. Any host that can spawn a stdio server works the same way — LangGraph, VS Code, Zed, Windsurf, your own.

The Dataswap server, as a worked example #

npx @dataswap.io/mcp exposes 15 tools against an API with 130-plus routes. That ratio is the design decision, not an incomplete implementation. The tools are the jobs a research agent does:

tooljob
dataswap_searchweb search, you pick the pages
dataswap_answergrounded answer with citations
dataswap_read_urlread a specific page
dataswap_newsnews search
dataswap_local_searchlocal business search
dataswap_business_reviewsGoogle reviews of a business
dataswap_keyword_volumekeyword search volume
dataswap_serp_diffwhat changed in the rankings
dataswap_backlinks_summarybacklink profile of a domain
dataswap_extract_catalogextract an online shop catalogue
dataswap_rerankreorder documents by relevance
dataswap_contextcontext pack for grounding, to a token budget
dataswap_extract_schemafill a JSON schema from the web
dataswap_entity_cardeverything about a domain, at one timestamp
dataswap_answer_landscapehow AI engines answer a query

Three of those are worth calling out because they are unusual as agent tools rather than as endpoints.

dataswap_context returns grounding blocks already deduplicated, reranked and trimmed to a token budget — the conversion described in grounding an LLM agent with search data, done before the tokens enter the window rather than after. For a multi-turn agent that re-reads its context every turn, that is the difference that compounds.

dataswap_rerank is a tool the agent can use on its own candidates, not just ours — useful when it has assembled documents from several sources and needs them ordered against the user's question.

dataswap_extract_schema takes a JSON Schema and returns it filled, with a citation and a confidence score per field, and null for any field with no supporting evidence. For an agent, "I could not find this" as data beats a plausible invented value.

Every result ends with what the call cost and what is left, per the in-band rule above.

Limits and failure modes #

Curation is a judgement, and it can be wrong for you. Fifteen tools cover a research agent. If your agent needs an endpoint that is not in the belt, the tool-belt is the wrong interface for that job — call the REST API from your own code and give the agent a tool that wraps your use case. Do not wait for a vendor's tool list to match your product.

Compact returns lose information by design. A markdown list drops fields the full envelope carries. When an agent needs the complete structure, that is an API call, not a tool call.

Tool count is not the only context cost. Long descriptions and deep parameter schemas add up too. A server with 15 verbose tools can cost more than one with 25 terse ones. Measure the serialised size of your tools/list output — most people never look at it.

stdio servers are local processes. They inherit the environment and the network position of the machine running them, and npx fetches a package. In locked-down environments, pin the version and vendor it rather than resolving latest at spawn time.

Agents can loop expensively. Anything spending real money needs a budget the user controls, not just a per-call cost report. Prepaid credits give a hard ceiling — the agent cannot spend what is not there — but set the ceiling deliberately.

MCP is young and moving. Host support for parts of the protocol varies and changes. Test against the hosts you actually target rather than the specification alone.

The short version #

Do not mirror your API into MCP. Pick the jobs, name them so they cannot be confused, write descriptions that say when to choose one over its neighbour, return compact text, bound the output, make errors recoverable, and report cost in-band. Then measure your tools/list payload, because that is what you are paying on every turn.

The server is npx @dataswap.io/mcp; the routes underneath are in the API reference, the docs cover keys and the credit envelope, and agent grounding is the product page for the context tool.

More reading

3 min read

AI crawlers read your HTML, not your JavaScript

Before you measure whether an AI assistant cites you, check whether it can read you at all. The test is one curl command, and the result is often uncomfortable.

  • ai-visibility
  • crawling
  • geo
8 min read

Grounding an LLM agent with search data

Search results are the wrong shape for a prompt. How to turn a SERP into grounding context an agent can use: dedupe, rerank, budget tokens, keep citations.

  • grounding
  • rag
  • llm-agents
  • search-api
  • context-window