Description
Description
When using MockStdioResponsePlugin with blockUnmockedRequests: true, the @stdin.body.id placeholder stops resolving correctly for messages that immediately follow an id-less JSON-RPC notification (e.g. notifications/initialized). The placeholder is output as the literal text @stdin.body.id instead of the numeric id from the current message.
Steps to reproduce
Dev Proxy version: 3.1.0
OS: macOS (Apple Silicon)
Config (.devproxy/devproxyrc.json)
{
"$schema": "https://raw.githubusercontent.com/dotnet/dev-proxy/main/schemas/v3.1.0/rc.schema.json",
"plugins": [
{
"name": "MockStdioResponsePlugin",
"enabled": true,
"pluginPath": "~appFolder/plugins/DevProxy.Plugins.dll",
"configSection": "mockStdioResponsePlugin"
}
],
"mockStdioResponsePlugin": {
"$schema": "https://raw.githubusercontent.com/dotnet/dev-proxy/main/schemas/v3.1.0/mockstdioresponseplugin.schema.json",
"mocksFile": "stdio-mocks.json",
"blockUnmockedRequests": true
}
}Mocks (.devproxy/stdio-mocks.json)
{
"$schema": "https://raw.githubusercontent.com/dotnet/dev-proxy/main/schemas/v3.1.0/mockstdioresponseplugin.mocksfile.schema.json",
"mocks": [
{
"request": { "bodyFragment": "protocolVersion" },
"response": {
"stdout": "{\"jsonrpc\":\"2.0\",\"id\":@stdin.body.id,\"result\":{\"protocolVersion\":\"2024-11-05\"}}\n"
}
},
{
"request": { "bodyFragment": "accept_eula" },
"response": {
"stdout": "{\"jsonrpc\":\"2.0\",\"id\":@stdin.body.id,\"result\":{\"content\":[{\"type\":\"text\",\"text\":\"ok\"}]}}\n"
}
}
]
}Repro script (repro.mjs)
import { spawn } from "node:child_process";
const proc = spawn("devproxy", ["stdio", "--config-file", ".devproxy/devproxyrc.json", "--", "cat"], {
stdio: ["pipe", "pipe", "pipe"],
});
let buf = "";
proc.stdout.on("data", (c) => {
buf += c;
let i;
while ((i = buf.indexOf("\n")) >= 0) {
const line = buf.slice(0, i).trim();
buf = buf.slice(i + 1);
if (!line) continue;
console.log("← stdout:", line);
}
});
// 1. Send initialize (has id) — works fine
proc.stdin.write(JSON.stringify({ jsonrpc: "2.0", id: 1, method: "initialize", params: { protocolVersion: "2024-11-05" } }) + "\n");
// 2. Wait for response, then send notification + accept_eula back-to-back
setTimeout(() => {
// Fire-and-forget notification (no id field)
proc.stdin.write(JSON.stringify({ jsonrpc: "2.0", method: "notifications/initialized" }) + "\n");
// Immediately followed by a request with id
proc.stdin.write(JSON.stringify({ jsonrpc: "2.0", id: 2, method: "tools/call", params: { name: "accept_eula", arguments: {} } }) + "\n");
}, 500);
setTimeout(() => {
proc.kill();
process.exit(0);
}, 3000);Run
node repro.mjsExpected behavior
← stdout: {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":"2024-11-05"}}
← stdout: {"jsonrpc":"2.0","id":2,"result":{"content":[{"type":"text","text":"ok"}]}}
Actual behavior
← stdout: {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":"2024-11-05"}}
← stdout: {"jsonrpc":"2.0","id":@stdin.body.id,"result":{"content":[{"type":"text","text":"ok"}]}}
The first message resolves @stdin.body.id to 1 correctly. The second message (after the id-less notification) outputs the literal placeholder text.
Workaround
Adding a delay (e.g. 100ms) between the notification and the subsequent request makes it work — suggesting the issue is related to how messages are buffered/parsed when they arrive in the same I/O read.
Context
This blocks using MockStdioResponsePlugin to mock MCP servers that follow the standard JSON-RPC handshake (initialize → notifications/initialized → tools/call), where the notification and first tool call are sent synchronously in the same callback.
Activity
Fix stdin message splitting to handle back-to-back messages
Add end-to-end regression test for back-to-back stdio messages (dotne…
Use separate write gates for child stdin and parent stdout (dotnet#1757)