Lesson 9 of 12Track 3

Building MCP clients

Dynamic tool registration

Schema introspection and runtime discovery.

Interactive exercise ~10 min

When the tool list isn't fixed

So far we've assumed the tool registry is stable: the host connects to each server, lists its tools once, and the registry is set. Some servers don't fit that pattern. Their tools change at runtime: new database tables become accessible, a workflow tool's actions update, a vendor releases a new capability without restarting the server.

This lesson covers dynamic tool registration: how the host keeps its tool registry fresh, how the agent finds out about new tools, and what to watch out for in production.

How dynamic tools change

Three patterns:

Server-pushed list updates

The server declares tools.listChanged in its capabilities and emits notifications/tools/list_changed when the list changes. The client receives the notification and re-fetches the tool list.

async def on_notification(notification):
    if notification.method == "notifications/tools/list_changed":
        new_tools = await client.list_tools()
        host.update_tools(server_name, new_tools)

This is the cleanest pattern. The server is authoritative; the client follows.

Periodic poll

The client re-fetches the tool list on a timer (every 60 seconds, say). Useful when the server doesn't support listChanged or when notifications are unreliable.

async def poll_tools(client, host, server_name, interval=60):
    while True:
        await asyncio.sleep(interval)
        tools = await client.list_tools()
        host.update_tools(server_name, tools)

Polling is wasteful but simple. Use it as a fallback, not a primary mechanism.

Lazy refresh on use

The host re-fetches the tool list only when something obvious has changed: a tool call returned an error suggesting the tool no longer exists, or it's been a long time since the last refresh. Cheaper than polling but slower to react to additions.

What "dynamic" doesn't mean

A common mistake: thinking the schema of a single tool changes between calls. It doesn't (or shouldn't). What changes is the set of tools available, not how a given tool is shaped. If you find yourself wanting "the schema for query should change based on the current database connection," you're really asking for "different tools per database connection," which is a different design.

Static schemas, dynamic membership. That's what dynamic tool registration is.

The agent's view of dynamism

When the tool list changes, the agent's view has to refresh. Two options:

Re-render the system prompt

Every time the tool list changes, regenerate the system prompt's "available tools" section. The agent's next turn sees the updated list. Simple, works for most agents.

Inject a "tools changed" message

The host adds a system-style message to the conversation: "The available tools have changed. New: X. Removed: Y." The agent reads this on its next turn and adjusts. Useful when you don't want to invalidate the cached system prompt.

The first is more reliable; the second is more granular. Pick one and stick with it; mixing both creates inconsistencies.

Discovery that depends on input

Some servers have a huge number of potential tools (one per database table, one per Slack channel, one per Linear project), and listing all of them up-front is expensive or pollutes the agent's context. A pattern that helps: two-stage discovery.

@mcp.tool()
def list_tables() -> list:
    """List the tables this server can query."""
    return DB.list_tables()
 
 
@mcp.tool()
def query_table(table: str, where: str = None) -> dict:
    """Query a specific table. Use list_tables() first to find available tables."""
    if table not in DB.tables:
        return {"error": f"unknown table {table!r}"}
    return DB.query(table, where)

The agent sees two tools (list_tables, query_table). It calls list_tables to discover what's available, then calls query_table with a specific table name. The set of tables can be enormous; the tool list stays small.

This is two-stage discovery: the static tool surface is small; the dynamic surface is queried on demand. Most production MCP servers use this pattern when the data space is large.

Versioning dynamic tools

When a tool's behavior changes (not its schema), the agent's mental model has to change too. The agent might have memorized "tool X behaves Y" from earlier in the session. If you silently change the behavior, the agent will use it wrong.

Two practices:

Bump the tool's name on breaking changes

query becomes query_v2 when behavior changes. Old name stays for a deprecation window with a [DEPRECATED] description. The agent picks the new one because the old one's description signals "use the v2 instead."

Bump the server version

The server's serverInfo.version is part of the protocol. Hosts can log it; agents can be re-introduced to the surface when it changes. This is most useful for hosts that cache schemas across sessions.

Watching out for description rot

A subtle bug with dynamic tools: the description ages. A tool that was "lists pull requests in the active repo" might now mean "lists pull requests in any repo the GitHub MCP server has access to." The behavior changed, the description didn't. The agent uses the tool based on a stale model.

Whenever you change a tool's behavior, even mildly, update its description. Treat descriptions as part of the API surface.

Caching and invalidation

Hosts that talk to many servers cache things: tool lists, resource lists, sometimes results of read-only calls. Caching is a performance win and a correctness hazard. Invalidate when:

  • A listChanged notification arrives.
  • The connection reconnects (a new session might have a different surface).
  • Sufficient time has passed (TTL).

For the agent's tool registry specifically, prefer to be aggressive about invalidation: a stale registry is worse than re-listing once. Resources can have longer TTLs because the URIs are usually addressable individually.

Failure modes

The server says it supports listChanged but never sends notifications

Common bug. Add a polling fallback. Don't trust the capability declaration to mean the feature actually works.

A tool was removed mid-session

The agent calls a tool that's no longer there. The server returns an error. The host should refresh the tool list and tell the agent the tool is gone. If the agent retries blindly, it will keep failing.

Tool names change

If a server renames a tool (a real anti-pattern, but it happens), the host's cached registry is now wrong. Hosts should treat the server-reported tool list as authoritative on every refresh, not merge with cached state.

Two-stage discovery is your friend

The single most useful pattern for servers with large or growing capability surfaces is two-stage discovery: a small static tool list (list_X, describe_X) plus a generic action tool (use_X(name, args)). It scales to thousands of underlying capabilities while keeping the agent's tool list manageable. If your server has more than a dozen tools and they're all variations of the same action, you probably want this pattern.

Key takeaway

Tool lists are not always static. Servers can update them via listChanged notifications, polling, or lazy refresh on use. Schemas should stay stable; only set membership changes. For large capability surfaces, use two-stage discovery (list, then act) to keep the agent's tool list small. Update tool descriptions when behavior changes; bump tool names on breaking changes; invalidate cached registries aggressively. The next module shifts to running this whole stack in production: auth, security, orchestration, and observability.

>_dynamic-tools.py
Loading editor...
Output will appear here.

Done with this lesson?