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:

  1. 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.
  2. 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):

TierGateApproximate count
Alwaysnone4
With Gitea configctx.HasGiteaConfig~30
On demandmadt_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:

  • TestDomainCLIPrefixesUnique and TestDomainMCPPrefixesNoCollision (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 declared IntentionalGatedDomains set against the live domains registered by mcp.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 requiring madt_help on every call.
  • Domain gating keeps rare tools out of the picker. An agent that has never called madt_enable sees no admin or ActivityPub surface; those tools cannot be mis-selected for a routine workflow.
  • Relates to ADR 0008. The ActionSpec manifest (now in internal/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 to service.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_call exists 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/op enum properties now carry bare values only. The registry-derived enum set is unchanged (still the sorted action names, and the sorted union of op names), but applyActionEnumSchema no longer emits a per-value oneOf branch. A oneOf of bare consts would duplicate enum with zero added information, so the oneOf is 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 — summary for actions, op=name — summary for ops). A registry-derived guard, TestActionSummaryAppearsExactlyOnce, asserts every ActionSpec.Summary and 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 ActionSpec manifest, and TestActionParamSchemaEnumMatchesRegistry remains authoritative that enum == registry — it now also asserts the absence of oneOf on both the action and op properties.
  • 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.