# MCP stdio security boundaries for local AI agents

Local MCP servers are often treated as harmless because they speak stdio and run on the same Mac as the agent. That conclusion fails as soon as the server can call an external API, invoke SSH, read a credential file, or launch a shell command with the developer's authority. Local transport removes a network hop. It does not reduce the authority of the process receiving requests.

The boundary that matters is simple: an MCP server should expose context and narrowly bounded computation; a trusted action gateway should hold credentials and perform actions that leave the machine. Mixing those roles gives a language model a convenient route from untrusted instructions to durable authority. I have seen this mistake arrive disguised as a tidy one-file tool, then grow into a grab bag of tokens, subprocess calls, and exceptions nobody can explain during an incident.

## Stdio is a transport, not a trust decision

MCP stdio security starts with accepting that standard input and output do not authenticate intent. The MCP client launches the server and exchanges JSON-RPC messages over pipes. The Model Context Protocol specification describes stdio as a transport: the server reads messages from standard input and writes messages to standard output. It does not claim that the transport proves a request is safe, approved by a human, or even produced by the model you expected.

A local client can send a `tools/call` request directly. It can bypass the normal model loop, repeat calls at machine speed, choose arguments a tool description discouraged, and retain every result it receives. If a compromised editor extension starts the client, the server has no magical way to tell that it was not Claude Code or another expected caller unless the surrounding design gives it that information.

The usual process tree also gives you less isolation than people assume. If your agent starts an MCP server under your login, that server commonly inherits your user identity, working directory, environment, file permissions, network access, and any secret material you left in environment variables. A pipe does not make that authority smaller.

Treat every tool call as an untrusted request from a process that has persuaded the model, or impersonated the model, to make it. That sounds strict because it is strict. It is also the assumption that survives prompt injection, buggy clients, copied configuration, and a developer testing a request with a raw JSON-RPC script.

## The server should stop before reusable authority

An MCP server should end before it needs to reveal or manage a reusable credential. Its good jobs include searching an indexed repository, parsing build output, formatting a payload, reading a deliberately exposed project file, and producing a proposed command for review. Those tasks can still do damage if you write them badly, but they do not require a secret that remains useful after the session ends.

External actions need a different owner. An authenticated API request, an SSH connection, a package publish, a production query, or an issue update joins untrusted input to an identity with consequences. Put the credential in a component that does the request itself, then return a bounded result to the MCP server or agent.

This distinction is routinely blurred because both components may be local executables. They are not interchangeable:

- An MCP server translates an agent request into a limited operation or a request for an operation.
- An action gateway owns the credential, decides whether this process may use it, executes the external call, and records what happened.
- The agent receives output, not the means to repeat the authenticated action outside that gateway.

Do not send `API_TOKEN=...` as a tool result. Do not send a vault reference and call that safety. Do not expose a command that prints a private key to stdout and trust the model to avert its eyes. Once the client obtains a secret, every later control is advisory.

A gateway also should not become an all-purpose shell API. `run(command)` is an attractive shortcut because it avoids tool design. It also hands argument parsing, file access, network destinations, and often secret access to one opaque string. Build narrow actions instead: `get_deployment_status`, `create_issue`, `run_readonly_query`, or `ssh_exec` with a named host and constrained command family. Narrow actions make validation and review possible.

## Tool schemas describe calls but do not confine them

A JSON Schema for a tool is useful input validation, but it is not authorization. The MCP specification requires tools to publish input schemas, and clients can use them to form calls. A model can still select any schema-valid value. Worse, sloppy implementations often accept a schema-valid string and later splice it into a shell command or URL where its meaning changes.

Consider a tool intended to fetch a deployment status:

```json
{
  "name": "deployment_status",
  "inputSchema": {
    "type": "object",
    "properties": {
      "environment": {"enum": ["staging", "production"]},
      "service": {"type": "string", "pattern": "^[a-z0-9-]{1,48}$"}
    },
    "required": ["environment", "service"],
    "additionalProperties": false
  }
}
```

That schema prevents an unexpected top-level field and rejects obvious shell punctuation in `service`. It does not authorize the caller to inspect production, prove that `service` belongs to the current repository, or constrain the HTTP destination after the server builds a URL. A schema validator answers, "Is this shaped correctly?" Authorization answers, "May this caller do this action now with this identity?" Keep those questions separate in code and in review.

A bad implementation commonly looks like this:

```python
subprocess.run(
    f"ssh {host} systemctl status {service}",
    shell=True,
    check=True,
)
```

Even if `host` and `service` passed a loose schema, shell parsing creates another language with another attack surface. Use argument vectors, reject unknown hosts before opening a connection, and make the gateway select the credential by a fixed identifier rather than accepting a path or token name supplied by the agent.

For HTTP, parse the URL before connecting, require `https`, compare the normalized hostname to an approved exact host list, and disable or revalidate redirects. A redirect from an allowed host to an internal address or an attacker-controlled endpoint can turn a harmless-looking request into credential disclosure. Do not rely on a prefix check such as `url.startswith("https://api.example.com")`; user information, ports, and lookalike hostnames make string checks unreliable.

## Process identity must be visible at the approval point

A human approval button helps only when it says who is asking and what authority that approval grants. "Allow agent access" is weak because it hides the executable that will receive the permission and the duration of that permission. It trains people to approve a vague category of activity.

A better design identifies the requesting process through its code-signing authority, parent relationship, executable path, and process lifetime. The human can then approve one run of a known client rather than permanently blessing a label. When that process exits, its approval must end. A new process needs a new decision.

Code signing does not prove that every prompt or plugin inside the client is benign. It answers a narrower and still useful question: which signed executable asked for authority? That distinction matters when a malicious or altered local program tries to reuse a friendly name. On macOS, the operating system gives you code-signing information that a gateway can show before it lets a process act.

Sallyport uses that process identity for per-session authorization, while its locked vault denies every action until the user opens it with the Mac's hardware-gated controls. That model is intentionally small: a locked vault, approval for a new process, and optional approval for each use of a particular credential.

Do not try to fix approval fatigue with a complicated natural-language policy. People cannot reliably judge a dense rule set after it has grown dozens of exceptions. Use a small number of decisions that map to things a developer can see: whether secrets are available, which process may act for this run, and which credentials need a fresh confirmation every time.

## Approval scope must follow the damage an action can cause

One approval per session is appropriate for repetitive, low-impact work such as reading an issue tracker or checking the state of a development service. It becomes dangerous when the same approval silently covers destructive database changes, package publication, money movement, customer communications, or production SSH access.

Give each credential its own approval sensitivity. A read-only token may work after the process gets session approval. A production write token or SSH key should demand confirmation for every use. The gateway must show enough context for a person to judge the action: the credential identity, target host or service, method or command class, and sanitized arguments. Do not show the secret itself.

An approval should authorize a concrete request, not a promise that the agent will behave later. If a tool call says `POST /releases`, the approval view should not collapse that into "use release API." The method, final destination, and operation name are the facts that distinguish a harmless read from an irreversible write.

The popular alternative is a broad allowlist: approve a domain, a shell binary, or an agent for the workday. It feels efficient until an injected instruction turns the same allowed capability toward a different repository, endpoint, or argument. Broad grants reduce interruptions by shifting the review burden to the time when nobody can see the actual call.

Use expiration aggressively. A session grant should disappear with the client process. A per-call decision should expire after that one operation. If a gateway must support longer grants later, make the scope and expiry explicit rather than letting a cached approval masquerade as permanent trust.

## SSH needs its own boundary, not a shell escape hatch

SSH is where local agent designs often lose discipline. Developers already have an SSH agent, host aliases, forwarded keys, and a habit of typing arbitrary commands in a terminal. The temptation is to let the MCP server call `ssh` with the user's existing environment. That makes the agent a caller of every identity and host rule your shell can reach.

OpenSSH documents a serious limitation of agent forwarding: a remote user who can access the forwarded agent socket can request operations from your local agent, though they cannot extract private keys. That is enough to act as you while the forwarding lasts. An autonomous agent should not casually opt into that path because it expands authority beyond the original host.

Use a dedicated SSH identity for agent work and bind it to a named host record. On the server, constrain that identity with the options appropriate to the account, such as a forced command and disabled forwarding where the use case allows it. On the local side, select the host and identity by configuration held outside agent control. The agent may request `host: build-staging` and a limited command action, but it should not submit an arbitrary hostname, a private-key path, or `-o ProxyCommand=...`.

This is the minimum shape of a request an action gateway can validate:

```json
{
  "action": "ssh_exec",
  "host_id": "build-staging",
  "command_id": "read_service_status",
  "args": {"service": "worker"}
}
```

The gateway maps `build-staging` to its known host, host key policy, account, and dedicated credential. It maps `read_service_status` to a fixed argument vector. It does not concatenate this payload into a shell string. A rejected request should say why in an audit record without echoing secret material or potentially hostile data into a terminal.

If you need arbitrary remote diagnosis, make that a separate, high-friction operation with a per-call confirmation and obvious output limits. Do not hide arbitrary shell access under a cheerful tool name like `check_server`.

## Audit records need to survive the actor who caused them

A text log written by the same process that makes the action is evidence only until that process wants to change it. Agent activity needs a record that lets an operator reconstruct both the run and each external call, then detect removal or editing after the fact.

Record the process identity, session identifier, time, action type, credential identifier, approved target, sanitized request shape, approval result, response status, and error category. Separate a session journal from an action journal. The session view answers, "Which agent run had authority?" The action view answers, "What did it do with that authority?" Do not force an investigator to infer one from a flat stream of lines.

A hash chain gives a practical integrity check. For each record, compute a digest over the previous record digest and the canonical bytes of the new encrypted record. Store the new digest with the record. A verifier can then detect a changed record, a removed record in the middle, or a reordered sequence without needing the plaintext.

The audit verifier should run independently of the agent and should not need access to credentials. A command interface can be as plain as this:

```text
$ sp audit verify
records: 184
first sequence: 1
last sequence: 184
chain: valid
```

That output shape gives an operator something specific to capture in a ticket or incident record. If verification fails, the command should report the first sequence where continuity broke and return a nonzero exit status. "Log unreadable" is too vague to investigate.

Tamper evidence is not the same as tamper prevention. A local user with sufficient access may still delete the entire log or roll back its storage. Keep that limitation visible. If the stakes require proof against local rollback, export signed checkpoints to a separate controlled system. Do not claim that a local hash chain solves a threat it does not address.

## Keep secrets out of environment variables and tool output

Environment variables are convenient for a human shell session and poor containment for autonomous agents. A child process inherits them by default. Debug logging can print them. A command that lists its environment can return them to the model. Crash reports, support bundles, process inspection, and copied terminal transcripts have all exposed secrets this way.

A credential file in the workspace is worse. The model can read it, a tool can upload it, a Git command can stage it, and an indexing service can preserve a copy. Moving the file to a hidden directory changes the accident rate but not the security boundary.

Keep credentials in a vault controlled by the action gateway. The gateway selects the credential based on a fixed action mapping and injects it only into its own HTTP or SSH operation. It returns a response body after filtering only when that body is safe for the agent to see. A bearer token must never pass through the MCP result, even redacted, because redaction errors become a permanent part of the transcript.

For HTTP, prefer a response contract rather than raw pass-through. A deployment status action might return this:

```json
{
  "environment": "staging",
  "service": "worker",
  "state": "healthy",
  "revision": "a1b2c3d4"
}
```

It should not return response headers that may contain session identifiers, internal routing details, or a new token. Decide which fields the agent needs before implementation. A raw proxy response is another shortcut that becomes expensive to unwind.

## A small boundary is easier to operate under pressure

You can review a local agent integration without a policy language or a large security program. Start with the action inventory, then force each action into one of two buckets: it either reads or computes local context without reusable authority, or it crosses into an external service and needs a gateway.

For each external action, write down the fixed target identity, the credential selected by the gateway, the exact arguments the agent may influence, the approval scope, and the audit record produced. If any row says "arbitrary command," "any URL," "token from environment," or "agent chooses credential," the boundary is not finished.

Run this failure exercise before giving the agent a useful secret:

1. Send a raw schema-valid request that names an unexpected target or oversized argument.
2. Replay a previously approved request after the client process exits.
3. Attempt a redirected HTTP request and an SSH request with forwarding options.
4. Lock the vault, then confirm that every action fails before any network connection opens.
5. Alter one stored audit record and verify that the audit command identifies a broken chain.

These tests catch the design errors that a pleasant demo hides. A model following instructions perfectly is not a security test.

The cleanest local architecture keeps the MCP server ordinary and disposable. Let it expose useful context and request narrowly designed operations. Put secrets, process-aware approval, execution, and an auditable record behind the action boundary. When somebody asks why a tool cannot simply receive the production token, the answer should be visible in the design: the tool never needed the token to do its job.
