Extending mocapi¶
The one-stop reference for writing a mocapi extension — a module like
mocapi-apps or mocapi-tasks that plugs into mocapi-server without
core knowing anything about it. If you're writing an ordinary MCP
server (tools, prompts, resources, guards on your own handlers), start
with the Customizers guide instead; this page is for
authors adding a new seam consumer — a Spring bean that attaches to
one of mocapi's extension points from another module.
For the design decisions behind everything on this page, see
ADR-0039
(the taxonomy and the current shape of these seams) and
ADR-0038 (the seams
ADR-0039 built on: ReplayExecutor, ProgressSink, routed-param
contribution).
The taxonomy¶
Every mocapi extension point is named for one of five contracts. The suffix tells you the lifecycle and the merge rule before you've read a line of the interface:
| Word | Lifecycle | Merge rule | Members |
|---|---|---|---|
*Contributor |
Startup — called once, return value collected | Framework unions every contribution; a collision fails the boot, naming both parties | ResourceContributor, RoutedParamContributor |
*Customizer |
Startup — called once per handler/object being built; never per-request | Mutates or folds into a framework-owned object; multiple customizers apply in bean order | CallToolHandlerCustomizer + 3 siblings, ServerCapabilitiesCustomizer, the oauth2 filter-chain customizers |
*Interceptor |
Per-request; ordered by @Order/Ordered, ascending (lower runs outermost) |
Each may call proceed(), replace the result, or throw; declining is side-effect-free |
McpDispatchInterceptor, the six-strata MethodInterceptors |
*Store / *Source / *Strategy |
Deployment-supplied strategy bean | @ConditionalOnMissingBean default; a deployment supplies exactly one replacement |
TaskStore, McpPrincipalSource, McpTokenStrategy |
*Sink |
Runtime, one-way delivery callback, supplied at construction | Not mergeable — one sink per channel instance | ProgressSink |
| (no seam suffix) | An API you call, not an SPI you implement | N/A — this isn't an extension point | ToolCallReplayInvoker, McpProgressSource, McpElicitor |
Mcp-prefixed names are reserved for mocapi-api (author-facing) types
and genuinely cross-cutting server concepts (McpPrincipalSource, kept
prefixed as a grandfathered exception). An internal, mocapi-server-only
seam like RoutedParamContributor carries no prefix, matching its
sibling ResourceContributor.
The eight seams, one worked example each¶
1. ResourceContributor — contribute resources/templates at startup¶
public interface ResourceContributor {
default List<ReadResourceHandler> resources() { return List.of(); }
default List<ReadResourceTemplateHandler> resourceTemplates() { return List.of(); }
static ResourceContributor of(
List<ReadResourceHandler> resources, List<ReadResourceTemplateHandler> resourceTemplates) { … }
}
McpResourcesService merges every ResourceContributor bean at
construction into its immutable resource/template maps — there is no
runtime registration. mocapi-apps's serve-mode
(@McpUi(resource=…)) uses ResourceContributor.of(...) to contribute
one classpath-served ui:// resource per distinct URI, without a
hand-written @McpResource method. A duplicate URI across contributors
fails the boot.
2. RoutedParamContributor — extend Mcp-Name validation¶
@FunctionalInterface
public interface RoutedParamContributor {
Map<String, String> namedParamFields();
}
mocapi-tasks's TasksRoutedParamContributor returns
{"tasks/get": "taskId", "tasks/update": "taskId", "tasks/cancel": "taskId"}
so the Streamable HTTP transport's Mcp-Name header validation
(-32020 HeaderMismatch) covers task methods without the transport
module depending on mocapi-tasks. A method key colliding with another
contributor's, or with a transport built-in, fails the boot naming both
parties.
3. CallToolHandlerCustomizer (+ 3 siblings) — mutate a handler's descriptor or strata¶
public class AppsToolUiMetaCustomizer implements CallToolHandlerCustomizer {
@Override
public void customize(CallToolHandlerConfig config) {
Method method = config.method();
McpUi ui = method.getAnnotation(McpUi.class);
if (ui == null) return;
McpUiToolMeta uiMeta = new McpUiToolMeta(ui.value(), List.of(ui.visibility()));
Tool descriptor = config.descriptor();
ObjectNode meta = descriptor.meta() != null ? descriptor.meta() : mapper.createObjectNode();
meta.set("ui", mapper.valueToTree(uiMeta));
config.descriptor(descriptor.withMeta(meta));
}
}
(mocapi-apps's AppsToolUiMetaCustomizer, real code.) Every
*HandlerConfig (CallToolHandlerConfig, GetPromptHandlerConfig,
ReadResourceHandlerConfig, ReadResourceTemplateHandlerConfig) exposes
both T descriptor() and void descriptor(T) — the mutator folded a
former pair of standalone descriptor-only SPIs into the existing
per-handler-kind customizer (ADR-0039,
amending ADR-0034).
Identity contract: a replacement descriptor must keep the
original's identity field (name()/uri()/uriTemplate()) and, for
tools, both compiled schemas — other customizers in the same chain close
over the descriptor snapshotted at build time, and replacing identity or
schemas here desynchronizes what's advertised from what's enforced. Use
descriptor(T) to add or change title/description/_meta only.
The same *HandlerConfig also exposes one interceptor mutator for each
non-authorization stratum (correlationInterceptor,
observationInterceptor, auditInterceptor, validationInterceptor,
invocationInterceptor) plus resolver(ParameterResolver). The sixth
stratum, AUTHORIZATION, has no interceptor mutator of its own — it's
configured via guard(Guard) instead, since guard evaluation is a
distinct concern (allow/deny before invocation) from the interceptor
chain's proceed/replace/throw contract. See the
Customizers guide for the full six-stratum picture.
4. ServerCapabilitiesCustomizer — declare an extension capability¶
public class UiCapabilityCustomizer implements ServerCapabilitiesCustomizer {
@Override
public void customize(ServerCapabilities.Builder capabilities) {
ObjectNode config = mapper.createObjectNode();
config.putArray("mimeTypes").add("text/html;profile=mcp-app");
capabilities.extension("io.modelcontextprotocol/ui", config);
}
}
(mocapi-apps's UiCapabilityCustomizer, real code.) Applied when
mocapi builds its own default ServerCapabilities bean
(@ConditionalOnMissingBean). If a deployment supplies its own
ServerCapabilities bean, mocapi's factory backs off entirely and every
registered customizer is silently skipped — ServerCapabilitiesOverrideAuditor
(a SmartInitializingSingleton, registered unconditionally so its
detection is immune to auto-configuration ordering) logs one WARN naming
the discarded customizer beans when this happens, so the override
doesn't fail silently.
5. McpDispatchInterceptor<H, P> — intercept a dispatch before the handler chain runs¶
@FunctionalInterface
public interface McpDispatchInterceptor<H, P> {
Object intercept(H handler, P params, Supplier<Object> proceed);
}
mocapi-tasks's TaskToolCallDispatcher — the worked example, and the
full contract, are below in their own section.
6. TaskStore — a deployment-supplied persistence strategy¶
public interface TaskStore {
void create(TaskRecord record);
Optional<TaskRecord> get(String taskId);
Optional<TaskRecord> update(String taskId, UnaryOperator<TaskRecord> mutation);
void delete(String taskId);
}
InMemoryTaskStore is the @ConditionalOnMissingBean default (with a
startup WARN about its non-durability); a production, multi-node
deployment supplies its own bean. See the atomicity contract in
Tasks and the TaskStoreContractTest TCK
before writing one.
7. ProgressSink — a one-way delivery callback¶
@FunctionalInterface
public interface ProgressSink {
void accept(Number progress, Number total, String message);
}
mocapi-tasks's TaskProgressSource.forTask builds an
McpProgressSource whose sink writes a formatted statusMessage
through TaskStore.update instead of sending a wire
notifications/progress message — ProgressChannel's monotonic-increase
validation runs identically either way; only the delivery differs.
8. McpPrincipalSource / McpTokenStrategy — strategy beans¶
@FunctionalInterface
public interface McpPrincipalSource {
String currentPrincipal(); // null if unauthenticated
}
The @ConditionalOnMissingBean default returns null (mocapi core is
authentication-agnostic); mocapi-oauth2 supplies one reading the
Spring Security context. McpTokenStrategy is the analogous seam for
JWT-vs-opaque-token validation in the oauth2 filter chain. Both are
one-bean-per-deployment strategies, not multi-instance contributions.
The interceptor contract: proceed / own / abort¶
McpDispatchInterceptor<H, P> is the seam for per-request behavior on
the three MRTR-capable methods (tools/call, prompts/get,
resources/read). DispatchChains sorts registered interceptors once
per service at construction (@Order, ascending — lower values run
outermost) and folds them per-dispatch around the existing default
path. With zero interceptors, dispatch is byte-for-byte what it was
before this seam existed.
An interceptor's intercept(handler, params, proceed) has exactly
three outcomes:
- Proceed. Call
proceed.get()(optionally decorating the result) and return it. The request continues into the handler chain unchanged. - Own. Return a different
Objectwithout callingproceed(). The interceptor's return value becomes the response as-is. - Abort. Throw. The exception becomes the JSON-RPC error.
Interceptors run before the handler chain — and therefore before
guards and schema validation. An interceptor that owns the call
bypasses the six-stratum chain entirely, which means it inherits
responsibility for anything that chain would otherwise have provided.
TaskToolCallDispatcher (mocapi-tasks) is the worked example of
getting this right:
@Override
public Object intercept(
CallToolHandler handler, CallToolRequestParams params, Supplier<Object> proceed) {
McpTask annotation = AnnotatedElementUtils.findMergedAnnotation(handler.method(), McpTask.class);
if (annotation == null) {
return proceed.get(); // never a task
}
if (!isTaskCapable(params.meta())) {
if (annotation.required()) {
throw new McpTaskRequiredException("Tool \"" + handler.name() + "\"");
}
return proceed.get(); // graceful sync degrade
}
// Guards normally run inside the handler chain (GuardEvaluationInterceptor), but the task
// path never enters that chain — it short-circuits to createAndStart. Evaluate the same
// guard list here, before minting the task record, so an unauthorized capable client gets
// the identical synchronous -32010 the sync path would produce, instead of a taskId whose
// record later lands FAILED.
if (Guards.evaluate(handler.guards()) instanceof GuardDecision.Deny deny) {
throw new JsonRpcException(JsonRpcErrorCodes.FORBIDDEN, "Forbidden: " + deny.reason());
}
TaskRecord rec = newRecord(handler, params, annotation);
return engine.createAndStart(rec);
}
Three cases fall through to proceed() (no annotation; non-capable and
not required — call runs normally through the chain, guards included).
The fourth case — capable client, @McpTask present — owns the
call: before it does, it manually re-runs Guards.evaluate against the
handler's own guard list, throwing the identical -32010 Forbidden
JsonRpcException the chain's GuardEvaluationInterceptor would have
thrown. Without this, a denied capable client would get back a taskId
whose task record later fails asynchronously, instead of the
synchronous rejection every other guarded call gives. Input-schema
validation is deliberately not duplicated the same way — it still
runs once per execution inside the handler chain (both at task creation
and at every resume), which is correct because arguments don't change
between executions.
The rule for any interceptor that owns a call: enumerate what the handler chain would have done before the point you're short-circuiting at (guards, in mocapi's case — schema validation and the later strata still run once execution actually happens), and either re-run the security-relevant parts yourself or prove the call can't reach anything they protect.
Template-matched resources/read (a URI resolving to a
ReadResourceTemplateHandler rather than a fixed resource) does not
currently route through McpDispatchInterceptor — this is a deliberate,
documented gap (ADR-0039), not an oversight; open a new ADR before
assuming it's already covered if you need it.
Client-capability detection: hasExtension¶
public boolean hasExtension(String id) {
return extensions != null && extensions.containsKey(id);
}
ClientCapabilities.hasExtension(String) (mocapi-model, null-safe) is
the one place to check whether a request declared a given extension —
mere presence of the key counts, even as an empty object ({}).
TaskToolCallDispatcher.isTaskCapable and McpTasksService's gating
both delegate to it rather than re-implementing the null/containsKey
check:
static boolean isTaskCapable(RequestMeta meta) {
return meta != null
&& meta.clientCapabilities() != null
&& meta.clientCapabilities().hasExtension(TasksExtension.EXTENSION_ID);
}
Write your own extension's capability gate the same way instead of inlining the null checks.
Error-code guidance for extensions¶
mocapi partitions JSON-RPC's implementation-defined server-error range
per I9:
-32000..-32019 is mocapi-core-private (currently just -32010
Forbidden); -32020..-32099 is the spec-reserved band, where the
2026-07-28 core spec and its extension SEPs allocate codes (-32020
HeaderMismatch, -32021 MissingRequiredClientCapabilityError, -32022
UnsupportedProtocolVersionError).
Extensions never allocate a new private code in -32000..-32019
— that range is reserved for mocapi core itself. If your extension's
SEP defines its own error code (the way the Tasks extension's draft
text specifies one, even though mocapi ultimately follows the core
registry's MissingRequiredClientCapabilityErrorData.CODE for that
particular case — see
tasks.md), use the spec-defined code
verbatim. If your extension has no SEP-defined code for a given
failure, prefer an existing spec-generic code (-32602 Invalid params,
-32603 Internal error) over minting a new one. This mirrors ADR-0023's
rationale for why mocapi's own -32010 sits where it does — a shared,
documented sub-range keeps every module's error codes distinguishable
from the wire's spec-defined ones at a glance.
API vs. SPI: what you call vs. what you implement¶
Not every public type in mocapi-server is an extension point. A
handful are APIs you call — ToolCallReplayInvoker,
McpProgressSource, McpElicitor — with a single production
implementation you're not expected to replace. They carry no seam
suffix precisely so they don't read as one; the taxonomy table above
excludes them from its "members" column for the same reason. If you
find yourself writing a class that implements ToolCallReplayInvoker
to intercept tool calls, you want McpDispatchInterceptor instead —
ToolCallReplayInvoker is the detached re-invocation seam
TaskExecutionEngine calls, not a customization point.
When in doubt: if the type name ends in Contributor, Customizer,
Interceptor, Store/Source/Strategy, or Sink, it's an SPI —
read its section above. If it doesn't, check whether it's documented as
an API before writing an implementation of it.