Custom Parameter Resolvers¶
Handler methods normally take parameters that bind from the
JSON-RPC request arguments (tools / resource templates) or the
prompt arguments map. Sometimes you want a parameter to come from
somewhere else entirely — the per-request McpExchange, the Spring
Security SecurityContextHolder, a per-request ScopedValue, a
tenant-scoped bean lookup. That's what custom ParameterResolvers
are for.
Mocapi supports them via the customizer SPI (see customizers.md for the broader pattern).
Example — @CurrentTenant String tenant¶
You want @CurrentTenant-annotated parameters populated from the
authenticated caller's JWT claim, so tool code doesn't have to pluck
it itself:
@McpTool(name = "list_tenant_widgets")
public List<Widget> listTenantWidgets(@CurrentTenant String tenant) {
return widgetService.listForTenant(tenant);
}
The resolver¶
import org.jwcarman.methodical.param.ParameterInfo;
import org.jwcarman.methodical.param.ParameterResolver;
import tools.jackson.databind.JsonNode;
import java.util.Optional;
public final class CurrentTenantResolver implements ParameterResolver<JsonNode> {
@Override
public Optional<Binding<JsonNode>> bind(ParameterInfo info) {
if (!info.parameter().isAnnotationPresent(CurrentTenant.class)
|| info.resolvedType() != String.class) {
return Optional.empty(); // not our parameter; let the next resolver try
}
return Optional.of(arguments -> {
// Runs per-invocation. `arguments` is the whole request
// arguments JsonNode — we ignore it and read the caller's JWT.
if (!(SecurityContextHolder.getContext().getAuthentication()
instanceof JwtAuthenticationToken jwt)) {
throw new IllegalStateException("@CurrentTenant requires an authenticated caller");
}
return jwt.getToken().getClaimAsString("tenant");
});
}
}
Two things to notice about the new (Methodical 0.7+) shape:
bindruns once per parameter at startup. Annotation lookup, type checks, any expensive setup (e.g., a cachedObjectReader) happens here — not per call.- The returned
Bindingis the per-invocation hot path. It receives the handler's argument object and returns the resolved value. Keep this method tight; it runs on every request.
Attaching the resolver¶
@Configuration
class ResolverWiring {
@Bean
CallToolHandlerCustomizer currentTenantResolverCustomizer() {
CurrentTenantResolver resolver = new CurrentTenantResolver();
return config -> config.resolver(resolver);
}
}
If the same @CurrentTenant shows up on prompt methods or resource
templates, add the corresponding GetPromptHandlerCustomizer /
ReadResourceTemplateHandlerCustomizer beans. Each kind has its
own customizer type because the argument types differ (JsonNode
vs Map<String, String> vs Object).
Resolution order¶
Mocapi and the transports provide structural resolvers already. Customizer-added user resolvers slot in between mocapi's specific and catch-all structural resolvers.
Tools (JsonNode):
McpToolContextResolver (specific — matches McpToolContext parameters)
McpToolParamsResolver (specific — matches @McpToolParams)
[ your custom resolvers, in customizer-bean @Order ]
Jackson3ParameterResolver (catch-all — JSON body binding by name / index)
Prompts / Resource Templates (Map<String, String>):
[ your custom resolvers ]
StringMapArgResolver (catch-all — name-based lookup + conversion)
Resources (Object / no args from request — resources don't
take user args):
McpTransportResolver (specific)
[ your custom resolvers ]
(no catch-all — resource handlers typically take only
McpResourceContext-style scoped values)
Your custom resolver runs before the catch-all. Typical design: guard it with an annotation or type check so it only claims the parameter it's meant to. Uncclaimed parameters fall through to the catch-all, which binds from the argument shape.
Why "specific before catch-all" matters¶
The catch-all resolvers (Jackson3ParameterResolver,
StringMapArgResolver) claim any parameter they're handed —
they'll gladly try to JSON-deserialize @CurrentTenant String from
the arguments blob and produce a blank string (or throw, depending
on inputs). Putting user resolvers ahead of the catch-all gives
them a chance to claim the parameter by annotation before the
catch-all swallows it.
Binding-time caching — the real performance win¶
Before Methodical 0.7 the ParameterResolver API was
supports(info) + resolve(info, args) — both called per
invocation. Now bind(info) runs once at handler construction
and returns a Binding that holds whatever state the resolver
needs.
For simple cases that's a convenience. For expensive cases it's a
real win — Jackson's ObjectReader creation, annotation lookups,
method-handle resolution, whatever per-parameter setup you need
— all happen once, then the hot path just consults captured
state.
// Good — ObjectReader built once per parameter at bind
@Override
public Optional<Binding<JsonNode>> bind(ParameterInfo info) {
if (!info.parameter().isAnnotationPresent(FromCookies.class)) {
return Optional.empty();
}
ObjectReader reader = mapper.readerFor(info.resolvedType());
String cookieName = info.parameter().getAnnotation(FromCookies.class).value();
return Optional.of(args -> {
String raw = currentRequest().getCookie(cookieName);
return raw == null ? null : reader.readValue(raw);
});
}
// Avoid — rebuilding per call
@Override
public Optional<Binding<JsonNode>> bind(ParameterInfo info) {
if (!info.parameter().isAnnotationPresent(FromCookies.class)) {
return Optional.empty();
}
return Optional.of(args -> {
ObjectReader reader = mapper.readerFor(info.resolvedType()); // per-call allocation
String cookieName = info.parameter()
.getAnnotation(FromCookies.class).value(); // per-call reflection
String raw = currentRequest().getCookie(cookieName);
return raw == null ? null : reader.readValue(raw);
});
}
Thread safety¶
Your ParameterResolver instance AND the Binding it returns must
be thread-safe.
bind() runs single-threaded at startup and can safely build
captured state. Binding.resolve(argument) runs concurrently on
every invocation — no mutable shared state without synchronization.
If you need per-invocation state, use a ScopedValue (bound by
an upstream filter/interceptor) or pull from
SecurityContextHolder / similar thread-local sources that have
their own propagation story.
When NOT to write a ParameterResolver¶
- Request-scoped data available on
McpExchange.CURRENT. JustMcpExchange.CURRENT.get()(protocol version, client info, client capabilities) inside the tool method. Cleaner than inventing a parameter resolver for single-use state. - Per-method policy / config. That's closer to an
interceptor than a resolver. Attach via the stratum method that
matches the intent (
config.validationInterceptor(...),config.invocationInterceptor(...), etc. — see customizers.md). - Authorization decisions. Use a
Guard(see guards.md). Guards run before the invocation with proper error mapping to-32010 Forbidden; a parameter resolver that throws mid-invocation produces a less-clean error shape.
Non-goals¶
- Reordering structural resolvers. You can't insert before
McpToolContextResolveror after the Jackson catch-all; the structural slots are mocapi-owned. If you need catch-all behavior, write a resolver that claims parameters by a predicate so it runs ahead of the structural catch-all. - Removing structural resolvers. Same reason.
- A Spring
HandlerMethodArgumentResolverbridge. Mocapi's resolver SPI is from Methodical; Spring's is from Spring MVC. They're separate concerns at separate layers. If a Spring argument resolver helps, use it in your own Spring controllers, not here.