# MCP process trees: find unexpected child processes

Local MCP servers make it easy to mistake a clean protocol boundary for a clean execution boundary. An agent sends a tool call, the server returns a result, and the transcript looks contained. Meanwhile the server may start a shell, a package runner, a language runtime, a compiler, an SSH client, or a helper downloaded into a project directory. The process tree tells you where the work actually went.

I do not treat an MCP server as one process for review purposes. I treat the server and every descendant it starts as one execution family until those descendants exit. That rule catches the failures that tool transcripts routinely miss: an argument that caused a shell to invoke something else, a helper selected through `PATH`, a child that retained an API token, or a long-lived program that opened a socket after the original request appeared finished.

## A tool call can become many local programs

An MCP server often starts children for ordinary reasons. A repository tool may call `git`; a code tool may invoke a formatter; an infrastructure tool may call `ssh`; a package-aware server may start a runtime and then a package executable. The existence of a child does not establish a breach.

The useful distinction is between an **expected helper** and an **unaccounted execution path**. An expected helper has a documented job, a known executable location, arguments that fit the request, and a lifetime that fits the job. An unaccounted path breaks one of those assumptions. It may still be harmless, but it deserves an explanation before it receives a credential, filesystem access, or network reachability.

A parent PID alone is not enough. Unix process creation creates a lineage, not a security contract. A descendant commonly inherits the parent's user ID, current working directory, environment variables, resource limits, and sometimes open file descriptors. The exact inheritance depends on how the parent starts it, but a child has usually received enough context to act as the parent in places that matter.

That is why "the MCP server only calls one command" is not a useful assurance. `tool-server` might start `/bin/sh -c`, which starts `node`, which starts a package script, which starts `curl`. A reviewer who records only `tool-server` has documented the least interesting part of the chain.

The POSIX `exec` model explains the sharp edge. A process can replace its image with another program without changing PID. A simple process list taken before and after a request can therefore miss a short-lived intermediary. You need both a tree view and event evidence when the task has enough sensitivity to warrant it.

## Establish a baseline before you let an agent work

A baseline is a record of the children your server starts during a boring, approved request. It gives later observations a comparison point. Without it, every interpreter looks suspicious and every warning turns into an argument about what might be normal.

Start the server manually in a dedicated Terminal session. Record its PID immediately, then capture the process table with full command lines:

```sh
ps -axo pid,ppid,user,etime,stat,command > mcp-process-baseline.txt
```

The columns matter. `PID` identifies the process, `PPID` identifies its direct parent, `USER` shows which account owns it, `ETIME` gives elapsed runtime, and `STAT` can reveal a stopped or zombie process. `COMMAND` is only as complete as the operating system can report, but it is still the first place to look for a shell wrapper, a temporary path, or an unexpected flag.

On macOS, `ps` does not ship with a universally available tree display. You can enumerate direct children with `pgrep`:

```sh
pgrep -P 48192 -alf
```

Replace `48192` with the server PID. Typical output has the form:

```text
48207 /usr/bin/python3 /Users/me/tools/format_request.py
48211 /usr/bin/ssh -o BatchMode=yes build.example
```

Then repeat the command for every child PID until no new descendants appear. That sounds tedious, and for a one-off review it is. It also forces you to see the actual chain rather than trusting a diagram in documentation. If you install `pstree` through your normal package process, `pstree -p 48192` gives a more readable snapshot. Do not make the display tool part of the security claim. It merely saves typing.

Run a small set of expected requests: a read-only repository query, the formatter action, an SSH action if the server supports one. Save a snapshot for each. Note the executable paths, normal arguments, normal working directories, and whether helpers exit. A compiler that appears for a build request may be expected. The same compiler during a request to summarize a text file is a deviation.

Do not baseline against a development checkout full of mutable wrappers and package scripts, then call the result trusted. That setup tells you what your current machine happens to run. It does not tell you what the server should be permitted to run. Write down the expected executable paths separately.

## Shell wrappers hide the process you meant to inspect

Shell invocation is popular because it makes dynamic commands easy to assemble. It also turns an argument boundary into text, and text can acquire meaning through quoting, expansion, globbing, redirects, command substitution, and shell functions. The server author may intend to run a formatter while the actual child process launches a shell that decides what the formatter command means.

This is a frequent source of misleading reviews. Someone sees an allowlisted binary name in source code, then assumes that binary is the one executed. The runtime evidence shows `/bin/sh -c ...`, and the shell resolves the rest later. Those are different claims.

Compare these two patterns in a server implementation:

```js
spawn("/usr/bin/git", ["status", "--short"], {
  cwd: repositoryPath,
  shell: false
});
```

```js
exec(`git -C ${repositoryPath} status --short`);
```

The first pattern keeps the executable and arguments separate. It still needs validation of `repositoryPath`, but it avoids shell parsing. The second pattern gives `repositoryPath` a chance to change the command grammar if the code gets quoting wrong. It also adds a shell process to the tree, which may spawn more processes before the final command starts.

Do not accept "we sanitize input" as a substitute for an argument vector. Sanitizers decay as options grow. Developers add a feature, permit spaces, add a conditional flag, and the old filter no longer describes the command grammar. An explicit executable plus an array of arguments makes the boundary visible in code and in audit output.

If a server genuinely needs shell syntax, narrow it. Use a fixed script stored outside the agent-writable workspace, pass data through positional parameters, and record the script's path and digest in your deployment notes. Never compose a shell line from tool input and call the resulting child expected just because it originated in your source tree.

The awkward case is package tooling. Commands such as package runners often execute lifecycle scripts supplied by the project. A server that runs a package command inside an agent-edited checkout may therefore launch commands the agent placed in the project configuration. That is not a package manager defect. It is an execution decision you made by treating a mutable repository as a source of trusted instructions.

## PATH and working directories change the meaning of a command

`git` is not an executable identity. It is a lookup request. The process resolves it by searching `PATH`, and an agent-controlled repository can influence that search if the server includes project-local directories or inherited shell configuration. A file named `git` in such a directory can run before `/usr/bin/git`.

Inspect the environment that a process receives, not just the command shown in a tool transcript. For a process you own, macOS lets you query environment information through `ps` on many versions, though availability varies. A practical approach is to have the server log a deliberately short, redacted environment at startup: `PATH`, `HOME`, `TMPDIR`, current directory, and the paths of fixed executables. Do not log access tokens, session cookies, or complete environment dumps into a shared project log.

Resolve sensitive commands to absolute paths. This is not ceremony when an agent can alter the working tree. For example:

```sh
/usr/bin/git -C /Users/me/work/repo status --short
/usr/bin/ssh -o BatchMode=yes -o IdentitiesOnly=yes host.example
```

Absolute paths remove one lookup problem. They do not make the target safe. `git` can invoke hooks or external diff programs under some configurations. SSH can load configuration and invoke helpers. The working directory also controls relative file reads, project configuration, and temporary outputs. Record it when you inspect a child.

Use `lsof` to examine a process's current directory and opened files:

```sh
lsof -nP -p 48207 | sed -n '1,35p'
```

Look for `cwd` in the file descriptor column, executable text under `txt`, and files under the project, temporary directories, or credential locations. `lsof` is a snapshot. A quick child can appear and vanish before you run it, but the output is still good at catching a server that quietly keeps a helper alive.

A child launched from `/private/var/folders/...` needs more scrutiny than one launched from a managed application directory, particularly if its name resembles a normal utility. Temporary locations are legitimate places for build products. They are also convenient places for a process to hide among disposable files. Ask which component created it and why that component needed an executable there.

## Network activity must fit the requested action

A local child can reach past the boundary of the MCP request even when it never writes a suspicious file. A formatter should not usually open an outbound connection. An SSH helper should connect to the requested host and then exit. A package installation may contact registries, but that action needs explicit review because it can fetch and execute new code.

Inspect sockets for the server and every child that persists long enough:

```sh
lsof -nP -i -p 48211
```

The output typically includes protocol, local address, remote address, and connection state. The `-nP` flags prevent name and service lookups, which keeps the output literal and avoids extra resolver traffic during an investigation. A `LISTEN` entry means the process accepts local or network connections. An `ESTABLISHED` entry means it has an active peer. Match each entry against the action that caused it.

Do not overreact to every network library process. Some developer tools check update services, certificate status, or dependency metadata. I still treat that behavior as a reason to tighten the server. A tool call that supposedly reads local code should not acquire an undeclared outbound channel merely because a helper thinks it is convenient.

Separate credential injection from process observation. Process inspection can tell you that `curl` ran. It cannot guarantee that the token passed in an environment variable was never copied, logged, or inherited by a grandchild. A common recommendation is to "just put the API key in the child environment for one command." It is popular because it is easy and works in a demo. It is wrong for agent-run actions because a child can print its environment, pass it on, or remain alive after the visible command returns.

Sallyport takes a different boundary for its HTTP and SSH channels: the agent does not receive the stored API or SSH credential, and the app performs the action instead. That does not remove the need to inspect local MCP children, but it avoids turning every helper process into a potential holder of the secret.

## A failure often starts with a reasonable convenience wrapper

Consider a local repository MCP server that offers a `run_test` tool. The author wants a flexible command, so the handler changes into the repository and runs a project-defined test script through a package command. The agent has permission to edit the checkout because editing code is the job.

The agent changes the project script as part of a proposed fix. The server runs the test tool. The package command starts a shell, which starts the runtime, which runs the modified script. That script starts a background helper with output redirected to a temporary file. The tool returns "tests passed" because the foreground command exits successfully.

Nothing in that sequence requires an exotic exploit. The problem is that the server treated agent-writable project metadata as approved executable configuration. A reviewer may see only the original MCP request and the success result. The process tree shows the meaningful story:

```text
mcp-repo-server(48192)
  package-runner(48230)
    sh(48233)
      runtime(48234)
        test-script(48240)
          helper(48247)
```

The background helper is the investigation point. Check its full command line, executable path, parent chain, working directory, open files, sockets, and start time. Check whether it survives after the server session exits. Then inspect the project change that supplied the script. Do not frame this as an agent failure alone. The server offered execution over mutable instructions and called the result a test.

The repair depends on the intended product behavior. A cautious server can run a fixed test executable with fixed arguments. If project-defined scripts are necessary, treat the script file and package metadata as executable inputs: show them for approval, run them in a constrained environment, and prohibit backgrounding where possible. At minimum, the server must report every descendant it started, including one that outlives the tool call.

The important distinction is between an agent asking a server to execute a known test command and an agent changing what the test command means before the server executes it. Both may appear as `run_test` in an MCP transcript. They present very different risk.

## Observe process births, not only snapshots

Snapshots answer "what is alive now?" They do not answer "what ran for 200 milliseconds and exited?" For suspicious or sensitive tool calls, observe process births while the request runs.

macOS includes Endpoint Security for security products with the required entitlement, but ordinary local tooling cannot assume it. Do not write a design that depends on privileged event collection unless you actually ship and operate that capability. For a developer investigation, use a controlled wrapper around the executable and collect start and exit details, or run the server under a process monitor available in your environment.

A simple wrapper can make execution visible when you control the server's command path:

```sh
#!/bin/sh
printf '%s pid=%s ppid=%s cwd=%s argv=%s\n' \
  "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$$" "$PPID" "$(pwd)" "$*" \
  >> "$HOME/.local/state/mcp-exec.log"
exec /usr/bin/git "$@"
```

This artifact records the wrapper's identity and then uses `exec` to replace the wrapper with `git`. It prevents the wrapper from lingering as an irrelevant parent. It does not capture every descendant that `git` might create, and it must not receive secrets in arguments. Use it to test a controlled hypothesis, not as a complete audit system.

For an existing process, macOS `dtruss` can reveal system calls but often requires elevated privileges and produces a great deal of output. It is an investigation tool, not routine monitoring. Start with the process tree, `lsof`, and application logs. Reach for syscall tracing when you need to answer a specific question, such as whether a child executed another path or connected to a socket.

Keep timestamps in UTC and record the parent PID with every event. A command line without a parent and time is weak evidence. PID values are reused after processes exit, so a late snapshot can accidentally attach a new, unrelated process to an old incident. The process start time in `ps` or your own event log helps avoid that mistake.

## Approval must name the executable boundary

A confirmation that says "Allow run_test?" gives a human too little information. The person approving needs to know which signed process initiated the action, which executable will run, the working directory, whether the action can reach the network, and whether it can start further programs. A vague approval card teaches people to approve vague actions.

Do not try to solve this by prompting for every `fork` call. That creates approval fatigue, then users approve mechanically or turn prompts off. Review the meaningful transitions: a new executable path, a transition from local work to network access, a command sourced from a mutable project file, or a child that persists past the request.

Code signing information gives a useful identity signal on macOS, but do not oversell it. Inspect an executable with:

```sh
codesign -dv --verbose=4 /path/to/executable 2>&1 | sed -n '1,20p'
```

The command reports signing authority when present and reports an error for unsigned code. That tells you who signed the file macOS inspected. It does not tell you whether the command arguments are safe, whether its configuration is trustworthy, or whether it will execute an unsigned script afterward. Inspect the child path and its execution context separately.

Sallyport's session authorization leads with the connecting process's code-signing authority, which is useful for deciding whether a new agent process may act at all. Its per-call key setting fits the narrower case where a particular credential use needs a human decision every time. Neither control should be used to pretend that an approval of a parent automatically explains every descendant it may create.

Build approval language around consequences. "This request will run `/usr/bin/ssh` as your account and connect to `host.example`" is reviewable. "This tool needs access" is not. If the tool can invoke project scripts, say that directly. Humans can make informed decisions when the request has a concrete shape.

## Contain the server before you investigate the child

When you find an unexpected child, preserve enough evidence to explain it, then stop the execution family. Do not begin by deleting temporary files or rebooting. Those actions may remove the only path, command line, and timing evidence you have.

Use this order when the child is still running:

1. Capture `ps` output for the server, its parent, and known descendants. Record PIDs, PPIDs, elapsed time, and full commands.
2. Run `lsof -nP -p <pid>` and `lsof -nP -i -p <pid>` for the suspicious process. Save the output outside the agent workspace.
3. Stop the suspicious descendant with `kill <pid>` if a normal termination is safe. Escalate to `kill -KILL <pid>` only if it will not exit and continued execution creates unacceptable risk.
4. Stop the MCP server and revoke or end the agent session that initiated the action. Check for children that were reparented after the server exited.
5. Inspect the executable, its launch source, and changes to the repository or configuration that produced the command.

`kill` does not automatically terminate a process group or all children. A backgrounded descendant can continue after its direct parent exits. This is why you need the tree before cleanup. On a controlled server, place helpers in a dedicated process group or supervision scope so that the server can terminate the group on session end. Test that behavior with a deliberately backgrounded helper before relying on it.

Also check persistence. On macOS, `launchctl print` can inspect loaded launch services when you have a specific label or domain to examine. Do not blindly unload unrelated jobs because their names look unfamiliar. Match a suspected child to its executable path, launch configuration, and timestamps first. A process that returns after a clean reboot or fresh server start needs a different investigation than one that existed only in a test session.

## Make every action explainable after the fact

A useful audit record connects a user-approved agent process to a request, an executed action, and its observed result. Process telemetry should add the executable path, parent chain, working directory, start and exit time, and destination for network activity. If the record lacks the process tree, it cannot answer whether a helper was part of the request or an unrelated machine process.

Do not put secrets into audit records to make them complete. Store credential names or opaque identifiers, request metadata that reviewers need, and the action result after redaction. A log that solves provenance by copying bearer tokens has created a second credential store with weaker controls.

Tamper evidence matters after an incident because local logs can be edited by the same account that ran the command. Sallyport projects both its Sessions and Activity journals from an encrypted, hash-chained audit log, and `sp audit verify` checks that chain offline without a vault key. That is useful evidence for an agent action, but the process records still need enough context to explain what the local server started.

Put one review question into your server's operating procedure: "Which executable did this request cause to run, and why was that executable allowed to exist in this working directory?" If nobody can answer it from a request record and a process capture, reduce the tool's scope. A local MCP server that cannot account for its descendants has more authority than its operators can safely review.
