ADR 0015 — One MCP tool per resource noun, action-dispatched
- Status: Accepted
- Date: 2026-07-01
- Tracking: legacy tracker 1693 (OperationSpec registry), legacy tracker 1695 (action enum), legacy tracker 1646 (call-time git-repo check)
Context
madtea’s MCP server must expose the full Forgejo/Gitea API surface to an agent. Naively mapped, this is upwards of 200 discrete operations across a dozen resource domains. Two competing pressures shape the design:
- An agent’s tool-picker is a search space. Every additional tool the model must discriminate among adds latency and increases mis-selection probability. A 200-tool surface is impractical.
- A single opaque “call anything” tool loses discoverability. Without structure, an agent cannot learn what operations are available without reading extensive prose; it cannot self-correct from a bad choice; and the schema cannot carry per-operation constraints.
The existing madt_api_call passthrough tool fills the second role when
needed, but it carries no first-class operation semantics — it is the escape
hatch, not the primary surface.
Additionally, some API domains (admin, org management, package registry, hooks, ActivityPub) are low-traffic and should not occupy the default tool list, inflating it for the vast majority of sessions that will never use them.
Decision
The MCP surface is approximately 44 consolidated tools — one per resource
noun — each action-dispatched via an action= parameter. Low-traffic and
administrative domains load on demand via madt_enable.
One tool per resource noun
Each madt_<noun> tool covers all CRUD and workflow operations for one
resource: madt_issues, madt_prs, madt_repos, madt_commits,
madt_branches_remote, madt_releases, and so on. The tool’s JSON Schema
description is built automatically from the registered ActionSpec manifest
(action_spec.go, backed by the surface-neutral internal/opspec package),
so a newly added action appears in the schema without any manual description
update.
Action dispatch
Each tool exposes an action parameter whose value selects the operation. The
valid values are exposed as bare enum values in the tool’s JSON Schema
(action_enum.go, legacy tracker 1695) derived from the live ActionSpec manifest; each
action’s summary lives in the tool Description, not the enum (see the legacy tracker 2057
amendment below). The enum is registry-derived — there is no hand-maintained
list — and TestActionParamSchemaEnumMatchesRegistry ensures the enum can
never drift from what dispatch actually routes.
For tools that model a resource with a small set of sub-operations (such as
dependency management), an op= sub-dispatch level is also available; op
values are similarly enumerated from the manifest.
Registration tiers
Tools register in three tiers (described fully in docs/architecture/mcp-registration.md):
| Tier | Gate | Approximate count |
|---|---|---|
| Always | none | 4 |
| With Gitea config | ctx.HasGiteaConfig | ~30 |
| On demand | madt_enable(domains=[…]) | 11 domains |
The on-demand domains — activitypub, admin, git-objects, hooks,
notifications, orgs, packages, project, tags, users, wiki — stay
gated so the default tool list remains compact. An agent loads a domain with
madt_enable(domains=["admin","users"]).
Two guards enforce domain integrity:
TestDomainCLIPrefixesUniqueandTestDomainMCPPrefixesNoCollision(domain_collision_test.go) — prevent two domains claiming the same CLI or MCP prefix, which would silently drop commands from generated reference docs.TestGatedDomainsDeclarationCurrent(exposure_test.go) — compares the declaredIntentionalGatedDomainsset against the live domains registered bymcp.NewServerWithRegistry, so the declaration stays honest.
Batch by identifier; never loop
Tool parameters use collective forms: numbers, ids, shas. An agent
sending a single call with multiple IDs is faster and avoids rate-limiting;
looping single-ID calls is an antipattern the server instructions and ADR 0001
(§server instructions) explicitly steer against.
Middleware stack
Three CI-tested middlewares run between the MCP client and each handler
(documented in docs/architecture/mcp-registration.md): scalar coercion
(stringified integers and booleans), validation enrichment (unknown-field hint
on schema rejection), and terminal-control-character sanitization over all
returned text. These apply uniformly at the registration layer, not per handler.
Consequences
- Small, navigable default surface. A session opening with Gitea config gets approximately 34 tools. An agent can enumerate and reason about these without context-budget pressure.
- Self-describing schema is the Tier-1 reference. Per ADR 0001, the tool
schema must be self-sufficient; the tool Description carries every action’s
summary — with the enum supplying the machine-validated set of valid
action=values (see the legacy tracker 2057 amendment below) — so the schema is self-sufficient without requiringmadt_helpon every call. - Domain gating keeps rare tools out of the picker. An agent that has never
called
madt_enablesees no admin or ActivityPub surface; those tools cannot be mis-selected for a routine workflow. - Relates to ADR 0008. The
ActionSpecmanifest (now ininternal/opspec) is the OperationSpec registry that ADR 0008 establishes as the single source of record. The consolidated tool shape is the MCP projection of that registry. - Relates to ADR 0014. The thin-surface rule means
madt_issues’s handler does no forge logic — it delegates immediately toservice.Issues— and the action dispatch is a routing layer, not a business-logic layer.
Alternatives considered
- One tool per operation (~200 tools). Rejected: the tool-picker search space becomes impractical at this scale, and the schema surface inflates the agent’s context on every session start. Tool-per-noun with action dispatch is the documented MCP best practice for consolidated surfaces.
- A single free-form passthrough tool. Rejected: loses per-operation schema,
required-field validation, and action-level descriptions.
madt_api_callexists as this escape hatch but is not the primary surface. - Group by domain in the tool name (e.g.
madt_issue_create,madt_issue_list). Rejected: produces a tool per operation under a shallow namespace — effectively the first option with different naming. The noun-consolidated shape is more compact and mirrors the CLI grouping that users already know.
ADR 0015 update (legacy tracker 2057, 2026-07-08)
Every consolidated tool serialized each action (and op) summary twice: once
in the tool Description built by buildToolDescription, and again as that
value’s oneOf branch description in the action/op enum schema emitted by
applyActionEnumSchema. In madt_issues alone this double-paid ~5.7 KB of the
serialized payload. legacy tracker 2057 removes the duplication.
What changed:
- The
action/openum properties now carry bare values only. The registry-derivedenumset is unchanged (still the sorted action names, and the sorted union of op names), butapplyActionEnumSchemano longer emits a per-valueoneOfbranch. AoneOfof bare consts would duplicateenumwith zero added information, so theoneOfis dropped entirely — not merely its descriptions. - The tool Description is the sole carrier of action and op summaries. Each
summary is now serialized exactly once, in the Description (
name — summaryfor actions,op=name — summaryfor ops). A registry-derived guard,TestActionSummaryAppearsExactlyOnce, asserts everyActionSpec.Summaryand every op summary appears exactly once in the full serialized payload (Description + marshaled inputSchema) of each consolidated tool, so the duplication cannot creep back.
What did NOT change:
- The enum stays registry-derived (this ADR’s core rule). There is no
hand-maintained list; the values still come straight from the
ActionSpecmanifest, andTestActionParamSchemaEnumMatchesRegistryremains authoritative thatenum == registry— it now also asserts the absence ofoneOfon both theactionandopproperties. - Machine validation and discoverability are preserved (ADR 0015). The enum
still constrains
action=/op=to the valid set an MCP client can validate against and enumerate. - Schema self-sufficiency is preserved (ADR 0001). The Description already
carried every summary plus the Required / One-of / op clauses the enum
branches never held, so nothing agent-critical lived only in the removed
oneOf. The tool schema remains the self-sufficient Tier-1 reference.
This lands before legacy tracker 2000, which pins the serialized-size budgets against the reduced payload.
Canonical source: docs/adr/0015-consolidated-action-dispatched-tools.md in the madtea repo.