From 30 Tools to 3: Designing a Token-Efficient MCP Tool Surface
DEV Community

From 30 Tools to 3: Designing a Token-Efficient MCP Tool Surface

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 ; 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. C

Read on DEV Community ↗ ← Back to News

Comments

No comments yet. Start the discussion.