Designing MCP tools for agents
An MCP server can expose one query_data(dataset, filters) tool and call it finished. The model sees a generic name and a loose filters object tied to a dataset string. It cannot see 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 sees 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.
Tools and HTTP routes #
A remote MCP server usually exposes its tools through one Streamable HTTP endpoint. Adding a tool does not mean adding another route.
I split tools when the model needs a different contract to choose or call them correctly. Permissions can force the same split. A caller that can read_invoice should not automatically get issue_refund, even if both hit /billing behind the server.
I split servers when ownership or authentication changes. In acme-mcp, I mount orders and billing together because they belong to one product. I show analytics behind a separate proxied server so another team can deploy it independently.
OpenAPI already describes the HTTP routes. MCP gives an agent client one way to discover and call the tools it may use, so I do not build the same adapter into every client.
Describe each dataset where it helps selection #
A generic query tool works when its datasets accept the same filters and return the same shape. Once those differ, a dataset="sales" parameter cannot change the static schema the model saw.
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 function signatures advertise valid arguments and the return models describe different results. The docstrings use the same words as the people working with that data.
The agent clients I use usually give the model a code execution environment. Once a tool returns rows the caller may receive, the agent can use Python or DuckDB to filter and aggregate them. I do not add a server tool for every chart or slice when the full result is safe to hand over. The server still applies tenant boundaries and any row or column restrictions before the data leaves it. If the full result is too large or includes data the caller should not 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 #
With hundreds of datasets, one tool per dataset fills the model’s starting context with definitions it will not use.
I use a small discovery API instead:
list_datasets(query)
describe_dataset(dataset_id)
query_dataset(dataset_id, query)
The agent searches by name and reads the schema for the dataset it picks. It only calls query_dataset once that context is loaded. This is the flow in MCP’s progressive tool discovery guidance.
Serving skills with the tools #
A schema describes the data. A skill explains how to use it, such as reading a resource first or handling a signed download link.
Skills usually live in the user’s agent config or project repo. FastMCP’s Skills Provider can publish the same files from the server as resources for the client to fetch.
I use that 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. I tag both with reports, so the server applies the same access rules to them.
I leave executable work in tools and put changing context or workflow instructions in resources. I only split the server when ownership or authentication changes.
Discussion