ADR-0038 — Three generic mocapi-server seams for the Tasks extension¶
- Status: Amended by ADR-0039
- Date: 2026-08-02
Context¶
ADR-0037 needs mocapi-tasks to run an
@McpTask tool a second time, off the tools/call dispatch thread,
resuming against a ledger that lives in a TaskStore record instead of
an encrypted wire token. Before this change, the MRTR replay mechanics
(call-ordinal cursor, fingerprint enforcement,
InputRequiredException raising) were private to MrtrElicitationEngine,
and full tool invocation (handler lookup, DefaultMcpToolContext
construction, ScopedValue binding, the six-stratum interceptor chain,
result/exception → CallToolResult mapping) was private to
McpToolsService.invokeTool. Nothing in mocapi-server could be called
from outside the wire request/retry path.
mocapi-tasks also needs to intercept tools/call before it runs the
default path (to redirect an @McpTask call into task creation instead
of synchronous execution), and needs the Mcp-Name routing-header
validation table — currently a fixed map owned by the transport — to
accept tasks/get|update|cancel alongside the built-in methods.
All three needs are generic (extension-agnostic): nothing about them is Tasks-specific, and the existing MRTR test suite passing unchanged after each extraction is the acceptance gate.
Decision¶
Add three seams to mocapi-server, each a small, behavior-preserving
refactor of an existing private mechanism.
ReplayExecutor— the extracted ledger-replay core. The ordinal-cursor / fingerprint-check /InputRequiredExceptionmachinery moves out ofMrtrElicitationEngineinto a standalone class implementing the existingElicitationDispatcherseam.execute(ledger, invocation)runs aSupplier<Object>with the ledger bound viaScopedValueand returns a sealedReplayOutcome(Completed/InputRequired).MrtrElicitationEnginebecomes a caller: it owns only the wire-token carrier (RequestStateCodecdecode/encode, the-32602validation table, principal/target verification) around a call toReplayExecutor.execute(...).ToolCallReplayInvoker— detached tool invocation. A new public interface whoseinvoke(toolName, arguments, ledger, progressOverride, exchange)runs a registered tool by name against a caller-supplied ledger, off the normal dispatch thread, with no wire envelope (norequestState, no principal/target verification — the caller owns the ledger's identity and lifecycle).McpToolsServiceimplements it directly, reusing the same handler lookup,DefaultMcpToolContextconstruction, and six-stratum chain the synchronous path uses, so wire and task execution cannot drift semantically — there is exactly one execution core with two carriers (wire token, task store).ToolCallDispatchCustomizer— atools/calldispatch hook.McpToolsService.callToolconsults an orderedList<ToolCallDispatchCustomizer>after handler lookup and before the default MRTR invocation path. Each customizer'sdispatch(handler, params)returnsOptional<Object>; the first non-empty result short-circuits the request and becomes the response as-is (the spec's response union — core neither inspects nor interprets it).mocapi-tasksregisters the sole v1 implementation,TaskToolCallDispatcher: it claims a call iff the handler carries@McpTask(viaAnnotatedElementUtils.findMergedAnnotation, the ADR-0032 meta-annotation mechanism) and the request declares thetasksclient capability. Unclaimed calls fall through to today's behavior, byte-for-byte — an empty customizer list is a no-op.McpRoutedParamContributor— routing-header validation contribution. The transport'sMcp-Namevalidation table (-32020 HeaderMismatch) gains a contribution seam:namedParamFields()returns aMap<String, String>of JSON-RPC method → theparamsfieldMcp-Namemust mirror. It lives inmocapi-server(transport-agnostic) so any module can implement it; transports that don't validate routing headers (stdio) ignore contributed instances.mocapi-taskscontributestasks/get,tasks/update,tasks/cancel→params.taskIdviaTasksRoutedParamContributor. Transports keep owning wire validation (I2) without hardcoding extension knowledge.
A fourth, smaller seam rides alongside: ProgressSink — the
delivery point every progress emitter calls once its monotonic-increase
guard accepts an update. Extracting it from the wire-only
notifications/progress send lets mocapi-tasks supply a sink that
writes a task's statusMessage via store mutation instead, while
ProgressChannel's validation logic runs identically in both modes.
Consequences¶
What this buys us. mocapi-tasks needs zero core changes beyond
these four seams, and each is generic enough that a future extension
needing the same shapes (detached replay, dispatch interception, routed
params, alternate progress delivery) reuses them without touching core
again. The wire MRTR path is unchanged in behavior — the refactor gate
for seam 1 and 2 is the existing MRTR unit/integration suite passing
unchanged before any task code is built on top.
Costs. McpToolsService now carries two public-facing roles
(ToolCallReplayInvoker implementor and dispatch-customizer host)
instead of one opaque service; ToolCallReplayInvoker and
ToolCallDispatchCustomizer are now part of mocapi-server's public
surface and need the same compatibility discipline as any other SPI.
Non-goals. Prompts and resources keep using ReplayExecutor
internally through their own MRTR engines; only the tool invoker
(ToolCallReplayInvoker) is public in v1 — a task-augmenting
prompts/get/resources/read is explicitly out of scope for the
Tasks extension (ADR-0037), so there is
no detached invoker for those kinds yet.
Code anchors:
mocapi-server/src/main/java/com/callibrity/mocapi/server/mrtr/ReplayExecutor.javamocapi-server/src/main/java/com/callibrity/mocapi/server/tools/ToolCallReplayInvoker.java- ~~
mocapi-server/src/main/java/com/callibrity/mocapi/server/tools/ToolCallDispatchCustomizer.java~~ (deleted, see amendment below) - ~~
mocapi-server/src/main/java/com/callibrity/mocapi/server/routing/McpRoutedParamContributor.java~~ (renamed, see amendment below) mocapi-server/src/main/java/com/callibrity/mocapi/server/progress/ProgressSink.java
Amended (ADR-0039, 2026-08-02): Seams 2 and 3 above are superseded.
ToolCallDispatchCustomizer(seam 3) is deleted in favor of the generalized, three-serviceMcpDispatchInterceptor<H, P>+DispatchChains, which also coversprompts/getandresources/read, not justtools/call. The "McpToolsServiceimplementsToolCallReplayInvoker" placement (seam 2) moves to a newToolInvocationCore, shared by both the wire and detached paths, closing a bean-graph cycle that previously needed anObjectProviderworkaround.McpRoutedParamContributor(seam 4) is renamedRoutedParamContributor(package unchanged) for taxonomy consistency; its semantics — and seam 1 (ReplayExecutor) and theProgressSinkseam — stand unchanged.