feat(DDE-365): stdio transport — subprocess mode for Claude Desktop / Cursor / VS Code#9
Closed
nc-markovic wants to merge 6 commits into
Closed
Conversation
- 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.
Collaborator
Author
|
Closing to reopen from renamed branch feat/DDE-365-stdio-transport for proper Jira DDE-365 linking. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Adds a
--stdio/-emode 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
startStdioServer()— lifecycle, signal handling, stdout hygiene985d775doReloadCerts) from SIGHUP handler71671ebe84377dprocess.exit(0)on SIGINT/SIGTERM to unblock event loop180e679What changed
Stdout hygiene (the hard part)
In stdio mode
stdoutis the JSON-RPC wire — a single stray byte corrupts the frame and silently drops the client connection. Three layers of protection:LOGGER.useStderr()— all LOGGER levels route toconsole.error.console.log/info/debug → stderrreassignment — catches dependency writes.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. Awaitsserver.onclose(nottransport.onclose— the SDK overwrites that internally) or a signal. SIGINT/SIGTERM triggerdestroyAllCertStrategies()+process.exit(0). Strict XOR dispatch incli.tsensures port 8502 is never bound in stdio mode.Stderr audit trail (B3)
One sanitized line per
CallToolvia anMCPServerHooks.onToolCallcallback — transport-agnostic hook so noif (stdio)leaks into the MCP handler:redactToolArgsdeep-walks nested arg objects, collapsing credential-like keys (pass|secret|token|authorization|…) to***at any depth. Claude Desktop persists subprocess stderr tomcp-server-*.log— free tamper-resistant audit trail.Credential env vars (B4)
AEM_USER/AEM_PASSgive subprocess clients a safe credential channel (env vars are not visible inps aux). Precedence:flag > env > 'admin'.CliParamsSchema(B2)Zod schema validates
--hostURL shape and--stdioboolean 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
Manual verification (step-by-step):
initialize+tools/listhandshake ✅tools/callfires audit line on stderr, returns JSON-RPC error (no AEM) ✅[stdio] SIGINT received — closing✅Claude Code CLI — server connected and tools loaded (43 tools):
Not covered locally (needs real client UI):
Checklist
npm run build && npm run build:types)stdio-smoke.mjs— all 5 assertions passps auxargs