AI & RPC Protocol Mocking (SSE, WebSocket, JSON-RPC, MCP, A2A, gRPC)

July 20, 2026 · View on GitHub

Overview

MockServer supports mocking AI protocol servers including MCP (Model Context Protocol) and A2A (Agent-to-Agent Protocol). This is distinct from MockServer's own MCP control plane (/mockserver/mcp) — these features enable mocking other people's MCP and A2A servers for testing.

MockServer's own MCP control plane (/mockserver/mcp) enforces both authentication and per-tool authorization when controlPlaneAuthorizationEnabled=true. McpToolRegistry classifies every tool as read or mutate (fail-closed — unclassified tools default to MUTATE), and McpRequestProcessor calls HttpState.controlPlaneToolAuthorized before executing any tools/call. A principal with only the READ role is 403'd on mutating tools (create_expectation, clear_expectations, reset, etc.). See tls-and-security.md for the full authorization model.

Architecture

Core Building Blocks

Two primitive building blocks enable all AI protocol mocking:

  1. SSE Streaming Responses (HttpSseResponse) — an Action type that streams Server-Sent Events to the client
  2. JSON-RPC Body Matching (JsonRpcBody) — a Body matcher that validates JSON-RPC 2.0 method names and optionally validates params against a JSON Schema

Higher-Level Builders

Built on top of the primitives:

  • McpMockBuilder — generates a complete set of Expectation[] objects for a mock MCP server
  • A2aMockBuilder — generates a complete set of Expectation[] objects for a mock A2A agent

Layer Architecture

flowchart TB
    subgraph "Client API Layer"
        MCPBuilder["McpMockBuilder"]
        A2ABuilder["A2aMockBuilder"]
    end

    subgraph "Expectation Generation"
        JsonRpcBody["JsonRpcBody matcher"]
        JsonPathBody["JsonPathBody matcher"]
        VelocityTemplate["Velocity Templates"]
        SseResponse["HttpSseResponse action"]
    end

    subgraph "Core MockServer"
        Expectation["Expectation"]
        Matcher["HttpRequestPropertiesMatcher"]
        ActionHandler["HttpActionHandler"]
        SseHandler["HttpSseResponseActionHandler"]
        TemplateHandler["HttpResponseTemplateActionHandler"]
    end

    MCPBuilder --> JsonRpcBody
    MCPBuilder --> JsonPathBody
    MCPBuilder --> VelocityTemplate
    A2ABuilder --> JsonRpcBody
    A2ABuilder --> JsonPathBody
    A2ABuilder --> VelocityTemplate

    JsonRpcBody --> Matcher
    JsonPathBody --> Matcher
    VelocityTemplate --> TemplateHandler
    SseResponse --> SseHandler

    Matcher --> Expectation
    SseHandler --> ActionHandler
    TemplateHandler --> ActionHandler

SSE Streaming Responses

Model

  • SseEvent (mockserver-core/src/main/java/org/mockserver/model/SseEvent.java) — a single SSE event with fields: event, data, id, retry, delay
  • HttpSseResponse (mockserver-core/src/main/java/org/mockserver/model/HttpSseResponse.java) — action type extending Action<HttpSseResponse> with statusCode, headers, a list of SseEvent objects, and a closeConnection flag

Action Type

Action.Type.SSE_RESPONSE was added to the Action.Type enum. HttpActionHandler routes requests matching an HttpSseResponse action to HttpSseResponseActionHandler.

Handler

HttpSseResponseActionHandler (mockserver-core/src/main/java/org/mockserver/mock/action/http/HttpSseResponseActionHandler.java) writes the SSE stream directly via Netty's ChannelHandlerContext. It:

  1. Writes HTTP response headers (Content-Type: text/event-stream, Transfer-Encoding: chunked, Cache-Control: no-cache, Connection: keep-alive) plus any custom headers from the action
  2. Recursively schedules each event via Scheduler, using the per-event Delay if present or executing immediately if not
  3. Formats each event per the SSE specification — multi-line data values are split into multiple data: lines; id, event, and retry fields are written when non-null
  4. Writes LastHttpContent.EMPTY_LAST_CONTENT to terminate the chunked stream, then closes the channel if closeConnection is true (or null, which defaults to closing)

Known inconsistency — an absent closeConnection does not mean the same thing across the three streaming actions. SSE (HttpSseResponseActionHandler) and WebSocket (HttpWebSocketResponseActionHandler) treat an absent value as close; gRPC streaming (GrpcStreamResponseActionHandler) treats it as don't close. None of the schemas declares a default, so a client that omits the field gets opposite behaviour depending on the action.

Recommendation (deferred, not yet actioned): normalise all three to "absent = don't close". Closing is the surprising direction for an action whose whole purpose is to hold a stream open, and gRPC already behaves that way. This is deliberately not bundled with the fixes that exposed it, because it changes server behaviour for existing SSE/WebSocket users who rely on the current default, so it needs its own change, review and prominent changelog entry. Deferring is safe because the dashboard code generator now always emits closeConnection explicitly, so generated snippets no longer depend on the default in either direction.

Since T1.2, streaming payloads can be templated: setting an optional templateType (VELOCITY/MUSTACHE/JAVASCRIPT) on the httpSseResponse (or httpWebSocketResponse, or per-message on grpcStreamResponse) renders each event's data (each WebSocket text frame / each gRPC message json) as a response template against the triggering request via the shared StreamTemplateRenderer — same request/template context as httpResponseTemplate ($!request.body, $jsonPath(...), built-in helpers, faker, scenario). Rendering is per event/message and opt-in; with no templateType payloads are emitted byte-for-byte unchanged. See Templated streaming payloads in request-processing.md.

sequenceDiagram
    participant Client
    participant MockServer
    participant Handler as HttpSseResponseActionHandler
    participant Scheduler

    Client->>MockServer: GET /events
    MockServer->>Handler: SSE_RESPONSE action matched
    Handler->>Client: HTTP 200 headers (text/event-stream, chunked)
    loop For each SseEvent
        Handler->>Scheduler: schedule with per-event Delay
        Scheduler->>Handler: execute after delay
        Handler->>Client: SSE event chunk (id, event, retry, data lines)
    end
    Handler->>Client: LastHttpContent (terminates chunked transfer)
    Handler->>Client: close connection (if closeConnection true or null)

Serialization

  • SseEventDTO (mockserver-core/.../serialization/model/SseEventDTO.java) and HttpSseResponseDTO (mockserver-core/.../serialization/model/HttpSseResponseDTO.java) handle REST API serialization and deserialization
  • ExpectationDTO includes an httpSseResponse field mapped to HttpSseResponseDTO
  • BodyDTODeserializer and StrictBodyDTODeserializer handle the JSON_RPC body type
  • BodyDTO.createDTO() maps JsonRpcBody to JsonRpcBodyDTO

JSON-RPC Body Matching

Model

JsonRpcBody (mockserver-core/src/main/java/org/mockserver/model/JsonRpcBody.java) extends Body<String> with Body.Type.JSON_RPC. It has two fields:

FieldRequiredPurpose
methodYesMethod name to match (exact string or Java regex)
paramsSchemaNoJSON Schema string; when present, params is validated against it

Matcher

JsonRpcMatcher (mockserver-core/src/main/java/org/mockserver/matchers/JsonRpcMatcher.java) validates:

  1. jsonrpc field equals "2.0"
  2. method field matches — first by exact equality, then by String.matches() (regex)
  3. If paramsSchema is set, params is validated using JsonSchemaValidator; a missing params field fails validation
  4. Batch requests (JSON arrays) — matches if any element in the array satisfies all the above conditions

Integration Points

  • HttpRequestPropertiesMatcher.buildBodyMatcher() — added the JSON_RPC case to route to JsonRpcMatcher
  • BodyDTODeserializer and StrictBodyDTODeserializer — support two JSON representations:
    • Typed: {"type": "JSON_RPC", "method": "tools/list"}
    • Wrapped: {"jsonRpc": {"method": "tools/list"}}

Template Object Enhancement

HttpRequestTemplateObject (mockserver-core/.../templates/engine/model/HttpRequestTemplateObject.java) was extended with three fields extracted from JSON-RPC request bodies:

FieldVelocity variableValue
jsonRpcId$!{request.jsonRpcId}String representation of the ID (text nodes use text value; numeric/null use toString())
jsonRpcRawId$!{request.jsonRpcRawId}Raw JSON representation — preserves 1 for numbers and "abc" for strings; used for embedding directly in JSON response bodies
jsonRpcMethod$!{request.jsonRpcMethod}The method field value

Extraction is best-effort: any parse error is silently swallowed, leaving all three fields null.

WebSocket Mocking

Model

  • WebSocketMessage (mockserver-core/.../model/WebSocketMessage.java) — Single WebSocket message with text, binary, and delay fields
  • HttpWebSocketResponse (mockserver-core/.../model/HttpWebSocketResponse.java) — Action type extending Action<HttpWebSocketResponse> with subprotocol, messages list, and closeConnection flag

Action Type

Action.Type.WEBSOCKET_RESPONSE was added to the enum. This triggers the HttpWebSocketResponseActionHandler.

Handler

HttpWebSocketResponseActionHandler performs the WebSocket handshake using Netty's WebSocketServerHandshakerFactory, then sends configured messages as TextWebSocketFrame or BinaryWebSocketFrame. It:

  1. Reconstructs a Netty FullHttpRequest from the MockServer HttpRequest (preserving headers including Sec-WebSocket-Key)
  2. Performs the WebSocket handshake
  3. Removes HTTP codecs from the pipeline
  4. Sends each message with optional per-message delays
  5. Optionally sends CloseWebSocketFrame and closes the connection

Usage

mockServerClient.when(
    request().withMethod("GET").withPath("/ws")
).respondWithWebSocket(
    HttpWebSocketResponse.webSocketResponse()
        .withMessage(WebSocketMessage.webSocketMessage("hello"))
        .withMessage(WebSocketMessage.webSocketMessage("world"))
        .withCloseConnection(true)
);

Proxy passthrough (relay to a real upstream)

WebSocket is no longer mock-only. When MockServer is used as a proxy and a WebSocket upgrade request matches no WEBSOCKET_RESPONSE expectation (or matches a plain FORWARD expectation), MockServer relays the connection through to the real upstream WebSocket server — completing the upstream ws/wss handshake, relaying the 101 back to the client, then relaying frames bidirectionally until either side closes. The relayed frames are recorded (bounded by webSocketProxyMaxRecordedFrames) so retrieveRecordedRequests and the dashboard show the traffic. This is implemented at the proxy/relay layer by WebSocketProxyRelayHandler, not the mock action handler — see netty-pipeline.md → WebSocket Proxy Passthrough.

Realtime Voice API Mocking (OpenAI Realtime, Gemini Live)

Outcome

MockServer mocks the two dominant realtime (voice) LLM protocols — the OpenAI Realtime API (GA 2025 event protocol) and the Google Gemini Live API (BidiGenerateContent) — so an agent/app that speaks them can be tested fully offline, with no real API and no audio hardware. It is a thin layer over the existing WebSocket mock primitive: no new Action.Type, DTO, or JSON schema. A pure event codec generates the provider-correct event JSON; the Java client RealtimeMockBuilder wires that into a single httpWebSocketResponse expectation (initial pushed frame + per-incoming-frame matchers), exactly as A2A streaming reuses httpSseResponse.

flowchart LR
    Builder["RealtimeMockBuilder\n(client-java)"] --> Codec["OpenAiRealtimeCodec /\nGeminiLiveCodec\n(core, pure)"]
    Codec --> WS["HttpWebSocketResponse\nmessages + matchers"]
    WS --> Handler["HttpWebSocketResponseActionHandler\n+ BidirectionalWebSocketFrameHandler"]
    Handler --> Client["Realtime SDK client"]

Components

ClassModulePackagePurpose
RealtimeProvidercoreorg.mockserver.llm.realtimeOPENAI_REALTIME / GEMINI_LIVE (separate from the HTTP Provider enum)
RealtimeModalitycoreorg.mockserver.llm.realtimeAUDIO (transcript + audio deltas) or TEXT
RealtimeTurncoreorg.mockserver.llm.realtimeProvider-neutral scripted assistant turn (text, audio transcript, audio bytes, usage) — the realtime analogue of Completion
RealtimeStreamingPhysicscoreorg.mockserver.llm.realtimeDeterministic tokensPerSecond + time-to-first-token timing (jitter-free WS analogue of StreamingPhysics)
RealtimeEventcoreorg.mockserver.llm.realtimeOne rendered event frame {json, delayMillis}
OpenAiRealtimeCodeccoreorg.mockserver.llm.realtimePure OpenAI Realtime event codec
GeminiLiveCodeccoreorg.mockserver.llm.realtimePure Gemini Live event codec
RealtimeMockBuilderclient-javaorg.mockserver.clientBuilds the httpWebSocketResponse expectation

How the flow maps onto the WebSocket primitive

The realtime session is inherently request-driven, which is exactly what the WebSocket mock's matchers model (incoming frame → scripted responses) provides, plus one connect-time push (messages):

  • OpenAIsession.created is pushed on connect (messages); matchers answer session.updatesession.updated, conversation.item.createconversation.item.created, and response.create → the full scripted response event sequence.
  • Gemini — nothing is pushed on connect; matchers answer setupsetupComplete and clientContent → the scripted serverContent chunk stream. Matchers use a DOTALL regex on the distinctive top-level key ("setup", "clientContent") / OpenAI "type" value.

Because the bidirectional matcher schedules a match's response frames concurrently (each with a delay relative to the match instant), the builder converts the codec's per-event gaps into monotonically-increasing cumulative absolute delays (RealtimeMockBuilder.toMessages, MIN_STEP_MILLIS floor) so the event stream stays strictly ordered. The same script answers every matching frame, so a client that repeats response.create / clientContent receives the scripted turn each time. closeConnection is false — the client owns disconnect.

Protocol coverage matrix (event type → status)

OpenAI Realtime (server events)

EventStatus
session.created (connect push)✅ mocked
session.updated (← session.update)✅ mocked
conversation.item.created (← conversation.item.create)✅ mocked
response.created✅ mocked
response.output_item.added✅ mocked
response.content_part.added✅ mocked
response.output_audio_transcript.delta / .done✅ mocked (AUDIO)
response.output_audio.delta / .done✅ mocked (AUDIO, silence placeholder bytes)
response.output_text.delta / .done✅ mocked (TEXT)
response.content_part.done, response.output_item.done✅ mocked
response.done (with usage)✅ mocked
input_audio_buffer.* / server VAD (speech_started/stopped)⛔ deferred
conversation.item.input_audio_transcription.*⛔ deferred
function-call output items, rate_limits.updated, error⛔ deferred

Gemini Live (server messages)

MessageStatus
setupComplete (← setup)✅ mocked
serverContent.modelTurn text parts (← clientContent)✅ mocked (TEXT)
serverContent.modelTurn inlineData audio + outputTranscription✅ mocked (AUDIO, silence placeholder bytes)
serverContent.generationComplete / turnComplete✅ mocked
usageMetadata✅ mocked
realtimeInput / realtimeInputAcknowledgement⛔ deferred
toolCall / toolCallCancellation / toolResponse⛔ deferred
goAway, sessionResumptionUpdate, interrupted⛔ deferred

Audio bytes are opaque silence placeholders — the fidelity target is the event protocol, not audio DSP.

Usage

import static org.mockserver.llm.realtime.RealtimeTurn.realtimeTurn;

// OpenAI Realtime — point the SDK at ws://localhost:1080/v1/realtime
RealtimeMockBuilder.openAiRealtime()
    .withModel("gpt-realtime")
    .respondingWith(realtimeTurn("The capital of France is Paris.")
        .withInputTokens(20).withOutputTokens(7))
    .applyTo(mockServerClient);

// Gemini Live
RealtimeMockBuilder.geminiLive()
    .respondingWith("Bonjour le monde")
    .applyTo(mockServerClient);

MCP Mock Builder

Purpose

McpMockBuilder generates a complete set of Expectation[] objects that make MockServer behave as a mock MCP server. This allows testing MCP clients against a predictable, configurable mock.

Location

mockserver-client-java/src/main/java/org/mockserver/client/McpMockBuilder.java

Defaults

PropertyDefault
path/mcp
serverNameMockMCPServer
serverVersion1.0.0
protocolVersion2025-06-18

Protocol Version Negotiation (server)

MockServer's own MCP server (McpRequestProcessor) advertises and negotiates the 2025-06-18 MCP spec revision, while remaining backward compatible with older clients:

  • The client sends its preferred protocolVersion in initialize. When it is one the server supports (2025-06-18, 2025-03-26, 2024-11-05) the server echoes it back; otherwise (or when omitted) the server replies with its latest, 2025-06-18 (negotiateProtocolVersion).
  • The negotiated version is stored on the McpSession and governs whether version-specific response fields are emitted for that session.
  • Mcp-Session-Id is emitted on the initialize response and required (and echoed) on subsequent requests — already handled by McpStreamableHttpHandler.

2025-06-18 Capabilities

CapabilityServer (McpRequestProcessor)Mock builder (McpMockBuilder)
Structured tool output (structuredContent)tools/call results include structuredContent (the raw tool-result object) when the session negotiated 2025-06-18+respondingWithStructured(text, structuredJson) + withOutputSchema(schema) (advertised in tools/list)
Resource links (type: resource_link)respondingWithResourceLink(uri, name, description, mimeType) on a tools/call result
Mcp-Session-IdEmitted on initialize, required on subsequent requestsSession handling is the client's responsibility against the mock

Deferred (require a server→client push channel): elicitation/create (server-initiated) and the GET SSE server-push stream are not mocked — MockServer's request/response expectation model has no channel to initiate requests to the client. sampling/createMessage remains a deterministic mocked completion on the server. JSON-RPC batching (removed in 2025-06-18) is still accepted for back-compat with older clients.

Generated Expectations

MCP MethodRequest MatcherResponse Type
initializePOST {path} + JsonRpcBody("initialize")Velocity template — echoes jsonRpcRawId, returns server info and capabilities
pingPOST {path} + JsonRpcBody("ping")Velocity template — echoes jsonRpcRawId, returns {}
notifications/initializedPOST {path} + JsonRpcBody("notifications/initialized")Static HttpResponse 200 with empty JSON body
tools/listPOST {path} + JsonRpcBody("tools/list")Velocity template — returns configured tools array
tools/call (per tool)POST {path} + JsonPathBody matching method == 'tools/call' and params.name == '{toolName}'Velocity template — returns text content and isError flag
resources/listPOST {path} + JsonRpcBody("resources/list")Velocity template — returns configured resources array
resources/read (per resource)POST {path} + JsonPathBody matching method == 'resources/read' and params.uri == '{uri}'Velocity template — returns resource text and mimeType
prompts/listPOST {path} + JsonRpcBody("prompts/list")Velocity template — returns configured prompts array
prompts/get (per prompt)POST {path} + JsonPathBody matching method == 'prompts/get' and params.name == '{promptName}'Velocity template — returns messages array

The tools/list, resources/list, and prompts/list expectations are generated whenever tools, resources, or prompts are registered respectively, or when the corresponding capability flag (withToolsCapability(), etc.) is explicitly set.

JSON-RPC ID Echoing

All Velocity templates embed $!{request.jsonRpcRawId} as the id field in the JSON-RPC response body. This preserves the original ID type (number or string) and ensures correct request-response correlation for MCP clients.

Usage

McpMockBuilder.mcpMock("/mcp")
    .withServerName("TestMCP")
    .withServerVersion("1.0.0")
    .withTool("get_weather")
        .withDescription("Get weather for a city")
        .respondingWith("72F and sunny")
        .and()
    .withResource("config://app")
        .withName("App Config")
        .withMimeType("application/json")
        .withContent("{\"debug\": true}")
        .and()
    .withPrompt("summarize")
        .withDescription("Summarize text")
        .withArgument("text", "Text to summarize", true)
        .respondingWith("assistant", "Here is your summary.")
        .and()
    .applyTo(mockServerClient);

applyTo(MockServerClient) calls client.upsert(build()). build() can also be called directly to obtain the Expectation[] array without applying it.

A2A Mock Builder

Purpose

A2aMockBuilder generates expectations for a mock A2A (Agent-to-Agent Protocol) agent. The A2A protocol uses JSON-RPC 2.0 over HTTP with an Agent Card discovery mechanism (GET /.well-known/agent.json).

Location

mockserver-client-java/src/main/java/org/mockserver/client/A2aMockBuilder.java

Defaults

PropertyDefault
path/a2a
agentCardPath/.well-known/agent.json
agentNameMockAgent
agentDescriptionA mock A2A agent
agentVersion1.0.0
agentUrlhttp://localhost{path} (derived)
defaultTaskResponseTask completed successfully

Generated Expectations

EndpointRequest MatcherResponse Type
Agent CardGET {agentCardPath}Static HttpResponse — JSON agent card with name, description, version, url, capabilities, and skills
tasks/sendPOST {path} + JsonRpcBody("tasks/send")Velocity template — completed task with default response text
tasks/getPOST {path} + JsonRpcBody("tasks/get")Velocity template — completed task with default response text
tasks/cancelPOST {path} + JsonRpcBody("tasks/cancel")Velocity template — canceled task with status.state: "canceled"
Custom task handlers (per handler)POST {path} + JsonPathBody matching method == 'tasks/send' and params.message.parts[0].text =~ /{pattern}/Velocity template — completed or failed task with custom response text

Custom task handlers are evaluated in registration order. Because MockServer matches expectations in priority/registration order, more specific handlers should be registered before the generic tasks/send catch-all.

Streaming and Push Notifications

Both A2A capabilities are opt-in and additive — by default the agent card still advertises streaming: false and pushNotifications: false, and build() produces the same expectations as before.

Builder methodEffect
withStreaming() / withStreamingMethod(String)Agent card advertises capabilities.streaming: true. Adds an httpSseResponse expectation matching POST {path} + JsonRpcBody({streamingMethod}) (default message/stream, legacy tasks/sendSubscribe). The SSE stream emits three events, each a JSON-RPC 2.0 response envelope: a TaskStatusUpdateEvent with status.state: working (final: false), a TaskArtifactUpdateEvent carrying the default task response text, and a final TaskStatusUpdateEvent with status.state: completed (final: true). The expectation reuses the existing HttpSseResponse / HttpSseResponseActionHandler SSE infrastructure.
withPushNotifications(webhookUrl)Agent card advertises capabilities.pushNotifications: true. Adds a tasks/pushNotificationConfig/set expectation that echoes the registered config ({"url": "..."}), and replaces the generic tasks/send expectation with an HttpOverrideForwardedRequest that POSTs the completed task (the push payload) to the parsed webhook host/port/scheme/path. The caller's JSON-RPC response is produced by a Velocity response template so the request's id is echoed ($!{request.jsonRpcRawId}), matching the non-push tasks/send contract.

Because the streaming and push-delivery expectations match message/stream / tasks/send respectively and are registered before the generic tasks/send catch-all (which is omitted when push is configured), they take precedence in registration order.

Notes and limitations:

  • Escaping: the caller response is Velocity-templated (response text is Velocity-escaped so $/# render literally), whereas the webhook POST body is a literal request override (JSON-escaped only — no Velocity escaping, which would corrupt $/#).
  • Custom handlers + push: push delivery fires only for the generic tasks/send catch-all. Custom onTaskSend(...) handlers still return a task response to the caller but do not POST to the webhook.
  • Delivery failures are non-fatal-but-visible: the caller response template renders only when the webhook returns a response; if the webhook is unreachable the caller receives the forward error rather than a synthesised 200.
  • Streaming JSON-RPC id: the A2A builder's SSE event envelopes use a fixed placeholder id ("1"); streaming clients correlate by the stream itself. Since T1.2, HttpSseResponse (and HttpWebSocketResponse / gRPC grpcStreamResponse) support an optional templateType, so a hand-authored streaming expectation can now template each event/message payload — e.g. echo the request's JSON-RPC id via $jsonPath.find("$.id") — against the triggering request. The A2A builder itself still emits static envelopes.

Usage

A2aMockBuilder.a2aMock("/agent")
    .withAgentName("TranslationAgent")
    .withAgentDescription("Translates text between languages")
    .withSkill("translate")
        .withName("Translation")
        .withDescription("Translates text")
        .withTag("nlp")
        .and()
    .onTaskSend()
        .matchingMessage("translate.*")
        .respondingWith("Bonjour")
        .and()
    .applyTo(mockServerClient);

gRPC Mocking

MockServer supports mocking gRPC services without requiring grpc-java as a dependency. Instead, it uses a pure Netty pipeline approach: gRPC requests are decoded from HTTP/2 + protobuf framing into JSON, routed through the existing matching engine as POST /<service>/<method>, and responses are re-encoded back to gRPC framing. This means all existing JSON/JSONPath/JSONSchema matchers work with gRPC automatically.

Architecture

flowchart LR
    Client["gRPC Client"] -->|HTTP/2 + protobuf| GRH["GrpcToHttpRequestHandler\n(decode protobuf→JSON)"]
    GRH -->|POST /service/method\nJSON body| MH["HttpRequestHandler\n(standard matching)"]
    MH -->|JSON response| GWH["GrpcToHttpResponseHandler\n(encode JSON→protobuf)"]
    GWH -->|HTTP/2 + protobuf\n+ grpc-status trailers| Client

Proto Descriptor Infrastructure

gRPC mocking requires proto descriptors so MockServer can convert between protobuf binary and JSON. Three loading mechanisms are supported:

MechanismConfig PropertyDescription
Descriptor files (.dsc/.desc)grpcDescriptorDirectoryDirectory of pre-compiled descriptor set files
Proto source files (.proto)grpcProtoDirectoryDirectory of .proto files compiled at startup via protoc
Runtime REST API uploadPUT /mockserver/grpc/descriptorsUpload descriptor bytes at runtime via client API

Core classes:

ClassModulePurpose
GrpcProtoDescriptorStorecoreRegistry of loaded service/method descriptors, provides converters
GrpcProtoFileCompilercoreCompiles .proto files to descriptors via protoc
GrpcJsonMessageConvertercoreConverts protobuf binary ↔ JSON using com.google.protobuf.util.JsonFormat
GrpcFrameCodeccoreEncodes/decodes the 5-byte gRPC length-prefixed framing
GrpcStatusMappercoreMaps between gRPC status codes and names

Netty Pipeline Integration

gRPC handlers are conditionally inserted into both h2c (HTTP/2 cleartext) and TLS-negotiated HTTP/2 pipelines when the descriptor store has loaded services:

graph LR
    H2C["HTTP/2 Connection Handler"] --> CB[CallbackWebSocketServerHandler]
    CB --> DASH[DashboardWebSocketHandler]
    DASH --> CODEC[MockServerHttpServerCodec]
    CODEC --> GRPC_RESP["GrpcToHttpResponseHandler"]
    GRPC_RESP --> GRPC_REQ["GrpcToHttpRequestHandler"]
    GRPC_REQ --> HANDLER[HttpRequestHandler]

The handlers are placed after MockServerHttpServerCodec so they operate on MockServer model objects. GrpcToHttpRequestHandler intercepts inbound HttpRequest objects with content-type: application/grpc, extracts the service and method from the path, decodes the protobuf body to JSON, and forwards with x-grpc-service, x-grpc-method headers.

GrpcToHttpResponseHandler is an outbound encoder that encodes the JSON body back to protobuf binary with gRPC framing and emits grpc-status / grpc-message as trailers.

Request → response service/method propagation

The x-grpc-service / x-grpc-method headers that convertGrpcRequest sets are on the request only — the matching pipeline does not copy internal headers onto the matched response. Resolution order in GrpcToHttpResponseHandler.encode() is therefore:

  1. Explicit x-grpc-service / x-grpc-method response headers — set by GrpcForwardTranslator.decodeResponseFromUpstream on the forward-proxy path, or by a user opting in. These always win.
  2. The per-connection GrpcPendingRequests registry — recorded by GrpcToHttpRequestHandler.convertGrpcRequest when it decoded the request, and looked up in encode() by the response's HTTP/2 stream id. This is what makes conversion fire for an ordinary mock expectation whose matcher (not response) carries the gRPC headers.
Why the record is keyed by stream id

Both handlers are @ChannelHandler.Sharable, so this state must live on the channel, never in a field. It is tempting to conclude that a single-slot channel attribute suffices because "each HTTP/2 stream gets its own child channel" — that is false in the default configuration, and getting it wrong breaks every concurrent call but one:

ConfigurationPipeline installed by PortUnificationHandlerWhat ctx.channel() is
grpcBidiStreamingEnabled off (default)connection adapter — InboundHttp2ToHttpAdapter, both gRPC handlers added to the connection-level pipelinethe shared TCP connection, common to every multiplexed stream
grpcBidiStreamingEnabled onswitchToHttp2MultiplexGrpcMultiplexChildInitializera per-stream child channel
HTTP/1.1connection pipelinethe connection (but only one exchange is ever in flight)

Only the middle row makes a single slot per-stream. On the default path all requests are read before any response is written, so each record would overwrite the last: measured with four concurrent unary calls on one ManagedChannel, three returned unconverted JSON and only the last succeeded. With two different RPCs in flight the mix-up is worse than a dropped conversion — a response can be converted against the other method's output type, yielding a wrong-typed message or a fabricated grpc-status: 13 INTERNAL.

What actually makes the state safe on each path:

  • HTTP/2 (either pipeline) — the record is keyed by HttpRequest.getStreamId(), set in FullHttpRequestToMockServerHttpRequest only when the protocol really is HTTP/2 (so an HTTP/1.1 client cannot forge it) and copied onto the response by ResponseWriter.writeResponse. encode() removes the entry for its own stream, so no record is visible to another stream.
  • HTTP/1.1 — there is no stream id and no intra-connection concurrency: exactly one response per request, in order. A single slot is consumed-and-cleared on use, and is additionally discarded when a non-gRPC request arrives on the connection, so a record left by an abandoned gRPC exchange cannot convert an unrelated later response (for example a control-plane JSON response on the same port).
  • Abandoned exchanges — a drop-connection action, an unreleased request-phase breakpoint, or an exception before the write can leave a record that is never consumed. The registry dies with the connection, and evicts in insertion order beyond GrpcPendingRequests.MAX_PENDING_STREAMS so a long-lived HTTP/2 connection cannot accumulate them without bound.

Eviction must never reach a live stream: a stream whose record was evicted skips conversion and goes out as raw JSON, silently reintroducing #2419 under load. That is guaranteed structurally rather than assumed — MAX_PENDING_STREAMS is derived from PortUnificationHandler.HTTP2_MAX_CONCURRENT_STREAMS (× 2), the SETTINGS_MAX_CONCURRENT_STREAMS value MockServer advertises and Netty enforces with REFUSED_STREAM (AbstractHttp2ConnectionHandlerBuilder.enforceMaxActiveStreamsconnection.remote().maxActiveStreams(...)). A test pins the inequality so the two constants cannot drift, and eviction logs at WARN so the condition is diagnosable if the invariant is ever broken.

That limit is advertised explicitly rather than inherited. Netty's default is not stable across versions: 4.1's Http2Settings.defaultSettings() sets only maxHeaderListSize (no concurrent-stream limit, so RFC 9113 permits unbounded streams), while 4.2 added maxConcurrentStreams(SMALLEST_MAX_CONCURRENT_STREAMS) = 100. Depending on that default would make the registry's correctness a silent function of the Netty version. MockServer therefore sets 100 itself on all three HTTP/2 pipeline sites — the same value 4.2 supplies, so behaviour is unchanged.

The HTTP/1.1 slot is single-shot. Netty keeps reading after channelRead returns, so with HTTP/1.1 pipelining and an asynchronous action a second request can genuinely be decoded before the first response is written. Rather than let the second record overwrite the first — which would convert the first response against the second request's method, producing a wrong-typed message or a fabricated grpc-status: 13GrpcPendingRequests.record marks the slot ambiguous and consume then returns null, so neither response is converted. Refusing to convert is strictly safer: the response goes out unconverted (the pre-#2419 behaviour, visible and debuggable) rather than silently wrong. The ambiguity is logged at WARN and clears on the next consume, so it cannot wedge the connection.

This is pinned by shouldRefuseToConvertWhenTheHttp11SlotIsAmbiguous and shouldPassPipelinedResponsesThroughUnconvertedRatherThanMisconvert in GrpcToHttpResponseHandlerTest.

Only matched, successful responses are converted

A non-2xx response carrying no explicit gRPC status did not come from a matched gRPC expectation — overwhelmingly the 404 notFoundResponse produced when nothing matched. Converting it would resolve the absent status to OK, let the descriptor example synthesizer invent a schema-valid body, and overwrite the 404 with 200: the server log would say 404 Not Found while the client received a plausible success. Such responses are instead mapped to a gRPC error via GrpcStatusMapper.fromHttpTransportStatus, the gRPC-over-HTTP/2 spec's HTTP-status mapping — 404 becomes UNIMPLEMENTED, which is what a real gRPC server returns for an unknown method.

Note this is deliberately not GrpcStatusMapper.fromHttpStatus, which inverts the gRPC → HTTP rendering carried on GrpcStatusCode (404 → NOT_FOUND). An explicitly-authored grpc-status / grpc-status-name still takes precedence over the HTTP status.

The health-check, reflection and chaos paths short-circuit in channelRead0 before convertGrpcRequest and write already-framed responses, so they deliberately do not record the attribute — otherwise their bodies would be framed twice.

Before this wiring existed, the documented unary expectation returned raw JSON on a stream the client expected to be framed protobuf (issue #2419). The HTTP/3 path avoids the same trap by capturing service/method from the original request in Http3GrpcResponseWriter.

grpc-message percent-encoding

grpc-message is a Percent-Encoded field in the gRPC wire specification: ASCII only, with every byte outside 0x20-0x7E — plus % itself — escaped as %XX over the value's UTF-8 bytes. Clients percent-decode on receipt, so writing the raw string is wrong even for ordinary input.

The rule: every write of a grpc-message value goes through GrpcStatusMapper.percentEncodeMessage, exactly once. That covers the unary encoder, both HTTP/3 frame builders, both bidi handlers, the server-streaming handler, the gRPC-Web trailer frame, and the responses GrpcToHttpRequestHandler writes directly (health check, reflection errors, decode errors, chaos faults, the deadline response) — currently 14 call sites, which is why this is stated as a rule rather than an inventory that drifts. Grep for GRPC_MESSAGE_HEADER to enumerate them.

Exactly once matters as much as at-all: convertToGrpcWebResponse decodes the already-encoded trailer before buildTrailerFrame re-encodes it, because encoding twice sends quota 50%2525 exceeded for an authored quota 50% exceeded. GrpcForwardTranslator.decodeResponseFromUpstream applies the inverse, percentDecodeMessage, so a message from a real upstream server appears decoded in the log and in verifications and is not double-encoded when re-emitted.

Three distinct failures this prevents:

InputWithout encodingWhy
invalid escape %41 in patternclient sees invalid escape A in patternthe client decodes %41
paiement refusémojibake on h1/h2; literal ? on gRPC-WebNetty's AsciiString byte-casts char & 0xFF; the gRPC-Web trailer frame is written US_ASCII
denied\r\ngrpc-status: 0a second grpc-status line is injectedthe gRPC-Web trailer frame is a CRLF-delimited block

That last row is a security issue, not just conformance: depending on whether the client takes the first or last grpc-status, an error can be turned into a success. The outbound mapper's sanitizeHeaderValue cannot help — GrpcToHttpResponseHandler runs before MockServerHttpServerCodec on the outbound path (tail→head), so by then the bytes are already inside the body. Trailer names and values are additionally CRLF-stripped in buildTrailerFrame as a second layer.

Note grpc-java decodes leniently, so a % not followed by two hex digits happens to survive unencoded — which is why the round-trip test uses the %41 form that genuinely corrupts, rather than a bare %.

Deadlines (grpc-timeout)

A client's deadline arrives as grpc-timeout: <1-8 digits><unit>, parsed by GrpcTimeout (units are case-sensitive: Hours, Minutes, Seconds, millis, umicros, nanos — M and m differ by a factor of 60,000). The header is still passed through as an ordinary request header, so it remains matchable; enforcement is additive.

TransportWhere scheduledHow the race is resolved
HTTP/1.1, HTTP/2, gRPC-WebGrpcToHttpRequestHandler.scheduleDeadline, timer held on the GrpcPendingRequests recordclaimForDeadline — whichever of the response and the deadline arrives first wins; both run on the channel event loop
HTTP/3Http3GrpcResponseWriter.scheduleDeadline (each QUIC stream is its own channel)an AtomicBoolean completed

When the deadline wins, DEADLINE_EXCEEDED trailers are written and the stream id is remembered, so the late response — the Delay that outran the client — is dropped rather than written as a second response onto a stream that already carries terminal trailers. Timers are cancelled when the exchange is answered, when its record is evicted, and on channelInactive, so none can outlive its exchange.

This is a behaviour change: previously the client timed out locally while MockServer went on writing to an abandoned stream.

Mid-stream cancellation. Streaming RPCs are covered too, via GrpcStreamDeadline (mockserver-core), one instance per RPC invocation threaded through the emission recursion. It is deliberately not a channel attribute: on the HTTP/2 connection-adapter pipeline one channel is shared by every multiplexed stream, so a channel-scoped guard would be replaced by the next overlapping RPC — the same error class as the single-slot service/method attribute this change set already had to fix.

PathTerminal guardDeadline writes
GrpcStreamResponseActionHandler (h1/h2 server streaming, gRPC-Web)GrpcStreamDeadline.tryTerminate() CAS, checked in scheduleMessages/writeGrpcFrameDefaultLastHttpContent trailers
GrpcBidiStreamHandler (h2 bidi)finished converted from volatile boolean to AtomicBoolean — the previous check-then-set was not atomic, so two terminal paths could each write a terminal HEADERS framewriteTrailer(DEADLINE_EXCEEDED, …)
Http3GrpcResponseWriter (h3 unary + server streaming)AtomicBoolean completed for unary; for streaming an AtomicReference<StreamState> (IDLE → STREAMING → COMPLETED), so starting the stream and an elapsed deadline are a single atomic transition and the terminal frame shape (trailing vs trailers-only) is chosen from the state the deadline actually observedtrailing or trailers-only HEADERS
Http3GrpcBidiStreamHandler (h3 bidi)finishedGuard (also converted to AtomicBoolean)writeErrorTrailer(DEADLINE_EXCEEDED, …)

Every emission point additionally checks the guard before writing, because a per-message delay or a breakpoint resume can land after the deadline fired — including GrpcBidiStreamHandler's initial HEADERS, which are deferred by the action delay and so can be scheduled to run after the deadline has already terminated the stream. A terminal frame written before those initial HEADERS is emitted as a complete Trailers-Only response (carrying :status), since it is then the first frame on the stream. Timers are cancelled on normal completion, on channelInactive, and — for the HTTP/3 writers and the server-streaming handler, whose deadlines are not registered in GrpcPendingRequests — via a closeFuture listener, so none can outlive its stream.

A separate CAS is needed on the streaming entry point specifically because writeGrpcStreamResponse is dispatched off the stream's event loop by the scheduler whenever a delay is configured: a plain "check completed, then mark started" pair is not atomic against the deadline, and losing that race put two :status HEADERS frames on one stream.

How this is verified. The end-to-end real-client test cannot distinguish server-side termination from the client giving up: grpc-java reports DEADLINE_EXCEEDED either way, at the same instant. GrpcStreamDeadlineTest therefore asserts at handler level on an EmbeddedChannel with a manually driven scheduler, where the frames MockServer actually wrote are directly observable — exactly one terminal trailer carrying status 4, and no message frame after it even once the pending 5s message delay elapses.

Message size and encoding negotiation

GrpcFrameCodec.maxMessageSize() is the single definition of the decoded-message limit (see maxGrpcMessageSize); IncrementalGrpcFrameDecoder reads it from there rather than keeping its own copy. Exceeding it raises a GrpcException carrying RESOURCE_EXHAUSTED. The status now travels on the exception rather than being inferred from the message text, which is what previously collapsed everything except "unknown gRPC method" to INTERNAL.

The frame's compressed flag says that a message is compressed, not howgrpc-encoding does. It is now read and validated, so an unsupported encoding (deflate, snappy) returns UNIMPLEMENTED with a grpc-accept-encoding: identity, gzip response header telling the client what to retry with, instead of failing inside gzip as an opaque INTERNAL. grpc-accept-encoding is advertised on gRPC responses generally.

Trailers-Only on HTTP/2

A body-less gRPC response on HTTP/2 is collapsed into the gRPC Trailers-Only form — one end-of-stream HEADERS frame carrying :status, content-type and grpc-status, with no DATA frame and no separate trailing HEADERS frame — by GrpcToHttpResponseHandler.asTrailersOnlyIfHttp2. Moving the status into the headers makes the response mapper take its no-trailers branch and emit a DefaultFullHttpResponse with empty content, which HttpToHttp2ConnectionHandler writes as a single endStream=true HEADERS frame.

Gated on the response carrying an HTTP/2 stream id. Trailers-Only is an HTTP/2 concept, and on HTTP/1.1 putting grpc-status in the headers is precisely the #2419 defect, so HTTP/1.1 keeps real trailers. HTTP/3 already emitted Trailers-Only; this makes HTTP/2 agree.

Trailer contract

Per gRPC-over-HTTP/2 (and HTTP/3), a unary response must deliver grpc-status (and grpc-message) in a terminal trailing HEADERS frame. Only content-type: application/grpc is a real header. This holds across every gRPC path in the codebase — GrpcToHttpResponseHandler, the direct responses in GrpcToHttpRequestHandler (health check, reflection, chaos buildFaultResponse including its customTrailers), GrpcStreamResponseActionHandler, GrpcBidiStreamHandler, GrpcBidiReflectionHandler, GrpcHttp3Adapter and Http3GrpcResponseWriter.

Trailers are set with HttpResponse.withTrailer(...) and written by MockServerHttpResponseToFullHttpResponse.mapResponseWithTrailers, which forces chunked transfer-encoding on HTTP/1.1 and rides the trailing HEADERS frame on HTTP/2 / HTTP/3. The chaos omitGrpcStatus fault is only a genuine fault simulation because the non-faulted case emits a trailer.

Status resolution lives in GrpcResponseStatusResolver (mockserver-core) and is shared by every gRPC response path — GrpcToHttpResponseHandler (HTTP/1.1, HTTP/2), GrpcHttp3Adapter and Http3GrpcResponseWriter (HTTP/3). The order is: grpc-status-name header → explicit numeric grpc-status header or trailer → HTTP-status mapping for a non-2xx response (see above) → OK.

It is shared rather than reimplemented per transport because the rules are a property of the gRPC contract, not the wire protocol — and duplicating them is exactly how HTTP/3 drifted. Before extraction, HTTP/3 read the status from headers only and defaulted to "0", so an expectation authored with withTrailer("grpc-status", "5") (the form the consumer docs recommend) returned NOT_FOUND over HTTP/2 but OK over HTTP/3, and an unmatched request over HTTP/3 fabricated a success with the 404 body as its payload.

Two further HTTP/3-only gaps are fixed alongside: a unary HTTP/3 response now copies the expectation's own headers onto the initial (or trailers-only) HEADERS frame — previously it emitted only :status, content-type and server, silently dropping withHeader(...), even though HTTP/2 preserved them via clone() and the HTTP/3 server-streaming path copied them via addConfiguredHeaders. gRPC protocol metadata is excluded from the copy so the transport remains the single source of grpc-status.

Server-streaming must carry the request's stream id too. GrpcStreamResponseActionHandler writes raw Netty objects straight to the channel, bypassing MockServerHttpResponseToFullHttpResponse, so nothing stamped streamId and the entire stream went to a fresh server-initiated stream — a real gRPC client received nothing and hung until its deadline (measured: 0 of 2 messages before, 2 of 2 after). The initial DefaultHttpResponse now carries HttpConversionUtil.ExtensionHeaderNames.STREAM_ID; Netty's adapter latches that id from the initial HttpMessage and reuses it for the subsequent HttpContent frames, so setting it once covers the whole stream.

Direct responses must carry the request's stream id. Health check, server reflection, chaos faults and request-decode errors are written straight from GrpcToHttpRequestHandler rather than through the matching engine, so ResponseWriter.writeResponse never stamps streamId on them. Without it HttpToHttp2ConnectionHandler.getStreamId falls back to connection().local().incrementAndGetNextStreamId() and replies on a fresh server-initiated stream, so the client's call hangs until deadline. Every direct write funnels through tagGrpcWebResponse(response, request, grpcWebContentType), which stamps the stream id and the gRPC-Web marker together, so a new direct-response path cannot forget one.

A numeric grpc-status is emitted verbatim (parsed as an integer, so whitespace is normalised, then re-rendered) rather than round-tripped through GrpcStatusMapper.fromCode. That lookup is getOrDefault(code, UNKNOWN), so round-tripping would silently rewrite a user simulating a non-standard or future status — 42 would arrive at the client as 2. The GrpcStatusCode enum is used only where a typed status is genuinely required.

GrpcUnaryClientIntegrationTest (mockserver-netty, test scope) drives this contract with a real grpc-java client over h2c using DynamicMessage and the loaded descriptor set, so both halves of the contract are verified against an actual client rather than only at handler level. It uses grpc-netty-shaded so grpc's bundled Netty 4.1 cannot clash with MockServer's Netty 4.2.

gRPC-Web Support

gRPC-Web is a variant of gRPC designed for browser clients that cannot use HTTP/2 trailers. MockServer supports gRPC-Web as a translation layer in front of the existing gRPC pipeline:

Content types: application/grpc-web, application/grpc-web+proto (binary), application/grpc-web-text, application/grpc-web-text+proto (base64-encoded).

Request path:

  1. GrpcToHttpRequestHandler detects gRPC-Web content types and calls translateGrpcWebRequest() before any gRPC processing
  2. For the -text variant, the body is base64-decoded
  3. The content-type is replaced with application/grpc and the original content-type is stored in x-grpc-web-content-type header
  4. The translated request passes through the normal gRPC pipeline unchanged

Response path:

  1. GrpcToHttpResponseHandler.encode() checks for the x-grpc-web-content-type header on outbound responses
  2. If present, convertToGrpcWebResponse() re-frames the response: grpc-status/grpc-message are read from the response trailers (falling back to headers), embedded in a trailer frame (flag byte 0x80) appended to the message body, and then stripped from both the headers and the trailers so the status is not also emitted as a real HTTP trailer. Reading trailers first is load-bearing: GrpcWebTranslator.buildTrailerFrame defaults a missing status to "0", so a headers-only read would silently report OK on every gRPC-Web error
  3. For the -text variant, the entire body (message frames + trailer frame) is base64-encoded
  4. The response content-type is set to the matching gRPC-Web type

Pipeline placement: gRPC handlers are added to the HTTP/1.1 pipeline (in switchToHttp()) in addition to the HTTP/2 pipelines, since gRPC-Web works over both HTTP/1.1 and HTTP/2.

Core class: GrpcWebTranslator (mockserver-core, org.mockserver.grpc) provides the encoding/decoding utilities (trailer frame construction, base64 handling, content-type detection). The handler modifications in mockserver-netty are localized to the existing GrpcToHttpRequestHandler and GrpcToHttpResponseHandler.

Content-type discrimination: GrpcStatusMapper.isGrpcContentType() explicitly excludes application/grpc-* prefixes (e.g. application/grpc-web) so that gRPC-Web requests are not misrouted through the standard gRPC path.

Connect protocol (unary): Supported as a convenience layer over plain HTTP — see Connect Protocol (Unary) below. Connect streaming is not supported.

Connect Protocol (Unary)

Connect (buf.build Connect) unary RPCs are, unlike gRPC, ordinary HTTP POST requests to /package.Service/Method carrying the request message directly (JSON or proto) with Content-Type: application/json (or application/proto) — there is no gRPC length-prefixed framing and no HTTP/2 trailer envelope. Because they are plain HTTP, MockServer's normal expectation matching already handles them: a user can mock a Connect unary call with a standard httpRequest/httpResponse expectation (body matchers, headers, delays, verification, the dashboard all work unchanged). The Connect support is therefore a thin convenience + correctness layer, not a new protocol pipeline — no new Action.Type, DTO, JSON schema, or Netty handler, and real gRPC (application/grpc) traffic is completely unaffected because nothing in the gRPC pipeline is touched.

ClassModulePackagePurpose
ConnectErrorcoreorg.mockserver.grpc.connectThe Connect error model {code, message, details} plus the canonical Connect error-code ↔ HTTP-status mapping (Code enum)
ConnectResponsecoreorg.mockserver.grpc.connectStatic factory returning a plain HttpResponse: success(json) (HTTP 200 + application/json) and error(ConnectError) (mapped non-200 + JSON error envelope)
ConnectUnaryDetectorcoreorg.mockserver.grpc.connectConservative detection of Connect unary requests (POST + /pkg.Svc/Method path + JSON/proto, never application/grpc*) and optional descriptor-aware request-body validation

Connect error code ↔ HTTP status (Connect codes are the lower-case snake_case forms of the gRPC status names; mapping per the Connect spec / connectrpc/connect-go codeToHTTP):

Connect codeHTTP statusConnect codeHTTP status
canceled499aborted409
unknown500out_of_range400
invalid_argument400unimplemented501
deadline_exceeded504internal500
not_found404unavailable503
already_exists409data_loss500
permission_denied403unauthenticated401
resource_exhausted429failed_precondition400

There is no Connect code for gRPC OK; a successful unary response is an HTTP 200 with the message body, not an error envelope.

Usage (Java client):

// success: HTTP 200, application/json, body is the response message directly
mockServerClient
    .when(request().withMethod("POST").withPath("/pkg.Svc/Method"))
    .respond(ConnectResponse.success("{\"greeting\":\"Hello World\"}"));

// error: HTTP 404, {"code":"not_found","message":"..."}
mockServerClient
    .when(request().withMethod("POST").withPath("/pkg.Svc/Method"))
    .respond(ConnectResponse.error(ConnectError.Code.NOT_FOUND, "greeting not found"));

Deferred: Connect server/bidi streaming (the application/connect+json framed stream), the GET-side unary variant, and request/response compression.

h2c Detection

PortUnificationHandler.decode() includes isH2cPreface() which detects the HTTP/2 connection preface (PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n) on cleartext connections. When detected, switchToH2c() assembles the HTTP/2 pipeline with gRPC handlers, enabling gRPC over plaintext HTTP/2.

Streaming Support

GrpcStreamResponse is an action type for gRPC server streaming (and as a building block for other streaming patterns). It follows the same recursive scheduling pattern as HttpSseResponse:

ClassModulePurpose
GrpcStreamMessagecore (model)A single message in a stream: JSON body + optional per-message Delay
GrpcStreamResponsecore (model)Action containing a list of GrpcStreamMessage objects and a statusCode
GrpcStreamResponseActionHandlercore (action)Recursively schedules messages via Scheduler, encodes each to gRPC-framed protobuf, writes grpc-status trailers after last message
sequenceDiagram
    participant Client
    participant MockServer
    participant Handler as GrpcStreamResponseActionHandler
    participant Scheduler

    Client->>MockServer: gRPC request (e.g., ListGreetings)
    MockServer->>Handler: GRPC_STREAM_RESPONSE action matched
    Handler->>Client: HTTP/2 200 headers (content-type: application/grpc)
    loop For each GrpcStreamMessage
        Handler->>Scheduler: schedule with per-message Delay
        Scheduler->>Handler: execute after delay
        Handler->>Client: gRPC-framed protobuf message
    end
    Handler->>Client: grpc-status trailers (OK)

Serialization

  • GrpcStreamMessageDTO and GrpcStreamResponseDTO handle REST API serialization
  • ExpectationDTO includes a grpcStreamResponse field mapped to GrpcStreamResponseDTO
  • grpcStreamResponse.json JSON schema is registered in JsonSchemaExpectationValidator

gRPC Fault Injection

GrpcChaosProfile (org.mockserver.model.GrpcChaosProfile) is a declarative gRPC fault/chaos injection profile. It is stored in a GrpcChaosRegistry keyed by gRPC service name and applied by GrpcToHttpRequestHandler before normal request conversion. An empty-string key registers a default profile that covers all services without a more-specific override.

Profile fields:

FieldTypeDescription
errorStatusCodeStringgRPC status code name (e.g. "UNAVAILABLE", "DEADLINE_EXCEEDED") — one of the 17 GrpcStatusMapper.GrpcStatusCode enum names
errorMessageStringOptional grpc-message trailer value
errorProbabilityDouble0.0–1.0 probability of fault injection; null/0 = never, 1.0 = always
seedLongOptional seed to make fractional probability reproducible
latencyMsLongMilliseconds of artificial delay before the response; >= 0
succeedFirstIntegerFirst N calls per service are not eligible for chaos; >= 0
failRequestCountIntegerAfter succeedFirst, the next M calls are eligible; >= 1; null = unlimited
quotaNameStringShared rate-limit counter key
quotaLimitIntegerMax calls allowed per quota window; >= 1
quotaWindowMillisLongFixed-window length in ms; calls over the limit return RESOURCE_EXHAUSTED; >= 1
omitGrpcStatusBooleanWhen true, the fault response contains no grpc-status trailer at all, simulating an incomplete or broken RPC stream. Takes precedence over corruptGrpcStatus when both are set.
corruptGrpcStatusBooleanWhen true (and omitGrpcStatus is false), the grpc-status trailer is set to the non-numeric value "malformed" — a genuine protocol violation (the gRPC spec requires grpc-status to be a decimal integer) that tests how clients cope with an unparseable status trailer.
customTrailersMap<String,String>Arbitrary trailer key/value pairs injected on the fault response in addition to (or instead of) the normal status trailers. Applied after omitGrpcStatus/corruptGrpcStatus — always added regardless of which status variant fires.
abortAfterMessagesIntegerFor client-streaming requests: when the number of decoded gRPC messages in the request body is >= this threshold, inject an ABORTED status immediately. The message count is determined by decoding the 5-byte gRPC length-prefixed frames in the request body; >= 1.

Trailer-fault precedence in buildFaultResponse: omitGrpcStatus: true → no grpc-status trailer is written at all; else corruptGrpcStatus: truegrpc-status: malformed is written (a non-numeric value that violates the gRPC wire spec); else the normal numeric status code is written. All of these are real trailers, not headers — which is what makes omitGrpcStatus a genuine fault simulation, since the non-faulted case emits a trailer. customTrailers are always appended after the status decision, for every fault response. Custom trailer keys and values are validated against CR/LF injection at the model layer and defensively skipped at the handler layer, and again when folded into the gRPC-Web trailer frame.

On the gRPC-Web path these trailers (including customTrailers) are folded into the in-body trailer frame by GrpcToHttpResponseHandler.convertToGrpcWebResponse and the real HTTP trailers are then cleared. This is required, not cosmetic: browser fetch/XHR do not expose HTTP trailers, so a trailer left on the response is unreachable by a gRPC-Web client.

Serialization uses GrpcChaosProfileDTO (org.mockserver.serialization.model.GrpcChaosProfileDTO).

This feature is distinct from GrpcHealthRegistry — gRPC fault injection fires on application RPC methods; health-check chaos controls the grpc.health.v1.Health/Check serving-status response.

REST endpoints:

EndpointAction
PUT /mockserver/grpcChaosRegister, remove, or clear gRPC chaos profiles; supports ttlMillis for auto-expiry
GET /mockserver/grpcChaosRead all active profiles and TTL countdowns
PATCH /mockserver/grpcChaosJSON Merge Patch a single service's profile (preserves TTL)

See Service-scoped chaos REST API below for the full request/response shapes, which are identical across all three endpoints (substituting service for the key field and GrpcChaosProfile fields in the chaos object).

Service-Scoped Chaos REST API

Three parallel REST APIs expose service-scoped chaos registration — one for each protocol layer. All three follow the same request/response structure; the differences are the endpoint path, the key field name (host vs service), and the profile type (HttpChaosProfile vs TcpChaosProfile vs GrpcChaosProfile).

ProtocolEndpointsKey fieldProfile type
HTTPPUT/GET/PATCH /mockserver/serviceChaoshostHttpChaosProfile (see profile fields in the consumer chaos docs)
TCPPUT/GET/PATCH /mockserver/tcpChaoshostTcpChaosProfile
gRPCPUT/GET/PATCH /mockserver/grpcChaosserviceGrpcChaosProfile (fields documented above)

PUT — register, remove, or clear

Request body shapes (all fields except clear/host/service are optional):

Register or replace a profile — sets or replaces the chaos profile for a single host/service:

{
  "host": "payments.internal:8080",
  "chaos": { "errorStatus": 503, "errorProbability": 0.3 },
  "ttlMillis": 60000
}
  • ttlMillis (optional, >= 1) — auto-reverts the registration after this many milliseconds. When the TTL expires the profile is removed and the host returns to normal behaviour.
  • Omitting ttlMillis registers the profile indefinitely.

Remove a single host — omit chaos or supply remove: true:

{ "host": "payments.internal:8080", "remove": true }

Clear all registrations:

{ "clear": true }

clear and host/service are mutually exclusive.

Responses — all 200 with a status field:

ScenarioResponse body
Registered{"status":"registered","host":"...","ttlMillis":60000} (ttlMillis omitted when no TTL)
Removed{"status":"removed","host":"..."}
Cleared{"status":"cleared"}
Error400 {"error":"<message>"}

GET — snapshot

Returns all currently registered profiles. For serviceChaos the top-level key is services; for tcpChaos it is hosts. A ttlRemainingMillis map is included only when at least one TTL-bearing registration exists.

GET /mockserver/serviceChaos example response:

{
  "services": {
    "payments.internal:8080": { "errorStatus": 503, "errorProbability": 0.3 }
  },
  "ttlRemainingMillis": {
    "payments.internal:8080": 42310
  }
}

GET /mockserver/tcpChaos uses hosts as the outer key instead of services.

GET /mockserver/grpcChaos uses services and the keys are gRPC service names (e.g. helloworld.Greeter); an empty string key is the catch-all default profile.

PATCH — merge-patch a single profile

Only the fields present in the chaos object are updated; all other fields of the existing profile are preserved. The TTL on the existing registration is also preserved (the PATCH does not reset or remove it).

Request body:

{
  "host": "payments.internal:8080",
  "chaos": { "errorProbability": 0.5 }
}

Both host/service and chaos are required. A missing key returns 400.

Response body on success:

{
  "status": "patched",
  "host": "payments.internal:8080",
  "chaos": { "errorStatus": 503, "errorProbability": 0.5 }
}

The chaos field in the response reflects the merged profile as serialised by the corresponding *ChaosProfileDTO.

Implementation references: all nine handlers (handleServiceChaosPut, handleServiceChaosPatch, handleServiceChaosGet, handleTcpChaosPut, handleTcpChaosPatch, handleTcpChaosGet, handleGrpcChaosPut, handleGrpcChaosPatch, handleGrpcChaosGet) are in mockserver/mockserver-core/src/main/java/org/mockserver/mock/HttpState.java around lines 2070–2503.

GraphQL-Semantic HTTP Chaos

HttpChaosProfile carries four fields for injecting GraphQL-semantic errors into HTTP responses. These fields are part of the broader HttpChaosProfile model (documented on the consumer-facing chaos page) but are relevant here because they are specifically designed for testing GraphQL clients.

New fields (added alongside the existing body-corruption fields):

FieldTypeDescription
graphqlErrorsBooleanWhen true, activates GraphQL error injection. The response is rewritten as an HTTP 200 GraphQL error envelope: {"data":...,"errors":[{"message":...,"extensions":{"code":...}}]}, with Content-Type: application/json and Content-Length stripped.
graphqlErrorMessageStringThe errors[0].message value. Defaults to "simulated GraphQL error" when graphqlErrors is true and this field is unset.
graphqlErrorCodeStringOptional value placed in errors[0].extensions.code (e.g. "INTERNAL_SERVER_ERROR"). The extensions object is omitted entirely when this field is null.
graphqlNullifyDataBooleanWhen true (the default), data is set to null. When false, the handler attempts to parse the original response body as JSON and embed it as the data value, enabling partial-success simulation. Falls back to data: null if the original body is not valid JSON.

Precedence in applyResponseChaos: graphqlErrors takes precedence over truncateBodyAtFraction and malformedBody — when graphqlErrors is true, body corruption is skipped because the envelope is the intended body. The slow-response dribble (slowResponseChunkSize + slowResponseChunkDelay) composes normally with GraphQL injection since it only affects delivery timing. The fault is metered as fault_type="graphql".

Scope: GraphQL error injection works on both expectation-level chaos (attached to an Expectation) and service-scoped chaos (ServiceChaosRegistry / PUT /mockserver/serviceChaos). It respects the count window (succeedFirst / failRequestCount) in the same way as other body-corruption faults.

gRPC Health Checking Protocol

MockServer auto-responds to grpc.health.v1.Health/Check without requiring a proto descriptor. The implementation uses manual protobuf encode/decode so health checks work even when the descriptor store is empty.

Key classes:

ClassPackageRole
GrpcHealthRegistryorg.mockserver.grpcSingleton map of service name → ServingStatus; falls back to a configurable default (SERVING) when no per-service entry exists
GrpcHealthCheckHandlerorg.mockserver.grpcDecodes the gRPC-framed HealthCheckRequest (5-byte header + protobuf field 1 varint), looks up GrpcHealthRegistry, encodes a gRPC-framed HealthCheckResponse
ServingStatusorg.mockserver.grpcEnum: UNKNOWN(0), SERVING(1), NOT_SERVING(2), SERVICE_UNKNOWN(3)

Interception point: GrpcToHttpRequestHandler checks whether the request path equals GrpcHealthCheckHandler.HEALTH_CHECK_PATH (/grpc.health.v1.Health/Check) before performing any descriptor lookup. When matched, the response is written directly and the request never reaches the expectation matching engine.

Configuration: grpcHealthCheckEnabled (default true) controls whether health check interception is active.

REST endpoints:

EndpointAction
PUT /mockserver/grpc/healthSet the ServingStatus for a named service (service + status fields)
GET /mockserver/grpc/healthRead all status overrides plus the global default

All overrides are cleared on HttpState.reset(). An empty service string sets the global default. The GET response uses _default as the key for the global default entry.

Control Plane REST API

EndpointAction
PUT /mockserver/grpc/descriptorsUpload a compiled proto descriptor set (binary body)
PUT /mockserver/grpc/servicesList all loaded gRPC services and their methods
PUT /mockserver/grpc/clearClear all loaded descriptors and reset the store

gRPC Forward Proxy + Record/Replay

MockServer can forward a gRPC call to a real upstream gRPC server and record the decoded exchange, bringing the record-then-mock workflow to gRPC. When a decoded gRPC request (produced by GrpcToHttpRequestHandler, i.e. carrying x-grpc-service/x-grpc-method + a JSON body + application/grpc) matches a FORWARD-class expectation, or arrives in proxy mode with no matching expectation, the forward path:

  1. re-encodes the JSON request body back into gRPC length-prefixed protobuf frames, sets content-type: application/grpc, forces HTTP/2, adds te: trailers, and strips the internal x-grpc-* helper headers so they do not leak upstream;
  2. decodes the upstream's gRPC-framed protobuf response back to JSON and re-stamps x-grpc-service/x-grpc-method (and grpc-status-name) onto the response so GrpcToHttpResponseHandler re-frames it for the calling client and the logged FORWARDED_REQUEST entry carries decoded JSON.
flowchart LR
    Client["gRPC client"] -->|"application/grpc\nprotobuf frame"| Proxy["MockServer proxy\n(descriptors loaded)"]
    Proxy -->|"decode → JSON,\nmatch FORWARD / proxy no-match"| Enc["GrpcForwardTranslator\nencodeRequestForUpstream"]
    Enc -->|"re-framed protobuf,\nHTTP/2"| Upstream["real gRPC server"]
    Upstream -->|"framed protobuf\nresponse"| Dec["GrpcForwardTranslator\ndecodeResponseFromUpstream"]
    Dec -->|"JSON + x-grpc-* stamped"| Log["FORWARDED_REQUEST\n(decoded, replayable)"]
    Dec -->|"re-frame"| Client
ClassModulePurpose
GrpcForwardTranslatorcore (org.mockserver.grpc)encodeRequestForUpstream (JSON → gRPC-framed protobuf, force HTTP/2) and decodeResponseFromUpstream (framed protobuf → JSON + re-stamp headers). Both fail-safe: non-gRPC / unknown-method / error → original message returned unchanged.

Wiring: the matched FORWARD-family path threads the descriptor store into HttpForwardAction (setGrpcDescriptorStore, set in the HttpActionHandler handler getters) and applies the transform once in HttpForwardAction.sendRequest (covers all FORWARD* action types); the response is decoded via a composed overrideHttpResponse so both the client write and the recorded log entry see the decoded JSON. The unmatched/anonymous proxy paths in HttpActionHandler apply the same helper (grpcEncodeForForward / grpcDecodeOverride) around their inline httpClient.sendRequest calls.

Boundaries and requirements:

  • Descriptors on the proxy are required to decode. GrpcToHttpRequestHandler only converts inbound gRPC to JSON when the descriptor store hasServices(). Without descriptors on the proxy the raw application/grpc bytes are still forwarded verbatim over HTTP/2 and recorded, but undecoded (binary body, no method JSON).
  • Unary + client-streaming requests (single JSON object / JSON array body) and unary + server-streaming responses (one or more frames) are handled. Full bidirectional streaming forward is out of scope — it is driven by the multiplex bidi pipeline, not the request/response forward path.
  • Transport downgrade. The re-encoded request is marked HTTP/2; NettyHttpClient downgrades a non-secure HTTP/2 request to HTTP/1.1, so a cleartext (h2c) upstream receives the framed application/grpc body over HTTP/1.1 (which MockServer-to-MockServer handles, since the gRPC handlers are content-type driven and present on the HTTP/1.1 pipeline). Reach a strict HTTP/2-only gRPC server over TLS (h2 via ALPN).
  • Terminal status from upstream trailers. Real gRPC servers send grpc-status/grpc-message in HTTP/2 (or chunked HTTP/1.1) trailers, not as headers. FullHttpResponseToMockServerHttpResponse.setHeaders folds the upstream trailingHeaders() into the response model (only for trailer names not already present as headers), so decodeResponseFromUpstream sees the real grpc-status and stamps grpc-status-name — otherwise a non-OK upstream RPC would be relayed and recorded as OK.
  • FORWARD_REPLACE (httpOverrideForwardedRequest) ordering with gRPC. The gRPC response decode is composed after any user-supplied overrideHttpResponse, so a user override on a gRPC FORWARD_REPLACE sees the raw framed protobuf upstream response (not the decoded JSON); the decode-to-JSON + header re-stamp runs last. Overriding the request side of FORWARD_REPLACE is applied before the gRPC re-encode, so an override that rewrites the JSON body is honoured. Editing the framed bytes directly in an override is not supported.

Streaming Limitations

  • True client streaming and bidirectional streaming are supported via the Http2MultiplexHandler multiplex pipeline (per-stream child channels), opt-in behind grpcBidiStreamingEnabled; when disabled the default InboundHttp2ToHttpAdapter path aggregates full messages and bidi actions return 501
  • WAR deployment returns 501 for GRPC_STREAM_RESPONSE actions (no ChannelHandlerContext available)
  • Proto reflection is supported — a GrpcServerReflectionHandler (core, with a GrpcBidiReflectionHandler on the multiplex path) answers v1 and v1alpha ServerReflection requests without a generated stub; descriptors may still be provided via files or API upload

Module Boundaries

ComponentModulePackage
SseEvent, HttpSseResponse, JsonRpcBodymockserver-coreorg.mockserver.model
JsonRpcMatchermockserver-coreorg.mockserver.matchers
HttpSseResponseActionHandlermockserver-coreorg.mockserver.mock.action.http
SseEventDTO, HttpSseResponseDTO, JsonRpcBodyDTOmockserver-coreorg.mockserver.serialization.model
HttpRequestTemplateObject (jsonRpc fields)mockserver-coreorg.mockserver.templates.engine.model
GrpcStreamMessage, GrpcStreamResponsemockserver-coreorg.mockserver.model
GrpcFrameCodec, GrpcJsonMessageConverter, GrpcProtoDescriptorStore, GrpcProtoFileCompiler, GrpcStatusMapper, GrpcWebTranslator, GrpcForwardTranslator, GrpcExceptionmockserver-coreorg.mockserver.grpc
ConnectError, ConnectResponse, ConnectUnaryDetectormockserver-coreorg.mockserver.grpc.connect
GrpcHealthRegistry, GrpcHealthCheckHandler, ServingStatusmockserver-coreorg.mockserver.grpc
GrpcChaosProfilemockserver-coreorg.mockserver.model
GrpcChaosRegistrymockserver-coreorg.mockserver.mock.action.http
GrpcChaosProfileDTOmockserver-coreorg.mockserver.serialization.model
GrpcStreamResponseActionHandlermockserver-coreorg.mockserver.mock.action.http
GrpcStreamMessageDTO, GrpcStreamResponseDTOmockserver-coreorg.mockserver.serialization.model
GrpcToHttpRequestHandler, GrpcToHttpResponseHandlermockserver-nettyorg.mockserver.netty.grpc
McpMockBuilder, A2aMockBuilder, RealtimeMockBuildermockserver-client-javaorg.mockserver.client
RealtimeProvider, RealtimeModality, RealtimeTurn, RealtimeStreamingPhysics, RealtimeEvent, OpenAiRealtimeCodec, GeminiLiveCodecmockserver-coreorg.mockserver.llm.realtime

Test Coverage

Test ClassModuleTestsType
SseEventTestcore19Unit
HttpSseResponseTestcore22Unit
JsonRpcBodyTestcore21Unit
JsonRpcMatcherTestcore12Unit
HttpSseResponseDTOTestcore5Unit
JsonRpcBodyDTOTestcore5Unit
ExpectationWithSseAndJsonRpcSerializationTestcore4Unit
HttpRequestTemplateObjectJsonRpcTestcore11Unit
McpMockBuilderTestclient-java12Unit
A2aMockBuilderTestclient-java25Unit
SseStreamingIntegrationTestnetty9Integration
McpMockBuilderIntegrationTestnetty12Integration
A2aMockBuilderIntegrationTestnetty13Integration
WebSocketMessageTestcore14Unit
HttpWebSocketResponseTestcore19Unit
WebSocketMessageModelDTOTestcore5Unit
HttpWebSocketResponseDTOTestcore5Unit
ForwardChainExpectationTestclient-java10Unit
WebSocketMockingIntegrationTestnetty6Integration
OpenAiRealtimeCodecTestcore11Unit
GeminiLiveCodecTestcore6Unit
RealtimeMockBuilderTestclient-java4Unit
RealtimeMockingIntegrationTestnetty3Integration
GrpcFrameCodecTestcore6Unit
GrpcJsonMessageConverterTestcore7Unit
GrpcProtoDescriptorStoreTestcore7Unit
GrpcStatusMapperTestcore9Unit
GrpcWebTranslatorTestcore20Unit
GrpcForwardTranslatorTestcore13Unit
HttpForwardActionHandlerGrpcTestcore3Unit
GrpcStreamResponseDTOTestcore3Unit
GrpcIntegrationTestnetty11Integration
GrpcForwardProxyIntegrationTestnetty3Integration
GrpcWebHandlerTestnetty12Handler
ConnectErrorTestcore6Unit
ConnectResponseTestcore9Unit
ConnectUnaryDetectorTestcore10Unit
ConnectUnaryIntegrationTestnetty5Integration

Client Library Support

All four client libraries support the new action types and body matchers:

FeatureJavaNode.jsPythonRuby
SSE Response (httpSseResponse)respondWithSse()Expectation.httpSseResponserespond_with_sse()respond_with_sse
WebSocket Response (httpWebSocketResponse)respondWithWebSocket()Expectation.httpWebSocketResponserespond_with_websocket()respond_with_websocket
JSON-RPC Body (JSON_RPC)jsonRpc("method"){ type: 'JSON_RPC', method: '...' }Body.json_rpc("method")Body.json_rpc("method")
MCP Mock BuilderMcpMockBuilder.mcpMock()N/A (use REST API)N/A (use REST API)N/A (use REST API)
A2A Mock BuilderA2aMockBuilder.a2aMock()N/A (use REST API)N/A (use REST API)N/A (use REST API)
gRPC Stream Response (grpcStreamResponse)respondWithGrpcStream()N/A (use REST API)N/A (use REST API)N/A (use REST API)
gRPC Descriptor UploaduploadGrpcDescriptor()N/A (use REST API)N/A (use REST API)N/A (use REST API)
gRPC Services ListretrieveGrpcServices()N/A (use REST API)N/A (use REST API)N/A (use REST API)
gRPC Descriptors ClearclearGrpcDescriptors()N/A (use REST API)N/A (use REST API)N/A (use REST API)
Callback SupportFull (WebSocket)Full (WebSocket)Full (WebSocket)Full (WebSocket)

OpenAPI Contract Verification

In addition to mocking AI protocols, MockServer's MCP control plane provides OpenAPI contract verification tools:

OpenApiTrafficValidator (mockserver-core)

Validates recorded request/response pairs against an OpenAPI spec. For each pair, it locates the matching spec operation, validates the request with OpenAPIRequestValidator, and validates the response with OpenAPIResponseValidator. Exposed via the verify_traffic_against_openapi MCP tool.

OpenApiContractTest (mockserver-core)

Builds representative example requests for each operation in an OpenAPI spec (resolving path parameters, query parameters, headers, and request bodies from spec examples and ExampleBuilder-generated values), sends them via an injected Function<HttpRequest, HttpResponse>, and validates responses with OpenAPIResponseValidator. The class is HTTP-client-agnostic; the MCP tool layer wires in the real HTTP transport.

OpenApiResiliencyTest (mockserver-core)

Reuses OpenApiContractTest.buildExampleRequest() to generate a valid base request for each operation, then produces a bounded mutation catalogue:

  • Omit required path/query parameter -- only when the parameter is marked required
  • Omit required body field -- only when the schema lists required fields
  • Type violation -- sends a string where schema expects integer/boolean, or vice versa
  • Numeric boundary violation -- minimum-1 and maximum+1 when schema defines bounds
  • String length violation -- minLength-1 and maxLength+1 when schema defines length constraints
  • Oversized string -- 10,000-character string for string fields without explicit maxLength
  • Malformed JSON body -- unparseable JSON

Each mutated request is sent via the injected Function<HttpRequest, HttpResponse> and the response is classified as HANDLED (4xx) or UNEXPECTED (5xx, 2xx, connection error). The class is HTTP-client-agnostic like OpenApiContractTest. Exposed via the run_resiliency_test MCP tool with a 5-second timeout per request.

flowchart TB
    subgraph "MCP Tools"
        VT["verify_traffic_against_openapi"]
        CT["run_contract_test"]
        RT["run_resiliency_test"]
    end

    subgraph "Core Validators"
        OTV["OpenApiTrafficValidator"]
        OCT["OpenApiContractTest"]
        ORT["OpenApiResiliencyTest"]
        RV["OpenAPIRequestValidator"]
        RSV["OpenAPIResponseValidator"]
        EB["ExampleBuilder"]
    end

    VT --> OTV
    CT --> OCT
    RT --> ORT
    OTV --> RV
    OTV --> RSV
    OCT --> EB
    OCT --> RSV
    ORT --> OCT

Deterministic LLM Record/Replay

Overview

MockServer supports recording LLM API traffic (Anthropic Claude, OpenAI, MCP servers, etc.) through its forwarding proxy and replaying it deterministically from fixture files. This enables AI application tests that are offline, free (no metered API calls), and reproducible.

Architecture

flowchart LR
    subgraph "Record Phase"
        App["AI Application"] -->|HTTP/SSE| Proxy["MockServer\n(forwarding proxy)"]
        Proxy -->|Forward| LLM["Real LLM API\n(Anthropic, OpenAI)"]
        Proxy -->|Log| EventLog["FORWARDED_REQUEST\nentries"]
    end

    subgraph "Snapshot Phase"
        EventLog -->|record_llm_fixtures| Conv["SseAwareExpectationConverter"]
        Conv --> Redact["FixtureRedactor"]
        Redact -->|Write JSON| Fixture["fixture.json\n(committable)"]
    end

    subgraph "Replay Phase"
        Fixture -->|load_expectations_from_file| Active["Active Expectations"]
        App2["AI Application"] -->|HTTP| MockReplay["MockServer\n(mock mode)"]
        MockReplay -->|SSE stream| App2
    end

Components

ClassModulePurpose
FixtureRedactorcoreRedacts sensitive headers (Authorization, api-key, Cookie, etc.) from expectations before writing to fixture files; operates on copies, never mutates live entries
SseBodyParsercoreParses raw text/event-stream bytes into SseEvent objects; replays captured per-chunk delays when available, falling back to a fixed inter-event delay (50ms default)
SseAwareExpectationConvertercoreDetects SSE-streamed responses (via x-mockserver-streamed header or text/event-stream content type) and converts them to HttpSseResponse actions; falls back to static response with warning for truncated captures

MCP Tools

ToolPurpose
record_llm_fixturesSnapshots recorded proxy traffic into a fixture file: retrieves FORWARDED_REQUEST entries, converts SSE responses, redacts secrets, writes to the specified path
load_expectations_from_fileLoads a fixture file and adds its expectations as active mocks for replay

Expectation-authoring and record/replay control tools

So an AI coding agent (Claude Code, Cursor, …) can stand up and drive mocks from the IDE without leaving the MCP protocol, McpToolRegistry exposes authoring and control tools that each delegate to the corresponding HttpState control-plane operation — there is no separate matching/verification path. The /mockserver/mode and /mockserver/recordings/promote REST handlers and their MCP tools share the same HttpState.setMode(...) / HttpState.promoteRecordings(...) methods.

ToolClassDelegates toPurpose
create_expectationMUTATEHttpState.addCreate a mock from a simplified method/path/response DSL (plus chaos)
raw_expectationMUTATEHttpState.addCreate a mock from the full MockServer expectation JSON (PUT /mockserver/expectation schema)
list_expectationsREADHttpState.retrieve (ACTIVE_EXPECTATIONS)List the active expectations, optionally filtered by method/path, in full JSON incl. id
clear_expectationsMUTATEHttpState.clearClear expectations by request matcher or expectation id
verify_requestREADHttpState.verifyVerify a request pattern met its VerificationTimes; returns pass/fail + closest-match diff
retrieve_recorded_requestsREADHttpState.retrieve (REQUESTS)Return recorded requests, optionally filtered
retrieve_request_responsesREADHttpState.retrieve (REQUEST_RESPONSES)Return recorded request/response pairs
set_operating_modeMUTATEHttpState.setModeSwitch the high-level mode SIMULATE / SPY / CAPTURE (PUT /mockserver/mode)
promote_recordingsMUTATEHttpState.promoteRecordingsTurn recorded (forwarded) traffic into active mocks with redaction + consolidation + parameterization (PUT /mockserver/recordings/promote)

The read-vs-mutate class drives control-plane authorization: when controlPlaneAuthorizationEnabled=true, a MUTATE tool requires the MUTATE role and a READ tool the READ role (see the control-plane note at the top of this page). A typical "record then mock" agent flow is set_operating_mode SPY → drive the app so unmatched requests are forwarded and recorded → promote_recordingslist_expectations to confirm.

SSE Timing

When the capture records per-chunk timing, the delays are carried on the x-mockserver-chunk-delays-ms header (a comma-separated list of millisecond gaps). SseAwareExpectationConverter parses that header and replays each SSE event with its captured delay, reproducing the original stream timing. When the header is absent, empty, or malformed, replay falls back to a fixed inter-event delay (50ms default).

Secret Redaction

The FixtureRedactor replaces header values for a configurable set of header names with ***REDACTED***. Default sensitive headers:

  • Authorization
  • x-api-key / api-key
  • Cookie / Set-Cookie
  • Proxy-Authorization

Custom header lists can be provided for application-specific secrets.

Truncation Handling

When the captured SSE body exceeds maxStreamingCaptureBytes, the capture is truncated (x-mockserver-stream-truncated header). The converter falls back to a static response with an x-mockserver-fixture-warning header explaining the truncation. Increasing maxStreamingCaptureBytes ensures full capture.

  • #2143 — SSE Streaming Support
  • #2168 — WebSocket Mocking
  • #2115 — Streaming Response Support
  • #1936 — gRPC Protocol Support (under #2173 Protocol Extensions)