Modern agentic applications rarely suffer from a lack of tools.
They suffer from too many of them.
As an AI agent grows, it is common to connect it to Jira, GitLab, Confluence, Sentry, Elasticsearch, Jaeger, databases, monitoring systems, internal APIs, deployment platforms, and dozens of other services.
Each integration can expose many 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 is easy to end up with 30, 50, or even hundreds of tools.
At first, this looks like a capability problem.
It is actually a tool-surface problem.
The agent does not necessarily need fewer capabilities.
It needs fewer top-level tools.
This article describes a pattern I have been using to reduce large MCP/tool surfaces into a small number of domain-oriented tools while preserving the underlying capabilities.
The core idea is simple:
Consolidate the tool surface, not the capabilities.
Instead of exposing:
30 MCP tools
we can expose:
3–5 domain tools
and use an action discriminator to route requests internally.
For example:
jira_search
jira_get_issue
jira_create_issue
jira_update_issue
jira_add_comment
jira_transition_issue
...
can become:
jira({
action: "search",
...
})
jira({
action: "getIssue",
...
})
jira({
action: "createIssue",
...
})
The backend still has all the original capabilities.
The model simply sees a much smaller tool surface.
1. The Problem With Large Tool Surfaces
An MCP server is not only an execution interface.
It is also part of the model's context.
When an agent connects to an MCP server, the model generally needs to understand:
- tool names
- descriptions
- input schemas
- parameters
- enums
- constraints
- sometimes additional metadata
Imagine an agent connected to 40 tools.
Even if each tool has a relatively small schema, the aggregate context can become significant.
More importantly, the model now has a larger decision space:
User request
│
▼
Which tool?
│
┌───┼────┬────┬────┬────┐
▼ ▼ ▼ ▼ ▼ ▼
T1 T2 T3 T4 T5 ...
The model has to distinguish between many semantically related operations.
For example:
jira_search_issues
jira_search_projects
jira_get_issue
jira_get_issue_comments
jira_get_issue_transitions
jira_get_issue_worklogs
are all part of the same conceptual domain.
There is little value in forcing the model to treat every operation as a completely independent top-level capability.
2. The Core Idea: Tool Multiplexing
The pattern is to introduce an intermediate discriminator:
{
"action": "search",
"query": "authentication bug"
}
Instead of:
jira_search
we expose:
jira
The tool becomes a small router.
Conceptually:
jira
│
action field
│
┌──────────────┼──────────────┐
▼ ▼ ▼
search getIssue createIssue
│ │ │
▼ ▼ ▼
searchHandler issueHandler createHandler
The important point is that the tool is not the capability.
The tool is the external interface.
The action represents the capability.
This gives us:
30 capabilities
↓
3 domain-oriented tools
without throwing away functionality.
3. Use Domain Boundaries, Not Arbitrary Grouping
The easiest mistake is to think:
"I have 30 tools, so I will put 10 operations into each tool."
That is not the goal.
Grouping should follow semantic domains.
For example:
jira
├── search
├── issue
├── comment
├── transition
└── project
gitlab
├── project
├── issue
├── mergeRequest
└── pipeline
observability
├── search
├── trace
├── log
└── error
The exact grouping depends on the integration.
For a database:
db_tables
db_query
db_advanced
might make sense.
For Jira:
jira_issues
jira_projects
jira_search
may be better.
For GitLab:
gitlab_repository
gitlab_issues
gitlab_mergeRequests
may be more natural.
There is no universal number.
The goal is to find the smallest tool surface that still preserves clear semantic boundaries.
4. A Database Example
A database integration is a useful example because database APIs can easily expose a large number of operations.
Instead of:
listTables
getTableSchema
getSampleData
getTableSize
executeQuery
getDatabaseInfo
listRelationships
getIndexes
profileColumn
searchSchema
listProcedures
getTriggers
compareSchemas
we can expose:
db_tables
db_query
db_advanced
The first tool can use:
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,
}),
]);
The model sees one tool:
db_tables
with an explicit action space:
list
schema
sampleData
size
The runtime still has four separate 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 distinction is important:
Consolidation happens at the MCP interface, not inside the business logic.
The internal handlers remain independently testable and maintainable.
5. Zod Discriminated Unions Are a Natural Fit
For TypeScript applications, z.discriminatedUnion() provides a clean way to express 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"),
}),
]);
The type can then be inferred directly:
type QueryInput = z.infer<typeof querySchema>;
This gives us three useful properties:
- A single MCP tool.
- Explicit action routing.
- Strict runtime validation.
The resulting architecture becomes:
MCP Tool
│
▼
Discriminated Union
│
┌─────┴─────┐
│ action │
└─────┬─────┘
│
┌────────┼────────┐
▼ ▼ ▼
Handler A Handler B Handler C
│ │ │
└────────┼────────┘
▼
Backend
This is much more predictable than asking the model to navigate dozens of unrelated top-level tools.
6. Tool Count Is Not Capability Count
This is probably the most important conceptual distinction.
Suppose we have:
3 tools
13 actions
That does not mean we lost 10 capabilities.
We have:
3 external interfaces
13 internal capabilities
Therefore:
Tool count ≠ capability count
This distinction becomes increasingly important as agent systems grow.
A large organization may have:
Jira 20 operations
GitLab 25 operations
Confluence 15 operations
Sentry 8 operations
ELK 10 operations
Jaeger 6 operations
Database 20 operations
That can easily become 100+ operations.
Exposing all of them directly to the model creates an unnecessarily large tool surface.
Instead, we can build:
jira
gitlab
confluence
sentry
observability
database
and keep the underlying operation count unchanged.
7. Why This Can Reduce Token Usage
The primary optimization is reducing the amount of tool metadata the model needs to process.
Instead of presenting:
Tool 1
Tool 2
Tool 3
Tool 4
...
Tool 30
we present:
Tool A
Tool B
Tool C
This can reduce:
- tool descriptions
- repeated parameter metadata
- duplicated semantic information
- top-level tool-selection complexity
- context consumed by tool definitions
However, this should not be described as a guaranteed linear cost reduction.
If we transform:
30 tools → 3 tools
it does not necessarily mean:
90% lower token cost
because the consolidated schemas themselves can become larger.
The correct statement is:
Tool consolidation can significantly reduce the tool metadata exposed to the model, but the actual token and latency savings depend on schema size, descriptions, provider behavior, and how the agent framework handles tools.
This distinction matters.
8. Consolidation Is Not Always Better
There is an important trade-off.
Consider a tool with 40 actions:
enterprise({
action: ...
})
with actions such as:
createCustomer
deleteCustomer
searchInvoice
rotateCredentials
deployService
createRepository
getTrace
searchLogs
...
This is technically possible.
It is also terrible design.
The model now has one enormous schema.
The problem has simply moved from:
30 tools
to:
1 giant tool
The correct approach is domain-oriented consolidation.
For example:
customer
billing
repository
observability
deployment
The ideal number might be 5 rather than 1.
Therefore:
Minimize the tool surface, but do not minimize it blindly.
9. The Schema Is Part of the Agent Interface
Once multiple capabilities share one tool, the action field becomes extremely important.
Bad:
action: z.string()
Better:
action: z.enum([
"search",
"get",
"create",
"update"
])
Best, when actions have different parameters:
z.discriminatedUnion("action", [
searchSchema,
getSchema,
createSchema,
updateSchema,
])
Now the action and its parameters form a strongly typed relationship.
For example:
action = "search"
→ query required
action = "get"
→ issueId required
action = "create"
→ title + description required
This is much more expressive than one generic schema with dozens of optional fields.
10. The MCP JSON Schema Problem
In practice, there is another layer of complexity.
MCP tool schemas ultimately need to be represented as JSON Schema.
A Zod discriminated union can produce a schema based on:
{
"anyOf": [...]
}
or:
{
"oneOf": [...]
}
depending on the converter.
Some MCP schema handling paths expect an object at the root.
That creates a compatibility problem.
In one implementation, the MCP SDK's Zod compatibility layer expected an object shape and did not naturally handle the discriminated union in the same way.
A compatibility layer can therefore normalize the generated schema.
Conceptually:
Zod discriminated union
│
▼
JSON Schema
│
▼
MCP normalization
│
▼
MCP-compatible object schema
The important architectural principle is:
Adapt the schema exposed to the model without weakening the runtime validator.
The original Zod schema should remain the source of truth for validation.
11. LLM-Facing Schema vs Runtime Schema
This leads to an important distinction.
There are effectively two concerns:
LLM-facing representation
│
▼
Tool selection and argument generation
Runtime representation
│
▼
Validation and execution
The LLM-facing schema needs to be:
- understandable
- compact
- explicit
- easy to select from
The runtime schema needs to be:
- strict
- safe
- deterministic
- authoritative
These do not necessarily have to be identical.
For example, a flattened object representation may make action choices easier for a model to see, while the original discriminated union remains responsible for strict validation.
This is a useful general principle for agent infrastructure:
Optimize schemas for model consumption, but never use model-facing schemas as the only security boundary.
12. Descriptions Become More Important
When there are 30 tools, each tool can have a very specific description.
When there are 3 tools, the description needs to explain the action space.
For example:
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 is not documentation only.
It is part of the model's routing interface.
A useful mental model is:
Tool name
+
Description
+
Action enum
+
Parameter schema
↓
Agent routing signal
Therefore, when consolidating tools, descriptions should become more intentional, not less.
13. The Pattern Generalizes Beyond Databases
This approach becomes much more valuable when applied across an entire engineering environment.
Jira
Instead of:
searchIssues
getIssue
createIssue
updateIssue
addComment
transitionIssue
assignIssue
getTransitions
use:
jira_issues
with:
search
get
create
update
comment
transition
assign
GitLab
Instead of exposing every repository, issue, merge request and pipeline operation:
gitlab_repository
gitlab_issues
gitlab_mergeRequests
gitlab_pipelines
Each can expose a focused action set.
Confluence / Wiki
Instead of:
searchPages
getPage
createPage
updatePage
deletePage
getChildren
use:
wiki
with:
search
get
create
update
children
Sentry
Potentially:
sentry
with:
searchIssues
getIssue
events
releases
projects
ELK
Potentially:
logs
with:
search
aggregate
fields
indices
Jaeger
Potentially:
tracing
with:
search
getTrace
services
operations
The important point is not the exact names.
It is the architectural pattern.
14. Existing MCP Servers Can Be Wrapped
One of the most practical aspects of this approach is that we do not necessarily need to rewrite every integration.
There are already MCP implementations for many popular systems.
The problem is that their exposed tool surface may not be optimal for a specific agent.
Instead of:
Agent
│
├── 20 Jira tools
├── 20 GitLab tools
└── 15 Confluence tools
we can introduce a customized layer:
Agent
│
Custom MCP Layer
│
┌──────────┼──────────┐
▼ ▼ ▼
Jira GitLab Confluence
│ │ │
existing existing existing
APIs MCP MCP
The custom layer becomes an agent-oriented facade.
This is particularly useful when the existing integration is technically correct but exposes too many low-level operations.
15. MCP as an Adapter Layer
This suggests a broader architectural pattern.
Traditional integration:
Agent → MCP → Service
Customized integration:
Agent
↓
Agent-oriented MCP facade
↓
Service-specific MCP/API
↓
Service
The facade can provide:
- tool consolidation
- permission checks
- input normalization
- output normalization
- logging
- tracing
- caching
- rate limiting
- organization-specific policies
The MCP layer therefore becomes more than a transport.
It becomes an agent interface layer.
16. Permissions Should Follow Actions
There is an important security implication.
If we consolidate:
jira_create
jira_update
jira_delete
into:
jira({
action: ...
})
we should not simply grant:
permission: jira
and assume all operations are equivalent.
Permissions should still be evaluated at the action level.
For example:
jira.search → allowed
jira.get → allowed
jira.create → allowed
jira.update → approval required
jira.delete → denied
The unified tool is only an interface optimization.
It must not become a security boundary that accidentally grants excessive privileges.
17. Observability Should Include the Action
With consolidated tools, logging only the tool name is no longer sufficient.
Bad:
tool = jira
Better:
{
"tool": "jira",
"action": "search",
"duration_ms": 142
}
Even better:
{
"tool": "jira",
"action": "search",
"status": "success",
"duration_ms": 142,
"request_id": "...",
"agent_id": "...",
"user_id": "..."
}
The fundamental execution unit becomes:
tool + action
rather than just:
tool
This is particularly important for debugging agent behavior.
18. Keep Internal Handlers Independent
Tool consolidation should happen at the boundary.
Do not create a 2,000-line function like:
async function jira(args) {
// everything
}
Instead:
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 remains independently testable:
jira()
│
├── searchIssues()
├── getIssue()
├── createIssue()
└── updateIssue()
This preserves maintainability while reducing the external tool surface.
19. A Useful Design Rule
A practical rule for deciding whether two operations should be consolidated is:
Would a human developer naturally describe these operations as belonging to the same capability?
For example:
Jira search
Jira get issue
Jira update issue
Yes.
But:
Jira update issue
Elasticsearch search logs
Create AWS infrastructure
No.
Another useful test:
Would the same context usually be required to decide between these operations?
If yes, consolidation is often beneficial.
If no, keep them separate.
20. What Should Not Be Consolidated?
There are cases where separate tools are better.
Completely different domains
database
payments
infrastructure
email
should not become:
enterprise({
action: ...
})
Extremely large action sets
If a single tool has 50 actions, the schema itself may become expensive and confusing.
Highly privileged operations
Operations such as:
deleteProductionData
rotateCredentials
deployProduction
may deserve separate permission boundaries even if they belong to the same domain.
Different lifecycle semantics
Read-only operations and destructive operations can sometimes benefit from separate interfaces:
jira_read
jira_write
instead of one enormous tool.
Again, there is no universal rule.
21. The Sweet Spot Is Not "As Few Tools As Possible"
The goal is not:
30 → 1
The goal is:
30 → smallest useful semantic surface
For one system that might be:
30 → 3
For another:
30 → 7
For another:
30 → 12
The optimization target is:
minimize unnecessary tool metadata
while preserving:
semantic clarity
validation
security
maintainability
22. A General Architecture
A scalable implementation can look like this:
Agent
│
▼
┌──────────────────┐
│ Agent MCP Layer │
└────────┬─────────┘
│
┌──────────────┼──────────────┐
▼ ▼ ▼
jira gitlab wiki
│ │ │
┌────┼────┐ ┌────┼────┐ ┌────┼────┐
▼ ▼ ▼ ▼ ▼ ▼ ▼ ▼ ▼
search get create issues MR pipeline search get update
│ │ │
└──────────────┼──────────────┘
▼
Existing APIs/MCPs
The agent sees a compact interface.
The integration layer retains the full capability set.
23. This Also Changes How We Think About MCP
MCP is often treated as:
"Expose every API operation as a tool."
That is a reasonable starting point.
For production agent systems, however, a better question is:
What interface should the model see?
Those are not necessarily the same thing.
A REST API might expose:
GET /issues
GET /issues/:id
POST /issues
PATCH /issues/:id
POST /issues/:id/comments
POST /issues/:id/transition
We do not have to expose every HTTP endpoint as a separate model-facing tool.
The MCP layer can become a semantic abstraction over those endpoints.
This is similar to designing an API specifically for a consumer rather than simply mirroring an underlying database or service API.
24. Tool Design Becomes an Agent UX Problem
Traditional API design asks:
What endpoints does the system provide?
Agent interface design asks:
What decisions does the model need to make?
These are different questions.
A human developer may prefer:
getIssue
getIssueComments
getIssueTransitions
getIssueWorklogs
because each API operation is explicit.
An agent may perform better with:
jira_issue({
action: "get",
...
})
and:
jira_issue({
action: "comments",
...
})
because the model first identifies the domain and then chooses the operation.
This creates a two-level routing model:
Domain
↓
Action
↓
Arguments
rather than:
One large global tool-selection problem
25. A Simple Implementation Pattern
The pattern can be generalized with 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 implementation remains simple.
The important engineering work is deciding:
- Which operations belong together?
- What should the action names be?
- What should the tool description contain?
- Which fields are required for each action?
- Which actions require special permissions?
- How should the output be normalized?
- How should actions be logged and traced?
26. Measure Before and After
Tool consolidation should be measurable.
Before:
Tools: 32
Tool schema tokens: X
Average tool-selection latency: Y
Tool-selection errors: Z
After:
Tools: 5
Tool schema tokens: X'
Average tool-selection latency: Y'
Tool-selection errors: Z'
The most useful metrics include:
- number of exposed tools
- total schema size
- prompt/context token usage
- tool-selection accuracy
- invalid tool calls
- argument validation failures
- latency
- execution success rate
- agent task completion rate
The objective is not merely:
fewer tools
but:
better agent performance per unit of context
27. The Bigger Pattern: Agent-Oriented API Design
This approach can be generalized beyond MCP.
The same principle applies to:
- function calling
- OpenAI-style tools
- SDKs
- internal agent runtimes
- workflow engines
- plugin systems
- REST-to-agent adapters
- GraphQL-to-agent interfaces
The pattern is essentially:
Traditional API
↓
Many low-level operations
↓
Agent-oriented facade
↓
Small semantic tool surface
↓
Action routing
↓
Strict execution layer
This is not really a database trick.
It is an agent interface design pattern.
28. The Principle
The final mental model is simple:
┌──────────────────────┐
│ 30 APIs │
│ capabilities │
└──────────┬───────────┘
│
Consolidation
│
▼
┌──────────────────────┐
│ 3–5 tools │
│ semantic domains │
└──────────┬───────────┘
│
Action routing
│
▼
┌──────────────────────┐
│ 30 handlers │
│ original abilities │
└──────────────────────┘
The important transformation is therefore not:
30 capabilities → 3 capabilities
It is:
30 exposed tools → 3 exposed interfaces
The capabilities remain.
The model-facing surface becomes smaller.
Conclusion
As agent systems become more integrated, the number of available tools will continue to grow.
Jira, GitLab, Confluence, Sentry, ELK, Jaeger, databases, cloud platforms and internal systems can easily produce dozens or hundreds of operations.
Exposing every operation directly to the model is not always the best architecture.
A better approach is to introduce an agent-oriented tool layer:
Many low-level operations
↓
Semantic domain grouping
↓
Discriminated action schemas
↓
Small MCP tool surface
↓
Strict runtime validation
↓
Original capabilities
The central principle is:
Reduce the number of tools the model has to understand, not the number of capabilities your system provides.
A 30-tool integration does not necessarily need 30 model-facing tools.
It may need three well-designed interfaces with clear action spaces.
And this pattern becomes especially powerful when existing MCP servers are treated not as untouchable interfaces, but as underlying integrations that can be wrapped, adapted and optimized for the needs of the agent.
The future of MCP design may therefore be less about exposing everything an API can do, and more about designing the smallest useful interface through which an agent can do what it needs to do.
Top comments (0)