MockStdioResponsePlugin: @stdin.body.id placeholder fails to resolve when messages arrive back-to-back after an id-less message
- Dominant language
- C#
- Stars
- 832
- Forks
- 89
- Avg merge
- 12h 1m
- Merged PRs (30d)
- 23
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`)
```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`)
```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`)
```javascript
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
```bash
node repro.mjs
```
## Expected 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.
Contributor guide
Assessment
This issue has not been assessed yet.