Skip to content

feat(DDE-365): stdio transport — subprocess mode for Claude Desktop / Cursor / VS Code#9

Closed
nc-markovic wants to merge 6 commits into
feat/DDE-365-cert-auth-mtlsfrom
feat/DDE-366-stdio-transport
Closed

feat(DDE-365): stdio transport — subprocess mode for Claude Desktop / Cursor / VS Code#9
nc-markovic wants to merge 6 commits into
feat/DDE-365-cert-auth-mtlsfrom
feat/DDE-366-stdio-transport

Conversation

@nc-markovic

@nc-markovic nc-markovic commented Jun 25, 2026

Copy link
Copy Markdown
Collaborator

Summary

Adds a --stdio / -e mode that serves the full MCP tool surface over newline-delimited JSON-RPC on stdin/stdout, with no HTTP port bound. Used by Claude Desktop, Cursor, and VS Code, which spawn the binary as a subprocess rather than connecting to an HTTP endpoint.

Stack note

This branch stacks on feat/DDE-365-cert-auth-mtls (PR #8). When #8 merges, this PR auto-retargets to its base and the diff narrows to the 4 stdio commits.

Commits shipped

# Title Commit
stdio #1 startStdioServer() — lifecycle, signal handling, stdout hygiene 985d775
stdio #2 Decouple cert reload (doReloadCerts) from SIGHUP handler 71671eb
stdio #3 Credential env vars, URL validation, stderr audit trail e84377d
stdio #4 Fix: call process.exit(0) on SIGINT/SIGTERM to unblock event loop 180e679

What changed

Stdout hygiene (the hard part)
In stdio mode stdout is the JSON-RPC wire — a single stray byte corrupts the frame and silently drops the client connection. Three layers of protection:

  1. LOGGER.useStderr() — all LOGGER levels route to console.error.
  2. console.log/info/debug → stderr reassignment — catches dependency writes.
  3. sanitizeForWire() — collapses CR/LF in error strings interpolated into text fields.

startStdioServer() (app.server.ts)
Sibling to startServer(). No Express, no CORS, no session map, no port. Awaits server.onclose (not transport.onclose — the SDK overwrites that internally) or a signal. SIGINT/SIGTERM trigger destroyAllCertStrategies() + process.exit(0). Strict XOR dispatch in cli.ts ensures port 8502 is never bound in stdio mode.

SIGINT/SIGTERM exit fix (180e679): attaching a process.on('SIGINT') listener suppresses Node's default termination behaviour. Without an explicit process.exit(0), the stdin readable stream kept the event loop alive after the signal was handled — the process printed the closing message but never exited. Verified manually: single Ctrl+C now exits cleanly and returns the terminal prompt immediately.

Stderr audit trail (B3)
One sanitized line per CallTool via an MCPServerHooks.onToolCall callback — transport-agnostic hook so no if (stdio) leaks into the MCP handler:

[stdio] tool_call: listPages {"siteRoot":"/content"} 2026-06-25T12:26:15.268Z

redactToolArgs deep-walks nested arg objects, collapsing credential-like keys (pass|secret|token|authorization|…) to *** at any depth. Claude Desktop persists subprocess stderr to mcp-server-*.log — free tamper-resistant audit trail.

Credential env vars (B4)
AEM_USER / AEM_PASS give subprocess clients a safe credential channel (env vars are not visible in ps aux). Precedence: flag > env > 'admin'.

CliParamsSchema (B2)
Zod schema validates --host URL shape and --stdio boolean at startup. Bare hostnames without a scheme (e.g. author.example.com) fail fast with a clear message rather than a silent fetch error later.

Client config

Claude Desktop / Cursor (claude_desktop_config.json / ~/.cursor/mcp.json):

{
  "mcpServers": {
    "AEM": {
      "command": "npx",
      "args": ["-y", "@netcentric/aem-mcp-server", "--stdio", "-H=https://author.example.com"],
      "env": { "AEM_USER": "your-user", "AEM_PASS": "your-pass" }
    }
  }
}

VS Code (.vscode/mcp.json):

{
  "servers": {
    "AEM": {
      "type": "stdio",
      "command": "npx",
      "args": ["-y", "@netcentric/aem-mcp-server", "--stdio", "-H=https://author.example.com"],
      "env": { "AEM_USER": "your-user", "AEM_PASS": "your-pass" }
    }
  }
}

Test evidence

$ npm run build && node src/test/stdio-smoke.mjs
PASS: port 8502 is NOT bound in stdio mode
PASS: all 2 stdout line(s) parse as JSON
PASS: initialize returned a protocolVersion
PASS: tools/list returned a non-empty tool array (43 tools)
PASS: clean exit on stdin EOF (exit code: 0)

ALL PASSED

Manual verification (step-by-step):

  • initialize + tools/list handshake ✅
  • tools/call fires audit line on stderr, returns JSON-RPC error (no AEM) ✅
  • Single Ctrl+C exits cleanly with [stdio] SIGINT received — closing
  • Ctrl+D (stdin EOF) exits cleanly with code 0 ✅
  • Port 8502 not bound during stdio mode ✅

Claude Code CLI — server connected and tools loaded (43 tools):

aem-mcp stdio connected

Not covered locally (needs real client UI):

  • Wire into Claude Desktop and confirm the server appears and a tool call round-trips (manual — cannot be driven headlessly from this session).

Checklist

  • Build clean (npm run build && npm run build:types)
  • stdio-smoke.mjs — all 5 assertions pass
  • Port 8502 not bound in stdio mode (lsof verified by smoke test)
  • Credentials never reach stdout or appear in ps aux args
  • SIGINT/SIGTERM exit cleanly (manually verified)
  • HTTP mode unchanged

- await startStdioServer in cli.ts with .catch() so startup rejections
  are not silently swallowed
- register uncaughtException/unhandledRejection handlers in stdio mode
  (previously only present in startServer/HTTP path)
- add SIGTERM/SIGINT handlers that call destroyAllCertStrategies() and
  resolve the closed promise for a clean exit
- guard against non-Error thrown values producing "Error: undefined" by
  using `error instanceof Error ? error.message : String(error)`
- LOGGER.warn now respects forceStderr flag, consistent with log/info
Extract doReloadCerts() so the mtime watcher invokes the reload logic
directly instead of routing through onSighup (avoids the SIGHUP-specific
log line and shuttingDown guard). Warn when certWatchIntervalMin is set
without a cert path, and make BasicAuthStrategy.encodedToken private.
B2 – CliParamsSchema (Zod) validates --host shape at startup so bare
hostnames without a scheme fail with a clear message rather than an
opaque fetch error later; --stdio typed as boolean.

B3 – MCPServerHooks.onToolCall fires for every CallTool request and
writes a sanitized stderr line in stdio mode. redactToolArgs
deep-walks nested arg objects, collapsing credential-like keys
(pass|secret|token|authorization|…) to *** at any depth.

B4 – AEM_USER/AEM_PASS env vars give subprocess clients (Claude
Desktop, Cursor, VS Code) a safe credential channel; precedence is
flag > env > 'admin'. README documents the stdio config blocks for
all three clients including mTLS/OAuth and the trust boundary.
Attaching a process.on('SIGINT') listener suppresses Node's default
termination behaviour. Without an explicit process.exit(0), the stdin
readable stream kept the event loop alive after resolveClose() — the
process printed the closing message but never exited.
process.exit(0) is still required to unblock the event loop (stdin keeps
it alive when a SIGINT listener is attached), but an immediate exit drops
in-flight tool calls. A 5s unref'd timer mirrors the HTTP drain pattern
and gives bulk operations a chance to finish before the process exits.
@nc-markovic nc-markovic changed the title feat(DDE-366): stdio transport — subprocess mode for Claude Desktop / Cursor / VS Code feat(DDE-365): stdio transport — subprocess mode for Claude Desktop / Cursor / VS Code Jun 26, 2026
@nc-markovic

Copy link
Copy Markdown
Collaborator Author

Closing to reopen from renamed branch feat/DDE-365-stdio-transport for proper Jira DDE-365 linking.

@nc-markovic nc-markovic deleted the feat/DDE-366-stdio-transport branch June 26, 2026 09:18
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.

1 participant