ATHANOR
RU GitHub ↗

6 / 8

MCP server

Every previous chapter described a tool for a human. This one is about how those same secrets reach an AI agent — the entire reason this project exists (chapter 1).

What MCP is, briefly

Model Context Protocol is an open standard for tools an agent can call. Athanor implements a server for it (athanor serve-mcp), speaking over stdio — a process's plain stdin/stdout, not a network connection. None of this is tied to Claude: any MCP-compatible client (Claude Code, Claude Desktop, Cursor, Windsurf, Cline, and others) connects to the same binary through its own config. Athanor doesn't know or care which client it's talking to — from the server's side, these are just calls to two tools.

The get_secret tool

input:  { "name": "STRIPE_TEST_KEY" }
output: { "value": "sk-test-..." }             — if Exposable(name) == true
        error: 'secret "DB_PASSWORD" is not exposable
               (expose:false in athanor.yaml); use run_with_secrets instead,
               or set access_mode: full to override'   — if false

This is the only tool that returns a secret's value as literal text — the one place in the whole system where the Exposable() decision from chapter 3 actually gets applied. Run the server with --full-access (or set access_mode: full in the manifest) and the check is skipped for every secret — the server prints a loud warning to stderr on startup so that setting can't quietly get left on.

The run_with_secrets tool

input:  { "command": ["sh", "-c", "psql -c 'select 1' $DB_URL"] }
output: { "stdout": "...", "stderr": "...", "exitCode": 0,
          "stdoutTruncated": false, "stderrTruncated": false }

expose isn't checked here at all — every secret in the project goes into the command's environment. The response contains the command's stdout/stderr, so the command can print a secret and return it to the agent. Each stream is capped at 1 MiB; its *Truncated field reports clipping. command is argv, an array of arguments, not a shell string: exec.Command(argv[0], argv[1:]...) skips the shell interpreter entirely, so special characters inside a secret can't accidentally break the command or open the door to injection. When the agent needs shell features — pipes, substitutions — it wraps the command itself as ["sh", "-c", "..."]; the command text then gets interpreted by the shell, but secret values still arrive through environment variables, never through the command text.

Being honest about the limits here

run_with_secrets isn't a wall nothing can get through — it's the wall moved to where it's actually useful. An agent allowed to run arbitrary commands can, in principle, build a curl call with a secret in the URL and exfiltrate it; the OS can't tell that apart from any other command reading an environment variable. Athanor doesn't try to make that impossible — doing so would mean sharply limiting what the agent can run at all, which would defeat the tool's purpose. Instead, it makes a leak like that visible after the fact.

The audit log

Every call to run_with_secrets (and athanor run from the CLI) appends a line to ~/.local/state/athanor/audit.log:

2026-08-29T10:34:58Z    command="sh -c echo $TEST_KEY"  secrets=TEST_KEY

It records the command and the secret names involved, never values. A failed write to this log never blocks the underlying call — if the log is unwritable, the secret still reaches the process. The log exists for traceability, not enforcement.

Checkpoint question. Why doesn't run_with_secrets check the expose flag for each secret, while get_secret does?

Answer: expose governs direct retrieval through get_secret. run_with_secrets is a separate, broader trust boundary: it gives secrets to a subprocess that may use, print, or transmit them. Granting this tool therefore means trusting the commands it runs, independently of expose.