Skip to content

In --mcp mode every tool returns null and leaks raw output onto stdout, corrupting JSON-RPC #387

@beckitrue

Description

@beckitrue

Check existing issues

Describe the bug

Summary

With the process.exit(0) race from Issue #1 worked around, mppx --mcp connects but every tool call returns null AND writes extra non-JSON-RPC lines to stdout, which corrupts the stdio transport and causes the MCP client to disconnect. Example reproduction with sign:

MCP tool result:  {"content":[{"type":"text","text":"null"}]}
Extra stdout:     {"authorization":"Payment eyJ..."}

The second line (the actual payload) is what the user wants, but it's written as raw console.log output on the same file descriptor that the JSON-RPC transport is using — so the client reads it as an invalid frame and drops the connection.

Root cause

incur's MCP handler (incur/dist/Mcp.js) builds tool responses from result.data:

const result = await Command.execute(tool.command, { ... });
...
const data = result.data ?? null;
return {
  content: [{ type: 'text', text: JSON.stringify(data) }],
  ...(data !== null && tool.outputSchema ? { structuredContent: data } : {}),
};

But mppx's CLI command handlers never set result.data — they write their output with console.log(...) instead. Examples from dist/cli/cli.js:

Line Code Command
874 console.log(JSON.stringify({ authorization: credential })); sign --format json
877 console.log(credential); sign
500 console.log('Account "..." saved to keychain.'); account create
530 console.log('Default account set to ...'); account default
542 console.log('Default account set to ...'); same
592 console.log('Account ... deleted'); account delete
674 console.log(... address ... balance ...) account list
704 console.log(key); account export
732+ console.log(...) (address/balance/name/type lines) account view
979 console.log(json); discover generate
1059 console.log(...) per validation issue discover validate

None of these propagate a structured payload back through the c.* context, so (a) the MCP tool response is always {text: "null"}, and (b) the console.log output bleeds onto stdio, corrupting the JSON-RPC channel.

Link to Minimal Reproducible Example

https://github.com/woven-record-media/mppx-mcp-repro

Steps To Reproduce

Reproduction

Minimal repo: https://github.com/woven-record-media/mppx-mcp-repro (same repo as Issue #386).

git clone https://github.com/woven-record-media/mppx-mcp-repro
cd mppx-mcp-repro && npm install
npm run bug2

The reproducer works around Issue #1 by importing dist/cli/cli.js directly and calling cli.serve(['--mcp']) without the process.exit(0) wrapper, then acts as a minimal JSON-RPC 2.0 client over stdin/stdout against that subprocess. It calls tools/call name="account_list" (chosen because it needs no args and hits a per-command handler).

Expected output:

[bug2] tool call result:
{
  "result": { "content": [ { "type": "text", "text": "null" } ] },
  "jsonrpc": "2.0",
  "id": 3
}
[bug2] non-JSON-RPC lines observed on stdout (these are the leak):
  >> <one or more lines of human-formatted account_list output>

[bug2] BUG CONFIRMED: tool result is "null" AND stdout was polluted with N non-JSON-RPC line(s).

(The exact leaked content depends on what's in the local mppx keychain — either account rows, or No accounts found. on a clean machine. Either way: the tool result is null, AND there is stray stdout outside the JSON-RPC framing. A real MCP client drops the connection on the first unparseable frame.)

The same pattern reproduces with sign (line 874/877), account_view (line 732+), and every other per-command handler listed above.

Package Version

[email protected]

Anything else?

Fix

Two parts:

  1. Return data through the runtime, not console.log. Each command handler should populate whatever incur exposes as the structured result (likely c.ok({...}) / return {data: ...} — whichever the incur API uses). Example for sign:

    -    if (c.format === 'json') {
    -        console.log(JSON.stringify({ authorization: credential }));
    -    } else {
    -        console.log(credential);
    -    }
    +    return c.ok({ authorization: credential });
  2. Render for humans only when not in agent/MCP mode. For the CLI mppx sign case, keep human-friendly stdout, but gate it on c.agent !== true (or however incur signals the MCP runtime). Alternatively, let incur's default text renderer format result.data for CLI mode — that way the handler stays runtime-agnostic.

    +    if (c.agent) {
    +        return c.ok({ authorization: credential });
    +    }
    +    if (c.format === 'json') {
    +        console.log(JSON.stringify({ authorization: credential }));
    +    } else {
    +        console.log(credential);
    +    }

This pattern needs to be applied to every console.log in dist/cli/cli.js that represents tool output (not user-facing progress). The tool definitions should also declare outputSchema so the MCP runtime emits structuredContent alongside text.

Impact

Every mppx MCP tool is currently non-functional. For agent use cases that need programmatic signing (Weftly, other MPP-speaking APIs with unattended payment flows), --mcp cannot be used without an external proxy. I've worked around this locally with a ~180-line stdio proxy that shells out to mppx sign per call, but the right fix is upstream.

Metadata

Metadata

Assignees

No one assigned

    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