MCP Dynamic Tool Registration and Discovery, Explained
MCP dynamic tool registration and discovery: how clients find tools via tools/list, pagination and list_changed, and how to add or remove tools at runtime.
MCP clients discover tools by calling tools/list on each connected server, following nextCursor until every page is fetched. When a server's tool set changes at runtime, a server that advertises tools.listChanged sends notifications/tools/list_changed, and the client calls tools/list again. Dynamic tool registration is simply the server side of that loop: add, remove, enable or update a tool, then send the notification.
That's the whole mechanism. The details that trip people up are the new rules in the current spec revision (2026-07-28), SDK APIs that changed with the v2 TypeScript and Python SDKs, and clients that handle notifications differently. This guide covers all three.
How Tool Discovery Works
Discovery happens in four steps, all defined in the Tools section of the MCP spec:
- Capabilities. A server that exposes tools declares the
toolscapability. SettinglistChanged: truepromises it will notify clients when the list changes. - List. The client sends
tools/list. Each tool comes back with aname, optionaltitle,description,inputSchema(JSON Schema), optionaloutputSchema,annotationsandicons. - Paginate. If the response has a
nextCursor, the client sendstools/listagain with that cursor, until no cursor is returned. - Stay fresh. The client re-fetches when it receives
notifications/tools/list_changed, or when a cached result goes stale.
A tools/list response in the current revision looks like this:
{
"jsonrpc": "2.0",
"id": 1,
"result": {
"resultType": "complete",
"tools": [
{
"name": "get_weather",
"description": "Get current weather information for a location",
"inputSchema": {
"type": "object",
"properties": { "location": { "type": "string" } },
"required": ["location"]
}
}
],
"nextCursor": "next-page-cursor",
"ttlMs": 300000,
"cacheScope": "public"
}
}
Pagination rules
The pagination spec is short but strict:
- The cursor is opaque. Clients must not parse or modify it.
- Page size is chosen by the server; clients must not assume a fixed size.
- A missing
nextCursormeans the end. An empty string is a valid cursor, not the end. - Invalid cursors should return error
-32602(Invalid params).
A minimal manual loop:
let cursor: string | undefined;
const all = [];
do {
const page = await client.listTools(
cursor === undefined ? undefined : { cursor }
);
all.push(...page.tools);
cursor = page.nextCursor;
} while (cursor !== undefined);
In practice you rarely need this: the v2 TypeScript client's listTools() with no cursor aggregates every page for you (capped at 64 pages by default via listMaxPages).
Caching hints (new in 2026-07-28)
The current revision adds caching. Servers must include ttlMs and cacheScope on tools/list results:
ttlMs— how long the client may treat the list as fresh. It is a freshness hint, not a polling interval.cacheScope: "public"— identical for every user; shared gateways may cache it.cacheScope: "private"— the list depends on who is asking (for example, filtered by OAuth scopes) and must not be shared across authorization contexts.
TTL and listChanged work together: a notification immediately invalidates a cached list, even if its TTL hasn't expired. A server that can't push notifications can still keep clients reasonably fresh with a short TTL.
Change Notifications: Old Session vs. New Stream
How the list_changed notification reaches the client depends on the protocol revision negotiated:
| 2025-11-25 and earlier | 2026-07-28 (current) | |
|---|---|---|
| Delivery | Server sends it on the session any time after initialization | Only on a subscriptions/listen stream the client opened with toolsListChanged: true |
| Server confirms | — | notifications/subscriptions/acknowledged lists what it will send |
| Can tool list vary per connection? | Common in practice (session state) | No — must not vary per connection or as a side effect of other requests |
| Can it vary by caller? | Yes | Yes, by the authorization on the request (e.g. granted scopes) |
The third row matters most for "dynamic tools." Under 2026-07-28, MCP has no protocol-level session, so the old pattern of "call login, then new tools appear on this connection" is no longer allowed. Tools may change globally (a plugin loads, a feature flag flips, an upstream API adds an endpoint), or differ by the caller's credentials. For per-workflow state, the spec recommends returning an explicit handle from one tool and passing it to the next.
The spec also asks servers to return tools in a deterministic order, which keeps client caches valid and improves LLM prompt-cache hit rates.
Registering and Unregistering Tools at Runtime
TypeScript SDK (v2)
The v2 TypeScript SDK ships as @modelcontextprotocol/server (replacing @modelcontextprotocol/sdk). registerTool returns a handle with enable(), disable(), update() and remove(). Per the SDK notifications guide, every mutation through the handle sends list_changed automatically, and McpServer advertises tools.listChanged: true as soon as you register a tool.
import { McpServer } from '@modelcontextprotocol/server';
import { serveStdio } from '@modelcontextprotocol/server/stdio';
import * as z from 'zod/v4';
serveStdio(() => {
const server = new McpServer({ name: 'reports', version: '1.0.0' });
const exportCsv = server.registerTool(
'export_csv',
{
description: 'Export a report as CSV',
inputSchema: z.object({ reportId: z.string() }),
},
async ({ reportId }) => ({
content: [{ type: 'text', text: `exported ${reportId}` }],
})
);
// Hidden from tools/list until the feature is turned on
exportCsv.disable();
// Your own feature-flag hook (not part of the SDK)
onFeatureFlag('exports', (on) =>
on ? exportCsv.enable() : exportCsv.disable()
);
return server;
});
A disabled tool disappears from tools/list and calls to it fail with an Invalid params error. update({ description, paramsSchema, callback, ... }) changes a tool in place, and remove() deletes it. If the change comes from somewhere the SDK can't see, call server.sendToolListChanged() yourself.
On stateless HTTP deployments built with createMcpHandler, the McpServer instance is created per request, so publish through the handler instead: handler.notify.toolsChanged() reaches every open subscriptions/listen stream that asked for tool changes.
Python SDK (v2)
In mcp 2.x, FastMCP was renamed MCPServer (from mcp.server.mcpserver import MCPServer). It has add_tool() and remove_tool(), but unlike the TypeScript handles they do not notify on their own — publish the event yourself. Inside a tool, use await ctx.notify_tools_changed(). Outside a request (a background job, a file watcher), pass your own subscription bus and publish to it:
from mcp.server.mcpserver import MCPServer
from mcp.server.subscriptions import InMemorySubscriptionBus, ToolsListChanged
bus = InMemorySubscriptionBus()
mcp = MCPServer("reports", subscriptions=bus)
def export_csv(report_id: str) -> str:
"""Export a report as CSV."""
return f"exported {report_id}"
async def set_exports_enabled(enabled: bool) -> None:
if enabled:
mcp.add_tool(export_csv)
else:
mcp.remove_tool("export_csv")
await bus.publish(ToolsListChanged())
if __name__ == "__main__":
mcp.run()
ctx.notify_tools_changed() and the bus reach subscriptions/listen streams, which is how 2026-07-28 clients listen. For clients on older revisions, the session-level await ctx.session.send_tool_list_changed() sends the legacy notification. For multi-replica deployments, implement the SubscriptionBus protocol over Redis, NATS or similar so every replica's streams get the event.
The standalone FastMCP framework has its own API (mcp.disable(names=...), mcp.enable(tags=..., only=True), tag-based visibility) and documents automatic list_changed notifications when called inside an active request. Check the docs for the version you install.
Writing the client side
With the v2 TypeScript client, register a handler once and the SDK re-fetches for you (with a 300 ms debounce by default):
import { Client } from '@modelcontextprotocol/client';
const client = new Client(
{ name: 'my-host', version: '1.0.0' },
{
listChanged: {
tools: {
onChanged: (error, tools) => {
if (error) return console.error('Refresh failed:', error);
console.log('Tools refreshed:', tools?.length);
},
},
},
}
);
The handler only activates if the server advertises tools.listChanged. If you aggregate tools from several servers, prefix names with a server identifier: the spec warns that two servers can both expose a search tool, and serverInfo.name isn't guaranteed to be unique.
Client Support Caveats
The spec says servers should send notifications; it doesn't force hosts to react the same way. Before you design around dynamic tools, check what your target hosts actually do:
- Claude Code documents that in an interactive session it re-fetches tools, prompts and resources on
list_changed; in-p(headless) mode and the Agent SDK it refreshes only the tool list. On the 2026-07-28 runtime it holds a notification stream open and limits how often it reopens one that keeps closing, so on serverless hosts updates can lag until you reconnect from/mcp. - Other hosts vary by version and protocol revision. Some fetch tools only at connect time. Treat runtime changes as an optimization and keep your server usable with the initial list.
- Context budget is the real limit. Hosts that defer tool definitions (Claude Code's tool search, for example) make large tool sets cheaper, which often removes the need to hide tools dynamically at all.
When runtime registration is the wrong tool, consider: static toolsets chosen at startup (the GitHub MCP Server uses a --toolsets flag), splitting one big server into several focused ones, or a gateway that aggregates servers behind one endpoint (see our Docker MCP Gateway guide).
Registries: Discovering Servers, Not Tools
"Discovery" also gets used for finding servers. That's a separate layer:
- The official MCP Registry (in preview) stores
server.jsonmetadata — name, package or remote URL, install instructions — under verified namespaces. It's meant to be consumed by downstream aggregators and marketplaces, not by host applications directly. - Directories like MCP.so layer curation, categories and search on top, so people can find a server before any
tools/listhappens. - Within the protocol,
server/discover(new in 2026-07-28) returns a server's supported versions, capabilities and identity before other requests.
So the full chain is: registry or directory finds the server, server/discover checks compatibility, tools/list finds its tools, and list_changed keeps them current.
Test It Before You Ship
Use the MCP Inspector to verify dynamic behavior: connect, flip your feature flag, and watch notifications/tools/list_changed and the follow-up tools/list in the Protocol tab. Turn on Fetch Lists One Page at a Time in Server Settings to step through your pagination page by page. And because a changing tool list is also a security surface — a tool's description can change after a user approved it — read our MCP security best practices too.
Next Steps
- Browse MCP servers by category to see how real servers structure large tool sets, such as Playwright MCP and the Filesystem server.
- Check which MCP clients you need to support, then test
list_changedagainst each one. - Find your next server in the MCP.so directory.
Frequently asked questions
How do MCP clients discover tools?
- The client calls tools/list on each connected server and follows nextCursor until all pages are fetched. It re-fetches when the server sends notifications/tools/list_changed or when the cached list's ttlMs expires.
Can an MCP server add or remove tools at runtime?
- Yes. The server changes its tool set and sends notifications/tools/list_changed, provided it declared tools.listChanged. In the TypeScript SDK the registerTool handle's enable, disable, update and remove methods send the notification automatically; in the Python SDK you publish it yourself.
Can an MCP server show different tools to different users?
- Under the 2026-07-28 spec the tool list may vary by the authorization on the request, such as granted OAuth scopes, but must not vary per connection or as a side effect of other requests. Mark such lists with cacheScope 'private'.
Do all MCP clients support list_changed notifications?
- No. Support varies by host, version and protocol revision, and some clients only fetch tools when they connect. Claude Code documents re-fetching on list_changed, but you should test each target client and keep your server usable with its initial tool list.