
Fifteen years building engineering platforms, currently focused on advanced AI infrastructure at yeet. I love turning the deeply complex topics into something everyone can understand. I relate deeply with the core yeet philosophy that you can just build things.
Last updated: September 2026
TL;DR. On the stdio transport, the MCP specification says "the client launches the MCP server as a subprocess," and Claude Code's docs say stdio servers "run as local processes on your machine." That means an MCP server you installed from npm runs as your user, with your filesystem and network access, and your agent's transcript records the tool call it requested rather than the syscalls the server actually made. Nothing in the MCP protocol confines a local server. To see what one really does, watch its process subtree from the kernel:
yeet run github:yeet-src/claudefeed -- --match=node.
I write eBPF tooling at yeet, and the tools I build for watching agent sessions kept surfacing the same thing once people pointed them at MCP: a server they had installed weeks earlier was opening files nobody expected it to touch. I am not going to tell you which MCP servers are safe, because that list would be wrong by the time you read it and I do not audit npm packages for a living. What I can tell you is where the record lives. An agent's tool-call log and a server's actual behavior are two different accounts, and only one of them is produced by the kernel.
No. It shows the tool call your agent sent and the result it got back, which is a record of the conversation between two processes and not a record of what the second one did. When your agent calls a search_files tool, the transcript holds the request parameters and the JSON response. Between those two entries the server process opened files, possibly spawned a shell, possibly made an HTTPS request, and none of that appears anywhere in the transcript because the transcript is written by the client and the client cannot see inside the subprocess it launched.
This is a different gap from the one in agent sandboxing, and the difference is worth being exact about. There, the issue is that a sandbox governs Bash subprocesses while the agent's own file tools go through a permission layer. Here, the issue is that an MCP server is a separate process with its own syscalls, and the agent's permission system sits between the model and the tool call rather than between the server and the kernel. Approving search_files approves the request. It does not constrain what the process does while servicing it, and there is no protocol-level mechanism that would.
The MCP specification is explicit about the process model, which is what makes the consequence unambiguous. The stdio transport page states that "the client launches the MCP server as a subprocess" and that the two communicate over that subprocess's standard streams, with the client reading newline-delimited JSON-RPC from its stdout. Claude Code's MCP documentation puts the operational version of the same fact plainly: "Stdio servers run as local processes on your machine. They're ideal for tools that need direct system access or custom scripts." Direct system access is the feature. It is also the audit gap.
Yours, in full, because it is a child process of your agent and inherits your user's ambient authority. There is no sandbox in the MCP protocol, no capability negotiation that restricts filesystem or network reach, and no manifest declaring what a server will touch. A server that advertises one read-only tool has exactly the same access as one that advertises a shell. The tool list is an interface description, not a permission boundary, and treating it as the latter is the central mistake in how teams evaluate these things.
Four consequences follow, and they are the reason a kernel-side record is worth having:
CLAUDE_PROJECT_DIR in the spawned server's environment so the server can resolve project-relative paths, which is a convenience and also a hint about how much context a server is handed for free.execve. A tool that describes itself as a formatter can shell out, and the resulting process is a grandchild of your agent that appears in no MCP-level log.headersHelper as an arbitrary shell command," gated on accepting the trust dialog for the project directory the server is declared in. Until you trust the folder, it connects with static headers alone. That gate is real, and it means a .mcp.json in a repository is a file that can carry executable intent.Because installing one looks like editing a config file and not like adding a dependency, and the review habits people apply to each are completely different. A new npm package in package.json goes through a pull request, and somebody at least glances at the name. A new entry in .mcp.json is three lines that end up on a laptop, often pasted from a README or a social post, running a package the reader has never looked at. The install command is frequently npx -y some-mcp-server, which fetches and executes the latest published version at launch with no lockfile and no pinned hash.
That combination is a supply-chain shape the ecosystem already has hard-won lessons about, arriving in a place where none of the tooling followed. There is no npm audit for MCP servers, no signature requirement, and no convention that a server declares its filesystem scope. The -y in that command suppresses the one prompt that would have told you a package was being fetched. None of this is exotic and all of it is ordinary practice right now, which is why the useful move is not a policy but a measurement: run the server, watch what it does, and decide with the list in front of you.
Watch the server's process subtree from the kernel while you exercise the tool you care about, because the kernel is the only observer that sees the server's syscalls rather than its JSON-RPC replies. Two tools cover the two halves of the question, and both take one command.
The routes differ less in power than in what they scope to, and scope is the whole difficulty with a server your agent launched at a moment you did not choose.
| Route | Scopes to | Catches a spawned grandchild | Needs a PID up front | Shows files, execs and network |
|---|---|---|---|---|
| claudefeed | A process subtree, matched by name | Yes, the set propagates at exec | No, it finds live sessions itself | All three, plus listening sockets |
| exectop | One application's tree, through fork | Yes, followed in the kernel | No, launch or pid or container | Execs only, folded and ranked |
strace -f | One PID you name | Yes, with -f | Yes, and the server starts without you | Syscalls, at a real slowdown |
auditd | The whole machine, filtered after | Yes | No | All three, in a system-wide log |
| The agent's own transcript | Tool calls | No, it sees no syscalls | No | Neither; requests and replies only |
For the full picture across files, execs and network, claudefeed streams five event classes gated on a process subtree:
curl -fsSL https://yeet.cx | sh # install the yeet daemon, once
yeet run github:yeet-src/claudefeed -- --match=node # most stdio servers are node
--match=<name> sets the program-name needle, tested against the exec'd basename as a case-insensitive prefix and truncated to 15 characters before it reaches the kernel. It is never matched against the full command line, so a process that merely mentions the needle in an argument cannot masquerade as a session. Match on node for a JavaScript server, python or uv for a Python one, and check the actual basename with ps -eo comm if the header reports zero seeded processes. Flags go after -- so the runtime routes them to the script instead of consuming them itself, which is the most common first-run mistake.
The output is one line per event, append-only, newest at the bottom, with no full-screen repaint, so it is safe to pipe or redirect and colors no-op to plain text off a TTY. That makes the audit reproducible:
yeet run github:yeet-src/claudefeed -- --match=node --secs=60 > mcp-audit.log
yeet run github:yeet-src/claudefeed -- --match=node --only=exec,conn # skip the noisy opens
--secs=60 bounds the run so it exits on its own, which is what makes this scriptable from CI or from an agent. --only=exec,conn drops the file-open class, which is by far the noisiest, and leaves a readable narrative of what the server ran and what it dialed. Run the tool you want to audit two or three times during the window, then read the log.
exectop answers a narrower question and answers it better, which matters when the server you are auditing spawns a lot. It follows one application's process tree through fork in the kernel using the sched_process_exec, sched_process_fork and sched_process_exit tracepoints, then folds repetition into one row per kind and ranks the commands that do not fit the pattern above the rest.
The folding is the point. A server that shells out in a loop produces hundreds of identical lines in a flat feed, and a single unexpected curl is one more line going past. Collapsed to eight rows, an outlier is legible as one. The README's own illustration is a build where 425 execs fold into rows led by echo ×124 and date ×90, with four flagged commands ranked above them. Point it at a shell and everything that shell starts:
yeet run gh:yeet-src/exectop -- --pid $$ # watch this shell and its children
Then launch your agent from that shell, exercise the MCP tool, and read the flagged rows. What you are looking for is any exec you cannot map to the tool's stated job.
The record is only useful if you know what counts as surprising, and for MCP servers the list is short and specific:
~/.ssh, ~/.aws, ~/.config, or a sibling checkout. This is the highest-signal class and it is why the noisy open events are worth keeping for at least one run.curl, base64, chmod, or a shell is a question, and exectop ranks exactly these above the folded ordinary rows.conn class reads the destination address and port straight off struct sock, so it records where the connection actually went and not what a hostname claimed.stdin and stdout. A listen event from a stdio server is worth understanding before you keep using it.Not usefully, and the reason is that a remote server's work does not happen on your machine. On the Streamable HTTP transport, each message is an HTTP POST to a single MCP endpoint, so what a kernel probe on your host sees is one TLS connection to the server's domain and nothing about the file reads or commands happening on the far side. The process-subtree tools in this post have nothing to attach to.
That changes what you can verify rather than eliminating the question. For a remote server the audit surface is the network boundary, meaning which endpoint it talks to and what you send it, and beyond that you are relying on the operator's own controls and whatever contract you have with them. The tradeoff runs in both directions and it is worth naming plainly: a remote server cannot read your ~/.ssh because it is not on your machine, and a local one cannot quietly retain your data on someone else's infrastructure because it is. Neither is strictly safer. They fail differently, and the local case is the one where you have a kernel to ask.
Yes, and the enforcement options are the same layers that confine any process, because an MCP server is not special. Watching tells you what happened; confinement decides what may happen, and for a server you have already decided to distrust, the second is what you want. Four routes, in rough order of how much friction they add:
lsm/file_open and returns -EPERM for any open outside a directory you name, propagating membership at sched_process_fork so the whole subtree is covered. Because a server is a child of the agent, jailing the agent covers the server. Its own limits apply: matching is by process name and an unenrolled process runs unconfined, path enforcement allows a hardlink out of the jail, and it needs CONFIG_BPF_LSM=y with bpf in the active LSM list.The filesystem side of this is covered in depth in how to sandbox an AI coding agent on Linux, including why seccomp cannot express a path rule and how the symlink and hardlink cases differ. This post does not re-answer it.
Because strace -f needs a PID you name up front, and your agent launches the server at a moment you do not choose, so by the time you have the PID the interesting work is often done. Both strace and auditd work, and both are the wrong shape for a moving process tree, which is the specific difficulty an MCP server presents. The tree also moves: the agent spawns the server, the server spawns a shell, the shell spawns git. auditd catches all of it and hands you a system-wide log to filter afterward, which is the right artifact for compliance and a poor one for answering a question in the next thirty seconds.
The kernel-side difference is where the filtering happens. In claudefeed a tracked hash map of session tgids gates the file and network probes, so a system-wide firehose of openat never crosses the kernel boundary, and the set self-propagates across exec so a git three levels deep stays in scope without being named. Unlike ptrace, these probes impose no stop on the traced process, so the server runs at normal speed while you watch it. The full three-way comparison of these tools against each other is in how to audit what an AI agent ran on Linux, which covers the general agent case; what matters here is only that a server launched mid-session is the case strace handles worst.
What the incumbents give you that these do not is durability. There is no retention, no persistence, and no aggregation across hosts; the record is what you piped to a file. If the requirement is a signed audit trail your security team queries next quarter, that is auditd or Falco, and it always was.
Five, and each one fails in a direction worth knowing before you rely on the record. Stating them together is more useful than scattering them, because the pattern is that this is a scoping tool and not an escape-proof monitor:
open events carry the path and the access mode. The bytes read or written are never captured, by design, because copying file contents through a ring buffer is a different tool with a very different cost profile. You learn that a server read your .env, not what it did with the value.openat only. The older open(2), openat2, and anything reaching a file through mmap or an already-open descriptor passed across a socket do not appear. Most modern userspace uses openat, so this bites rarely, and a determined process can step around it.kprobe/tcp_connect fires when a connection is initiated, so a peer that refuses or times out still produces a line. UDP and raw sockets are not hooked at all, which means DNS over UDP is invisible.exec event fires at syscall entry, so a command that fails to launch still produces a line. The following exit line is what disambiguates.If you want to know what an MCP server did, do not read the agent's transcript, because it records the call and the reply and nothing between them. If you want the fastest complete answer, run claudefeed with --match= set to your server's program basename, exercise the tool two or three times, and read the opens outside your project, the execs that are not the server's toolchain, and any listen event at all. If the question is only what a server launched, exectop folds the repetition and ranks the outliers, which is what makes one unexpected curl visible among hundreds of ordinary execs. If you have already decided not to trust a server, stop watching and start refusing: agent-lock for a kernel-enforced path boundary that covers the subtree, agent-jail when you need inode-exact enforcement or cannot load BPF, or a container with only the project volume when you want the environment isolated too. If you need a durable trail rather than an answer right now, that is auditd or Falco and it always was. And if the server's job is small, the cheapest control is not installing a permanent subprocess with your full ambient authority to do it.
The part worth carrying is structural. MCP standardized how an agent asks a server to do something, and standardized nothing about what the server may do while it does it. That gap is filled by the operating system or it is not filled at all.
On the stdio transport, yes. The MCP specification states that the client launches the server as a subprocess, so it inherits your user's filesystem and network access along with the parent's environment variables. The protocol has no sandbox, no capability negotiation and no manifest of what a server will touch, so the tool list a server advertises is an interface description and not a permission boundary.
Yes, by watching its process subtree from the kernel. A probe on the openat tracepoint gated on the server's process ids reports every path it opens, which is information the agent's own transcript cannot contain because the transcript records the tool call and its reply. claudefeed does this for a named process tree and also reports execs and TCP connections.
No. Approval gates the request your agent sends over the JSON-RPC channel; it does not constrain the syscalls the server process makes while servicing it. A server handling an approved read can also open unrelated files, spawn a subprocess, or make a network request, and none of that appears in the client's log.
Replace the floating invocation with an exact version in the server's command, so the package manager resolves one published artifact instead of the newest one at launch. For npm-based servers that means naming the version explicitly rather than relying on the latest tag. Pinning does not review the code, but it does mean the code you reviewed once is the code that runs tomorrow.
Yes, for a local stdio server. A subprocess inherits the parent process environment by default, so tokens exported in the shell that launched the agent are visible to it. Claude Code also sets CLAUDE_PROJECT_DIR in the spawned server's environment so it can resolve project-relative paths. Stripping credentials from subprocesses requires configuration on the client side.
Not with process-level tools, because the server's work happens on someone else's machine. On the Streamable HTTP transport each message is an HTTP POST to a single endpoint, so a kernel probe on your host sees one TLS connection and nothing about the far side's file reads or commands. The audit surface for a remote server is the network boundary and the operator's own controls.
The process boundary. An agent audit covers what the agent and its shell commands did; an MCP server is a separate subprocess with its own syscalls, so it needs to be inside the watched subtree to appear at all. Because a stdio server is a child of the agent, a subtree-scoped tool that follows fork covers both, while a tool keyed on the agent's process name alone does not.
Yes, with a kernel path boundary rather than an MCP setting. agent-lock attaches a BPF LSM program to file_open and returns -EPERM for opens outside a named directory, propagating membership at the fork tracepoint so the whole subtree including spawned servers is covered. agent-jail takes the Landlock route and enforces on the resolved inode instead, which closes the hardlink case.
Not meaningfully. Unlike ptrace, these probes are passive observers that impose no stop on the traced process, and a well-built tool gates its probes on an in-kernel map so events outside the watched subtree are dropped after one hash lookup and never reach userspace. The cost scales with the watched subtree's activity, not the machine's total syscall volume.
Worth investigating. A stdio server's entire transport is its standard input and output, so it has no protocol reason to listen on a port. A listen event might be a legitimate internal helper, a debug interface, or a language server the tool wraps, but it is a capability the transport does not require and it is the kind of thing a per-tool description would not mention.
Yes. Nothing in the protocol or the transport prevents a server from calling execve, and the resulting process is a grandchild of your agent that appears in no MCP-level log. This is why the exec class matters in an audit: a tool that describes itself as read-only can still shell out, and the folded view is what makes an unexpected command legible among ordinary ones.
It addresses the process model rather than confinement. The stdio transport page defines the server as a client-launched subprocess communicating over standard streams and specifies the lifecycle, including that clients should restart a server that exits unexpectedly. Confinement is left to the host, which is why the practical controls are operating-system boundaries and vendor-side trust prompts.
stderr and the client should not assume stderr indicates errors; the shutdown sequence of closing stdin, waiting, then escalating SIGTERM to SIGKILL; and that a client should restart a server that exits unexpectedly.CLAUDE_PROJECT_DIR is set in the spawned server's environment; and that a headersHelper is executed "as an arbitrary shell command" only after the project trust dialog is accepted.exec, exit, open, conn, listen); the tracked tgid hash map that gates the file and network probes in the kernel so a system-wide openat firehose never reaches userspace; the self-propagating tracked set across exec; the --match needle tested as a case-insensitive basename prefix and truncated to 15 characters, never matched against the full command line; and the --only, --except and --secs flags.open events carry paths and never contents; the openat-only gap excluding open(2), openat2 and mmap; that exec fires at syscall entry so requested rather than successful execs are recorded; that it is TCP only with UDP and raw sockets unhooked so DNS over UDP is invisible; that it observes and "does not stop, hold, or modify anything"; and that nothing is retained.sched_process_exec, sched_process_fork and sched_process_exit tracepoints; selection by launch, container or pid with the tree followed through fork in the kernel; the folding that turns 425 execs into rows led by echo ×124 and date ×90 with four flagged commands ranked above; and the argument that compression is what makes a single unexpected curl legible as an outlier.lsm/file_open returning -EPERM outside a named directory, with bpf_d_path resolving traversal and symlink spellings to one target and sched_process_fork propagating jail membership so a spawned subtree stays covered. Also its stated limits: process-name matching where an unenrolled process runs unconfined, the allowed hardlink out of the jail, and the CONFIG_BPF_LSM=y requirement with bpf in the active LSM list.lsm= list omits bpf.openat only, TCP only, observe-only, no retention.Built with yeet, a JS runtime for writing eBPF programs on Linux machines. Join us on discord.