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.
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:
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.
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.
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.
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.
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:
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.
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.
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:
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.
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.
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.
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.
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:
The runtime schema needs to be:
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.
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.
This approach becomes much more valuable when applied across an entire engineering environment.
Instead of:
searchIssues
getIssue
createIssue
updateIssue
addComment
transitionIssue
assignIssue
getTransitions
use:
jira_issues
with:
search
get
create
update
comment
transition
assign
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.
Instead of:
searchPages
getPage
createPage
updatePage
deletePage
getChildren
use:
wiki
with:
search
get
create
update
children
Potentially:
sentry
with:
searchIssues
getIssue
events
releases
projects
Potentially:
logs
with:
search
aggregate
fields
indices
Potentially:
tracing
with:
search
getTrace
services
operations
The important point is not the exact names.
It is the architectural pattern.
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.
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:
The MCP layer therefore becomes more than a transport.
It becomes an agent interface layer.
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.
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.
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.
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.
There are cases where separate tools are better.
database
payments
infrastructure
email
should not become:
enterprise({
action: ...
})
If a single tool has 50 actions, the schema itself may become expensive and confusing.
Operations such as:
deleteProductionData
rotateCredentials
deployProduction
may deserve separate permission boundaries even if they belong to the same domain.
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.
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
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.
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.
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
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:
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:
The objective is not merely:
fewer tools
but:
better agent performance per unit of context
This approach can be generalized beyond MCP.
The same principle applies to:
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.
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.
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.