Skip to content

Stdio MCP porting: structure travels, secrets never do - #20

Merged
ohansFavour merged 4 commits into
mainfrom
feat/stdio-mcp
Sep 17, 2026
Merged

ohansFavour merged 4 commits into
mainfrom
feat/stdio-mcp

Conversation

@ohansFavour

@ohansFavour ohansFavour commented Sep 17, 2026 •

Copy link
Copy Markdown
Member

Command-based (stdio) MCP servers become portable as STRUCTURE, never secrets — superseding the earlier rule that they are never applied.

  • Classification: portable stdio = command + args + env NAMES, values dropped at extraction; machine-local paths, bare script filenames, and localhost keep a server blocked.
  • Export: structure travels only behind repeatable --mcp <name> (symmetric to hooks); opt-in picker group with command hints.
  • Apply: full revalidation of the untrusted entry before argv assembly (claude mcp add --transport stdio --env K=V -- cmd args, execFile, no shell). Env values resolve on the target — masked prompt in the guided flow, process.env in the static path failing closed by name. Deliberately no --mcp-env flag: secrets never touch argv or shell history. Missing binary warns, never refuses.
  • Threat model + README updated.

187 tests, typecheck clean. A planted env value is asserted absent from every manifest, bundle byte, rendered frame, and log.

@coldtea-pr-lens

coldtea-pr-lens Bot commented Sep 17, 2026 •

Copy link
Copy Markdown

◈ PR Lens

🟢 +1 new · 🟠 ~15 changed · 🔴 -0 removed · 2 flows · 17 files · commit dfb15a3


Architecture

Architecture diagram for coldteadotai/agent-sync at dfb15a3

16 components touched across 4 lanes.

Open the interactive canvas


Inside the changed components — 2 views

Component view — Stdio MCP Export & Classification

How command-based MCP servers are scanned, sanitized, and packed without secret values.

Architecture view of Component view — Stdio MCP Export & Classification in coldteadotai/agent-sync

Component view — Stdio MCP Apply & Registration

How untrusted command definitions are re-validated and registered with freshly resolved secrets.

Architecture view of Component view — Stdio MCP Apply & Registration in coldteadotai/agent-sync

Data flow

Data flow diagram for coldteadotai/agent-sync at dfb15a3

Exporting a command-based MCP server · Applying and registering a command-based MCP server

Open the interactive canvas


The other flows — 1 sequence

Applying and registering a command-based MCP server

Sequence diagram of Applying and registering a command-based MCP server in coldteadotai/agent-sync

View

  • Architecture lens
  • Data flow lens
  • Expand every detail

Tip

Push a commit and the comment redraws for the new head. A slow older run never overwrites a newer one.

🪧 More tips
  • Run npx skills add coldteadotai/pr-lens, then tell your coding agent: "Diagram the change you just made with PR Lens and attach it to the pull request."
  • Run npx @coldtea/pr-lens-cli analyze --base origin/main on a branch, then npx @coldtea/pr-lens-cli render .pr-lens/graph.json. Same lenses, your own model key, before the pull request exists.
  • Untick Architecture lens or Data flow lens under View to hide a diagram, or tick Expand every detail to open every section. The comment redraws in a few seconds.
  • Click the link under each diagram to open it on a canvas you can zoom, pan and step through.
  • The diagrams are links. Click one to open it on the canvas, then press W or click play to walk through the change.
  • Open a diagram on the canvas, then press W or click play to walk through the change one step at a time.
  • The CLI's render reads .github/pr-lens.yml and applies your renames, exclusions and lane pins at draw time.
  • Set github.comment.collapsed: true in .github/pr-lens.yml to fold the comment behind one View architecture and data flow row. Drawing still runs on every push.
  • Add .github/workflows/pr-lens.yml with coldteadotai/pr-lens/packages/action@v0 and your model provider's key as its api-key to run PR Lens from your own CI. Any /chat/completions endpoint works.
  • Switch GitHub to dark mode and the diagrams follow. The moving dots are this pull request's data in motion.

Thanks for using PR Lens! It's built by Coldtea, free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

…er do

Supersedes the earlier rule that command-based servers are never
applied: hooks are code and port with double consent, so stdio servers
get the same treatment — with the one inviolable line intact.

Classification extracts the re-creatable shape (command, args, env
NAMES) and drops env values at extraction, so no downstream layer can
see one. Portability is earned: any machine-local absolute path, bare
script filename, or localhost URL keeps the server blocked. Export
carries the structure only behind a repeatable --mcp <name>, symmetric
to hooks, and the travel picker gains an opt-in MCP group whose hints
show the command and the env names it needs. Apply revalidates the
untrusted manifest entry in full (name shape, control-byte-free
strings, env-name shape, size caps) before assembling the claude mcp
add argv — no shell anywhere. Env values resolve on the target: the
guided apply asks with a new masked prompt (asterisks on screen,
plaintext only in memory; plain mode says its input is visible), and
the static path reads the machine's own environment, failing closed by
variable name before any file writes — deliberately no flag, so
secrets never touch argv or shell history. A missing command binary
warns and never refuses.

Seven new tests: classification with a planted env value swept from
every output, consent-gated manifest shape, picker group and flag
echo, argv construction with every refusal, the static-path env round
trip through a shim, the guided flow end to end proving the typed
secret reaches argv but never a rendered frame, and the masked prompt
unit. Threat model and README updated accordingly.
…ion, the gate reruns on apply

Dry runs plan with a placeholder resolver, so a resolved secret cannot
exist to be echoed, and every argv display goes through a masker that
replaces env values with the name and an ellipsis — including claude's
own stderr when a registration fails, which could otherwise quote the
argv back into our error message.

String hygiene now rejects the entire C0 range: CR, LF and TAB were
accepted, and a CR-bearing arg could both forge the consent frame and
register. Validation now precedes display everywhere — the guided flow
validates a stdio entry through the full gate before building any
consent text, so a hostile manifest refuses the whole apply without a
single byte of it reaching a frame, and the static path's hint lines
sanitize names and commands it never validated. The consent block
itself is no longer one truncatable line: command and args wrap across
full lines with no elision, because that display is the security
boundary.

The stdio branch now performs the same complete re-derivation the
remote branch always did: the exact export-side classifier reruns over
the untrusted entry, closing the blocked-class smuggling hole and the
portability-gate bypasses found live (script filenames with directory
prefixes or in the command position, more script extensions, private
and shorthand-IP URLs in args). Token-shaped args are refused at
classification: an argument that looks like a credential is a value,
and values never travel — the fix is moving it to env.

Five new tests: dry-run placeholder round trip both with and without
the env set, C0 rejection, the re-derivation refusal table, the
hostile-manifest consent-forgery attempt asserting the forged text
reaches no frame and nothing is written, and the wrap/mask units.
…sanitize, and the hygiene class covers C1 and bidi

The wrap width now follows the live terminal (columns minus the note
prefix and continuation indent), so the zero-elision promise holds on
the default 80-column terminal where the fixed width was quietly
re-truncated by the renderer — an attacker controls layout, so a fixed
hidden tail was addressable. The one error message that can carry an
unvalidated server name sanitizes it first, closing the last raw path
to stderr. And string hygiene grows past C0+DEL to everything a
terminal or a reader can be steered by: the C1 range (U+009B is a
single-codepoint CSI), zero-widths, bidi and directional-isolate
controls, line and paragraph separators, and the BOM — commands and
args have no legitimate use for any of them. displayString strips the
same class.

Two new tests: the 80-column consent wrap proving the tail of a long
arg stays on screen, and the hostile-name refusal asserting raw ESC
never reaches the message while C1 and bidi args refuse outright.
wrapDisplay accumulates visual cells instead of slicing by JS
characters, so CJK and emoji content can no longer overflow the
renderer's budget and hide consent text behind an ellipsis on narrow
terminals — the same boundary finding, now closed for non-ASCII too.

String hygiene collapses from a growing codepoint list to the Unicode
properties that define the category: Cc (every control, C0 through C1)
and Cf (every invisible format character — zero-widths, bidi and
isolate controls, BOM, Arabic letter mark, soft hyphen, word joiner),
plus the Zl/Zp separators explicitly. Invisibles let a displayed
command read identically to a different argv; none of them have a
legitimate place in a command line. displayString strips the same
class.

Tests: a 120-CJK-character arg keeps its tail marker through the wrap
with every line inside the cell budget, and U+061C, U+2060 and U+00AD
args refuse.
@ohansFavour
ohansFavour merged commit bd2f23b into main Sep 17, 2026
4 checks passed
@ohansFavour
ohansFavour deleted the feat/stdio-mcp branch September 17, 2026 03:33
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