Designing MCP tools for agents
The tempting way to build a data MCP server is one query_data(dataset, filters) tool. Every new slice adds another branch to its filter grammar.
The agent clients I use give the model a code execution environment, so I first ask whether the server needs to shape the result. If the caller may see the full dataset, the tool can return the rows and let the agent work with them in Python or DuckDB. A small skill can explain the dataset’s conventions.
Filters belong on the server when rows or columns must be scoped before they leave it. Governed data often needs that work. For data the caller may already see, I leave the shaping to the agent.
The generic tool has another problem: its real contract is hidden behind the dataset string. The model sees a loose filters object, with no indication that sales takes a date range and returns revenue while inventory takes a warehouse and returns stock levels.
Separate tools make those contracts visible:
query_sales(start_date, end_date) -> SalesRows
query_inventory(warehouse, below_quantity) -> InventoryRows
When the client calls tools/list, the model can see what each tool does and which arguments it accepts. A tool can also publish an output schema for the client to validate. The MCP tool contract defines both schemas.
A remote MCP server usually exposes all of its tools through one Streamable HTTP endpoint. Adding a tool doesn’t add another route.
Tools need separate contracts when the model must choose or call them differently. Permissions can force the same split: a caller who can read_invoice shouldn’t automatically get issue_refund, even if both hit /billing behind the server.
Ownership and authentication set the server boundary. In acme-mcp, orders and billing mount together because they belong to one product. Analytics sits behind a separate proxied server so another team can deploy it on their own schedule.
OpenAPI already describes the HTTP routes. MCP gives agent clients one way to discover and call the tools they may use, so I don’t need to build the same adapter for each client.
Describe each dataset where it helps selection #
A generic query tool is fine while its datasets accept the same filters and return the same shape, but once those differ, a dataset="sales" parameter can’t change the static schema the model already saw.
A separate contract doesn’t mean one tool per filter. I keep typed parameters together while they describe the same operation and return the same shape. I split again when the model needs a different contract.
For a small catalog, I give each dataset its own tool:
@mcp.tool(tags={"analytics"})
def query_sales(start_date: date, end_date: date) -> SalesResult:
"""Return net sales grouped by day for the requested date range."""
...
@mcp.tool(tags={"analytics"})
def query_inventory(warehouse: Warehouse, below_quantity: int) -> InventoryResult:
"""Return stock below a quantity threshold for one warehouse."""
...
The signatures show the model which arguments are valid, and the return models describe the different results. The docstrings use the same words as the people who work with that data.
Neither tool needs a filter grammar. The agent can shape safe results itself, so the tool returns the rows the caller may see. Tenant boundaries and any row or column restrictions still apply before the data leaves the server. If the full result is too large or includes data the caller shouldn’t see, the tool needs a narrower query.
Column definitions change more often than the operation itself, and they can be too large for a tool description. MCP resources fit that data better:
dataset://sales/schema
dataset://inventory/schema
The MCP server concepts guide uses this control split:
| MCP primitive | Who selects it | What I put there |
|---|---|---|
| tool | model | an operation |
| resource | application | data or context |
| prompt | user | a reusable template |
Use discovery when the catalog gets large #
Schema size determines when a catalog becomes too expensive to load up front. MCP’s client guidance suggests progressive discovery once tool definitions take 1-5% of the available context. The detailed tools stay in the catalog while the client loads their definitions on demand:
search_tools("sales by date")
get_tool_details("query_sales")
query_sales(start_date, end_date) -> SalesRows
The search query only looks through the tool catalog. Once the model selects query_sales, the client loads its full definition and the model calls the typed interface. The server still returns every definition through tools/list, but the client keeps the unused ones out of model context. This follows MCP’s progressive tool discovery guidance.
Serving skills with the tools #
A schema describes the data. A skill explains how to work with it, such as reading the schema resource before shaping the rows in code. For a signed download link, the skill can tell the agent to hand the link to the user without loading the file into context.
Skills usually live in the user’s agent config or project repo, and FastMCP’s Skills Provider can publish the same files from the server as resources for the client to fetch.
I use this in acme-mcp. The server publishes skill://handle-downloads/SKILL.md beside the report tool. The tool returns a signed URL, and the skill tells the client to show it without reading the file into context. Both carry the reports tag, so the server applies the same access rules to them.
In practice, I keep executable work in tools and put changing context or handling instructions in resources. I add filters where data needs scoping, and keep servers together until ownership or authentication gives me a reason to split them.
Discussion