Use MCP with Vertique
View MarkdownVertique provides a server-side Model Context Protocol (MCP) implementation over stateless
Streamable HTTP. This page first states what the server supports, then shows the smallest working
composition and a runnable example with one generated tool. The vertique-mcp-server module reference
owns the complete protocol, configuration, and lifecycle contract.
Current support
Section titled “Current support”The server supports a defined subset of MCP:
| Surface | Supported behavior |
|---|---|
| Transport | Stateless Streamable HTTP on one configured path. Requests are POST JSON-RPC envelopes; successful discovery returns JSON and authorized tool calls return an SSE-framed result. There are no sessions, cookies, or affinity requirements, so instances can be load-balanced. |
| Server operations | server/discover, authorized and paginated tools/list, and authorized tools/call. The registry is immutable, so listChanged notifications are not advertised. |
| Tool registration | Public, dependency-injected Java methods annotated with @McpTool and @McpToolParam. Compile-time code generation produces the registry, input schemas, and direct invokers; the runtime does not scan the classpath. |
| Client capability requirements | A tool may declare bounded top-level client capabilities with @McpTool(requiredClientCapabilities = {"sampling"}). Missing capabilities are rejected before invocation with HTTP 400 and JSON-RPC -32021; this declares a requirement but does not implement sampling requests. |
| Input and output | Generated schema validation, input-processing policies, materialization, optional Bean Validation, named JSON profiles, bounded output normalization, output-schema validation, structured JSON results, and complete results containing standard text, image, audio, resource-link, or embedded-resource blocks. |
| Progress | A tool may report standard request-scoped notifications/progress when the request supplies params._meta.progressToken. Progress is strictly increasing, bounded to 100 frames and 4,096-character messages, and opaque tokens are preserved losslessly up to 4,096 UTF-8 bytes. Progress and the complete terminal SSE frame share the configured output-byte budget; terminal capacity is protected, and an oversized terminal is replaced by a bounded JSON-RPC -32603 SSE fallback rather than ending the stream without a result. Progress never replaces or fragments the one complete tool result. |
| Governance | MCP reuses Vertique’s authorization decision point and policy annotations, with origin checks plus ordered request and tool interceptors. Ordinary and restricted tools are filtered through the same fail-closed boundary. |
| Lifecycle and cancellation | Exactly-once terminal/completion observation, disconnect/reset/write-failure cancellation, opt-in request observation, and a completion listener that can read the raw request, the raw response, and the tool values. Handler cancellation is cooperative. |
| Limits and liveness | Startup validation, the shared HTTP body and idle/read/write limits, MCP ingress/output token budgets, output byte limits, pagination budgets, and bounded envelope parsing. |
The mount also has three opt-in controls, each described below: rate-limit admission per tool, a
wall-clock deadline per request (mcp.requestDeadlineMs), and resilience annotations on a tool
method.
This server implements exactly 10 of the 37 scored scenarios in the frozen upstream 2026-07-28
conformance set: tools-list, tools-call-simple-text, tools-call-error, the four standard
content-result shapes (tools-call-image, tools-call-audio, tools-call-embedded-resource, and
tools-call-mixed-content), tools-call-with-progress, server-stateless, and
dns-rebinding-protection. The server claims conformance for those 10 scenarios only.
Not supported yet
Section titled “Not supported yet”The server does not provide these capabilities, so do not design against them:
- tasks, subscriptions, resources, prompts, and richer tool-result extensions beyond the standard content blocks listed above;
- partial application-result delivery or multiple terminal results while one tool call is running; progress notifications are supported only as standard non-terminal request status;
- MCP sessions, session identifiers, cookies, affinity, or stateful server coordination;
listChangednotifications or a mutable tool registry;- an unlimited JSON budget; ingress and output token budgets are independently bounded;
- the remaining 27 scored upstream conformance scenarios.
On HTTP/2, the shared idle/read timers are connection-level. A busy sibling stream can therefore
keep a hung MCP stream alive. Deployments using HTTP/2 should enforce per-stream or per-route
timeouts at the fronting proxy, or expose the mount over HTTP/1.1. Setting
mcp.requestDeadlineMs also bounds such a stream. See
Bound a request with a deadline.
Before you start
Section titled “Before you start”This walkthrough assumes a Vertique checkout, Java 21+, the repository’s Maven wrapper, and curl.
It uses the checked-in vertique-example-mcp application as the starting point; adapt the module
and package names when adding MCP to an existing application. Set VERTIQUE_VERSION to the
version of the Vertique artifacts you use.
For the released documentation line, that version is 0.3.0.
Decide whether MCP fits
Section titled “Decide whether MCP fits”Reach for MCP when an AI client needs to discover and invoke application capabilities through a tool protocol. Use REST APIs for ordinary HTTP resources and browser-facing clients, and services for typed event-bus execution inside a Vertique application. MCP is a separate server boundary for AI clients. It does not replace REST resources or internal service dispatch.
The runnable source for this page is
vertique-example-mcp, especially
WeatherTool
and McpExampleComponent.
Add a generated tool
Section titled “Add a generated tool”The example publishes a validated weather lookup. The method is public and dependency-injected, the protocol name is explicit, and the method-level JSON profile makes structured fields use snake-case on the wire.
public class WeatherTool { @Inject public WeatherTool() {}
@McpTool( name = "weather.current", description = "Returns the current weather for the requested location.", readOnlyHint = true, destructiveHint = false, idempotentHint = true, openWorldHint = false) @JsonProfile(ExampleJsonProfile.ID) public Future<WeatherResult> current( @Valid @McpToolParam(name = "request", description = "The location to look up.") WeatherRequest request) { return Future.succeededFuture(new WeatherResult(request.locationName(), 18, "clear")); }
// the example's other tools are omitted}@Valid preserves cascade validation on the generated input carrier. After the request is
materialized, Jakarta Bean Validation traverses the tagged WeatherRequest, so its
@NotBlank
constraint rejects a blank location. Direct constraints declared on a tool parameter are also
copied to the carrier. Schema validation runs before materialization, and Bean Validation then
checks the materialized object. The plain structured return value is mapped with the selected
profile. The generated input schema also carries the constraint, so an empty location is already
rejected at schema validation, before Bean Validation runs.
The framework invokes the method on the Vert.x context that owns the request. Offload blocking work, and have long-running work observe the cancellation signal described in the module reference.
Require a client capability
Section titled “Require a client capability”Use tool metadata when a tool is meaningful only for clients that advertise a top-level capability.
The following is a method excerpt; WeatherRequest is the application type shown above, and the
imports and enclosing class are omitted:
@McpTool( name = "model-backed.lookup", description = "Uses client-provided model sampling to complete a lookup.", requiredClientCapabilities = {"sampling"})public Future<String> lookup( @McpToolParam(name = "request", description = "The request to look up.") @Valid WeatherRequest request) { // The annotation declares the prerequisite; it does not initiate sampling. return Future.succeededFuture("lookup complete");}If the request does not advertise sampling, the server rejects the call before input preparation
or application invocation with HTTP 400 and JSON-RPC -32021. The error data contains
requiredCapabilities: {"sampling": {}}. Capability names are validated and bounded at compile
time. The server checks the prerequisite and does nothing else with the capability.
Add the MCP dependencies
Section titled “Add the MCP dependencies”The example uses the MCP core and server artifacts. The application parent or example parent
manages the versions. The complete dependency list is in the example’s
pom.xml.
<dependency> <groupId>dev.vertique</groupId> <artifactId>vertique-mcp-core</artifactId></dependency><dependency> <groupId>dev.vertique</groupId> <artifactId>vertique-mcp-server</artifactId></dependency>The vertique-codegen-all processor facade activates MCP code generation for an application
parented on vertique-app-parent. An off-parent build must put vertique-codegen-mcp on its
annotation-processor path; follow the artifacts page and the
processor module reference for that setup.
Compose the application
Section titled “Compose the application”Include the shared HTTP, JSON, security, sanitization, lifecycle, and deployment modules together
with McpServerModule. The MCP processor emits GeneratedMcpToolsModule into the application’s
package, and that module contributes the WeatherTool invoker. The example also serves a REST
resource, authenticates JWT bearer tokens, and demonstrates rate limiting and resilience, so its
component names those modules too:
@VertiqueApp@Singleton@Component( modules = { VertxModule.class, ConfigParsingModule.class, RestModule.class, JsonRuntimeModule.class, JwtAuthModule.class, RateLimitCoreModule.class, RateLimitAopModule.class, ResilienceModule.class, ResiliencePoliciesModule.class, ResilienceAopModule.class, RestRateLimitModule.class, McpServerModule.class, SanitizationModule.class, DeployerModule.class, CoreLifecycleStepsModule.class, McpExampleModule.class, GeneratedMcpToolsModule.class, GeneratedJaxRsResourcesModule.class, GeneratedAopModule.class })interface McpExampleComponent extends VertiqueApplicationComponent { // accessor methods used by the example's tests are omitted}The vertique-mcp-server module reference
lists which modules the server itself requires.
Bind McpServerConfig from the mcp configuration object. The example does that in
McpExampleModule
and contributes its named JSON profile from
ExampleJsonProfile.
An application that publishes restricted tools must also configure an authentication handler and
apply its normal authorization annotations; see Security.
Configure and start the server
Section titled “Configure and start the server”The example’s config/application.json
enables the mount at /mcp/*, selects the weather-example profile, and arms the required HTTP
idle timeout. Abridged to its HTTP settings and the top-level MCP keys, that file is:
{ "http": { "host": "127.0.0.1", "port": 8080, "idleTimeoutSeconds": 30 }, "mcp": { "enabled": true, "mountPath": "/mcp/*", "serverName": "vertique-weather-example", "serverVersion": "1.0.0", "jsonProfile": "weather-example", "authenticationScheme": "bearerAuth" }}mcp keys are flat. serverName and serverVersion are required when the mount is enabled.
authenticationScheme names the scheme that authenticates callers of restricted tools. The
demonstration tool weather.current carries no security annotation, so it stays public and the
requests below need no token. A production tool should declare the access policy it needs. A
request carrying an Origin header is rejected unless that exact origin is present in
mcp.allowedOrigins.
From the sources/vertique directory, build the example and its reactor dependencies:
cd sources/vertiqueexport VERTIQUE_VERSION=0.3.0./mvnw -ntp -Drevision="${VERTIQUE_VERSION}" -pl examples/vertique-example-mcp -am verifyThe observable success signal is BUILD SUCCESS. To keep the example running locally after that
build, invoke Maven from the example module:
cd sources/vertique/examples/vertique-example-mcpexport VERTIQUE_VERSION=0.3.0../../mvnw -ntp -Drevision="${VERTIQUE_VERSION}" exec:javaThe process listens on 127.0.0.1:8080 until it receives a shutdown signal. The launcher reads
config/application.json from the working directory, which is why the command runs from the
example module.
Optionally rate-limit MCP tools
Section titled “Optionally rate-limit MCP tools”MCP tool admission reuses the shared rate-limit engine. Add vertique-rate-limit-core and
RateLimitCoreModule to the application, define a policy under rateLimit.policies, then bind a
generated MCP tool name to that policy under mcp.rateLimit.tools. The checked-in example gives
weather.current the mcp-shared policy. This excerpt of its config/application.json shows
that binding and omits the example’s other tools and policies:
{ "mcp": { "rateLimit": { "tools": { "weather.current": {"policy": "mcp-shared"} } } }, "rateLimit": { "enabled": true, "policies": { "mcp-shared": { "enabled": true, "mode": "LOCAL", "failureMode": "OPEN", "revision": "v1", "defaultCost": 1, "algorithm": { "type": "TOKEN_BUCKET", "capacity": 2, "refill": {"type": "GREEDY", "tokens": 2, "periodMs": 3600000} } } } }}The tool key is the generated MCP name. The Java method name is not used. A configured per-tool
policy is checked before the handler runs. When its quota is exhausted, the call returns HTTP 429
with a Retry-After header and JSON-RPC -32010. When admission is unavailable, the call returns
HTTP 503 with the same code.
A mount with no authenticated callers treats every caller as anonymous, so the default subject
puts all of them in one bucket per policy. Set mcp.rateLimit.subject to ACTOR_OR_IP or IP
on such a mount. The vertique-mcp-server module reference
documents precedence, subject selection, failure handling, and all rate-limit configuration.
Bound a request with a deadline
Section titled “Bound a request with a deadline”mcp.requestDeadlineMs sets a wall-clock deadline for each request on the mount, measured from
the moment the mount first sees the request until its response has ended. The default is 0,
which disables it, and the maximum is 3,600,000. The deadline covers a stalled upload, a hung
authentication or authorization step, and a hung tool handler, including on HTTP/2 streams that
connection-level timers cannot reclaim.
What the client sees depends on how far the response got:
- If the response head is not committed, the client receives HTTP 504 with a JSON-RPC internal error.
- If a streaming response is already committed, the stream is reset on HTTP/2 and the connection
is closed on HTTP/1.1. The client sees an error instead of a truncated
200.
Keep these limits in mind when you choose a value:
- The deadline also cuts a legitimate long streaming or progress response. Set it above the longest legitimate request on the mount.
- Cancellation is cooperative. The request’s
McpCancellationSignalfires, but a handler that ignores it keeps running, and its side effects can complete after the client was told504. A client retry must be safe for the tool. - The deadline does not replace the shared HTTP idle and read timeouts. Those stay required at startup.
Add a timeout or retry to a tool
Section titled “Add a timeout or retry to a tool”A tool method can carry @Resilient together with @Timeout or the other resilience
declarations described on the AOP and events page.
The application includes the resilience annotation processor, ResilienceAopModule, and the
generated AOP module, as the example’s component does. Abridged from the example’s
ResilientMcpTool:
@McpTool( name = "weather.resilient-timeout", description = "A timeout-only resilient MCP tool.", readOnlyHint = true, destructiveHint = false, idempotentHint = true, openWorldHint = false)@Resilient@Timeout(value = 100, unit = TimeUnit.MILLISECONDS)public Future<String> timeout() { // The example returns a future that never completes.}When the timeout fires, the call returns HTTP 504 with JSON-RPC -32603 and the message
Request timed out. The resilience boundary starts at the handler invocation, after
authorization, validation, and tool interceptors, so a retry repeats only the handler method.
idempotentHint is informational and does not enable retry. Declare a retry only on a tool whose
side effects are safe to repeat.
Discover and call the tool
Section titled “Discover and call the tool”MCP requests need the protocol-version header, method header, JSON content type, and a body whose
_meta protocol version agrees with the header. From any directory with curl, discover the
server:
curl -sS http://127.0.0.1:8080/mcp/ \ -H 'Accept: application/json' \ -H 'Content-Type: application/json' \ -H 'MCP-Protocol-Version: 2026-07-28' \ -H 'Mcp-Method: server/discover' \ -H 'Mcp-Name:' \ --data '{ "jsonrpc": "2.0", "id": 1, "method": "server/discover", "params": { "_meta": { "io.modelcontextprotocol/protocolVersion": "2026-07-28", "io.modelcontextprotocol/clientInfo": {"name": "curl", "version": "1.0.0"}, "io.modelcontextprotocol/clientCapabilities": {} } } }'The response is HTTP 200 with a JSON-RPC result that includes the server name and the supported
protocol version.
A request whose header and body agree on a protocol version the server does not support returns
HTTP 400 with JSON-RPC -32022. Its error.data lists the supported versions, the requested one, and a reason of
UNSUPPORTED_VERSION. Every other negotiation rejection, such as a missing or mismatched header,
returns -32020 with its own reason. Have a client switch on reason, because the message
text is the same for several causes.
List the generated tools with a complete tools/list request:
curl -sS http://127.0.0.1:8080/mcp/ \ -H 'Accept: application/json' \ -H 'Content-Type: application/json' \ -H 'MCP-Protocol-Version: 2026-07-28' \ -H 'Mcp-Method: tools/list' \ -H 'Mcp-Name:' \ --data '{ "jsonrpc": "2.0", "id": 2, "method": "tools/list", "params": { "_meta": { "io.modelcontextprotocol/protocolVersion": "2026-07-28", "io.modelcontextprotocol/clientInfo": {"name": "curl", "version": "1.0.0"}, "io.modelcontextprotocol/clientCapabilities": {} } } }'The listing response is HTTP 200 and result.tools contains a tool named
weather.current. This is the success signal for registration and discovery.
Call it with the profile’s snake-case input property:
curl -sS http://127.0.0.1:8080/mcp/ \ -H 'Accept: text/event-stream' \ -H 'Content-Type: application/json' \ -H 'MCP-Protocol-Version: 2026-07-28' \ -H 'Mcp-Method: tools/call' \ -H 'Mcp-Name: weather.current' \ --data '{ "jsonrpc": "2.0", "id": 2, "method": "tools/call", "params": { "_meta": { "io.modelcontextprotocol/protocolVersion": "2026-07-28", "io.modelcontextprotocol/clientInfo": {"name": "curl", "version": "1.0.0"}, "io.modelcontextprotocol/clientCapabilities": {} }, "name": "weather.current", "arguments": {"request": {"location_name": "Helsinki"}} } }'The response is an SSE-framed JSON-RPC result whose structured content includes
location_name, temperature_celsius, and condition. An empty location_name is rejected at
the generated validation boundary, and the server returns a tool error without invoking the
method. To see that, send the same request with an empty value:
curl -sS http://127.0.0.1:8080/mcp/ \ -H 'Accept: text/event-stream' \ -H 'Content-Type: application/json' \ -H 'MCP-Protocol-Version: 2026-07-28' \ -H 'Mcp-Method: tools/call' \ -H 'Mcp-Name: weather.current' \ --data '{ "jsonrpc": "2.0", "id": 3, "method": "tools/call", "params": { "_meta": { "io.modelcontextprotocol/protocolVersion": "2026-07-28", "io.modelcontextprotocol/clientInfo": {"name": "curl", "version": "1.0.0"}, "io.modelcontextprotocol/clientCapabilities": {} }, "name": "weather.current", "arguments": {"request": {"location_name": ""}} } }'The response remains HTTP 200 and is an SSE-framed tool result with isError: true and the text
Invalid tool arguments: schema validation failed. This is the success signal for validation.
The application method is not invoked.
The example limits weather.current to two calls per hour, and both calls above count. Send the
first tools/call request once more. The response is HTTP 429 with a Retry-After header and
this body:
{"jsonrpc":"2.0","id":2,"error":{"code":-32010,"message":"Rate limit exceeded"}}The policy uses LOCAL mode, so restarting the example resets the quota.
Each header and metadata field has its own job:
MCP-Protocol-Versionand the_metaprotocol version must agree.Mcp-Methodidentifies the operation.Mcp-Nameis empty for discovery and listing, and must name the tool fortools/call.Accept: application/jsonis used for the JSON discovery and list responses, andAccept: text/event-streamrequests the streamed tool result.clientCapabilitiesadvertises what the client supports. It does not grant capabilities.- When an
Originheader is present, its exact value must be listed inmcp.allowedOrigins. Omitting the header is suitable for the local example.
For standard rich content, return McpToolResult.content(...) from a handler. The list order is
preserved on the wire, and resource links or embedded resources must use absolute URI values. The
media values below are Base64 fixtures that exercise the protocol. They are not renderable PNG or
audio files. Use the
two-argument overload when the same complete result also carries structured JSON
(McpToolResult.content(blocks, structuredValue)).
@McpTool( name = "weather.report", description = "Returns a report using standard MCP content blocks.", readOnlyHint = true, destructiveHint = false, idempotentHint = true, openWorldHint = false)public Future<McpToolResult<Void>> report( @Valid @McpToolParam(name = "request", description = "The location to report on.") WeatherRequest request, McpCancellationSignal cancellation) { return cancellation.progressReporter() .report(1, 1.0, "Preparing the weather report") .map(ignored -> McpToolResult.content(List.of( new McpContent.Text("Weather report for " + request.locationName()), new McpContent.Image("aGVsbG8=", "image/png"), new McpContent.Audio("aGVsbG8=", "audio/wav"), new McpContent.ResourceLink("https://example.test/weather", "weather-source"), new McpContent.EmbeddedResource( new McpContent.TextResource("urn:vertique:weather", "text/plain", "clear")))));}The following is the params fragment of a progress-enabled tools/call request. Include it as
the params value in a complete JSON-RPC request such as the one above:
{ "_meta": { "progressToken": "weather-2", "io.modelcontextprotocol/protocolVersion": "2026-07-28", "io.modelcontextprotocol/clientInfo": {"name": "example-client", "version": "1.0.0"}, "io.modelcontextprotocol/clientCapabilities": {} }, "name": "weather.report", "arguments": {"request": {"location_name": "Helsinki"}}}If the token is absent, progressReporter().report(...) is a successful no-op. The server emits
standard notifications/progress messages on the active SSE response stream, followed by one
terminal tools/call result. The success signal is at least one progress event followed by exactly
one terminal result. Progress never replaces the result.
Observe completed requests
Section titled “Observe completed requests”Contribute a McpRequestCompletedListener through a Dagger set multibinding to be told about
every request once its transport has completed. The example records the events in
McpResilienceObservationRecorder
(abridged) and contributes it from McpExampleModule:
public final class McpResilienceObservationRecorder implements McpRequestCompletedListener {
private final CopyOnWriteArrayList<McpRequestCompletedEvent> events = new CopyOnWriteArrayList<>();
@Override public void onCompleted(McpRequestCompletedEvent event) { events.add(event); }}@Provides@IntoSetstatic McpRequestCompletedListener mcpRequestCompletedListener(McpResilienceObservationRecorder recorder) { return recorder;}The event holds completion facts and no payload: the terminal event, the completion time, the transport outcome, and whether the response was committed. Implement the one-argument method when that is all you need.
A listener that needs the request’s bytes or values overrides the two-argument form,
onCompleted(event, request), and implements the one-argument form as an empty method. The
second argument is an McpRequestView, a read-only view of that one request:
| Accessor | Reports |
|---|---|
jsonRpcRequestId() |
The JSON-RPC id in its wire form; empty when the request carried none or it could not be decoded |
requestHeaders(), responseHeaders() |
Lower-cased header names mapped to their values in wire order |
requestBody(), responseBody() |
The bytes received and the bytes sent |
toolContext(), toolInput() |
The invocation context and the normalized arguments of a tools/call that reached a prepared invocation |
toolOutput() |
The normalized structured result, present only when the result’s own terminal write settled the request |
A part the request never reached is empty. A request rejected before a prepared call has no tool
context or input, and a disconnect before the write leaves the response body absent. A present
toolOutput() does not mean the client received it. event.transportOutcome() reports that.
The view carries whatever the caller and the tool sent, including Authorization and Cookie
headers, and every contributed listener receives it. Contribute a listener that overrides the
two-argument form only from code you trust with that data.
Keep the release boundaries visible
Section titled “Keep the release boundaries visible”MCP uses the same security decision model as REST. @RolesAllowed, @DenyAll, and
@RequiresAction are resolved during generated registration and request dispatch. Restricted or
denied tools are not revealed through listing. A call to a denied tool and a call to an unknown tool
name return the same JSON-RPC error and HTTP status, but timing can differ when a remote policy decision
point is in the path. If timing equivalence matters, use opaque tool identifiers, or decide from claims
already on the identity snapshot instead of a per-request remote call. The Security
page shows the cross-transport policy implications.
MCP also reuses Vertique’s JSON profiles and input-processing policies. Keep protocol envelopes under the server module’s control and use the module’s documented profile and configuration seams for tool values. The server does not provide sessions, tasks, prompts, resources, subscriptions, or server-initiated requests. Progress is the standard request-scoped notification only. There is no custom chunk protocol.
Learn more
Section titled “Learn more”vertique-mcp-coremodule reference: tool annotations, result shapes, cancellation, and lifecycle extension contracts.vertique-mcp-servermodule reference: the HTTP contract, configuration bounds, authorization, validation, output limits, and supported scope.vertique-codegen-mcpmodule reference: generated schemas, invokers, registry wiring, and processor setup.vertique-example-mcpsource: the complete buildable example behind this page.- Artifacts: MCP dependencies and optional observability adapters.
- Security: shared authorization at the MCP boundary.
- Code generation: how Vertique processors generate application wiring.
Continue reading
Section titled “Continue reading”- Previous: Services
- Next: Persistence