The Leaky Envelope
How a single missing else branch in an MCP gateway let internal JSON‑RPC protocol data leak straight into backend request bodies and how a second, unrelated bug in a five-line Python test script spent an afternoon convincing everyone the fix hadn’t worked.
References:
Public issue: wso2/api-manager#5214: “MCP JSON-RPC envelope is relayed to the backend when a tool’s schema declares no requestBody”
Summary, for the skimmers
- WSO2 API Platformcan wrap an existing REST API as an MCP Server, so AI agents can call its resources as tools over JSON‑RPC.
- When a tool’s underlying operation declared no request body, the default for auto‑generated resources the gateway forgot to strip the inbound MCP envelope before forwarding the request, so the backend received the raw JSON‑RPC message instead of a clean payload.
- The defect was a single if (hasBody) block with no corresponding else in MCPUtils.processRequestBody().
- Decompiling the actually‑deployed gateway JAR confirmed the defect at the bytecode level, independent of source review.
- The first live reproduction attempt showed no bug at all, a false negative caused by a test‑harness script that never decoded Transfer-Encoding: chunked bodies.
- The naive fix (just clear the payload) shipped a new bug: an HTTP 500 from the outbound JSON message formatter. The real fix sets an empty JSON object instead of no payload at all.
MCP servers, in APIM’s terms
The Model Context Protocol gives an AI agent a uniform way to discover and call “tools.” WSO2 API Manager 4.6.0 lets you generate an MCP server directly from an existing REST API every resource becomes a tool, and the gateway takes care of translating between the two wire formats.
An MCP client talks JSON‑RPC 2.0. A call to a tool looks like this, wrapped in an envelope that carries protocol bookkeeping alongside the actual arguments:
tools/call — what the client actually sends
{
"jsonrpc": "2.0",
"id": 12,
"method": "tools/call",
"params": {
"name": "post_hhh",
"arguments": { },
"_meta": { "progressToken": 11 }
}
}The backend behind that tool, though, was never written to speak JSON‑RPC. It expects a plain REST request a JSON body matching its own schema, with none of jsonrpc, method, id, or _meta anywhere in sight. Translating between the two is the gateway’s job, and it happens in one specific place: MCPUtils.processRequestBody(), inside org.wso2.carbon.apimgt.gateway.
Whether a tool’s underlying operation has a request body at all is captured in a single boolean, hasBody, computed upstream by PrefixBasedSchemaMappingParser from the tool’s stored schema: it’s true only if that schema contains a requestBody key. processRequestBody() takes that flag and the resolved arguments, and decides what actually goes out over the wire to the backend.
The bug, as reported
A customer reported that invoking a tool sent the entire JSON‑RPC envelope to their backend, verbatim, in place of a request body.
what the backend actually received
{"method":"tools/call","params":{"name":"post_hhh","arguments":{},"_meta":{"progressToken":11}},They’d isolated it to one condition: it only happened when the matched tool’s stored schema had no requestBody property. With one present, the flow behaved correctly, the backend got a clean payload. Without one, the raw envelope sailed straight through.
Why this isn’t an edge case
A POST with no body is completely legitimate per RFC 9110 and it’s also APIM’s default. Any resource generated by the standard “Design a New REST API” flow gets wildcard operations with no declared request body. Every MCP server built from one of those defaults was affected. This wasn’t a corner case; it was closer to the common case.
The reproduction was almost insultingly simple:
- Create a REST API pointing at a backend that just echoes whatever it receives. Give it one explicit resource:
POST /hhh, with no request body defined. - Generate an MCP server from that API, selecting
POST /hhhas a tool. Deploy it. - Call
tools/list. The tool's schema comes back exactly as expected an empty object:
inputSchema for post_hhh
{ "type": "object", "properties": {}, "required": [] }4. Call tools/call for that tool with empty arguments. The backend logs the full envelope instead of an empty request.
As a control: add a real requestBody to the same operation, redeploy, regenerate the tool. Now tools/call with a matching argument correctly delivers just the resolved payload, no envelope. Same resource, same method, same backend. The only variable that changed was whether the schema declared a body.
Root cause: reading the source
Once you know exactly one variable matters, the search space collapses to wherever that variable is consumed. That’s MCPUtils.processRequestBody().
MCPUtils.java — before
public static void processRequestBody(MessageContext messageContext, ResolvedRequest resolvedRequest,
boolean hasBody, String contentType) throws McpException {
if (hasBody) {
org.apache.axis2.context.MessageContext axis2MessageContext =
((Axis2MessageContext) messageContext).getAxis2MessageContext();
JsonObject payload = resolvedRequest.getBody();
String ct = contentType != null ? contentType : "";
if (ct.startsWith(APIConstants.APPLICATION_JSON_MEDIA_TYPE)) {
try {
JsonUtil.removeJsonPayload(axis2MessageContext);
JsonObject safePayload = (payload != null) ? payload : new JsonObject();
JsonUtil.getNewJsonPayload(axis2MessageContext, new Gson().toJson(safePayload), true, true);
axis2MessageContext.setProperty(Constants.Configuration.MESSAGE_TYPE,
APIConstants.APPLICATION_JSON_MEDIA_TYPE);
axis2MessageContext.setProperty(Constants.Configuration.CONTENT_TYPE,
APIConstants.APPLICATION_JSON_MEDIA_TYPE);
} catch (AxisFault e) {
throw new McpException(/* ... */);
}
} else {
throw new McpException(/* "Unsupported content type" */);
}
}
// ← nothing here. hasBody == false simply falls through and returns.
}There it is. The entire method is one if (hasBody) block. When it’s true, the code carefully removes whatever JSON payload is currently attached to the outbound message and substitutes the resolved backend body that’s the “clean” path the control test exercised. When it’s false, execution reaches the closing brace of the if and returns, having touched nothing at all. Whatever was attached to the message context before this method ran in this case, the client’s original JSON‑RPC envelope is still there when it leaves.
It’s not that the wrong thing gets sent. It’s that nothing gets sent on purpose the absence of an else means "no body declared" was silently read as "no action needed," when it should have meant "still make sure the backend gets a clean request."
The parser’s hasBody = false was never the bug, a tool with no request body is a perfectly valid thing for it to report. The bug was entirely in how the consumer of that flag treated "nothing to substitute" as "nothing to do."
Trust, but decompile
Reading source tells you what a repository says. It doesn’t tell you what’s actually running in a specific customer’s environment, on a specific patch level, built from a specific commit that may or may not match the tip of any branch you have checked out. So before writing a line of prose about root cause, the deployed artifact got decompiled.
The running pack’s gateway bundle was org.wso2.carbon.apimgt.gateway_<version>.jar. Pulling MCPUtils.class out of it and running it through javap -p -c lands on the real logic behind the method (AspectJ weaves a timing wrapper around it, so the interesting bytecode sits one level down, in a synthetic processRequestBody_aroundBody24):
javap -p -c MCPUtils.class — deployed jar, unpatched
private static final void processRequestBody_aroundBody24(...);
Code:
0: iload_2 // load hasBody (3rd parameter)
1: ifeq 181 // if hasBody == false, jump straight to the end
4: aload_0
5: checkcast #489 // Axis2MessageContext
... // (all the removeJsonPayload / getNewJsonPayload /
// body-substitution logic lives in here)
180: athrow
181: returnBytecode offset 1 is the entire bug, compiled: ifeq 181 means if the top-of-stack boolean is false, jump to offset 181 which is a bare return. No call to JsonUtil.removeJsonPayload, no call to anything, on that branch. This wasn't a stale build or a backport gap. It was exactly what the analysis of the source predicted, confirmed independently, one level below the compiler.
0
calls to JsonUtil on the hasBody=false path, before the fix
1
instruction separating “leak the envelope” from “clean request”
2
independent confirmations of the same defect source review, bytecode
The reproduction that wasn’t
With the root cause nailed down twice over, reproducing it live should have been a formality: stand up an echo backend, rebuild the exact scenario from the report, watch the envelope arrive. Instead, the backend logged an empty body. Every time.
Get Wishula Jayathunga’s stories in your inbox
Join Medium for free to get updates from this writer.
The rig was built exactly to spec a REST API with an explicit POST /hhh resource and no request body, an MCP server generated from it, a tool named post_hhh, confirmed via tools/list to have the same empty inputSchema the report described. tools/call against it, repeatedly, across several variants a bare one-shot call, a full initialize ->notifications/initialized ->tools/call handshake in case a real MCP client's session behavior mattered — and the backend's own log kept saying the same thing:
echo backend — terminal
$ tail -f echo_backend.log
POST /hhh
-- headers --
Content-Type: application/json
Transfer-Encoding: chunked
-- body --
(empty)That’s a genuinely confusing result. The code is provably broken; the running artifact is provably running the broken code; and yet the one observable, end-to-end symptom the report describes refuses to show up. The first working theory reached for the obvious culprit one layer up: McpMediator, the component that parses the inbound envelope before MCPUtils ever sees it, has its own JsonUtil.removeJsonPayload / getNewJsonPayload pairs sprinkled through its dispatch logic. Maybe it was already clearing the message payload as a side effect of its own parsing, before the hasBody == false no-op ever got a chance to matter.
That theory is plausible, it’s specific, and it would have been completely wrong to publish. Because the actual explanation was three lines away, in the test harness itself.
echo_backend.py — the bug in the thing meant to catch the bug
def _handle(self):
length = int(self.headers.get("Content-Length", 0))
body = self.rfile.read(length) if length else b""Look back at the headers in that log dump: Transfer-Encoding: chunked, and no Content-Length at all because the gateway streams its outbound requests rather than buffering a known-length body up front. This handler only ever read a body when Content-Length was present. Against a chunked request, self.headers.get("Content-Length", 0) always fell back to 0, and the script happily reported "(empty)" no matter what had actually arrived on the wire. It wasn't disagreeing with the bug. It was blind to the entire class of request the bug produces.
A test harness that can’t see the thing it’s testing for will confidently tell you it’s fine.
The fix for the harness is small decode chunked transfer encoding by hand, since Python’s BaseHTTPRequestHandler won't do it for you:
echo_backend.py — fixed
def _read_chunked(self):
chunks = []
while True:
size = int(self.rfile.readline().strip().split(b";")[0], 16)
if size == 0:
self.rfile.readline()
break
chunks.append(self.rfile.read(size))
self.rfile.read(2)
return b"".join(chunks)
def _handle(self):
if self.headers.get("Transfer-Encoding", "").lower() == "chunked":
body = self._read_chunked()
else:
length = int(self.headers.get("Content-Length", 0))
body = self.rfile.read(length) if length else b""With the harness actually able to see what arrives, re-running the exact same request against the exact same unpatched code produced a very different log line:
echo backend — terminal, chunked decoding fixed
$ tail -f echo_backend.log
POST /hhh
-- body --
{"jsonrpc":"2.0","id":12,"method":"tools/call","params":{"name":"post_hhh","arguments":{},"_metaByte for byte, the same envelope from the original report. The bug had been reproducing correctly the entire time; the instrument reading the result was the one lying.
None of this changed the fix. It changed how much confidence anyone could have in the words “we reproduced it,” which turns out to be exactly the confidence that matters when a fix is heading toward a support patch.
The fix, and its own bug
The obvious fix is one line: add an else that clears the payload. It compiles, it looks correct, and it broke the server.
Attempt 1 HTTP 500
Calling JsonUtil.removeJsonPayload(axis2MessageContext) with nothing set afterward leaves the outbound message with no JSON payload at all not an empty one, none. Synapse's JSON message formatter isn't built to serialize that state, and the very first live test after applying the "obvious" fix came back as Payload could not be written as JSON, HTTP 500.
The hasBody == true branch was already the answer, sitting right there: when the resolved body is null, it falls back to new JsonObject() rather than sending nothing. The no-body branch needed the same discipline not remove the payload, but replace it with an empty one, so the message stays well-formed all the way to the wire.
MCPUtils.java — after
org.apache.axis2.context.MessageContext axis2MessageContext =
((Axis2MessageContext) messageContext).getAxis2MessageContext();
if (hasBody) {
/* ...unchanged... */
} else {
// No requestBody declared for this tool — strip the inbound MCP JSON-RPC envelope
// instead of relaying it to the backend (RFC 9110: a POST body is optional). An empty
// JSON object is set, rather than leaving no payload at all, so the outbound message
// remains well-formed for the JSON message formatter.
try {
JsonUtil.removeJsonPayload(axis2MessageContext);
JsonUtil.getNewJsonPayload(axis2MessageContext, new Gson().toJson(new JsonObject()), true, true);
axis2MessageContext.setProperty(Constants.Configuration.MESSAGE_TYPE,
APIConstants.APPLICATION_JSON_MEDIA_TYPE);
axis2MessageContext.setProperty(Constants.Configuration.CONTENT_TYPE,
APIConstants.APPLICATION_JSON_MEDIA_TYPE);
} catch (AxisFault e) {
throw new McpException(/* "Failed to clear request body" */);
}
}Two details worth calling out, because they’re the difference between a fix that reads correctly and one that runs correctly:
- The
axis2MessageContextlookup had to move above theif, since both branches now need it a small, mechanical hoist, but one worth pinning with a regression test, since it touches code shared by the branch that already worked. - Setting
{}rather than removing the payload isn't cosmetic. It's the difference between a request with an empty JSON body and a request the transport layer can't serialize at all.
Decompiling the patched class confirms the shape of the fix at the same bytecode offset that used to be a bare return:
javap -p -c MCPUtils.class — patched
181: aload 5
183: invokestatic #550 // JsonUtil.removeJsonPayload
186: pop
187: returnProving it, before and after
With a fixed test harness and a fixed gateway, the same two-line reproduction from the report now tells the truth in both directions.
The second row matters as much as the first. A fix that only proves the broken path is fixed hasn’t proven it left the working path alone and the hasBody == true branch handles real traffic today. Confirming it byte-for-byte unaffected, both by bytecode inspection and by an actual round trip through a running gateway, is what makes this a fix rather than a trade.
End-to-end, on a real server
Every result above came from an actual running WSO2 API Manager instance a real published MCP server, a real subscription, a real access token, a real HTTP request over the loopback interface to a real backend process. Nothing here was simulated, mocked, or inferred from a diff.
Locking it in with tests
A fix without a test that would have caught it is a fix that can silently regress on the next refactor. Six JUnit tests went into MCPUtilsTest, built on the module's existing TestUtils helper for constructing a real (non-mocked) Axis2MessageContext.
One gotcha in the test setup: constructing a bare Axis2MessageContext and calling JsonUtil.getNewJsonPayload straight away silently fails to attach anything, because the JSON utilities expect a SOAP envelope to already exist on the underlying Axis2 context. The fix was a couple of lines borrowed from the one other test in the module that does similar setup:
MCPUtilsTest.java
private MessageContext newMessageContext() throws Exception {
MessageContext messageContext = TestUtils.getMessageContext("/echo-mcp", "1.0.0");
SOAPFactory factory = OMAbstractFactory.getSOAP11Factory();
SOAPEnvelope envelope = factory.getDefaultEnvelope();
axis2ContextOf(messageContext).setEnvelope(envelope);
return messageContext;
}And then the part that actually matters: does the regression test regress? The fix was temporarily git stash'd back to the buggy version, and the three no-body tests were re-run against it.
mvn test -Dtest=MCPUtilsTest — against the unpatched class
org.junit.ComparisonFailure:
expected:<{[]}>
but was: <{["method":"tools/call","params":{"name":"post_hhh","arguments":{}},"jsonrpc":"2.0","id":12]}>
Tests run: 6, Failures: 3The failure message is the leaked envelope itself, verbatim, inside a JUnit assertion diff. That’s about as close as a unit test gets to reproducing a real customer-visible bug in miniature. Restoring the fix brought all six back to green.
What this cost us to learn
Nothing here is a novel debugging technique. All of it is easy to skip under time pressure, which is exactly when it matters most.
Source review is a hypothesis, not a verdict
The bug looked airtight after five minutes of reading. It still got decompiled from the actual deployed artifact before anyone wrote it down as fact, because “the repository says X” and “the customer’s running Y” are only the same claim if you’ve checked.
A clean test result is not the same as a correct one
An “empty body” that should have been an envelope wasn’t the gateway agreeing with the fix hypothesis. It was a five-line Python script quietly discarding evidence. The lesson generalizes past chunked encoding: any harness sitting between you and the system under test can fail silently in the exact direction that looks like success.
The smallest correct fix is still worth running
Adding an else branch is about as low-risk as a code change gets. It still shipped a new 500 on its first real run, because "clear the payload" and "clear the payload and leave the message in a state the formatter can serialize" are not the same instruction, and only one of them is testable by reading the diff.
Regression tests should fail on purpose, once
A green test suite proves the code you have passes the tests you wrote. Stashing the fix and watching the exact same tests fail with the leaked envelope printed in the failure message is what proves the tests would have caught the original bug, not just that they’re compatible with the current state of the file.







