Skip to content

JSON output mode broken for MCP servers using structuredContent and isError shapes; recovery logs pollute stdout #160

@ChrisBot2026

Description

@ChrisBot2026

Summary

mcporter call <server>.<tool> --output json produces output that is not valid JSON in two common cases (resulting in three independent bugs), and an unrelated daemon recovery diagnostic line is logged to stdout, which corrupts the JSON output stream when the daemon needs to recover.

These bugs were surfaced during integration of openclaw's qmd memory backend with mcporter's daemon (keep-alive) mode, but they affect any MCP server using the standard structuredContent / isError shapes.

Encountered with mcporter@0.7.3 on macOS 25.4 / Node 25.5.

All three are 1-line fixes; full reproducers and proposed patches below.


Bug 1 — tryParseJson returns null for plain structuredContent objects

File: dist/result-utils.js, function tryParseJson.

When an MCP server returns a tool result like:

{
  "content": [{"type": "text", "text": "..."}],
  "structuredContent": {"results": [...]}
}

mcporter's Result.json() calls tryParseJson(structuredContent). The current implementation returns the value only if it has a .json or .data property; otherwise it falls through to return null. For structuredContent, both branches are typically absent — structuredContent IS the parsed value — so Result.json() returns null.

That null then defeats the case 'json' happy path in printCallOutput (dist/cli/output-utils.js), which falls through to printRaw(raw)util.inspect(raw). The result is emitted as a JS object literal (with [Object] placeholders for nested arrays), which JSON.parse cannot parse.

Reproducer

Any MCP server returning structuredContent triggers this. Concrete example using QMD (a memory-search MCP):

mcporter call qmd.query --args '{
  "searches":[{"type":"lex","query":"openclaw"}],
  "limit":3,
  "rerank":false
}' --output json --timeout 30000

Pre-patch output (broken):

{
  content: [ [Object] ],
  structuredContent: { results: [ [Object], [Object], [Object] ] }
}

Expected output (valid JSON):

{
  "content": [...],
  "structuredContent": {"results": [...]}
}

(or just the structuredContent body, depending on intent).

Proposed fix (1-line addition)

                 if ('data' in value) {
                     return value.data ?? null;
                 }
+                return value;
             }
             if (typeof value === 'string') {

This returns the plain object as-is when neither .json nor .data is present. It's safe because tryParseJson is only called on values mcporter already considers "JSON-like", and downstream attemptPrintJson will JSON.stringify it.


Bug 2 — printCallOutput case 'json' falls back to util.inspect for isError/text-only responses

File: dist/cli/output-utils.js, function printCallOutput, case 'json'.

Even with Bug 1 fixed, when an MCP tool returns an error envelope like:

{
  "content": [{"type": "text", "text": "..."}],
  "isError": true
}

(no structuredContent), wrapped.json() returns null because there's nothing JSON-shaped to extract. The case 'json' then falls through to printRaw(raw)util.inspect again. So callers parsing --output json choke on error responses.

Reproducer

mcporter call qmd.query --args '{
  "searches":[{"type":"vec","query":"verify-patches.sh"}],
  "limit":1
}' --output json --timeout 30000

(QMD's vec search rejects hyphens in queries with an isError envelope.)

Pre-patch output (broken):

{ content: [ [Object] ], isError: true }

Expected output (valid JSON):

{
  "content": [{"type": "text", "text": "Negation (-term) is not supported in vec/hyde queries. Use lex for exclusions."}],
  "isError": true
}

Proposed fix (1-line addition + comment, using existing attemptPrintJson helper)

         case 'json': {
             const jsonValue = wrapped.json();
             if (jsonValue !== null && attemptPrintJson(jsonValue)) {
                 return;
             }
+            if (raw != null && attemptPrintJson(raw)) {
+                return;
+            }
             printRaw(raw);
             return;
         }

attemptPrintJson is already an existing helper in the same file that does console.log(JSON.stringify(value, null, 2)) and returns true/false on success. This adds a JSON-stringify of the raw envelope as a fallback before the util.inspect path.


Bug 3 — logDaemonRetry writes to stdout, polluting --output json

File: dist/daemon/runtime-wrapper.js, function logDaemonRetry.

When the daemon's keep-alive child exits and a call needs to recover the server, mcporter logs:

[mcporter] Restarting '<server>' before retrying <op>: <reason>

via console.log. That goes to stdout — which is the JSON output stream. So the diagnostic line gets prepended to the JSON body, breaking JSON.parse(stdout) for callers (in our case openclaw's qmd-manager does exactly this).

Reproducer

# Start the daemon, kill the qmd-mcp child, then call:
mcporter daemon start
pkill -f 'qmd.*mcp'
mcporter call qmd.query --args '{"searches":[{"type":"lex","query":"x"}],"limit":1}' --output json --timeout 30000

Pre-patch output (broken — stdout is corrupted):

[mcporter] Restarting 'qmd' before retrying callTool: Not connected
{
  "results": [...]
}

Expected: stdout = pure JSON; the diagnostic line on stderr.

Proposed fix (1-character semantic change)

 function logDaemonRetry(server, operation, error) {
     const reason = error instanceof Error ? error.message : String(error);
-    console.log(`[mcporter] Restarting '${server}' before retrying ${operation}: ${reason}`);
+    console.error(`[mcporter] Restarting '${server}' before retrying ${operation}: ${reason}`);
 }

Diagnostics belong on stderr for any tool whose stdout is structured output.


Net impact

For an MCP integration that calls mcporter call --output json and parses the stdout as JSON — including any TypeScript SDK consumer — these three bugs make the integration unusable without local patches. We currently maintain all three fixes as 1-line patches in our installed mcporter@0.7.3 to make our QMD memory-backend integration work.

Repro script (combining all three) and side-by-side pre/post patch outputs available on request — happy to attach if useful.


Adjacent issues observed (out of scope, but useful context)

These were observed during the same integration work but are separate bugs and not necessarily mcporter's responsibility.

Bug D — mcporter daemon start is not idempotent

Running mcporter daemon start while a daemon is already up appears to spawn additional foreground daemon processes rather than no-op'ing. We observed up to 11 orphan mcporter daemon start --foreground processes accumulating across a test session. Each new daemon spawned its own child MCP servers, multiplying memory cost.

Workaround: mcporter daemon stop && pkill -f 'mcporter daemon' before each start.

Suggestion: have mcporter daemon start check mcporter daemon status first and no-op if already running, and/or have it close any prior socket cleanly.

Bug E — qmd MCP server treats hyphens as negation operators in vec/hyde search

Not mcporter's bug — this is in @tobilu/qmd's MCP server. Filed here only for cross-reference. Filenames like verify-patches.sh and dates like 2026-05-04 get rejected by qmd's vec/hyde parsers with Negation (-term) is not supported in vec/hyde queries. Use lex for exclusions. Affects any MCP wrapper that forwards user queries verbatim. Should be filed against tobilu/qmd — flagging here purely to explain the isError envelopes referenced in Bug 2's reproducer.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions