Skip to content

fix(dsh-plugin): decode newline-delimited stdio frames in mcp-env-proxy - #1107

Merged
binggg merged 1 commit into
TencentCloudBase:mainfrom
gzr123166:fix/dsh-plugin-env-proxy-newline-framing
Sep 29, 2026
Merged

binggg merged 1 commit into
TencentCloudBase:mainfrom
gzr123166:fix/dsh-plugin-env-proxy-newline-framing

Conversation

@gzr123166

Copy link
Copy Markdown
Contributor

What breaks

mcp-env-proxy.mjs parses stdio with parseFrames(), which understands Content-Length framing only:

const headerEnd = rest.indexOf("\r\n\r\n");
if (headerEnd === -1) break;   // a bare JSON line has no \r\n\r\n -> nothing is parsed

Both of its peers speak newline-delimited JSON, which is the framing the official MCP stdio spec uses:

  • host — dsh-mcp-client uses the MCP SDK StdioClientTransport, whose serializeMessage() is JSON.stringify(message) + "\n";
  • child — @cloudbase/cloudbase-mcp replies with a bare JSON line.

The same parseFrames() runs on process.stdin (host → proxy) and on child.stdout (child → proxy), so the omission breaks both legs.

Symptom

The host's initialize is never parsed, so the proxy never replies. The SDK request times out after 60s and mcp__cloudbase__* tools are never registered — the plugin looks installed and configured (its patch composes, its system-prompt section is present) but no CloudBase tool ever reaches the model.

Measured against @cloudbase/dsh-plugin@0.1.2 as published on npm, driving the proxy with the same MCP SDK client dsh-mcp-client uses:

proxy client.connect() tools/list
as shipped times out after 60.0 s (McpError -32001) —
with this fix 0.38 s 40 tools

The fix

parseMcpFrames() in src/server/mcp-client.ts — the in-process bridge in this same package — already carries this fallback (lines 73–84). The standalone proxy script never got it. This change copies that logic verbatim into parseFrames():

if (headerEnd === -1) {
  const nl = rest.indexOf(0x0a);
  if (nl === -1) break;
  const line = rest.subarray(0, nl).toString("utf8").trim();
  rest = rest.subarray(nl + 1);
  if (!line) continue;
  try {
    messages.push(JSON.parse(line));
  } catch {
    break;
  }
  continue;
}

The Content-Length path is untouched, so nothing that works today regresses.

Tests

tests/proxy-stdio-framing.test.ts adds four cases:

  1. the proxy source keeps the newline fallback (guards against drifting out of sync again);
  2. the proxy and parseMcpFrames decode the same framing;
  3. end-to-end through the real proxy process — initialize against a stub child that speaks newline-delimited JSON;
  4. end-to-end tools/list the same way.

The stub child uses the proxy's existing CLOUDBASE_MCP_COMMAND / CLOUDBASE_MCP_ARGS injection points, so the test needs no network and no npx cache.

Red/green, run locally:

suite unpatched main with this change
proxy-stdio-framing.test.ts 4 failed (2 assertion, 2 × 20s timeout) 4 passed (0.4s)
mcp-bridge.test.ts passed passed
patch-contract.test.ts passed passed
patch-js-path.test.ts passed passed
  • npm test passes for the touched suites (17 passed across the four files above)
  • no changes to any other file

中文摘要

mcp-env-proxy.mjs 的 parseFrames() 只认 Content-Length 分帧,而宿主(dsh-mcp-client 经 MCP SDK StdioClientTransport,其 serializeMessage() 追加的是换行)和子进程(cloudbase-mcp 回的是裸 JSON 行)说的都是换行分帧。同一个函数同时解析 process.stdin 和 child.stdout,所以两个方向都被卡住:initialize 永远解析不出来 → SDK 请求 60 秒超时 → mcp__cloudbase__* 一个工具都不注册。

同包内的 src/server/mcp-client.ts 的 parseMcpFrames() 一直都有这段回退(73–84 行),是独立脚本 mcp-env-proxy.mjs 漏了。本次按原样补齐,Content-Length 老路径不动。

已实测(npm 上发布的 0.1.2 版本、用 dsh-mcp-client 同款 SDK 客户端驱动):修复前 connect() 60.0 秒超时;修复后 0.38 秒连上并返回 40 个工具。

parseFrames() understood Content-Length framing only, while both of its peers
speak newline-delimited JSON: the host (dsh-mcp-client via the MCP SDK
StdioClientTransport, whose serializeMessage() appends a newline) and the
cloudbase-mcp child. The same function parses process.stdin (host -> proxy)
and child.stdout (child -> proxy), so the omission stalled the initialize
handshake on both legs: the proxy never replied, the SDK request timed out
after 60s, and no mcp__cloudbase__* tool was ever registered.

parseMcpFrames() in src/server/mcp-client.ts already carries this fallback
(lines 73-84); the standalone proxy script never got it. Copy that logic
verbatim. The Content-Length path is untouched.

tests/proxy-stdio-framing.test.ts covers the fallback, keeps the proxy and the
in-process bridge in sync, and drives the real proxy process end to end against
a newline-speaking stub child, using the existing CLOUDBASE_MCP_COMMAND
injection point so no network or npx cache is needed.

@binggg binggg left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verified locally — approving.

  • Root cause confirmed: parseFrames() in dsh-plugin/scripts/mcp-env-proxy.mjs understood Content-Length framing only, while both peers speak newline-delimited JSON. The in-process bridge (parseMcpFrames() in src/server/mcp-client.ts, lines 73–84) already carries the newline fallback; this copies it verbatim.
  • Both inbound legs use the same parser: host → proxy (MCP SDK StdioClientTransport serializes with a trailing \n) and child → proxy (cloudbase-mcp replies with bare JSON lines). The omission stalled the initialize handshake on both.
  • Outbound (Content-Length replies) is unaffected: SDK client/server processReadBuffer() catches the per-line parse error, reports via onerror, and keeps reading — real messages still get through.
  • Tests: ran the new suite plus the three neighbors locally — proxy-stdio-framing 4/4, mcp-bridge + patch-contract + patch-js-path 13/13, no regressions.

Two nits, not blocking:

  1. Tests 1–2 assert on source text (indexOf(0x0a)), so renames/reformatting would false-fail. Fine as a drift guard; feel free to leave as-is.
  2. After merge, @cloudbase/dsh-plugin needs a version bump past 0.1.2 for the npm release so users actually pick this up.

@binggg
binggg merged commit 79bf159 into TencentCloudBase:main Sep 29, 2026
4 checks passed
@binggg

binggg commented Sep 30, 2026

Copy link
Copy Markdown
Member

A follow-up on this one: the fix shipped, and then the thing it repaired went away.

scripts/mcp-env-proxy.mjs — the framing proxy this patch fixed — was removed entirely in the 0.2.0 line. The plugin now launches cloudbase-mcp with inherited stdio, so there is no NDJSON framing layer left to decode; the messages travel on the child process's own stdio.

So this change went out in 0.1.3 and was correct, and it is no longer reachable because the proxy itself is gone. Your work on the newline-delimited framing was right — thanks for tracking it down.


中文摘要:这个修复随 0.1.3 发布了,但被修复的对象 scripts/mcp-env-proxy.mjs 已在 0.2.0 整个移除——插件改为以继承 stdio 的方式启动 cloudbase-mcp,不再有 NDJSON framing 层,因此该改动现在不再是必经路径。当时判断正确,感谢定位这个 framing 问题。

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants