Skip to content

Handlers

Each MCP capability in mocapi is dispatched through a concrete "handler" class — one per request kind — built at startup from annotated Spring beans. Handlers are internal to mocapi-server; application code doesn't see them directly.

CallToolHandler — tools/call

Every @McpTool-annotated method on a Spring bean produces one CallToolHandler. The handler bundles the generated Tool descriptor (name, title, description, input/output schemas) with a MethodInvoker<JsonNode> that adapts tools/call arguments to the method signature. McpToolsService holds a Map<String, CallToolHandler> keyed by tool name and looks up the handler on every call before dispatching.

Handlers are built by mapping each (bean, method) pair from the central HandlerMethodsCache through CallToolHandlers.build(...) in MocapiServerToolsAutoConfiguration. There is no separate SPI interface for tool registration — the annotation scan is the only supported path.

A companion annotation, @McpTask (module mocapi-tasks), marks a @McpTool method eligible for task-augmented invocation under the io.modelcontextprotocol/tasks extension — no separate handler kind, just an McpDispatchInterceptor that reroutes matching calls before the default MRTR path runs. See MCP Tasks and Extension SPI.

GetPromptHandler — prompts/get

Every @McpPrompt-annotated method on a Spring bean produces one GetPromptHandler. The handler bundles the generated Prompt descriptor (name, title, description, argument list) with a MethodInvoker<Map<String, String>> that converts the client-supplied string arguments to the declared parameter types, plus the list of CompletionCandidates derived from enum-typed or @Schema(allowableValues=...) parameters. McpPromptsService holds a Map<String, GetPromptHandler> keyed by prompt name and looks up the handler on every prompts/get call before dispatching.

Handlers are built by mapping each (bean, method) pair from the central HandlerMethodsCache through GetPromptHandlers.build(...) in MocapiServerPromptsAutoConfiguration. The same bean method walks every handler's completionCandidates() and registers them with McpCompletionsService, so completion/complete keeps working for prompt arguments.

ReadResourceHandler — resources/read (fixed URIs)

Every @McpResource-annotated method on a Spring bean produces one ReadResourceHandler. The handler bundles the generated Resource descriptor (URI, name, description, MIME type) with a ResourceReader (a Supplier<ReadResourceResult>) that produces the result returned by resources/read (ADR-0035). For an annotation-scanned method the reader wraps the method's MethodInvoker<Object> and adapts its return value: a ReadResourceResult passes through, while a String/ CharSequence, byte[]/ByteBuffer, or Spring Resource is wrapped against the descriptor's URI and MIME type by ResourceResults (the @McpResource(content=…) enum disambiguates a Resource as text or blob). McpResourcesService holds a Map<String, ReadResourceHandler> keyed by URI and looks up the handler on every fixed-URI resources/read call before dispatching.

The reader indirection means a handler need not be method-backed at all: a reader-only constructor takes (descriptor, guards, reader) with no Method/MethodInvoker, which is how contributed (non-scanned) resources are served — see Resource contributors below.

Handlers are built by mapping each (bean, method) pair from the central HandlerMethodsCache through ReadResourceHandlers.build(...) in the AnnotationScanResourceContributor.

ReadResourceTemplateHandler — resources/read (templated URIs)

Every @McpResourceTemplate-annotated method on a @ResourceService bean produces one ReadResourceTemplateHandler. The handler bundles the generated ResourceTemplate descriptor (URI template, name, description, MIME type) with a MethodInvoker<Map<String, String>> that converts the URI's resolved path variables to the declared parameter types, plus the list of CompletionCandidates derived from enum-typed or @Schema(allowableValues=...) variables. McpResourcesService holds a Map<UriTemplate, ReadResourceTemplateHandler> and matches an incoming resources/read URI against the templates after the fixed-URI map lookup misses.

Handlers are built by mapping each (bean, method) pair from the central HandlerMethodsCache through ReadResourceTemplateHandlers.build(...) in the AnnotationScanResourceContributor. Template methods keep returning ReadResourceResult (they resolve their own concrete URI, so the convenience return types are a fixed-URI @McpResource feature). The resources autoconfiguration walks every registered template handler's completionCandidates() and registers them with McpCompletionsService, so completion/complete keeps working for resource-template variables.

Resource contributors — merging registrations (ADR-0035)

McpResourcesService is built once, at construction, by merging the handlers from every ResourceContributor bean:

public interface ResourceContributor {
  default List<ReadResourceHandler> resources()                { return List.of(); }
  default List<ReadResourceTemplateHandler> resourceTemplates() { return List.of(); }
}

The @McpResource/@McpResourceTemplate annotation scan is itself the primary, built-in contributor (AnnotationScanResourceContributor) — not a privileged path, just one contributor among peers. An extension supplies another: MCP Apps' serve-mode (AppUiResourceContributor, ADR-0036) contributes reader-only handlers that serve ui:// bundles from a fixed location. The service stays immutable; registration is construction-time only, with no runtime mutation. Duplicate URIs across contributors fail fast at construction.

Guards and observability are not generalized onto contributed readers. A method-backed reader keeps its baked-in MethodInvoker strata (correlation/observation/audit + guard enforcement); a contributed reader-only handler carries an empty guards list (so it is public and visible in listResources) and a bare reader with no interceptors. A resource that needs guards, observability, or logic is declared as a @McpResource/@McpAppResource method and reached by reference — the deliberate escape hatch.

No public handler SPI

After the 170–174 cleanup series, mocapi has no public handler-SPI interfaces at all. Tools, prompts, resources, and resource templates are all annotation-driven; each internal representation is a single concrete class built once at startup. There is no SPI users implement — only annotations.

Meta-annotation composition (ADR-0032)

Discovery is meta-annotation aware. HandlerMethodsCache detects handler methods with MergedAnnotations, and every attribute read (HandlerKind, the *Handlers.build factories) uses AnnotatedElementUtils.findMergedAnnotation, so a composed annotation that is itself meta-annotated with @McpTool / @McpPrompt / @McpResource / @McpResourceTemplate is discovered under its meta-annotation, with @AliasFor attribute overrides resolved. The four handler annotations carry @Target({METHOD, ANNOTATION_TYPE}) so they may be used as meta-annotations. This lets optional modules ship an ergonomic single annotation — e.g. @McpAppResource (mocapi-apps), meta-annotated @McpResource with the ui:// MIME type defaulted and uri aliased through — that registers through the existing scan with no bespoke registration SPI. Directly-annotated handlers are unaffected (merged detection is a strict superset of raw detection).

Descriptor mutators (ADR-0034, folded by ADR-0039)

Each of the four *HandlerConfig types (CallToolHandlerConfig, GetPromptHandlerConfig, ReadResourceHandlerConfig, ReadResourceTemplateHandlerConfig) exposes a void descriptor(T) mutator alongside its T descriptor() accessor. A customizer reads config.method() for its annotations and calls config.descriptor(T) to publish a modified Tool/Prompt/Resource/ResourceTemplate — typically to fold data into _meta, e.g. mocapi-apps's AppsToolUiMetaCustomizer / AppsResourceUiMetaCustomizer reading @McpUi/@McpAppResource and writing _meta.ui. This runs through the same four *HandlerCustomizer SPIs (ADR-0011) that attach interceptors and guards — there is no separate descriptor-only SPI; ADR-0034 originally introduced two standalone descriptor-customizer interfaces (see its amendment note), and ADR-0039 folded them into the existing per-handler-kind customizers instead.

Identity contract. A customizer calling descriptor(replacement) must preserve the original's identity field (name()/uri()/ 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.

Descriptors with no customizer calling descriptor(T) are unaffected (_meta stays absent, NON_NULL), so the change is wire-additive. Tool.withMeta/Resource.withMeta/Prompt.withMeta/ ResourceTemplate.withMeta each deepCopy() the 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 it passed in.

Handler context injection

Tool, prompt, and resource handler methods may declare a context parameter for mid-execution communication with the client. The contexts form one hierarchy (ADR-0025): MrtrContext (McpElicitor + McpProgressSource + handlerName()) is the shared base of the three leaf types McpToolContext, McpPromptContext, and McpResourceContext. The leaves exist only for the three MRTR-capable methods (tools/call, prompts/get, resources/read), making the spec's "interaction only on these three" boundary a compile-time fact.

Each of the three services builds a context per request (AbstractMrtrContext subclass, capturing the request's transport, _meta progress token, and exchange) and binds it to both its leaf CURRENT ScopedValue and McpElicitor.CURRENT around the handler invocation. The handler factories register the matching structural resolvers (the ScopedValueResolver pattern): the leaf-context resolver first, then McpElicitorResolver, so a handler can declare its full leaf context or a bare McpElicitor for elicitation alone. Progress flows through the captured transport as notifications/progress; elicitation routes through the MRTR engine (see elicitation-mrtr.md).

Interceptor chain

Since spec 175 (Methodical 0.6), every handler's reflective invocation runs through a MethodInterceptor chain. Interceptors attach per-handler via the *HandlerCustomizer SPI (spec 180) — a customizer bean receives each handler's *HandlerConfig at build time and calls one of the per-stratum mutators (correlationInterceptor, observationInterceptor, auditInterceptor, validationInterceptor, invocationInterceptor) to contribute an interceptor to the kind of concern it represents. The builder assembles the chain in a fixed outer-to-inner order; see extension-spi.md for the full stratum story. The customizer path gives per-handler metadata (descriptor, method, bean, annotations) and supports conditional attachment, which is why mocapi no longer autowires bare MethodInterceptor<? super T> beans at the handler layer.

Tools get one built-in interceptor: InputSchemaValidatingInterceptor is appended innermost per CallToolHandler, validating the incoming JsonNode against the compiled input schema and throwing JsonRpcException(-32602) on a mismatch. Because that exception propagates out of the invoker and into McpToolsService.invokeTool's generic catch (Exception), it surfaces to the client as CallToolResult { isError: true } — matching the MCP spec's "input validation errors belong in the result body so the LLM can self-correct" guidance.

A minimal timing interceptor wired via a customizer looks like:

public class ToolTimingInterceptor implements MethodInterceptor<JsonNode> {
  @Override
  public Object intercept(MethodInvocation<? extends JsonNode> invocation) {
    long start = System.nanoTime();
    try {
      return invocation.proceed();
    } finally {
      long elapsedMicros = (System.nanoTime() - start) / 1_000;
      log.info("tool {} took {}µs", invocation.method().getName(), elapsedMicros);
    }
  }
}

@Bean
CallToolHandlerCustomizer toolTimingCustomizer() {
  return config -> config.observationInterceptor(new ToolTimingInterceptor());
}

Returning such a customizer bean is enough to have the interceptor wrap every CallToolHandler. Output-schema validation is not wired in — the output schema is descriptive metadata only, and mocapi trusts handlers to produce conformant results.