MCP Tools: Giving Agents the Power to Act

Tools are the part of the Model Context Protocol that lets a model do things instead of just talk about them, and their design is what separates an agent that helps from one that flails.

Of the three capabilities a Model Context Protocol (MCP) server can expose, tools are the ones that change the world. A resource lets a model read; a tool lets it act — create an issue, run a query, send a message, book a meeting. Because tools carry side effects and because the model decides when to call them, they deserve careful design. This post covers what a tool is, how it is defined, how discovery and invocation work on the wire, how results and errors flow, and how to design tools a model can actually use well.

What a tool is

A tool in MCP is a named, model-controlled action the server offers. “Model-controlled” is the key phrase: unlike a resource (which the application decides to load) or a prompt (which the user invokes), a tool is something the model elects to call in the middle of its reasoning. You expose the capability; the model chooses whether and when to use it.

That control model is powerful and is exactly why tools need guardrails. A tool that deletes records or spends money should not fire silently because the model thought it was a good idea — hosts typically gate consequential tools behind user approval, which we will connect to tool annotations below.

Tool definitions: name, description, schema

Every tool is described by three things, and the model sees all of them:

These are not bureaucratic metadata; they are the model’s entire basis for deciding whether to call the tool and how to fill in the arguments. A vague description or a loose schema produces wrong calls. “Send a message” is worse than “Send a Slack message to a channel or user; use for team notifications, not for email.” args: object is worse than a schema that names each field, types it, marks which are required, and constrains enums. The schema is a contract and a prompt.

Discovery and invocation on the wire

Two methods drive tools. The client discovers what exists with tools/list:

{ "jsonrpc": "2.0", "id": 2, "method": "tools/list" }
{
  "jsonrpc": "2.0",
  "id": 2,
  "result": {
    "tools": [
      {
        "name": "get_forecast",
        "description": "Return a short weather forecast for a city.",
        "inputSchema": {
          "type": "object",
          "properties": {
            "city": { "type": "string", "description": "City name" },
            "days": { "type": "integer", "minimum": 1, "maximum": 7, "default": 1 }
          },
          "required": ["city"]
        }
      }
    ]
  }
}

When the model decides to use it, the client sends tools/call with the chosen arguments:

{
  "jsonrpc": "2.0",
  "id": 3,
  "method": "tools/call",
  "params": { "name": "get_forecast", "arguments": { "city": "Pune", "days": 2 } }
}

The server runs the tool and returns its output as content the model can read. A server may also advertise that its tool list can change and emit a notifications/tools/list_changed notification so clients re-list — useful when tools appear or disappear at runtime.

Results and errors

A tool call returns content — commonly text, but also structured data or other content types — which the client feeds back to the model. The important subtlety is how failure is represented.

There are two distinct failure modes, and conflating them is a classic bug:

Why the distinction matters: the model needs to see a tool failure to react to it — retry with different arguments, apologize, try another approach. If you bury a 404 in a protocol error, the model often never sees it and the agent stalls. So report execution failures as visible, in-band error results with a helpful message (“City ‘Xyz’ not found; check spelling”), and reserve protocol errors for genuinely malformed calls.

Defining a tool with the Python SDK

The official Python SDK’s FastMCP makes tool definition almost invisible — you write a normal function, and the decorator derives the schema from its type hints and docstring:

from mcp.server.fastmcp import FastMCP

mcp = FastMCP("weather")

@mcp.tool()
def get_forecast(city: str, days: int = 1) -> str:
    """Return a short weather forecast for a city.

    Args:
        city: City name, e.g. "Pune".
        days: Number of days, 1-7.
    """
    if not city.strip():
        raise ValueError("city must not be empty")
    # ... call the underlying weather API ...
    return f"{city}: sunny for the next {days} day(s)."

The city: str and days: int = 1 hints become the input schema (with days optional because it has a default); the docstring becomes the description. Raising a ValueError for bad input is surfaced to the model as a readable error rather than crashing the session. This is the payoff of a good SDK: the protocol details recede and you write ordinary, well-documented functions.

Annotations and approval

Tools can carry hints about their nature — for example, whether they are read-only or potentially destructive, and whether they are idempotent. Hosts use these hints to decide how much friction to add. A read-only get_forecast can run freely; a delete_repository should trigger an explicit “are you sure?” to the user. Annotations are advisory, not enforcement — never rely on them for security — but they let a well-behaved host apply human-in-the-loop approval where it matters without prompting on every trivial read.

Designing tools a model can use

A few principles separate tools that work from tools that frustrate:

Tools are where MCP earns its keep. Treat their descriptions and schemas as part of the prompt, because to the model, that is exactly what they are.

Key takeaways

Further reading

Sources & References

FastMCP tool definitions