Skip to main content

Dynamic Tool Registration: Why You (Almost Certainly) Shouldn't Use MCP

·7 mins

Part of the Prompting from Code series, this article explores tool design patterns and when MCP’s standardization helps vs. when custom tools are better.

Two different problems, one protocol #

MCP (Model Context Protocol) launched in November 2024 solving a legitimate problem. If you are using a chat application you do not control (Claude Desktop, a web UI, an IDE plugin), you cannot inject custom code to give the model tools. The model can generate SQL, but it cannot execute it against your systems. MCP exists for that. You run an MCP server, the app connects to it, and now your chat can safely run database queries, call internal APIs or pull data from third party systems.

But MCP got positioned as more than a connector for chat applications. It was announced as a “USB-C port for AI” that fixes the “M×N integration problem”. The implication was simple: if you build agents, you should use MCP for tool integration.

This distinction matters. If you are building your own agentic workflows with the API, you already control the environment. You can write the tool functions directly, register them dynamically and add guardrails where they belong. With MCP, you adopt a protocol designed for environments you do not control, even though you do control yours.

If you’re building agentic workflows in code against an LLM API, choosing MCP over custom tools means you’re paying for thousands of extra tokens per request for standardization you likely don’t need.


The real cost #

The cost is not hypothetical. Tool definitions are part of the request, not once per conversation, but every turn. The difference between MCP and custom tools is size and shape. MCP servers typically expose many operations and resources, inflating the tool schemas your host passes to the model. Custom tools let you expose a handful of powerful, flexible functions and keep definitions small.

Here is a concrete example using the Postgres MCP.

User triggers an action: "Get the last 10 orders for Andreas"

Postgres MCP #

Connecting the MCP server means your host now passes a bundle of database tools to the model. This typically requires multiple tool calls to reach a solution.

Turn 1
Model: "Find the user by name first."
Tool call: get_user_by_name("Andreas")

Turn 2
Tool result: { "id": 123, ... }
Model: "Fetch orders for the returned user ID."
Tool call: get_orders_by_user_id(123, limit=10)

Turn 3
Tool result: [ ... orders ... ]
Model: "Summarize the orders and return the result."

You could add another tool that does this in one call, but then you’re back to the problem of having to expose all possible operations to the model. Even if each tool call is fast, you pay for multiple inference cycles, multiple network round trips and a large tool schema that gets included again on every turn.

A simple query tool #

In your own application, you can expose one powerful tool and scope it correctly.

Turn 1
Model: "One query can do this."
Tool call: execute_query("
  SELECT o.*
  FROM orders o
  JOIN users u ON o.user_id = u.id
  WHERE u.name = 'Andreas'
  ORDER BY o.created_at DESC
  LIMIT 10
")
Tool result: [ ... orders ... ]
Model: "Summarize and return."

Here is an illustrative example. This is tool-definition overhead only, not the entire conversation payload.

Conversation Length MCP tool defs Custom tool defs Savings
1 turn 3,000 500 83%
10 turns 30,000 5,000 83%
25 turns 75,000 12,500 83%

This overhead scales linearly with conversation length. Most LLM chat APIs are stateless, so your application re-sends the full conversation history and tool definitions every turn. Unless you use prompt caching or fetch schemas on demand, you keep paying for bloated tool definitions on every request. With custom tools, you can keep the definitions tiny and cache static context.

At scale, this compounds quickly. If your tool definitions add a couple thousand input tokens per turn, that can easily turn into thousands of dollars per month with significant traffic. You also pay in latency. Granular tools force sequential tool calls and extra network round trips even when one query would have been enough.

Beyond token cost, there are practical issues. Authentication is not standardized, so security ends up being “whatever this server decided to do.” Every extra layer between your agent and the actual operation becomes another failure point.


The alternative: powerful tools, not granular ones #

The alternative is a different boundary. Instead of many narrow, constrained operations, you give the model a small number of powerful tools with proper scoping and enforce constraints in code.

MCP typically exposes many granular tools (get_user_by_name, get_user_by_id, get_orders_by_user_id, get_order_by_id, get_order_items, update_order_status, cancel_order, etc.) that the model calls sequentially. The custom approach gives the model one powerful tool with backend security and lets it construct the operations it needs.

def execute_query(query: str, context: UserContext) -> QueryResult:
    """Execute SQL with connection scoped to user permissions."""
    if context.is_admin:
        conn = admin_db_connection
    else:
        conn = readonly_db_connection
    
    if is_dangerous_query(query) and not context.is_admin:
        raise PermissionError("Query not allowed")
    
    return conn.execute(query)

# Schema lives in system prompt (cacheable)
system_prompt = """
You have access to execute_query(query: str) for database access.

Database schema:
- users (id, name, email, created_at)
- orders (id, user_id, status, total, created_at)
- products (id, name, price, stock)

Use SQL to answer questions. Use JOINs for queries across tables.
"""

If your provider supports prompt caching, this static prefix becomes cheap to reuse across turns.

Security comes from backend design, not from constraining tool names. Use read-only connections, schema access controls and application-layer validation. Modern models write complex SQL reliably, you do not need narrow operations to feel safe.

This flexibility extends to dynamic tool registration. You can select which tools the model sees based on user context, feature flags or conversation state:

def get_tools_for_user(user: User) -> list[Tool]:
    base_tools = [execute_query, api_request, search_documents]
    
    if user.is_admin:
        base_tools.append(execute_admin_query)
    if user.has_feature('advanced_analytics'):
        base_tools.append(run_analysis)
    if user.subscription_tier == 'enterprise':
        base_tools.append(call_external_api)
    
    return base_tools

agent = Agent(
    model="claude-sonnet-4.5",
    tools=get_tools_for_user(current_user),
    system_prompt=get_prompt_for_user(current_user)
)

With MCP, you’d need to run different servers for different user contexts or implement permission checking inside each tool. With custom tools, you control what the model sees directly in code.


When MCP actually makes sense #

MCP is not inherently bad. It solves specific problems for specific contexts. If you are using Claude Desktop, a web-based chat interface or an IDE that supports MCP, it may be your only option. Token overhead takes a back seat to capability.

If you are building tools for others to reuse across applications, MCP provides standardization. One server can work with Claude Desktop, Cursor and other compatible hosts. For popular services, an MCP server can become the official integration path.

In larger organizations, MCP can create separation of concerns. An AI team handles prompts and orchestration while an infrastructure team maintains servers with approved operations and auditing.

But if you are building your own application with the API, you control the environment directly. You want flexibility for dynamic tools, custom logic and per-user context. Token cost matters at scale. Custom tools win on all dimensions: efficiency, flexibility, cost and simplicity.


Conclusion #

In Intent Classification, the focus was routing in code rather than letting the model choose from all possible workflows. The principle was control through architecture rather than hoping prompts would reliably keep the model on track. Dynamic tool registration follows the same philosophy. You deliberately design what the model sees rather than adopting a standard not built for your use case.

By late 2024, when MCP launched, tool use had matured significantly. Claude Sonnet 3.5 and GPT-4 could reliably write complex SQL queries and handle multi-step operations. Claude Sonnet 4 and later Sonnet 4.5 reached top performance in tool use. These models do not need narrow operations designed for earlier models. Give them properly scoped access and let them work.

The question is not “Should I use MCP?” but “Am I building for applications I don’t control or am I building my own?” If you’re using Claude Desktop or building reusable tools for others, MCP makes sense. If you’re building your own agent application, custom tools with dynamic registration are almost always better.

The best tool architecture gives your model maximum capability with minimum overhead. For developers building their own systems, that means simple, powerful tools with proper security. Design for the models you have today, not the protocols built for yesterday’s limitations.