ADR-0039 — Extension-seam taxonomy and dispatch interception¶
- Status: Accepted
- Date: 2026-08-02
Context¶
By 1.1.0 mocapi had accumulated seven-plus extension points across four
ADRs (0011, 0031, 0034, 0038), each named at the moment its forcing case
appeared: *Customizer, *Contributor, *Strategy, a dispatch "hook"
suffixed Customizer even though it ran per-request, and Mcp-prefixed
names inconsistent with the rest of mocapi-server's internal package.
Two problems fell out of that growth:
- No shared vocabulary. A reader meeting
ToolCallDispatchCustomizerfor the first time had no way to tell, from the name alone, that it ran on every request (not once at startup like every other*Customizer), that it could veto or replace the response, or that ordering mattered.McpRoutedParamContributorcarried anMcpprefix inconsistent with its siblingResourceContributor, which has none. - Duplicated invocation cores.
McpToolsService.callTool(wire path) and its detachedToolCallReplayInvoker.invokeimplementation shared handler lookup,DefaultMcpToolContextconstruction, and the exception-to-CallToolResultcascade only by convention — both lived in the same class, but nothing enforced that they couldn't drift.MocapiTasksAutoConfigurationneeded anObjectProvider<McpToolsService>workaround to break a bean-graph cycle:McpToolsServiceneeded the task dispatcher, which neededTaskExecutionEngine, which neededMcpToolsServiceback as aToolCallReplayInvoker.
Two smaller correctness gaps rode along: TaskToolCallDispatcher created
task records before any guard ran, so a denied capable client got a
taskId whose record later failed instead of the synchronous -32010
the sync path gives; and nothing enforced collision-fails-boot uniformly
across the contributor-shaped seams — RoutedParamContributor silently
let two contributors (or a contribution and a built-in) claim the same
method, unlike ResourceContributor's existing duplicate-URI failure.
Decision¶
1. A five-word taxonomy, enforced by naming, not just documented. Every mocapi extension point is now named for one of five contracts — readers unfamiliar with a specific interface can infer its lifecycle and merge rule from the suffix alone:
| Word | Contract | Members after this refactor |
|---|---|---|
*Contributor |
Startup; returns values; framework unions them; collisions fail the boot | ResourceContributor, RoutedParamContributor |
*Customizer |
Startup; mutates/folds a framework-owned object; never per-request | CallToolHandlerCustomizer + 3 siblings (now descriptor-capable), ServerCapabilitiesCustomizer, oauth2 customizers |
*Interceptor |
Per-request; ordered (@Order); receives proceed; may short-circuit |
McpDispatchInterceptor, the six-strata MethodInterceptors |
*Store / *Source / *Strategy |
Deployment-supplied strategy bean, @ConditionalOnMissingBean default |
TaskStore, McpPrincipalSource, McpTokenStrategy |
*Sink |
Runtime one-way delivery callback supplied at construction | ProgressSink |
| (no seam suffix) | API you call (documented as such, excluded from the SPI table) | ToolCallReplayInvoker, McpProgressSource, McpElicitor |
Prefix rule: Mcp is reserved for mocapi-api (author-facing) types and
cross-cutting server concepts; McpPrincipalSource keeps its prefix
(grandfathered — pre-dates this taxonomy and isn't worth the churn);
McpRoutedParamContributor is renamed RoutedParamContributor
(package unchanged: com.callibrity.mocapi.server.routing) since it's an
ordinary mocapi-server-internal contributor, not a cross-cutting Mcp
concept.
2. McpDispatchInterceptor<H, P> replaces ToolCallDispatchCustomizer
as the per-request dispatch seam, generalized to all three MRTR-capable
methods:
@FunctionalInterface
public interface McpDispatchInterceptor<H, P> {
Object intercept(H handler, P params, Supplier<Object> proceed);
}
DispatchChains sorts registered interceptors once per service at
construction time (@Order/Ordered, ascending — lower values run
outermost) and folds them per-dispatch around a terminal supplier — the
existing default MRTR path. An empty interceptor list degenerates to
calling terminal directly, so the zero-interceptor path is byte-for-byte
identical to dispatch before this seam existed. McpToolsService,
McpPromptsService, and McpResourcesService each collect their own
generically-typed interceptor list via Spring's generics-aware injection.
An interceptor runs before the handler chain, and therefore before
guards and schema validation. Returning proceed.get() continues into
that chain unchanged; returning a different value owns the call and
is responsible for everything the chain would otherwise have done
(including authorization — see the task-mode guard-parity decision
below); throwing aborts with a JSON-RPC error. TaskToolCallDispatcher
becomes McpDispatchInterceptor<CallToolHandler, CallToolRequestParams>:
non-@McpTask or non-capable-and-not-required → proceed.get();
required and non-capable → throw; capable + @McpTask → own the call,
returning a CreateTaskResult.
Template-matched resource reads bypass the interceptor chain
(deliberate). McpResourcesService's template-match path (a
resources/read URI that matches a ReadResourceTemplateHandler rather
than a fixed ReadResourceHandler) resolves and invokes the template
handler directly; it does not currently route through the
McpDispatchInterceptor<ReadResourceHandler, ResourceRequestParams>
chain, because that chain's generic parameters are typed to the
fixed-resource handler kind and template handlers are a distinct type.
No extension currently needs to intercept template reads, and adding a
second, template-typed interceptor list purely speculatively would
double the resource-service seam surface for zero forcing case. This is
recorded here, rather than silently left as a gap, so a future extension
author who does need template-read interception knows to open a new
ADR instead of assuming the existing chain already covers it.
3. ToolInvocationCore unifies the two tools/call execution paths.
It owns handler lookup (via the shared CallToolHandlerRegistry),
DefaultMcpToolContext construction, the exception-to-CallToolResult
cascade, and ScopedValue binding for both the on-dispatch-thread wire
path and the detached replay path, and implements
ToolCallReplayInvoker directly. McpToolsService delegates its
synchronous path to the same core instead of duplicating the mechanics.
The bean graph is now acyclic — ToolInvocationCore sits below both
McpToolsService and TaskExecutionEngine — so
MocapiTasksAutoConfiguration's ObjectProvider<McpToolsService>
workaround is deleted.
ReplayOutcome becomes generic — ReplayOutcome<R, Q> sealed with
Completed<R, Q>(R result) and InputRequired<R, Q>(String key, Q
request, List<ResponseLedgerEntry> ledger) — so ReplayExecutor.execute
(ReplayOutcome<Object, ElicitRequestFormParams>) and
ToolCallReplayInvoker.invoke (ReplayOutcome<CallToolResult,
ElicitRequest>) share one hierarchy instead of two. The former nested
ToolCallReplayInvoker.Outcome sealed type is deleted; its
progressOverride parameter is renamed progress.
ToolCallReplayInvoker keeps its name but is reclassified in the
taxonomy as an API you call, not an SPI you implement — the
production implementation (ToolInvocationCore) is not meant to be
swapped out by extension authors the way a *Customizer or *Contributor
is.
4. The descriptor-customizer fold amends ADR-0034. ToolDescriptorCustomizer
and ResourceDescriptorCustomizer — 1.1.0 types — are deleted. All
four *HandlerConfigs (CallToolHandlerConfig, GetPromptHandlerConfig,
ReadResourceHandlerConfig, ReadResourceTemplateHandlerConfig) gain a
void descriptor(T) mutator alongside the existing T descriptor()
accessor; the four existing *HandlerCustomizer SPIs (ADR-0011) run at
the same point in the build pipeline descriptor customizers used to
(post-generation, pre-chain-assembly) and are now how descriptor
metadata is mutated — one customizer pass per handler, no second SPI.
mocapi-apps's AppsToolDescriptorCustomizer / AppsResourceDescriptorCustomizer
become AppsToolUiMetaCustomizer (CallToolHandlerCustomizer) /
AppsResourceUiMetaCustomizer (ReadResourceHandlerCustomizer) with
identical behavior. Prompts and resource templates gain descriptor
mutators for parity even though no customizer uses them yet.
Identity contract on descriptor(T). A customizer that calls
descriptor(replacement) must preserve the original's identity field
(Tool.name(), Resource.uri(), Prompt.name(),
ResourceTemplate.uriTemplate()) and, for tools, both compiled schemas.
Other customizers in the same chain (guards, audit, observability,
schema validation) commonly close over the descriptor snapshotted at
build time; replacing identity or schemas desynchronizes those closures
from what's actually enforced/registered. The mutator exists to replace
metadata (title, description, _meta) — replacing identity or
schemas is unsupported and done at the customizer's own risk. This
contract is documented on each *HandlerConfig.descriptor(T) javadoc,
not enforced at runtime (a runtime check would need to diff two
arbitrary records generically, at startup-only cost for a
should-never-happen author error).
Tool.withMeta / Resource.withMeta / Prompt.withMeta /
ResourceTemplate.withMeta deepCopy() their ObjectNode argument so a
published descriptor's _meta can't be mutated out from under it by a
caller retaining a reference to the node they passed in.
4a. _meta extended to Prompt and ResourceTemplate, reversing
ADR-0034's non-goal. ADR-0034 declined _meta on prompts and resource
templates because no use case needed it yet. That reasoning no longer
holds against this task's I7 (1:1 schema fidelity): docs/plans/2026-07-28-schema.ts
declares _meta?: MetaObject on both wire interfaces (ResourceTemplate
at ~lines 1481/1506, Prompt at ~lines 1659/1670), so omitting it from
the mocapi records was a translation gap, not a deliberate scope
decision — the same gap ADR-0034 identified and closed for Tool/
Resource. This ADR owns closing it for the remaining two: Prompt and
ResourceTemplate each gain an optional _meta (ObjectNode,
NON_NULL) field and a withMeta method with the same deep-copy
protection as Tool/Resource. The change is purely additive — the
5/6-arg canonical constructors are new, the pre-existing 4/5-arg
constructors are preserved as back-compat overloads, and a descriptor
built with no customizer touching _meta serializes identically to
before. Code anchors: mocapi-model/src/main/java/com/callibrity/mocapi/model/Prompt.java
and mocapi-model/src/main/java/com/callibrity/mocapi/model/ResourceTemplate.java
(both _meta, withMeta); also listed in the ADR-wide anchors below.
5. Semver-scope policy. mocapi is pre-1.0-equivalent for its
extension SPI surface specifically: types introduced in 1.1.0
(ToolDescriptorCustomizer, ResourceDescriptorCustomizer,
ToolCallDispatchCustomizer, McpRoutedParamContributor) may be renamed
or deleted in the very next minor release without a major bump, provided
(a) a CHANGELOG migration table names the old → new mapping for every
change, and (b) the type had shipped for at most one prior minor release.
A type that has shipped for two or more minor releases is subject to
mocapi's normal 1.x compatibility discipline (additive-only,
deprecate-then-remove). This is a narrow, time-boxed exception — it does
not retroactively apply to 1.0.0 API surface, and it closes automatically
once 1.1.0's types have had one release to prove their names.
6. Task-mode guard parity. TaskToolCallDispatcher.intercept
evaluates Guards.evaluate(handler.guards()) before minting a
TaskRecord, throwing the same -32010 Forbidden JsonRpcException the
synchronous path's GuardEvaluationInterceptor produces on a Deny. An
owning interceptor bypasses the handler chain entirely — including its
AUTHORIZATION stratum — so an interceptor that claims a call and skips
proceed() must re-run whatever pre-chain protections it's replacing.
Input-schema validation is deliberately not duplicated here: it
still runs once per execution inside the handler chain (both at task
creation and at every tasks/update resume), which is correct because
arguments don't change between executions and a schema failure inside an
execution fails that execution the same way a schema failure fails a
synchronous call.
7. Collision semantics, made uniform. RoutedParamContributor now
fails the boot on a duplicate method key across contributors, or a
contribution colliding with a transport built-in, naming both parties in
the failure message — mirroring ResourceContributor's existing
duplicate-URI treatment. Every *Contributor in the taxonomy now shares
one merge rule: union at startup, name-both-parties boot failure on
collision.
ServerCapabilitiesOverrideAuditor (SmartInitializingSingleton,
registered unconditionally by MocapiServerAutoConfiguration) warns
once, in afterSingletonsInstantiated(), when ServerCapabilitiesCustomizer
beans exist but the default ServerCapabilities bean was backed off by a
user-supplied one (so the customizers were silently discarded).
Unconditional registration — rather than gating the auditor bean itself
on ServerCapabilitiesCustomizer beans existing at auto-configuration
processing time — makes detection immune to which auto-configuration
happens to run first; every singleton from every module is guaranteed to
exist by afterSingletonsInstantiated().
ClientCapabilities.hasExtension(String id) is added to mocapi-model
(null-safe) as the one place extension-capability detection lives;
TaskToolCallDispatcher.isTaskCapable and McpTasksService's gating
delegate to it instead of re-implementing the null/containsKey check.
McpResourcesService's constructor overloads are consolidated to two
public constructors (the contributor-based primary, and a
convenience no-interceptor/default-settings overload) — down from
whatever ad hoc set of overloads had accumulated across ADR-0035 and
ADR-0038's interceptor addition.
Deferred (spec §9 — named, not silently dropped)¶
- Guard/audit/strata participation for extension-owned JSON-RPC
methods (
tasks/get/tasks/update/tasks/cancel, and any future extension's own methods). These currently bypass the six-stratum chain entirely;McpTasksServicekeeps its own hand-rolled principal checks. Next design cycle's forcing case: a second extension that also wants guards on its own methods, which would justify generalizing the strata to method-level rather than handler-level attachment. mocapi-o11yopenness to extension methods/attributes. The semconv observation attached viaJsonRpcMethodHandlerCustomizeruses a hardcoded method-name switch; extension methods (tasks/*) get nomcp.method.name-specific enrichment. Deferred until a second extension's methods make the hardcoded switch visibly unscalable.- An
McpExtensionaggregate façade bundling an extension's capability customizer, dispatch interceptor, routed-param contributor, and autoconfiguration into one registration unit. Two extensions (mocapi-apps,mocapi-tasks) isn't enough evidence to generalize the shape correctly; revisit at the third extension. - Renaming already-well-named 1.1.0 seams.
ResourceContributor,ServerCapabilitiesCustomizer, and the four*HandlerCustomizers already match this taxonomy and are not touched.
Consequences¶
What this buys us. One vocabulary for every extension point —
lifecycle and merge rule are readable off the type name, which is the
docs/guides/extending-mocapi.md guide's organizing structure. One
tool-invocation core instead of two mechanically-synchronized copies,
closing a bean-graph cycle as a side effect. Task-mode calls now get the
same synchronous authorization outcome as sync-mode calls. Collision
handling is one rule instead of an ad hoc per-seam decision.
Costs. ToolCallDispatchCustomizer, ToolDescriptorCustomizer,
ResourceDescriptorCustomizer, and McpRoutedParamContributor — all
1.1.0-vintage types — are deleted or renamed one release after shipping;
this is deliberate (see the semver-scope policy) but is still real
migration work for anyone who had already built against 1.1.0's shape.
ReplayOutcome<R, Q>'s double type parameter is less immediately
readable than two separate sealed hierarchies would have been; the trade
was made explicitly rather than silently reverted, per this task's design
spec (§4).
Supersedes. ADR-0038's
dispatch-hook (ToolCallDispatchCustomizer) and invoker-placement
(McpToolsService implements ToolCallReplayInvoker) decisions are
superseded by decisions 2 and 3 above. ADR-0038's other three decisions —
the ReplayExecutor extraction, ProgressSink, and the routing
contribution seam (renamed here, semantics unchanged) — stand unchanged.
Amends. ADR-0034's
descriptor-customizer seams (ToolDescriptorCustomizer,
ResourceDescriptorCustomizer) are folded into the four handler
customizers per decision 4 above; ADR-0034's _meta-on-descriptors
decision for Tool/Resource stands unchanged. ADR-0034's non-goal of
extending _meta to Prompt/ResourceTemplate is reversed by decision
4a above, which is this ADR's own addition, not a restatement of
ADR-0034.
Code anchors:
mocapi-server/src/main/java/com/callibrity/mocapi/server/dispatch/McpDispatchInterceptor.javamocapi-server/src/main/java/com/callibrity/mocapi/server/dispatch/DispatchChains.javamocapi-server/src/main/java/com/callibrity/mocapi/server/tools/ToolInvocationCore.javamocapi-server/src/main/java/com/callibrity/mocapi/server/tools/CallToolHandlerRegistry.javamocapi-server/src/main/java/com/callibrity/mocapi/server/tools/ToolCallReplayInvoker.javamocapi-server/src/main/java/com/callibrity/mocapi/server/mrtr/ReplayOutcome.javamocapi-server/src/main/java/com/callibrity/mocapi/server/tools/CallToolHandlerConfig.javamocapi-server/src/main/java/com/callibrity/mocapi/server/routing/RoutedParamContributor.javamocapi-tasks/src/main/java/com/callibrity/mocapi/tasks/TaskToolCallDispatcher.javamocapi-autoconfigure/src/main/java/com/callibrity/mocapi/server/autoconfigure/ServerCapabilitiesOverrideAuditor.javamocapi-model/src/main/java/com/callibrity/mocapi/model/ClientCapabilities.java(hasExtension)mocapi-model/src/main/java/com/callibrity/mocapi/model/Prompt.java(_meta,withMeta)mocapi-model/src/main/java/com/callibrity/mocapi/model/ResourceTemplate.java(_meta,withMeta)mocapi-server/src/main/java/com/callibrity/mocapi/server/resources/McpResourcesService.javamocapi-apps/src/main/java/com/callibrity/mocapi/apps/AppsToolUiMetaCustomizer.javamocapi-apps/src/main/java/com/callibrity/mocapi/apps/AppsResourceUiMetaCustomizer.java