Skip to content

MockStdioResponsePlugin: @stdin.body.id placeholder fails to resolve when messages arrive back-to-back after an id-less message #1757

Description

@garrytrinder

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

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Labels

No labels
No labels

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions