From 30 Tools to 3: Designing a Token-Efficient MCP Tool Surface A developer describes a pattern for reducing large MCP tool surfaces into a few domain-oriented tools by using an 'action' discriminator, which preserves capabilities while shrinking the model's context and decision space. The approach consolidates tools like Jira, GitLab, and Confluence into 3-5 domain tools, improving token efficiency and agent performance. 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: js 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: js 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