Skip to main content

How Many Tools Should an MCP Server Have?

Short answer

There's no single number that works everywhere. Every tool you expose sends its name, description and parameter schema to the model, which then has to pick one. Cover the jobs your users actually do, merge tools that differ only by a filter, and split servers by role when some tools should be off by default. The limits depend on the app: VS Code allows 128 tools per chat request, and Claude Code defers MCP tools until the model searches for them.

Tested withMCP spec 2026-07-28Python SDK (mcp) 2.3.0TypeScript SDK (@modelcontextprotocol/server) 2.3.1MCP Inspector 2.11.0Node.js 22Python 3.14Checked 20 Oct 2026

What you'll have at the end​

You'll have a small orders server with a read-only mode and a read-write mode. One environment variable decides whether it exposes two tools or four. From the example folder, this lists the tools in read-only mode:

npx @modelcontextprotocol/inspector@2.11.0 --cli -e ORDERS_ACCESS=read-only \
node "$PWD/typescript/build/index.js" --protocol-era modern --method tools/list | grep '"name"'
      "name": "search_orders",
"name": "get_order",

With ORDERS_ACCESS=read-write, the same command also prints cancel_order and refund_order.

Before you start

  • Node.js 22.19 or later, for MCP Inspector.
  • Python 3.10 or later and uv, if you want the Python version.
  • Node.js 20 or later and npm, if you want the TypeScript version.
  • Familiarity with building an MCP server. This page doesn't repeat the setup.

Why the number matters​

A tool is more than a function. The client sends each tool's name, description and input schema to the model, and the model reads all of them before it decides what to do. Two effects follow.

First, every definition takes up space. The OpenAI function calling guide says callable function definitions count against the model's context limit and are billed as input tokens. It suggests limiting the functions loaded up front, shortening descriptions, or deferring tools with tool search.

Second, overlapping tools make choices harder. If search_orders, find_orders and list_orders all sound right, the model has to guess. The fix is usually fewer tools with clearer names, not a longer description on each.

What limits you in each app​

The limits are set by the client, not by the MCP protocol. These are the ones the official docs describe:

AppWhat the docs sayWhat to do
VS Code (GitHub Copilot)A chat request can have at most 128 tools enabled. Above that you see Cannot have more than 128 tools per request.Deselect individual tools or whole MCP servers in the tools picker.
Claude CodeNo fixed per-server cap. Tool search is on by default on supported models, so MCP tools load when the model needs them. The auto setting loads tools up front until their definitions reach 10% of the context window.Usually nothing. Set ENABLE_TOOL_SEARCH=false to load every tool up front.
CursorIts MCP docs don't publish a tool limit.Click a tool's name in the tools list at the top of the chat panel to toggle it, or switch off a whole server under Customize > MCPs.
OpenAI APIFunction definitions count against the context limit and are billed as input tokens.Send fewer functions per request, or use tool search.

A server with 40 tools may work fine in one app and hit a wall in another. Design for the smallest set that does the job, then check the limits in the apps your users run.

Five questions that decide the count​

  1. Does the client load every definition up front, or search for them? If it searches, the count matters less. If it loads them all, every extra tool costs context on every request.
  2. Do some tools differ only by a filter or an ID type? Merge them. One tool with an optional parameter is usually clearer than several near-copies.
  3. Would a user ever want some tools off? Put them in a separate server, or read the mode from configuration, so people connect only what they need.
  4. Are some tools risky? Keep write and delete operations apart from reads, so a read-only connection can't reach them.
  5. Does the list change while the server runs? If it does, read the section on changing tool lists below.

Merge tools that do the same job​

Say a server offers get_order_by_id, get_orders_by_email, get_orders_by_status and get_orders_by_date_range. That's four tool slots for one job: finding orders. A single search_orders tool with optional status, customer_email and limit parameters covers all four. The model fills in whatever it knows, and the description only has to explain one thing.

Parameters are where merging happens. Tool input schemas covers how to describe optional filters and enums so the model uses them correctly.

Scope tools by configuration​

A read-write server doesn't need to show its write tools to every user. The example server reads one environment variable and registers the write tools only when it's set to read-write. The default is read-only, so a connection that forgets the variable can't cancel or refund anything.

python/orders.py
import json
import logging
import os
from typing import Annotated, Literal

from mcp.server import MCPServer
from mcp.server.mcpserver.exceptions import ToolError
from pydantic import Field

# Logs go to stderr. Anything written to stdout corrupts the stdio transport.
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger("orders")

# "read-only" registers the two read tools. "read-write" also registers the write tools.
ACCESS = os.environ.get("ORDERS_ACCESS", "read-only")
if ACCESS not in ("read-only", "read-write"):
raise SystemExit(f"ORDERS_ACCESS must be read-only or read-write, not {ACCESS!r}")

ORDERS: dict[str, dict] = {
"A-1001": {"id": "A-1001", "customer_email": "ana@example.com", "status": "open", "total": 42.5, "refunded": 0.0},
"A-1002": {"id": "A-1002", "customer_email": "ben@example.com", "status": "shipped", "total": 19.0, "refunded": 0.0},
"A-1003": {"id": "A-1003", "customer_email": "ana@example.com", "status": "returned", "total": 77.0, "refunded": 0.0},
}

mcp = MCPServer("orders")

Status = Literal["open", "shipped", "returned", "cancelled"]


def find_order(order_id: str) -> dict:
order = ORDERS.get(order_id)
if order is None:
raise ToolError(f"No order with id {order_id}. Use search_orders to find the right id.")
return order


def search_orders(
status: Annotated[Status | None, Field(description="Only return orders with this status")] = None,
customer_email: Annotated[str | None, Field(description="Only return orders placed by this email address")] = None,
limit: Annotated[int, Field(description="Maximum number of orders to return, from 1 to 50", ge=1, le=50)] = 10,
) -> str:
"""Find orders by status, customer email, or both. Use this first to get an order id for the other order tools."""
matches = [
order
for order in ORDERS.values()
if (status is None or order["status"] == status)
and (customer_email is None or order["customer_email"] == customer_email)
]
return json.dumps(matches[:limit], indent=2)


def get_order(
order_id: Annotated[str, Field(description="Order id, such as A-1001")],
) -> str:
"""Get the full details of one order by its id."""
return json.dumps(find_order(order_id), indent=2)


def cancel_order(
order_id: Annotated[str, Field(description="Order id, such as A-1001")],
) -> str:
"""Cancel an order that has not shipped yet. Only open orders can be cancelled."""
order = find_order(order_id)
if order["status"] != "open":
raise ToolError(f"{order_id} is {order['status']}, so it can't be cancelled.")
order["status"] = "cancelled"
logger.info("Cancelled order %s", order_id)
return f"Cancelled {order_id}."


def refund_order(
order_id: Annotated[str, Field(description="Order id, such as A-1001")],
amount: Annotated[float, Field(description="Amount to refund in the order currency, greater than 0", gt=0)],
) -> str:
"""Refund part or all of an order. The total refunded can't be more than the order total."""
order = find_order(order_id)
if order["refunded"] + amount > order["total"]:
raise ToolError(
f"Refund of {amount:.2f} is more than the {order['total'] - order['refunded']:.2f} left on {order_id}."
)
order["refunded"] += amount
logger.info("Refunded %.2f on order %s", amount, order_id)
return f"Refunded {amount:.2f} on {order_id}. Total refunded: {order['refunded']:.2f}."


mcp.tool()(search_orders)
mcp.tool()(get_order)

if ACCESS == "read-write":
mcp.tool()(cancel_order)
mcp.tool()(refund_order)


if __name__ == "__main__":
mcp.run(transport="stdio")

In both versions, the read tools are always registered and the write tools are registered only when ORDERS_ACCESS is read-write. Each tool's name, description and schema come from the function or the registerTool call, the same way as in the build guide.

You can register the same server twice in a client, once per mode. Cursor reads env for stdio servers, so this mcp.json lists two connections to one build:

configs/cursor-mcp.json
{
"mcpServers": {
"orders-read-only": {
"command": "node",
"args": ["/ABSOLUTE/PATH/TO/how-many-tools/typescript/build/index.js"],
"env": {
"ORDERS_ACCESS": "read-only"
}
},
"orders-read-write": {
"command": "node",
"args": ["/ABSOLUTE/PATH/TO/how-many-tools/typescript/build/index.js"],
"env": {
"ORDERS_ACCESS": "read-write"
}
}
}
}

Replace the placeholder path with the absolute path to the TypeScript build on your machine. You can then switch either connection off under Customize > MCPs.

Split servers by audience, not by endpoint​

Don't make one server per API endpoint. Make one server per audience or risk level. A support team's read-only lookup tools and its refund tools have different users and different risks, so they belong in different servers. A single server that mixes both forces everyone to load both.

Two servers can expose tools with the same name. Tool names should be unique within a server, and uniqueness isn't guaranteed across servers. Clients that combine several servers may run into collisions, and the spec suggests they prefix names with a server identifier. Pick names that won't clash with other servers you're likely to connect, such as orders_search rather than search. Tool naming and descriptions covers naming rules.

Change the tool list with care​

The current spec lets a server declare the tools capability with listChanged: true. When the tool list changes, the server should send notifications/tools/list_changed to clients that have opened a subscriptions/listen stream with toolsListChanged: true. The client then calls tools/list again. The spec also says servers should return tools in a deterministic order, because stable lists are easier for clients to cache and help prompt caching.

Clients don't all react the same way. Claude Code's docs say that in an interactive session it fetches the updated list when the notification arrives. In -p mode and the Agent SDK, it refreshes only the tool list. The Cursor and VS Code docs I checked don't say how they handle the notification, so test that in your client before you depend on it. Scoping by configuration, as in the example, needs no notifications at all.

Try the example​

The example has the same two read tools in both languages, plus two write tools that only appear in read-write mode. Build the TypeScript version, or set up the Python one:

cd how-many-tools/python
uv sync

Running uv run orders.py on its own just waits for a client on stdin. Use the Inspector command from the top of this page to list the tools.

The test script in the example folder starts both servers in both modes, checks the tool lists under both protocol eras, and calls one read tool and one write tool:

bash how-many-tools/test.sh

Common problems​

Cannot have more than 128 tools per request. VS Code counts every enabled tool, including tools from other servers and extensions. Open the tools picker in the chat view, deselect some tools or a whole MCP server, and send the request again. VS Code's docs also describe the github.copilot.chat.virtualTools.threshold setting, which defers large tool sets.

The model calls the wrong tool. Two tools overlap, or their descriptions say the same thing. Merge them if they do the same job. If they really differ, rewrite the names and descriptions so each one says when to use it. Tool naming and descriptions walks through this.

Two servers show a tool with the same name. Tool names are unique within a server, so clashes only happen across servers. Rename one of the tools with a server prefix, as described in the section on splitting servers.

FAQ​

Is there a maximum number of tools an MCP server can have?

The MCP spec doesn't set one. The limits come from the client. VS Code allows 128 tools enabled per chat request, and Claude Code doesn't set a fixed cap but defers tools until the model needs them. Cursor doesn't publish a number in its MCP docs.

Should I split a large MCP server into several smaller ones?

Split when the groups have different users, different risk levels or different connection needs, such as a read-only lookup server and a write server. Don't split just to hit a count. Merging tools that do the same job often helps more than splitting.

How do I hide tools from the model without changing the server?

Turn them off in the client. VS Code lets you deselect tools and whole MCP servers in the tools picker, and Cursor lets you click a tool name in the tools list to toggle it.

Does a tool with a long description cost more than a short one?

Yes. The description is part of the definition sent with the request, and the OpenAI docs say definitions count toward the context limit. Keep descriptions short and put the key guidance at the start.