Tenant-Scoped Tool Subsetting (Phase 1)
ADR-039 — Tenant-Scoped Tool Subsetting (Phase 1)
Status: Accepted
Date: 2026-05-06
Author: Claude Code / Mishaal Murawala
Invariant impact: Invariant 7 (34 MCP tools hard ceiling) — no change to ceiling; changes how tools are surfaced per tenant
Context
V5 currently registers all 34 MCP tools for every authenticated request, regardless of which providers the tenant has connected. A tenant that only uses Google Ads + HubSpot receives a tool list that includes Salesforce, LinkedIn Ads, DealCloud, AWS, etc. This creates three problems:
- Context pollution — AI clients (Claude, Cursor) see irrelevant tools, which increases the likelihood of hallucinated tool calls to unconnected providers.
- Token waste — MCP
tools/listresponses grow proportional to the number of registered tools. At 34 tools with richdescriptionfields, the payload is non-trivial. - Security surface — A misconfigured token in KV could theoretically be called for a provider the tenant never intended to connect.
tenant_config:{tenant} in KV already stores a connected_apis?: string[] field (typed in TenantConfig). The data exists. This ADR wires it to tool registration.
Decision
Phase 1 (this ADR): filter tool registration at McpServer init time.
At request entry in src/handlers/mcp.ts, tenantConfig is already fetched from KV (line 74) at zero additional cost. Phase 1 adds a TOOL_REGISTRY constant — an array of { register, provider } entries — and replaces the 34 individual registerXxx() calls with a single loop:
const connectedApis = tenantConfig.connected_apis; // string[] | undefinedfor (const { register, provider } of TOOL_REGISTRY) { if (provider === null || !connectedApis || connectedApis.includes(provider)) { register(server, getContext); }}Provider mapping rules:
provider: null→ platform / meta tools. Always registered. These have no external connection requirement (call_api,discover_apis,agent_state,batch,claude,llm_invoke,ai_invoke,search_knowledge,context_query,context_explain,web_fetch,submit_feedback).provider: 'google'→ covers Google Ads, GA4, GSC, Gmail, Google Calendar, and Gemini (all use the same OAuth connection).provider: 'microsoft'→ covers Microsoft Ads and Microsoft Calendar (same tenant OAuth).provider: 'aws'→ covers all four AWS tools (Bedrock, Bedrock Converse, Textract, SES, Nova Canvas — same IAM credentials).provider: 'salesforce'→ covers bothsalesforce_queryandsalesforce_crm.
Backward compatibility: If connected_apis is absent (tenants provisioned before this ADR, or admin-created tenants that haven’t been updated), the condition !connectedApis is true → all tools are registered. Identical to prior behaviour. Zero breaking changes.
Alternatives Considered
Phase 2: Runtime filtering at tools/list response level
Instead of skipping registerTool() calls, register all tools but filter the tools/list response. Rejected for Phase 1 because: (a) the SDK provides no hook for this without forking McpServer; (b) tools registered but not listed can still be called if a client sends tools/call directly — filtering at list-response level doesn’t prevent that.
Phase 3: OAuth-scope-driven filtering
Use the OAuth scopes from the bearer token to derive which providers are connected, rather than tenant_config.connected_apis. More elegant long-term (single source of truth = the token) but requires scope design work and is out of scope for Phase 1.
Consequences
Positive:
- Tenants with
connected_apisset see only relevant tools → cleaner AI context window. tools/listresponse size reduced proportionally (e.g. a Google-only tenant gets ~10 tools instead of 34).- No cold path changes, no KV read count increase, no latency impact.
Negative / risk:
connected_apismust be kept in sync when a tenant adds a new provider. If a tenant connects HubSpot butconnected_apisstill lists only['google'], HubSpot tools won’t appear. Mitigation: the Nango webhook handler (ADR-038) is the natural place to updateconnected_apison connection creation — Phase 2 work.- Phase 1 is “opt-in via config” rather than “automatic”. Until
connected_apisis populated for all tenants, the benefit is only realized for explicitly configured tenants.
Implementation
Single file change: src/handlers/mcp.ts
- Add
TOOL_REGISTRYconst (54 lines, including comments) abovemcpHandlerdeclaration. - Replace 34
registerXxx(server, getContext)calls with 4-line loop. - Net: ~-30 LOC.
No migration needed. No KV schema change. No D1 change.
Related
- ADR-038: Nango token management (the write path that populates
connected_apiswill eventually live here) - TOOLS.md: Provider-to-tool mapping table
- Invariant 7: tool ceiling (34 hard limit, ceiling 35) — this ADR does not change the ceiling