Skip to content

Use MCP with Vertique

View Markdown

Vertique 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.

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.

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;
  • listChanged notifications 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.

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.

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.

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.

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.

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.

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.

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:

Terminal window
cd sources/vertique
export VERTIQUE_VERSION=0.3.0
./mvnw -ntp -Drevision="${VERTIQUE_VERSION}" -pl examples/vertique-example-mcp -am verify

The observable success signal is BUILD SUCCESS. To keep the example running locally after that build, invoke Maven from the example module:

Terminal window
cd sources/vertique/examples/vertique-example-mcp
export VERTIQUE_VERSION=0.3.0
../../mvnw -ntp -Drevision="${VERTIQUE_VERSION}" exec:java

The 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.

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.

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 McpCancellationSignal fires, but a handler that ignores it keeps running, and its side effects can complete after the client was told 504. 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.

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.

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:

Terminal window
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:

Terminal window
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:

Terminal window
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:

Terminal window
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-Version and the _meta protocol version must agree.
  • Mcp-Method identifies the operation.
  • Mcp-Name is empty for discovery and listing, and must name the tool for tools/call.
  • Accept: application/json is used for the JSON discovery and list responses, and Accept: text/event-stream requests the streamed tool result.
  • clientCapabilities advertises what the client supports. It does not grant capabilities.
  • When an Origin header is present, its exact value must be listed in mcp.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.

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
@IntoSet
static 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.

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.