Designing Token-Efficient Tool Surfaces for Agentic MCP Servers
Learn how to collapse dozens of MCP tool definitions into a handful of domain-focused, action-routed tools without losing any underlying functionality.
This article is published in English.
Sources: **From 30 Tools to 3: Designing a Token-Efficient MCP Tool Surface**
The typical struggle with modern agentic systems isn't a shortage of tools.
It's having far too many.
As an agent's capabilities expand, it's common practice to wire it up to Jira, GitLab, Confluence, Sentry, Elasticsearch, Jaeger, various databases, monitoring dashboards, internal APIs, deployment pipelines, and countless other systems.
Each of these integrations tends to expose a long list of operations:
Jira
├── searchIssues
├── getIssue
├── createIssue
├── updateIssue
├── addComment
├── transitionIssue
├── getTransitions
├── assignIssue
└── ...
GitLab
├── listProjects
├── getProject
├── listIssues
├── createIssue
├── updateIssue
├── listMergeRequests
├── getMergeRequest
├── createComment
└── ...
Confluence
├── searchPages
├── getPage
├── createPage
├── updatePage
└── ...
It doesn't take long before you're maintaining 30, 50, or even hundreds of tool definitions.
At a glance, this looks like an issue of raw capability.
In reality, it's a tool-surface issue.
The agent doesn't need fewer things it can do.
What it needs is fewer top-level tools to choose from.
This piece walks through an approach for shrinking bloated MCP or tool surfaces down to a handful of domain-focused tools, without losing any of the underlying functionality.
The central idea is straightforward:
Shrink the tool surface, not the set of capabilities.
Rather than exposing something like:
30 MCP tools
you can expose:
3–5 domain tools
and rely on an action field to route each call internally.
So a set like:
jira_search
jira_get_issue
jira_create_issue
jira_update_issue
jira_add_comment
jira_transition_issue
...
collapses into something like:
jira({
action: "search",
...
})
jira({
action: "getIssue",
...
})
jira({
action: "createIssue",
...
})
The backend retains every original capability.
From the model's perspective, though, the tool surface is dramatically smaller.
1. The Problem With Large Tool Surfaces
An MCP server isn't purely an execution mechanism.
It also becomes part of what the model has to hold in context.
Whenever an agent connects to an MCP server, the model typically has to process:
- tool names
- descriptions
- input schemas
- parameters
- enums
- constraints
- sometimes extra metadata
Picture an agent wired to 40 different tools.
Even when each individual schema is compact, the combined footprint can add up fast.
Beyond that, the model now faces a bigger decision surface:
User request
│
▼
Which tool?
│
┌───┼────┬────┬────┬────┐
▼ ▼ ▼ ▼ ▼ ▼
T1 T2 T3 T4 T5 ...
It has to tell apart many operations that are conceptually adjacent.
Take this example:
jira_search_issues
jira_search_projects
jira_get_issue
jira_get_issue_comments
jira_get_issue_transitions
jira_get_issue_worklogs
These all belong to the same conceptual domain.
Treating each one as a fully independent top-level capability adds little value.
2. The Core Idea: Tool Multiplexing
The trick is to add a middle layer, a discriminator field:
{
"action": "search",
"query": "authentication bug"
}
Instead of defining:
jira_search
you expose just:
jira
The tool itself becomes a lightweight router.
Structurally, it looks like:
jira
│
action field
│
┌──────────────┼──────────────┐
▼ ▼ ▼
search getIssue createIssue
│ │ │
▼ ▼ ▼
searchHandler issueHandler createHandler
The key insight is that the tool is not the same thing as the capability.
The tool is simply the outward-facing interface.
The action field is what actually carries the capability.
The result:
30 capabilities
↓
3 domain-oriented tools
with no functionality lost along the way.
3. Use Domain Boundaries, Not Arbitrary Grouping
A common pitfall is reasoning like:
"I've got 30 tools, so I'll bundle 10 operations into each one."
That's not the point.
Grouping needs to be driven by semantic domains, not arbitrary bucket sizes.
For instance:
jira
├── search
├── issue
├── comment
├── transition
└── project
gitlab
├── project
├── issue
├── mergeRequest
└── pipeline
observability
├── search
├── trace
├── log
└── error
The right grouping will vary by integration.
For a database, something like:
db_tables
db_query
db_advanced
could work well.
For Jira, you might prefer:
jira_issues
jira_projects
jira_search
For GitLab, a layout such as:
gitlab_repository
gitlab_issues
gitlab_mergeRequests
might fit better.
There's no fixed number that works everywhere.
The objective is finding the smallest tool surface that still respects clear, meaningful semantic boundaries.
4. A Database Example
A database connector makes a good illustration, since database APIs tend to expose a long list of individual operations.
Rather than defining separate entries such as:
listTables
getTableSchema
getSampleData
getTableSize
executeQuery
getDatabaseInfo
listRelationships
getIndexes
profileColumn
searchSchema
listProcedures
getTriggers
compareSchemas
you can collapse them into:
db_tables
db_query
db_advanced
The first of these tools can rely on a schema like:
const tablesSchema = z.discriminatedUnion("action", [
z.object({
action: z.literal("list"),
schema: schemaField,
}),
z.object({
action: z.literal("schema"),
tableName: z.string(),
schema: schemaField,
}),
z.object({
action: z.literal("sampleData"),
tableName: z.string(),
schema: schemaField,
rowCount: z.number().optional().default(10),
}),
z.object({
action: z.literal("size"),
tableName: z.string(),
schema: schemaField,
}),
]);
From the model's point of view, there's just one tool:
db_tables
backed by a clearly defined set of possible actions:
list
schema
sampleData
size
Underneath, the runtime still dispatches to four distinct handlers:
switch (args.action) {
case "list":
return handleListTables(args.schema);
case "schema":
return handleGetTableSchema(
args.tableName,
args.schema
);
case "sampleData":
return handleGetSampleData(
args.tableName,
args.schema,
args.rowCount
);
case "size":
return handleGetTableSize(
args.tableName,
args.schema
);
}
This separation matters:
Merging happens at the level of the MCP interface, not within the underlying business logic.
Each internal handler stays independently testable and easy to maintain.
5. Zod Discriminated Unions Are a Natural Fit
In TypeScript codebases, z.discriminatedUnion() offers a clean way to model this pattern.
For example:
const querySchema = z.discriminatedUnion("action", [
z.object({
action: z.literal("execute"),
query: z.string(),
params: z.record(z.string()).optional(),
limit: z.number().optional().default(100),
}),
z.object({
action: z.literal("info"),
}),
]);
From there, the type can be derived directly:
type QueryInput = z.infer<typeof querySchema>;
This approach gives you three practical benefits:
- A single MCP tool.
- Explicit routing based on the action field.
- Strict validation enforced at runtime.
The overall shape of the architecture looks like this:
MCP Tool
│
▼
Discriminated Union
│
┌─────┴─────┐
│ action │
└─────┬─────┘
│
┌────────┼────────┐
▼ ▼ ▼
Handler A Handler B Handler C
│ │ │
└────────┼────────┘
▼
Backend
This is far easier for a model to navigate than dozens of unrelated top-level tools.
6. Tool Count Is Not Capability Count
This is arguably the most important idea in this approach.
Say you end up with:
3 tools
13 actions
That doesn't mean ten capabilities disappeared. What you actually have is:
3 external interfaces
13 internal capabilities
In short:
Tool count ≠ capability count
This distinction only grows in importance as agent systems scale up. A large organization's toolset might look like:
Jira 20 operations
GitLab 25 operations
Confluence 15 operations
Sentry 8 operations
ELK 10 operations
Jaeger 6 operations
Database 20 operations
which can easily add up to well over 100 individual operations. Exposing every one of them directly to the model produces an unnecessarily bloated tool surface. Instead, you can consolidate into something like:
jira
gitlab
confluence
sentry
observability
database
while leaving the total number of underlying operations untouched.
7. Why This Can Reduce Token Usage
The main gain here comes from shrinking the volume of tool metadata the model has to parse. Rather than listing:
Tool 1
Tool 2
Tool 3
Tool 4
...
Tool 30
you present:
Tool A
Tool B
Tool C
This can cut down on:
- tool descriptions
- repeated parameter metadata
- duplicated semantic explanations
- the complexity of choosing among top-level tools
- context space taken up by tool definitions
That said, this shouldn't be framed as a guaranteed proportional savings. Going from:
30 tools → 3 tools
doesn't automatically translate into:
90% lower token cost
because the merged schemas can themselves grow larger. A more accurate way to frame it:
Bundling several operations into one tool tends to shrink the metadata footprint an agent has to read, though how much you actually save in tokens or response time hinges on things like the size of the combined schema, how verbose the field descriptions are, quirks in the model provider's handling of tool definitions, and the specific way your agent framework manages tool calls.
That nuance is worth keeping in mind.
8. Bundling Everything Together Isn't Automatically Better
There's a trade-off worth calling out here.
Imagine a single tool exposing 40 different actions:
enterprise({
action: ...
})
with actions like:
createCustomer
deleteCustomer
searchInvoice
rotateCredentials
deployService
createRepository
getTrace
searchLogs
...
This is technically achievable.
It also makes for poor design.
The model is now stuck parsing one massive schema.
You haven't solved the original problem, you've just relocated it, from:
30 tools
to:
1 giant tool
The better path is consolidation along domain lines.
For instance:
customer
billing
repository
observability
deployment
Five tools might be the right target, not one.
Which leads to a guiding principle:
Shrink the tool surface, but don't shrink it carelessly.
9. The Schema Doubles as Part of the Agent's Interface
Once a single tool covers several capabilities, the action parameter carries a lot of weight.
A weak version:
action: z.string()
An improvement:
action: z.enum([
"search",
"get",
"create",
"update"
])
The strongest option, when each action needs distinct parameters:
z.discriminatedUnion("action", [
searchSchema,
getSchema,
createSchema,
updateSchema,
])
Here, the chosen action and its expected arguments are tied together with real type safety.
For example:
action = "search"
→ query required
action = "get"
→ issueId requiredaction = "create"
→ title + description required
This is far more precise than a single loosely-typed schema stuffed with optional fields for every possible action.
10. JSON Schema Complications Under MCP
There's an added wrinkle in practice.
MCP tool schemas eventually get compiled down to JSON Schema.
Depending on which converter you use, a Zod discriminated union might generate:
{
"anyOf": [...]
}
or:
{
"oneOf": [...]
}
Some parts of the MCP schema pipeline assume the root of the schema is a plain object.
That assumption doesn't always hold.
In at least one real implementation, the Zod compatibility layer inside the MCP SDK was built around object shapes and didn't gracefully translate a discriminated union.
The fix is to insert a normalization step for the generated schema.
Conceptually, it looks like this:
Zod discriminated union
│
▼
JSON Schema
│
▼
MCP normalization
│
▼
MCP-compatible object schema
The architectural takeaway is:
Reshape the schema you show the model without touching the strictness of your actual validator.
Your original Zod schema should stay the single source of truth for validation.
11. Two Different Schemas: One for the Model, One for Runtime
This points to a broader distinction worth making explicit.
There are really two separate jobs here:
LLM-facing representation
│
▼
Tool selection and argument generation
Runtime representation
│
▼
Validation and execution
The schema shown to the LLM should be:
- easy to read
- compact
- unambiguous
- simple to pick from
The schema used at runtime should be:
- strict
- safe
- predictable
- final
These two representations don't need to match exactly. A flattened, simplified object shape might help a model pick the right action more easily, while the underlying discriminated union still enforces validation behind the scenes.
This generalizes into a broader rule for agent systems:
Tune schemas for how models read them, but never rely on the model-facing schema as your only line of defense.
12. Descriptions Carry More Weight Once Tools Are Merged
With 30 separate tools, each one can afford a narrow, specific description.
With 3 tools, the description has to communicate the entire space of possible actions.
For instance:
Unified tool for advanced database analysis.
ACTIONS:
relationships
List foreign key relationships.
indexes
Get indexes for a table.
profileColumn
Analyze nullability, cardinality and top values.
searchSchema
Search tables and columns.
listProcedures
List stored procedures.
triggers
Get triggers.
compareSchemas
Compare two table definitions.
This isn't just documentation for humans.
It functions as part of how the model routes its calls.
A helpful way to think about it:
Tool name
+
Description
+
Action enum
+
Parameter schema
↓
Agent routing signal
So consolidating tools should push you toward writing more careful descriptions, not fewer of them.
13. This Pattern Isn't Limited to Databases
The same approach pays off even more once you apply it across a broader set of engineering systems.
Jira
Rather than exposing:
searchIssues
getIssue
createIssue
updateIssue
addComment
transitionIssue
assignIssue
getTransitions
expose:
jira_issues
with an action set like:
search
get
create
update
comment
transition
assign
GitLab
Rather than surfacing separate tools for repositories, issues, merge requests and pipelines:
gitlab_repository
gitlab_issues
gitlab_mergeRequests
gitlab_pipelines
you can fold them into a single entry point, each with its own focused action set.
Confluence / Wiki
Instead of scattering operations across multiple tools:
searchPages
getPage
createPage
updatePage
deletePage
getChildren
collapse them into one:
wiki
driven by an action list such as:
search
get
create
update
children
Sentry
A similar shape works here too:
sentry
backed by:
searchIssues
getIssue
events
releases
projects
ELK
Same idea applies to log search:
logs
with:
search
aggregate
fields
indices
Jaeger
And for distributed tracing:
tracing
with:
search
getTrace
services
operations
None of these examples depend on the specific naming chosen. What matters is the underlying architectural approach, not the exact labels used for each action.
14. You Can Wrap Existing MCP Servers Instead of Rebuilding Them
One of the more practical benefits of this design is that you rarely need to reimplement an integration from scratch. Ready-made MCP servers already exist for many common platforms. The catch is that their default tool surface is frequently not tuned for a given agent's needs.
Rather than connecting an agent directly to a sprawling set of tools:
Agent
│
├── 20 Jira tools
├── 20 GitLab tools
└── 15 Confluence tools
you can insert a thin, purpose-built layer in between:
Agent
│
Custom MCP Layer
│
┌──────────┼──────────┐
▼ ▼ ▼
Jira GitLab Confluence
│ │ │
existing existing existing
APIs MCP MCP
That custom layer acts as an agent-facing facade. It's especially valuable when the underlying integration is functionally sound but simply exposes far more granular operations than the agent should have to reason about directly.
15. Treating MCP as an Adapter Layer
This leads to a more general architectural idea. A conventional setup looks like:
Agent → MCP → Service
A customized setup instead looks like:
Agent
↓
Agent-oriented MCP facade
↓
Service-specific MCP/API
↓
Service
The facade in between can take on responsibilities such as:
- consolidating tools
- enforcing permission checks
- normalizing inputs
- normalizing outputs
- logging
- tracing
- caching
- rate limiting
- applying organization-specific policy rules
Under this view, the MCP layer stops being just a transport mechanism and becomes an actual interface layer for the agent.
16. Access Control Still Belongs at the Action Level
There's a security consequence worth calling out. If you merge:
jira_create
jira_update
jira_delete
into a single call shape like:
jira({
action: ...
})
you cannot simply hand out a blanket permission such as:
permission: jira
and assume every underlying operation carries equal risk. Permission checks need to keep operating at the level of the individual action, for instance:
jira.search → allowed
jira.get → allowed
jira.create → allowed
jira.update → approval required
jira.delete → denied
The consolidated tool is purely an interface-level simplification — it should never become a shortcut that silently grants broader access than intended.
17. Logs Need to Capture the Action, Not Just the Tool
Once tools are merged, recording only the tool name in your logs stops being useful. A weak log entry looks like:
tool = jira
A better one includes the action:
{
"tool": "jira",
"action": "search",
"duration_ms": 142
}
And an even richer one adds further execution detail:
{
"tool": "jira",
"action": "search",
"status": "success",
"duration_ms": 142,
"request_id": "...",
"agent_id": "...",
"user_id": "..."
}
The real unit of execution is now the pair:
tool + action
rather than the tool alone:
tool
This distinction matters a lot when you're trying to debug what an agent actually did.
18. Keep the Underlying Handlers Separate
Consolidation should happen at the boundary exposed to the agent, not inside your implementation. Avoid collapsing everything into one massive handler:
async function jira(args) {
// everything
}
Instead, dispatch to distinct functions:
async function jira(args: JiraInput) {
switch (args.action) {
case "search":
return searchIssues(args);
case "get":
return getIssue(args);
case "create":
return createIssue(args);
case "update":
return updateIssue(args);
}
}
Each handler stays independently testable:
jira()
│
├── searchIssues()
├── getIssue()
├── createIssue()
└── updateIssue()
This keeps your codebase maintainable even as the external tool count shrinks.
19. A Practical Rule for Deciding What to Merge
A good heuristic for whether two operations belong in the same tool is:
Would an engineer naturally group these operations under one capability?
For example, these clearly belong together:
Jira search
Jira get issue
Jira update issue
But these don't:
Jira update issue
Elasticsearch search logs
Create AWS infrastructure
A second useful check:
Would deciding between these operations typically require the same contextual information?
If the answer is yes, merging them usually pays off. If not, it's better to keep them as separate tools.
20. When Merging Tools Is a Bad Idea
Sometimes keeping tools separate is simply the right call.
Unrelated domains
database
payments
infrastructure
email
should not be flattened into something like:
enterprise({
action: ...
})
Mixing unrelated domains into a single dispatcher just because you can doesn't make the interface more coherent.
Action sets that are too large
If one consolidated tool ends up covering fifty distinct actions, the resulting schema can become bloated and hard for the model to reason about, defeating the purpose of consolidation.
Operations with elevated privileges
Actions like:
deleteProductionData
rotateCredentials
deployProduction
might warrant their own permission boundaries even when they technically live in the same domain as lower-risk operations.
Operations with different lifecycle guarantees
Read-only calls and destructive calls sometimes work better as separate interfaces:
jira_read
jira_write
rather than being folded into one oversized tool.
As before, there's no fixed formula — this depends on the system.
21. Fewer Tools Is Not the Actual Goal
The aim isn't to shrink everything down to:
30 → 1
The real target is:
30 → smallest useful semantic surface
In one system, the right number might look like:
30 → 3
In another:
30 → 7
And in another:
30 → 12
What you're actually optimizing for is:
minimize unnecessary tool metadata
without sacrificing:
semantic clarity
validation
security
maintainability
22. A Reusable Architecture Shape
A scalable setup tends to follow this shape:
Agent
│
▼
┌──────────────────┐
│ Agent MCP Layer │
└────────┬─────────┘
│
┌──────────────┼──────────────┐
▼ ▼ ▼
jira gitlab wiki
│ │ │
┌────┼────┐ ┌────┼────┐ ┌────┼────┐
▼ ▼ ▼ ▼ ▼ ▼ ▼ ▼ ▼
search get create issues MR pipeline search get update
│ │ │
└──────────────┼──────────────┘
▼
Existing APIs/MCPs
The model interacts with a small, clean surface. Underneath, the integration layer still has access to the full range of operations.
23. Rethinking What MCP Is For
A common mental model for MCP is:
"Turn every API call into its own tool."
That's a fine place to start.
But for systems running in production, the more useful question becomes:
What should the model actually see?
These two framings don't always lead to the same design.
Consider a REST API exposing:
GET /issues
GET /issues/:id
POST /issues
PATCH /issues/:id
POST /issues/:id/comments
POST /issues/:id/transition
There's no requirement to mirror each HTTP route as its own model-facing tool. The MCP layer can instead act as a semantic wrapper around those endpoints — much like how you'd design an API around what a specific consumer needs, rather than just exposing the underlying service or database schema directly.
24. Treating Tool Design as an Agent UX Problem
Classic API design asks:
What endpoints exist?
Designing for an agent asks a different question:
What decision is the model actually trying to make?
A human engineer integrating against an API might reach for:
getIssue
getIssueComments
getIssueTransitions
getIssueWorklogs
since each call maps cleanly to one operation.
An agent, on the other hand, may work better with:
jira_issue({
action: "get",
...
})
paired with:
jira_issue({
action: "comments",
...
})
because it first picks the domain, then narrows down to the specific action — a two-step routing process:
Domain
↓
Action
↓
Arguments
instead of forcing the model to solve:
One large global tool-selection problem
25. A Minimal Pattern You Can Reuse
This approach translates cleanly into TypeScript:
const toolSchema = z.discriminatedUnion("action", [
searchSchema,
getSchema,
createSchema,
updateSchema,
]);
server.registerTool(
"jira",
{
description: "...",
inputSchema: toolSchema,
},
async (args) => {
switch (args.action) {
case "search":
return search(args);
case "get":
return get(args);
case "create":
return create(args);
case "update":
return update(args);
}
}
);
The code itself stays fairly straightforward. The real design work is in answering:
- Which operations logically cluster together?
- What naming convention should the actions follow?
- What belongs in the tool's description?
- Which parameters are mandatory per action?
- Which actions need extra permission checks?
- How should responses be shaped consistently?
- How should each action be logged and traced?
26. Track the Impact With Real Numbers
Consolidating tools should not be a leap of faith — you need to verify it with data.
Before the change, you might record something like:
Tools: 32
Tool schema tokens: X
Average tool-selection latency: Y
Tool-selection errors: Z
After introducing the multiplexed design, you capture the same metrics again:
Tools: 5
Tool schema tokens: X'
Average tool-selection latency: Y'
Tool-selection errors: Z'
Some of the most informative signals to track are:
- how many tools are exposed
- the total size of the schemas
- how many tokens the prompt or context consumes
- how accurately the agent picks the right tool
- how often it issues invalid tool calls
- how often argument validation fails
- response latency
- how often executions actually succeed
- whether the agent completes the overall task
The goal isn't simply to chase:
fewer tools
but rather to optimize for:
better agent performance per unit of context
27. A Broader Pattern for Agent-Facing APIs
This idea extends well past MCP itself.
You'll find the same principle relevant to:
- function calling
- OpenAI-style tool definitions
- SDKs
- custom agent runtimes
- workflow engines
- plugin architectures
- REST-to-agent adapters
- GraphQL-to-agent bridges
At its core, the pattern looks like this:
Traditional API
↓
Many low-level operations
↓
Agent-oriented facade
↓
Small semantic tool surface
↓
Action routing
↓
Strict execution layer
This isn't a database-specific trick.
It's a general pattern for designing interfaces that agents consume.
28. The Underlying Principle
The mental model to hold onto is simple:
┌──────────────────────┐
│ 30 APIs │
│ capabilities │
└──────────┬───────────┘
│
Consolidation
│
▼
┌──────────────────────┐
│ 3–5 tools │
│ semantic domains │
└──────────┬───────────┘
│
Action routing
│
▼
┌──────────────────────┐
│ 30 handlers │
│ original abilities │
└──────────────────────┘
The meaningful shift is not:
30 capabilities → 3 capabilities
Instead, it is:
30 exposed tools → 3 exposed interfaces
Nothing about what the system can do changes.
What changes is how much of that surface the model has to reason about directly.
Wrapping Up
As agentic systems get wired into more of your infrastructure, the number of tools available to them will only keep expanding.
Systems like Jira, GitLab, Confluence, Sentry, ELK, Jaeger, various databases, cloud providers, and internal services can each contribute dozens or hundreds of individual operations.
Handing every single one of those operations to the model as a separate tool is rarely the right architecture.
A more effective strategy is to build an agent-facing tool layer on top of them:
Many low-level operations
↓
Semantic domain grouping
↓
Discriminated action schemas
↓
Small MCP tool surface
↓
Strict runtime validation
↓
Original capabilities
The guiding rule is:
Cut down on how many tools the model needs to reason about — not on how many capabilities your system actually offers.
An integration with thirty underlying operations doesn't have to translate into thirty tools visible to the model.
It might only need three carefully designed interfaces, each with a well-scoped action space.
This becomes even more valuable once you stop treating existing MCP servers as fixed, untouchable contracts, and instead treat them as building blocks you can wrap, reshape, and tune to match what your agent actually needs.
Going forward, good MCP design may be less about surfacing every operation an API supports, and more about crafting the smallest, most usable interface an agent needs to get its job done.