# Introduction

CLI that turns MCP servers into terminal commands.

## Why?

Companies invested years building MCP server integrations. 5,800+ servers, 10,000+ in production, 97M+ monthly SDK downloads. All that work exposes structured APIs over a standard protocol. [**Why MCP on the command line?**](/why-mcp-cli) explains why this matters and how `mcp` lets you reuse all of it from your terminal.

[**MCP servers are draining your hardware**](/mcp-servers-are-draining-your-hardware) — Every MCP client spawns all backend processes at startup and keeps them alive forever. We built lazy initialization and adaptive idle shutdown so the proxy only keeps alive what you're actually using.

## First steps

Are you new to `mcp`? Start here:

* [**Getting started**](/first-steps/getting-started) — Install, configure your first server, call your first tool. 5 minutes from zero to working.
* [**Tutorial**](/first-steps/tutorial) — A hands-on walkthrough that covers everything you need to use `mcp` day-to-day.

## Guides

Focused explanations for specific topics:

* [**Configuration**](/guides/configuration) — Config file format, environment variables, server types.
* [**Authentication**](/guides/authentication) — OAuth, API tokens, service-specific setup.
* [**Registry**](/guides/registry) — Finding and adding servers from the MCP registry.
* [**Scripting**](/guides/scripting) — Using `mcp` in shell scripts, piping, CI/CD.
* [**Proxy mode**](/guides/proxy-mode) — Expose all servers as a single MCP endpoint for LLM tools.
* [**Audit logging**](/guides/audit-logging) — Track every operation with queryable logs and real-time streaming.

## Reference

Technical details and complete specifications:

* [**CLI reference**](/reference/cli) — Every command, flag, and option.
* [**Config file reference**](/reference/config-file) — Full `servers.json` specification.
* [**Environment variables**](/reference/environment-variables) — All supported env vars.
* [**Architecture**](/reference/architecture) — How the codebase is organized.

## How-to

Recipes for common tasks:

* [**Supported services**](/how-to/services) — Setup guides for Sentry, Slack, Grafana, GitHub, and more.
* [**Troubleshooting**](/how-to/troubleshooting) — Common errors and how to fix them.


# Why MCP on the command line?

Companies invested millions building MCP servers. Sentry, Slack, Grafana, Honeycomb, GitHub — all of them shipped production-grade integrations. These servers expose structured APIs over a standard protocol. Why would we limit them to AI assistants?

We don't have to. Every MCP server is also a CLI tool waiting to happen.

## The investment is already done

The MCP ecosystem exploded since Anthropic [launched the protocol](https://www.anthropic.com/news/model-context-protocol) in November 2024. In less than a year:

* **5,800+ MCP servers** available across the ecosystem
* **10,000+ servers** actively running in production
* **97 million+ monthly SDK downloads** (Python + TypeScript combined)
* Server downloads grew from \~100k to 8 million between November 2024 and April 2025

Sources: [MCP Adoption Statistics 2025](https://mcpmanager.ai/blog/mcp-adoption-statistics/), [MCP Statistics](https://www.mcpevals.io/blog/mcp-statistics)

Every one of those servers implements a standardized interface: JSON-RPC 2.0 over stdio or HTTP. They handle auth, rate limiting, pagination, error handling. They expose structured tools with JSON Schema inputs. That's years of engineering work across hundreds of companies.

## From AI-only to everywhere

MCP was designed for AI assistants, but the protocol itself is simple: send a JSON request, get a JSON response. There's nothing AI-specific about calling `search_issues` or `list_channels`.

The industry is realizing this. Projects like [`mcp-tools`](https://blog.fka.dev/blog/2025-03-26-introducing-mcp-tools-cli/) and [`mcp-cmd`](https://github.com/developit/mcp-cmd) started exploring MCP servers as CLI tools in early 2025. The insight is the same: **why rewrite what already exists?**

If Sentry already built an MCP server that can search issues, get event details, and analyze errors — why would you write a separate Sentry CLI? Just talk to their MCP server directly.

## Who's behind MCP

This isn't a niche experiment. MCP moved to the [Linux Foundation's Agentic AI Foundation](https://www.linuxfoundation.org/press/linux-foundation-announces-the-formation-of-the-agentic-ai-foundation) in December 2025, co-founded by **Anthropic**, **Block**, and **OpenAI**, with support from **AWS**, **Google**, **Microsoft**, **Cloudflare**, and **Bloomberg**.

Before that:

* **OpenAI** adopted MCP in March 2025 across ChatGPT desktop, Agents SDK, and Responses API
* **Google** added native support in Gemini 2.5 Pro
* **Microsoft** integrated MCP into Copilot Studio and Azure
* **Salesforce** adopted MCP for Agentforce 3
* **Cloudflare** launched MCP Server Portals

Source: [Why the Model Context Protocol Won](https://thenewstack.io/why-the-model-context-protocol-won/), [A Year of MCP: From Internal Experiment to Industry Standard](https://www.pento.ai/blog/a-year-of-mcp-2025-review)

When this many companies converge on a protocol, the integrations become infrastructure. They're not going away.

## Don't throw away the work

Every MCP server is three things:

1. **An API client** — Handles authentication, rate limits, pagination for a specific service
2. **A tool catalog** — Structured operations with typed inputs and outputs
3. **A transport layer** — Standard JSON-RPC over stdio or HTTP

Traditionally, to use a service from the terminal, you'd either use a service-specific CLI (if one exists) or write curl commands with manual auth. With MCP, you get a uniform interface across all services. Same command structure, same output format, same auth flow.

```bash
# Same pattern, different services
mcp sentry search_issues '{"query": "is:unresolved"}'
mcp grafana search_dashboards '{"query": "api-latency"}'
mcp slack list_channels
mcp github search_repositories '{"query": "mcp"}'
```

One binary replaces a dozen service-specific CLIs. And every new MCP server that ships — from any company, in any language — immediately becomes another command you can use.

## The "good enough" protocol

As [The New Stack observed](https://thenewstack.io/why-the-model-context-protocol-won/), MCP won because it was "good enough at the right time." The protocol is simple. A server exposes tools. A client calls them. That's it.

This simplicity is a feature. MCP didn't try to solve every problem — it solved the integration problem. And because it's simple, it's easy to build clients for it. A CLI client is one of the most natural forms.

## What this means for you

If you use services that have MCP servers (and increasingly, most do), you can:

* **Query them from your terminal** without installing service-specific CLIs
* **Script across services** with a consistent interface
* **Pipe JSON output** through standard Unix tools
* **Automate** in CI/CD, cron jobs, and monitoring scripts
* **Prototype** integrations before writing code

The MCP servers already exist. The protocol is standard. The ecosystem is growing. `mcp` just gives you a front door to all of it from the command line.

## Further reading

* [Introducing the Model Context Protocol](https://www.anthropic.com/news/model-context-protocol) — Anthropic's original announcement (Nov 2024)
* [Why the Model Context Protocol Won](https://thenewstack.io/why-the-model-context-protocol-won/) — Analysis of MCP's adoption trajectory
* [One Year of MCP](https://thenewstack.io/one-year-of-mcp-looking-back-and-forward/) — Looking back at the first year
* [A Year of MCP: From Internal Experiment to Industry Standard](https://www.pento.ai/blog/a-year-of-mcp-2025-review) — Comprehensive review of MCP's evolution
* [Goodbye Plugins: MCP Is Becoming the Universal Interface for AI](https://thenewstack.io/goodbye-plugins-mcp-is-becoming-the-universal-interface-for-ai/) — MCP replacing proprietary plugin models
* [Linux Foundation Announces the Agentic AI Foundation](https://www.linuxfoundation.org/press/linux-foundation-announces-the-formation-of-the-agentic-ai-foundation) — MCP moves to Linux Foundation
* [Code Execution with MCP](https://www.anthropic.com/engineering/code-execution-with-mcp) — Anthropic engineering on MCP capabilities
* [Introducing MCP Tools CLI](https://blog.fka.dev/blog/2025-03-26-introducing-mcp-tools-cli/) — CLI inspector for MCP servers
* [Inspecting and Debugging MCP Servers Using CLI and jq](https://blog.fka.dev/blog/2025-03-25-inspecting-mcp-servers-using-cli/) — Using MCP servers as standalone tools


# Why MCP instead of CLI for teams

Your team already has CLIs. kubectl, terraform, aws, docker — they work. Why add MCP to the mix?

Because when AI agents enter the picture, CLI stops scaling. The problem isn't the tool — it's how credentials, access control, and observability work when 30 engineers have AI agents calling tools autonomously.

## The CLI model breaks with AI agents

A developer running `kubectl get pods` is one thing. An AI agent running it across 15 concurrent sessions is another.

**With CLI:**

* Every developer machine needs credentials for every tool
* Every AI tool (Claude Code, Cursor, Windsurf) needs its own config with the same credentials
* No central visibility into what agents are doing
* No way to restrict which commands an agent can run
* Credentials scattered across laptops — impossible to audit

**With MCP:**

* One proxy holds all credentials — developer machines have zero service tokens
* One config for all AI tools — they all connect to the same MCP endpoint
* Every tool call is logged with who, what, when
* ACL rules control exactly which tools each person can use
* Onboarding is one token, offboarding is one revocation

## The math

|                                             | CLI                                     | MCP (proxy)            |
| ------------------------------------------- | --------------------------------------- | ---------------------- |
| Credentials to manage (50 devs, 8 services) | 400 tokens on 50 laptops                | 8 tokens on 1 server   |
| Onboarding a new dev                        | Generate 8 tokens, configure 3 AI tools | 1 proxy token          |
| Offboarding                                 | Hunt down tokens across machines        | Delete 1 token         |
| Token rotation                              | Update 50 machines                      | Update 1 server        |
| Audit trail                                 | None                                    | Every call logged      |
| Access control                              | All or nothing                          | Per-user, per-tool ACL |

## CLI as MCP — best of both worlds

You don't need to choose. With [CLI as MCP](/guides/cli-as-mcp), your existing CLIs become MCP servers:

```json
{
  "mcpServers": {
    "kubectl": {
      "command": "kubectl",
      "cli": true,
      "cli_only": ["get", "describe", "logs", "top"]
    },
    "terraform": {
      "command": "terraform",
      "cli": true,
      "cli_only": ["plan", "show", "state", "output"]
    }
  }
}
```

Deploy this as a [centralized proxy](https://mcp.avelino.run/guides/enterprise-token-management) and your team gets:

1. **AI agents access kubectl and terraform via MCP** — no kubeconfig on developer machines
2. **`cli_only` restricts dangerous commands** — agents can `get` and `describe`, but not `delete` or `exec`
3. **Every call is audited** — `mcp logs` shows who ran what
4. **Credentials stay on the proxy** — `KUBECONFIG`, `AWS_ACCESS_KEY_ID` live in one place

## Real scenario

**Before:** 30 engineers, each with kubeconfig, AWS credentials, Terraform state access, GitHub token, Sentry token on their laptops. 3 AI tools per engineer, each with its own config. An engineer leaves — good luck revoking everything.

**After:**

```
Developer laptop                    Internal proxy (mcp serve --http)
┌──────────────┐                    ┌──────────────────────────────┐
│ Claude Code  │──── 1 token ──────>│  kubectl (cli, read-only)   │
│ Cursor       │                    │  terraform (cli, plan only)  │
│ Windsurf     │                    │  sentry (MCP server)         │
│              │                    │  grafana (MCP server)        │
│ Zero service │                    │  slack (MCP server)          │
│ credentials  │                    │                              │
└──────────────┘                    │  All credentials here.       │
                                    │  All calls logged.           │
                                    │  ACL per user.               │
                                    └──────────────────────────────┘
```

Engineer joins? One proxy token. Engineer leaves? Delete one token. Rotate AWS keys? Update one config.

## When CLI alone is fine

Not every situation needs MCP:

* **Solo developer** — credentials on your own machine is fine
* **CI/CD pipelines** — already have controlled environments with secret management
* **Interactive debugging** — you're the one typing commands, not an AI agent

MCP adds value when **AI agents act on behalf of people** and you need control over what they can do, where credentials live, and what gets logged.

## Get started

1. [CLI as MCP](/guides/cli-as-mcp) — wrap your CLIs as MCP servers
2. [Enterprise token management](/guides/enterprise-token-management) — centralize credentials with the MCP proxy
3. [Proxy mode](/guides/proxy-mode) — deploy `mcp serve` for your team
4. [Audit logging](/guides/audit-logging) — monitor what agents are doing


# MCP servers are draining your hardware

You open Claude Code. It spawns 10 MCP server processes. You open another session. 10 more. Cursor? 10 more. By lunch you're running 30+ background processes that sit idle 95% of the time, eating RAM and CPU just to exist.

This is the dirty secret of MCP adoption in 2025: **every client treats backend servers as permanent fixtures**. Connect on start, keep alive forever, kill on exit. No intelligence, no resource awareness.

## The problem

Here's what happens today when you configure MCP servers in any major client:

```
Claude Code session 1  →  spawns slack, sentry, github, grafana, honeycomb...
Claude Code session 2  →  spawns slack, sentry, github, grafana, honeycomb...
Cursor                 →  spawns slack, sentry, github, grafana, honeycomb...
```

Each session gets its own copy of every server. A typical stdio MCP server (Node.js via `npx`) uses 80-150 MB of RAM. Configure 10 servers, open 3 sessions:

**30 processes × \~100 MB = \~3 GB of RAM doing nothing.**

And it's not just RAM. Each process holds open connections, file descriptors, and event loops. Your laptop fan spins up. Your battery drains. Docker containers balloon. CI runners choke.

The irony: you probably use 2-3 of those servers in any given session. The other 7 are zombie processes waiting for a request that never comes.

## Why clients do this

The current behavior makes sense from a simplicity standpoint:

1. Connect to everything at startup — tools are available instantly
2. Keep connections alive — no latency on tool calls
3. Kill on exit — clean shutdown

It's the easiest thing to implement. And when MCP was new and people had 1-2 servers, nobody noticed the cost. But the ecosystem grew. People now configure 5, 10, 15 servers. The linear cost became unsustainable.

The MCP spec itself doesn't say anything about lifecycle management. It defines how to connect, how to list tools, how to call them — but not **when** to connect or **when** to disconnect. That decision is left to clients. And most clients chose the simplest path: always on.

## What we built

We solved this in the [`mcp` CLI](https://mcp.avelino.run) proxy with two mechanisms: **lazy initialization** and **adaptive idle shutdown**.

### Lazy initialization

No backend connects at startup. Zero processes spawned. The proxy starts instantly.

When a client sends `tools/list` for the first time, the proxy connects to all backends, discovers their tools, and caches the results. After that, idle backends are shut down — but their tools remain visible.

When a client calls `tools/call` targeting a disconnected backend, the proxy reconnects it transparently. The client never knows the difference.

```
Startup:     0 processes (instant start)
tools/list:  10 backends connect, discover tools, idle ones shut down
tools/call:  only the target backend reconnects on demand
```

### Adaptive idle shutdown

A background task checks every 30 seconds which backends are idle and shuts them down. But not all backends get the same timeout — it adapts to usage patterns.

The proxy tracks per-backend statistics:

* Request count
* Time since first use
* Exponential moving average (EMA) of intervals between requests

From this it classifies each backend into tiers:

| Usage                              | Requests/hour | Idle timeout |
| ---------------------------------- | ------------- | ------------ |
| **Hot** — you're actively using it | > 20          | 5 min        |
| **Warm** — occasional use          | 5–20          | 3 min        |
| **Cold** — barely touched          | < 5           | 1 min        |

A backend you haven't touched in 60 seconds gets shut down. One you're actively querying gets 5 minutes of grace. The algorithm adapts as your usage changes — a backend that was cold in the morning becomes hot when you start debugging a Sentry issue.

Usage stats survive reconnections. If a backend is shut down and reconnected, its history is preserved so the adaptive timeout has continuity.

### The result

Same scenario as before — 10 servers, 3 sessions — but using the `mcp` proxy:

```
Before:  30 processes running permanently (~3 GB RAM)
After:   1 proxy process + only active backends (~200-400 MB)
```

And there's no tradeoff in functionality. Every tool is still visible. Every call still works. The reconnection adds \~1-2 seconds of latency on the first call to a cold backend — after that, it's instant.

## How to use it

Run the proxy as a persistent service:

```bash
mcp serve --http
```

Point your clients to it:

```json
{
  "mcpServers": {
    "all": {
      "type": "sse",
      "url": "http://localhost:8080/mcp/sse"
    }
  }
}
```

All sessions share one proxy. The proxy manages backend lifecycles. You can configure per-backend behavior:

```json
{
  "mcpServers": {
    "slack": {
      "command": "npx",
      "args": ["@anthropic/mcp-slack"],
      "idle_timeout": "adaptive"
    },
    "sentry": {
      "url": "https://mcp.sentry.io",
      "idle_timeout": "never"
    },
    "github": {
      "command": "npx",
      "args": ["@modelcontextprotocol/server-github"],
      "idle_timeout": "2m"
    }
  }
}
```

* `"adaptive"` (default) — usage-based timeout
* `"never"` — keep alive forever
* `"2m"`, `"30s"`, `"1h"` — fixed timeout

Full configuration reference: [idle timeout options](https://mcp.avelino.run/reference/config-file#idle-timeout). Proxy mode setup: [proxy mode guide](https://mcp.avelino.run/guides/proxy-mode).

### Update (April 2026): N clients, M backends

The original `mcp serve` only solved half the problem — it stopped *one* client from spawning duplicate backends, but if you had multiple editors connecting at the same time, the proxy itself could serialize them or, worse, accumulate orphan processes when a client died. After [#51](https://github.com/avelino/mcp/issues/51) the proxy is now an actual orchestrator: a single backend process is shared across **every connected client**, requests run in parallel through the stdio multiplexer, and dead clients can never leak backend children. The numbers from a real run with 5 editor sessions and 9 backends:

```json
{
  "backends_configured": 9,
  "backends_connected": 9,
  "active_clients": 5,
  "tools": 213
}
```

9 processes serving 5 clients, not 45. That's the full version of the win this post described.

## What should change in the ecosystem

This isn't just a `mcp` CLI problem. Every MCP client should implement some form of lazy lifecycle management:

1. **Don't connect at startup.** Wait until the user actually needs a tool.
2. **Cache tool lists.** You don't need a live connection to advertise tools.
3. **Shut down idle backends.** If a backend hasn't been used in N minutes, kill it. Reconnect on demand.
4. **Track usage patterns.** Not all backends are equal. The one you use 50 times a day deserves a longer timeout than the one you use once a week.

The MCP spec could help by defining optional lifecycle hints — a `keepAlive` capability, a recommended idle timeout, a "lightweight discovery" mode that returns tool metadata without a full connection. But even without spec changes, clients can be smarter today.

## The math is simple

Every MCP server process you don't run is:

* \~100 MB of RAM you keep
* One fewer event loop burning CPU cycles
* One fewer set of open file descriptors
* One fewer process for your OS to schedule

Multiply by the number of servers. Multiply by the number of sessions. The savings compound fast.

MCP is becoming infrastructure. Infrastructure that doesn't manage its own resource footprint doesn't survive at scale. It's time for MCP clients to grow up.

***

`mcp` is an open-source CLI that turns MCP servers into terminal commands. [Getting started](https://mcp.avelino.run/getting-started) takes 5 minutes. Source code: [github.com/avelino/mcp](https://github.com/avelino/mcp).


# Getting started

This guide takes you from zero to calling your first MCP tool. It should take about 5 minutes.

## Installing mcp

**Homebrew (macOS and Linux):**

```bash
brew install avelino/mcp/mcp
```

**Pre-built binary:**

Download the latest binary for your platform from [GitHub Releases](https://github.com/avelino/mcp/releases), make it executable, and move it to your `$PATH`:

```bash
chmod +x mcp-*
sudo mv mcp-* /usr/local/bin/mcp
```

**Docker:**

```bash
docker pull ghcr.io/avelino/mcp
```

To use it like a native command, create an alias:

```bash
alias mcp='docker run --rm -v ~/.config/mcp:/root/.config/mcp ghcr.io/avelino/mcp'
```

Add the alias to your shell profile (`~/.bashrc`, `~/.zshrc`, or `~/.config/fish/config.fish`) to make it permanent. If your servers need environment variables (API tokens, etc.), pass them with `-e`:

```bash
alias mcp='docker run --rm -v ~/.config/mcp:/root/.config/mcp -e GITHUB_TOKEN ghcr.io/avelino/mcp'
```

**From source (requires Rust):**

```bash
# Install Rust if needed: curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
cargo install --path .
```

Verify it works:

```bash
mcp --help
```

You should see:

```
mcp — CLI that turns MCP servers into terminal commands

Usage:
  mcp --list                          List configured servers
  mcp <server> --list                 List tools from a server
  mcp <server> --info                 List tools with input schemas
  mcp <server> <tool> [json]          Call a tool
  mcp search <query>                  Search MCP registry
  mcp add <name>                      Add server from registry
  mcp add --url <url> <name>          Add HTTP server manually
  mcp remove <name>                   Remove server from config
  mcp update <name>                   Refresh server config from registry
```

## Adding your first server

The fastest way to get started is adding a server from the [MCP registry](https://registry.modelcontextprotocol.io). Let's add the `filesystem` server — it lets you read, write, and search files through MCP.

```bash
mcp add filesystem
```

You'll see something like:

```
✓ Server "filesystem" added to /home/you/.config/mcp/servers.json

Run to test:
  mcp filesystem --list
```

## Listing available tools

Now see what tools the server provides:

```bash
mcp filesystem --list
```

Output:

```json
[
  {
    "name": "read_file",
    "description": "Read the complete contents of a file"
  },
  {
    "name": "write_file",
    "description": "Create a new file or overwrite an existing file"
  },
  {
    "name": "list_directory",
    "description": "List directory contents"
  }
]
```

## Calling a tool

Call a tool by passing its name and a JSON object with the arguments:

```bash
mcp filesystem read_file '{"path": "/etc/hostname"}'
```

Output:

```json
{
  "content": [
    {
      "type": "text",
      "text": "my-machine\n"
    }
  ]
}
```

That's it. You just called an MCP tool from your terminal.

## What's next?

* [**Tutorial**](/first-steps/tutorial) — Walk through more realistic examples: HTTP servers, authentication, piping, and scripting.
* [**Configuration**](/guides/configuration) — Learn the full config file format.
* [**Supported services**](/how-to/services) — Setup guides for Sentry, Slack, Grafana, and more.


# Tutorial

This tutorial walks you through the most common things you'll do with `mcp`. By the end, you'll know how to configure servers, authenticate, explore tools, call them, and use `mcp` in scripts.

> **Prerequisites:** You've completed the [Getting started](/first-steps/getting-started) guide and have `mcp` installed.

## Part 1: Understanding servers

MCP servers come in two flavors:

### Stdio servers

These run as a local process. The CLI spawns them, sends JSON-RPC messages to their stdin, and reads responses from their stdout. Most community servers work this way.

```json
{
  "mcpServers": {
    "slack": {
      "command": "npx",
      "args": ["-y", "slack-mcp-server@latest", "--transport", "stdio"],
      "env": {
        "SLACK_MCP_XOXP_TOKEN": "${SLACK_TOKEN}"
      }
    }
  }
}
```

### HTTP servers

These are remote services. The CLI sends HTTP POST requests with JSON-RPC payloads. Some services like Sentry and Honeycomb offer hosted MCP servers.

```json
{
  "mcpServers": {
    "sentry": {
      "url": "https://mcp.sentry.dev/sse"
    }
  }
}
```

## Part 2: Configuring servers manually

The config file lives at `~/.config/mcp/servers.json`. You can edit it directly.

Let's add Sentry manually:

```bash
mkdir -p ~/.config/mcp
```

Edit `~/.config/mcp/servers.json`:

```json
{
  "mcpServers": {
    "sentry": {
      "url": "https://mcp.sentry.dev/sse"
    }
  }
}
```

Verify it's configured:

```bash
mcp --list
```

```json
[
  {
    "name": "sentry",
    "type": "http",
    "url": "https://mcp.sentry.dev/sse"
  }
]
```

## Part 3: Authentication

When you first connect to an HTTP server that requires authentication, `mcp` handles it automatically.

```bash
mcp sentry --list
```

If the server supports OAuth 2.0, your browser opens to authorize the app. After you approve, the token is saved to `~/.config/mcp/auth.json` and reused automatically.

If OAuth isn't supported, `mcp` recognizes popular services and shows you where to get a token:

```
This server requires a Sentry Auth Token.

  How: Create a token with org:read, project:read scopes
  URL: https://sentry.io/settings/account/api/auth-tokens/

Enter access token for https://mcp.sentry.dev:
>
```

You paste the token, it's saved, and you're in.

### Using environment variables for tokens

For servers that need a token in headers, use env vars instead of hardcoding secrets:

```json
{
  "mcpServers": {
    "my-api": {
      "url": "https://api.example.com/mcp",
      "headers": {
        "Authorization": "Bearer ${MY_API_TOKEN}"
      }
    }
  }
}
```

Then set the env var in your shell:

```bash
export MY_API_TOKEN="your-token-here"
mcp my-api --list
```

## Part 4: Exploring tools

Every server exposes different tools. Use `--list` to see what's available:

```bash
mcp sentry --list
```

```json
[
  { "name": "search_issues", "description": "Search for issues" },
  { "name": "get_issue_details", "description": "Get details of an issue" },
  { "name": "search_events", "description": "Search events in a project" }
]
```

To see the full input schema (what arguments each tool accepts):

```bash
mcp sentry --info
```

```json
[
  {
    "name": "search_issues",
    "description": "Search for issues",
    "inputSchema": {
      "type": "object",
      "properties": {
        "query": { "type": "string", "description": "Sentry search query" },
        "sort": { "type": "string", "enum": ["date", "priority", "freq"] }
      },
      "required": ["query"]
    }
  }
]
```

This tells you exactly what JSON to pass.

## Part 5: Calling tools

Pass the tool name and a JSON object:

```bash
mcp sentry search_issues '{"query": "is:unresolved level:error"}'
```

The response is always a JSON object with a `content` array:

```json
{
  "content": [
    {
      "type": "text",
      "text": "Found 23 issues matching query..."
    }
  ]
}
```

### Reading arguments from stdin

Instead of passing JSON on the command line, you can pipe it:

```bash
echo '{"query": "is:unresolved"}' | mcp sentry search_issues
```

This is useful when arguments are complex or come from another command:

```bash
cat query.json | mcp sentry search_issues
```

### Parsing output with jq

Since output is JSON, pipe to `jq` for filtering:

```bash
# Get just the tool names
mcp sentry --list | jq '.[].name'

# Extract the text content from a tool call
mcp sentry search_issues '{"query": "is:unresolved"}' | jq '.content[0].text'
```

## Part 6: Managing servers

### Search the registry

Find servers from the official MCP registry:

```bash
mcp search database
```

```json
[
  {
    "name": "sqlite",
    "description": "MCP server for SQLite databases",
    "install": ["npx @anthropic/sqlite-mcp-server"]
  }
]
```

### Add from registry

```bash
mcp add sqlite
```

The CLI fetches the server metadata, writes the config entry, and tells you which env vars to set.

### Add HTTP server manually

```bash
mcp add --url https://mcp.honeycomb.io/mcp honeycomb
```

### Remove a server

```bash
mcp remove sqlite
```

## Part 7: Real-world example

Let's put it together. You want to find unresolved Sentry errors, check Grafana dashboards for related metrics, and post a summary to Slack.

```bash
# Find errors
mcp sentry search_issues '{"query": "is:unresolved level:error"}' \
  | jq '.content[0].text' > /tmp/errors.txt

# Search for related dashboards
mcp grafana search_dashboards '{"query": "api-errors"}' \
  | jq '.[0]'

# Post to Slack
mcp slack send_message "{
  \"channel\": \"#incidents\",
  \"text\": \"Found errors — check Sentry and Grafana\"
}"
```

Each service is just another command. No SDKs, no client libraries, no boilerplate.

## What's next?

* [**Configuration guide**](/guides/configuration) — Full config file specification.
* [**Authentication guide**](/guides/authentication) — OAuth 2.0 details, token management.
* [**Scripting guide**](/guides/scripting) — Patterns for shell scripts and automation.
* [**Supported services**](/how-to/services) — Step-by-step setup for specific services.


# Configuration

This guide covers everything about configuring `mcp` servers.

## Config file location

By default, `mcp` reads from:

```
~/.config/mcp/servers.json
```

Override with the `MCP_CONFIG_PATH` environment variable:

```bash
MCP_CONFIG_PATH=./my-servers.json mcp --list
```

If the file doesn't exist, `mcp` starts with zero servers. No error, no default file created — you build it as you go with `mcp add` or by editing the file directly.

## File format

The config file is JSON with a single top-level key:

```json
{
  "mcpServers": {
    "server-name": { ... },
    "another-server": { ... }
  }
}
```

Each server entry is identified by its name (the key). This name is what you use on the command line: `mcp server-name --list`.

## Server types

### Stdio servers

Stdio servers run as local processes. The CLI spawns them, communicates via stdin/stdout using JSON-RPC 2.0.

```json
{
  "mcpServers": {
    "filesystem": {
      "command": "npx",
      "args": ["-y", "@anthropic/fs-mcp-server", "/home/me/documents"],
      "env": {}
    }
  }
}
```

| Field     | Type      | Required | Description                           |
| --------- | --------- | -------- | ------------------------------------- |
| `command` | string    | yes      | The executable to run                 |
| `args`    | string\[] | no       | Command-line arguments                |
| `env`     | object    | no       | Environment variables for the process |

The `command` is the only required field. `args` defaults to `[]` and `env` defaults to `{}`.

### HTTP servers

HTTP servers are remote endpoints. The CLI sends POST requests with JSON-RPC payloads.

```json
{
  "mcpServers": {
    "sentry": {
      "url": "https://mcp.sentry.dev/sse",
      "headers": {
        "Authorization": "Bearer ${SENTRY_TOKEN}"
      }
    }
  }
}
```

| Field     | Type   | Required | Description                              |
| --------- | ------ | -------- | ---------------------------------------- |
| `url`     | string | yes      | The server endpoint URL                  |
| `headers` | object | no       | HTTP headers to include in every request |

`mcp` handles both standard JSON responses and [Server-Sent Events (SSE)](https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events) responses automatically. It also maintains session IDs when the server returns `Mcp-Session-Id` headers — unless the server speaks MCP `2026-07-28`, which removed sessions; against those, the header is neither sent nor stored.

Every request also carries `Mcp-Method` and, when it targets a single tool/prompt/resource, `Mcp-Name` — plus `MCP-Protocol-Version` once the negotiated revision is `2026-07-28`. These are additive HTTP metadata for gateways; servers that don't know them ignore them. A `Mcp-Name` that isn't header-safe (a URI with UTF-8 or spaces, say) travels in the spec's Base64 sentinel form, `=?base64?…?=`, which the server decodes before comparing it to the body.

## Environment variable substitution

Any value in the config can use `${VAR_NAME}` to reference environment variables. They're resolved when the config is loaded.

```json
{
  "mcpServers": {
    "github": {
      "command": "npx",
      "args": ["-y", "@anthropic/github-mcp-server"],
      "env": {
        "GITHUB_TOKEN": "${GITHUB_PERSONAL_TOKEN}"
      }
    }
  }
}
```

If an env var is not set, it resolves to an empty string. This is intentional — it lets you have optional variables without breaking the config.

Works in any string value: `env`, `headers`, `args`, `url`, etc.

## Server names

Server names are used as the first argument on the command line (`mcp <name> ...`). A few names are reserved and cannot be used:

* `search`
* `add`
* `remove`
* `list`
* `help`
* `version`

If you accidentally name a server with a reserved name, `mcp` will warn you at startup:

```
warning: server "search" conflicts with a reserved command name
  → rename it in /home/you/.config/mcp/servers.json to avoid unexpected behavior
```

## Adding servers

### From the registry

```bash
mcp add filesystem
```

This looks up the server in the [MCP registry](https://registry.modelcontextprotocol.io), generates the config entry automatically, and tells you which env vars to set.

### Manually (HTTP)

```bash
mcp add --url https://api.example.com/mcp my-server
```

### Manually (edit file)

Just open `~/.config/mcp/servers.json` in your editor and add the entry.

## Removing servers

```bash
mcp remove filesystem
```

This removes the entry from the config file.

## Multiple configs

You can maintain different config files for different contexts:

```bash
# Work servers
MCP_CONFIG_PATH=~/.config/mcp/work.json mcp --list

# Personal servers
MCP_CONFIG_PATH=~/.config/mcp/personal.json mcp --list
```

Or use shell aliases:

```bash
alias mcp-work='MCP_CONFIG_PATH=~/.config/mcp/work.json mcp'
alias mcp-personal='MCP_CONFIG_PATH=~/.config/mcp/personal.json mcp'
```

## Container / Kubernetes deployments

This guide covers configuration on a workstation, where `mcp` reads JSON files from `~/.config/mcp/`. In a container or Kubernetes pod — typically with a read-only root filesystem — file mounts are awkward. For those scenarios, `mcp` accepts the same JSON content directly via environment variables:

| File           | Inline env var                                                              | Read/write                    |
| -------------- | --------------------------------------------------------------------------- | ----------------------------- |
| `servers.json` | [`MCP_SERVERS_CONFIG`](/reference/environment-variables#mcp_servers_config) | read-only                     |
| `auth.json`    | [`MCP_AUTH_CONFIG`](/reference/environment-variables#mcp_auth_config)       | read-only (writes are no-ops) |

When set, the inline env var takes priority over the corresponding file path (`MCP_CONFIG_PATH` / `MCP_AUTH_PATH`).

These env vars are intended **only** for container deployments — don't use them on a workstation. Putting an entire JSON config in your shell environment is awkward to edit and breaks `mcp add` / `mcp remove`, which write back to the file.

For full deployment instructions, see [Deploying on Kubernetes](/how-to/kubernetes) and [Running in Docker](/how-to/docker).

## Complete example

A real-world config with multiple server types:

```json
{
  "mcpServers": {
    "sentry": {
      "url": "https://mcp.sentry.dev/sse"
    },
    "honeycomb": {
      "url": "https://mcp.honeycomb.io/mcp"
    },
    "slack": {
      "command": "npx",
      "args": ["-y", "slack-mcp-server@latest", "--transport", "stdio"],
      "env": {
        "SLACK_MCP_XOXP_TOKEN": "${SLACK_TOKEN}",
        "SLACK_MCP_TEAM_ID": "${SLACK_TEAM_ID}"
      }
    },
    "roam": {
      "command": "npx",
      "args": ["-y", "roam-tui@latest", "--mcp"],
      "env": {
        "ROAM_GRAPH_API_TOKEN": "${ROAM_TOKEN}"
      }
    },
    "grafana": {
      "url": "https://grafana.example.com/mcp",
      "headers": {
        "Authorization": "Bearer ${GRAFANA_TOKEN}"
      }
    }
  }
}
```


# Authentication

`mcp` supports multiple authentication methods. For most services, authentication is automatic — you just connect and `mcp` handles the rest.

## How it works

When you call a tool on an HTTP server, `mcp` follows this sequence:

1. **Check for saved token** — Look in `~/.config/mcp/auth.json` for a valid, non-expired token
2. **Check config headers** — Use `Authorization` header from config if present
3. **On 401 response** — Start the authentication flow:
   * Try OAuth 2.0 (discovery + PKCE flow)
   * Fall back to manual token prompt

Tokens are stored per server URL and refreshed automatically when they expire.

## OAuth 2.0 (automatic)

Services like Sentry support OAuth 2.0 with the MCP protocol. When you first connect:

```bash
mcp sentry --list
```

```
Authenticating with https://mcp.sentry.dev...
Opening browser for authorization...
```

Your browser opens, you authorize the app, and the token is saved. Next time, it just works.

### What happens under the hood

1. `mcp` checks the server for [OAuth Protected Resource Metadata](https://datatracker.ietf.org/doc/rfc9728/) to find the authorization server
2. Fetches the OAuth Authorization Server Metadata (`.well-known/oauth-authorization-server`)
3. Registers as a client using [Dynamic Client Registration](https://datatracker.ietf.org/doc/rfc7591/) if supported
4. Generates a PKCE challenge (S256) for security
5. Opens your browser to the authorization URL
6. Listens on a local port for the callback (default range `localhost:8085-8099`, configurable via `MCP_OAUTH_CALLBACK_PORT`)
7. Exchanges the authorization code for tokens
8. Saves tokens to `~/.config/mcp/auth.json`

### Token refresh

When a token expires, `mcp` automatically tries to refresh it using the refresh token. If that fails, it starts the OAuth flow again.

## Manual token (interactive)

If a server doesn't support OAuth, `mcp` falls back to asking for a token. It recognizes popular services and shows helpful instructions:

```
This server requires a Honeycomb API Key.

  How: Go to Account → API Keys → Create API Key
  URL: https://ui.honeycomb.io/account

Enter access token for https://mcp.honeycomb.io:
>
```

Paste your token, press Enter. It's saved and used for future requests.

### Recognized services

`mcp` has built-in hints for these services:

| Service    | Token type            | Where to get it                                        |
| ---------- | --------------------- | ------------------------------------------------------ |
| Honeycomb  | API Key               | Account → API Keys                                     |
| GitHub     | Personal Access Token | Settings → Developer settings → Personal access tokens |
| Sentry     | Auth Token            | Settings → Account → API → Auth Tokens                 |
| Linear     | API Key               | Settings → API → Personal API Keys                     |
| Notion     | Integration Token     | My Integrations → Create integration                   |
| Slack      | Bot/User Token        | Your app → OAuth & Permissions                         |
| Grafana    | Service Account Token | Administration → Service Accounts                      |
| GitLab     | Personal Access Token | User Settings → Access Tokens                          |
| Jira       | API Token             | Manage profile → Security → API tokens                 |
| Cloudflare | API Token             | Profile → API Tokens                                   |
| Datadog    | API Key               | Organization Settings → API Keys                       |
| PagerDuty  | API Key               | User Settings → Create API User Token                  |

For unrecognized services, you get a generic prompt.

## Config-based authentication

You can set auth headers directly in the config file:

```json
{
  "mcpServers": {
    "my-api": {
      "url": "https://api.example.com/mcp",
      "headers": {
        "Authorization": "Bearer ${MY_TOKEN}"
      }
    }
  }
}
```

Use `${ENV_VAR}` to avoid hardcoding secrets. Set the env var in your shell profile:

```bash
export MY_TOKEN="your-token-here"
```

### Empty tokens are skipped

If an env var is not set, the `Authorization` header resolves to `Bearer` (empty token). `mcp` detects this and skips the header, falling back to OAuth or manual auth instead.

## Token storage

Tokens are saved in `~/.config/mcp/auth.json`:

```json
{
  "clients": {
    "https://mcp.sentry.dev": {
      "client_id": "abc123"
    }
  },
  "tokens": {
    "https://mcp.sentry.dev": {
      "access_token": "sntrys_...",
      "refresh_token": "sntryr_...",
      "expires_at": 1710000000
    }
  }
}
```

* **`clients`** — OAuth client registrations (client ID per server)
* **`tokens`** — Access tokens, refresh tokens, and expiry timestamps

### Clearing tokens

To re-authenticate, delete the entry from `auth.json` or delete the whole file:

```bash
rm ~/.config/mcp/auth.json
```

Next connection will trigger a fresh authentication flow.

## Authentication priority

When multiple auth sources exist, the priority is:

1. **Config headers** — `Authorization` header from `servers.json` (if non-empty)
2. **Saved token** — Token from `auth.json` (loaded on connect)
3. **OAuth flow** — Triggered on 401 response
4. **Manual prompt** — If OAuth registration fails

## Server-side authentication (proxy mode)

The sections above cover **client-side** authentication — how `mcp` authenticates when calling remote MCP servers. When running `mcp serve --http`, the proxy itself can also **authenticate incoming requests** from clients.

This is configured via the `serverAuth` key in `servers.json`. See the [proxy mode guide](/guides/proxy-mode#authentication) for full details.

> **Schema change.** As of the OAuth Authorization Server feature, `serverAuth` takes a `providers: ["..."]` array instead of a single `provider: "..."` string. The new shape lets a single instance accept multiple kinds of bearer credentials in parallel — for example, static tokens for local CLI tools *and* OAuth-issued JWTs for Claude.ai web. Configs using the old `provider` field will silently boot as `NoAuth`; rewrite them as shown below.

### Quick example

```json
{
  "mcpServers": { ... },
  "serverAuth": {
    "providers": ["bearer"],
    "bearer": {
      "tokens": {
        "tok-alice": "alice",
        "tok-bob": { "subject": "bob", "roles": ["dev", "oncall"] }
      }
    },
    "acl": {
      "default": "allow",
      "rules": [
        { "roles": ["dev"], "tools": ["sentry__*"], "policy": "deny" }
      ]
    }
  }
}
```

Each entry in `tokens` supports two shapes:

* **Legacy (string):** `"tok-alice": "alice"` — subject only, no roles.
* **Extended (object):** `"tok-bob": { "subject": "bob", "roles": ["dev", "oncall"] }` — subject plus a list of roles that will flow into ACL evaluation.

Both forms can coexist in the same config file.

#### Role-based ACL (recommended for new setups)

```json
{
  "mcpServers": { ... },
  "serverAuth": {
    "providers": ["bearer"],
    "bearer": {
      "tokens": {
        "tok-alice": { "subject": "alice", "roles": ["admin"] },
        "tok-bob": { "subject": "bob", "roles": ["dev"] }
      }
    },
    "acl": {
      "default": "deny",
      "roles": {
        "admin": [{ "server": "*", "access": "*" }],
        "dev": [
          { "server": ["github", "grafana"], "access": "read" },
          { "server": "github", "access": "write", "tools": ["gh_pr", "gh_issue"] }
        ]
      }
    }
  }
}
```

See [proxy mode ACL docs](/guides/proxy-mode#access-control-acl) for the full schema, access expansion table, and evaluation model.

### Available providers

| Provider                                   | Use case                                                                                                                                                                                                 |
| ------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `none` (default when `providers` is empty) | No auth — all requests are anonymous                                                                                                                                                                     |
| `bearer`                                   | Static token-to-user mapping, with optional per-token roles                                                                                                                                              |
| `forwarded`                                | Trust reverse proxy `X-Forwarded-User` header (and optional `X-Forwarded-Groups` for roles)                                                                                                              |
| `oauth_as`                                 | Run an OAuth 2.0 Authorization Server with Dynamic Client Registration so Claude.ai, ChatGPT, Cursor and other AI clients can connect as Custom Connectors. See the [OAuth AS how-to](/how-to/oauth-as). |

`providers` is a list — combine multiple in the same instance:

```json
{
  "serverAuth": {
    "providers": ["bearer", "oauth_as"],
    "bearer": { "tokens": { "tok-local-dev": { "subject": "avelino", "roles": ["admin"] } } },
    "oauthAs": {
      "issuerUrl": "https://mcp.example.com",
      "jwtSecret": "${MCP_OAUTH_AS_JWT_SECRET}",
      "trustedSourceCidrs": ["10.0.0.0/8"],
      "redirectUriAllowlist": ["https://claude.ai/api/mcp/auth_callback"],
      "injectedRoles": ["oauth-user"]
    }
  }
}
```

The chain tries each provider in order; the first one that accepts the request wins. When all reject, the chain returns the error of the *first* configured provider — which avoids leaking which token format hit a closer-to-success path. Order is a performance hint (cheap lookups first), not a correctness one.

### Forwarded provider and roles

With `"forwarded"` in `providers`, the proxy reads the user from the configured user header (default `x-forwarded-user`) and, optionally, a groups header (default `x-forwarded-groups`, following the oauth2-proxy convention) to populate roles.

```json
{
  "serverAuth": {
    "providers": ["forwarded"],
    "forwarded": {
      "header": "x-forwarded-user",
      "groups_header": "x-forwarded-groups"
    }
  }
}
```

Groups header value is parsed as a comma-separated list: each entry is trimmed and empty entries are dropped. Missing header yields an empty role list (not an error). Role matching is **case-sensitive**.

> Only use `forwarded` behind a trusted reverse proxy that strips these headers from incoming client requests — otherwise clients could forge identities and roles.

### Access control (ACL)

The ACL controls which authenticated users can access which tools. It supports two schemas:

* **Role-based** (recommended) — define reusable roles with server-aware, read/write-aware grants. Evaluation is union-based and order-independent. Deny always wins.
* **Legacy** — flat rules list with first-match-wins semantics, fully backward compatible.

See [proxy mode ACL docs](/guides/proxy-mode#access-control-acl) for the full schema reference and examples.


# Registry

The [MCP server registry](https://registry.modelcontextprotocol.io) is a public directory of MCP servers. `mcp` can search it and add servers directly from it.

## Searching

```bash
mcp search filesystem
```

```json
[
  {
    "name": "filesystem",
    "description": "MCP server for file system operations",
    "repository": "https://github.com/anthropics/mcp-servers",
    "install": ["npx @anthropic/fs-mcp-server"]
  }
]
```

Search with multiple words:

```bash
mcp search "database sql"
```

Results include:

* **name** — Server identifier (used with `mcp add`)
* **description** — What the server does
* **repository** — Source code link
* **install** — How to install/run (runtime + package)

## Adding from registry

```bash
mcp add filesystem
```

What happens:

1. Searches the registry for a server named `filesystem`
2. Reads the server metadata (command, args, env vars)
3. Generates a config entry in `~/.config/mcp/servers.json`
4. Prints which environment variables you need to set

```
✓ Server "filesystem" added to /home/you/.config/mcp/servers.json

Configure the following environment variables:
  ALLOWED_PATHS  — Directories the server can access

Run to test:
  mcp filesystem --list
```

### What gets generated

For a package-based server (most common), the config looks like:

```json
{
  "mcpServers": {
    "filesystem": {
      "command": "npx",
      "args": ["-y", "@anthropic/fs-mcp-server"],
      "env": {
        "ALLOWED_PATHS": "${ALLOWED_PATHS}"
      }
    }
  }
}
```

For a remote server with HTTP transport:

```json
{
  "mcpServers": {
    "remote-service": {
      "url": "https://example.com/mcp/sse"
    }
  }
}
```

The registry entry determines which type is used. Packages (stdio) take priority over remotes (HTTP).

## Already exists?

If you try to add a server that's already in your config:

```bash
mcp add filesystem
```

```
error: server "filesystem" already exists in config
```

Remove it first if you want to re-add:

```bash
mcp remove filesystem
mcp add filesystem
```

> If you only want to pull fresh metadata from the registry (new package version, new env vars, updated args), use `mcp update <name>` instead — it preserves your customizations (filled env values, headers, idle\_timeout, etc.). See [`mcp update`](/reference/cli#mcp-update-name).

## Manual HTTP servers

For servers not in the registry, add them manually:

```bash
mcp add --url https://mcp.example.com/sse my-server
```

This creates a minimal HTTP entry:

```json
{
  "mcpServers": {
    "my-server": {
      "url": "https://mcp.example.com/sse"
    }
  }
}
```


# Scripting

`mcp` automatically switches to JSON output when piped or redirected, making it easy to use in shell scripts, CI/CD pipelines, and automation. You can also force JSON with `--json`.

## Basics

### Output format detection

When piped (non-interactive), `mcp` outputs JSON by default. In interactive terminals, it shows human-readable tables. Use `--json` to force JSON in any context:

```bash
# These all produce JSON:
mcp sentry search_issues '{"query": "..."}' | jq '.'   # piped → JSON
mcp sentry --list > tools.json                           # redirected → JSON
mcp sentry --list --json                                 # explicit → JSON
```

### Output goes to stdout

All tool results are JSON on stdout:

```bash
result=$(mcp sentry search_issues '{"query": "is:unresolved"}')
echo "$result" | jq '.content[0].text'
```

### Errors go to stderr

Errors and status messages go to stderr, so they don't pollute your data:

```bash
# This captures only the JSON, not auth messages or warnings
mcp sentry search_issues '{"query": "is:unresolved"}' > results.json
```

### Exit codes

* `0` — Success
* `1` — Error (connection failed, tool error, bad config, etc.)

```bash
if mcp sentry --list > /dev/null 2>&1; then
    echo "Sentry is configured and reachable"
else
    echo "Sentry is not available"
fi
```

## Piping input

When no JSON argument is provided on the command line, `mcp` reads from stdin (if it's not a terminal):

```bash
# From a file
cat query.json | mcp sentry search_issues

# From another command
jq -n '{"query": "is:unresolved"}' | mcp sentry search_issues

# Here document
mcp sentry search_issues <<'EOF'
{"query": "is:unresolved", "sort": "date"}
EOF
```

If stdin is a terminal (interactive), `mcp` uses `{}` as the default argument. This is why `mcp sentry --list` works without any input.

## Parsing with jq

### Get tool names

```bash
mcp sentry --list | jq -r '.[].name'
```

### Extract text content

```bash
mcp sentry search_issues '{"query": "is:unresolved"}' \
  | jq -r '.content[] | select(.type == "text") | .text'
```

### Check for errors

```bash
result=$(mcp sentry search_issues '{"query": "bad query"}')
is_error=$(echo "$result" | jq -r '.isError // false')
if [ "$is_error" = "true" ]; then
    echo "Tool returned an error"
fi
```

## Common patterns

### Loop over results

```bash
# Get all tool names and call each one with empty args
mcp sentry --list | jq -r '.[].name' | while read tool; do
    echo "=== $tool ==="
    mcp sentry "$tool" 2>/dev/null || echo "(failed)"
done
```

### Build arguments dynamically

```bash
project="my-project"
query="is:unresolved level:error"
args=$(jq -n --arg q "$query" --arg p "$project" \
    '{"query": $q, "project": $p}')
mcp sentry search_issues "$args"
```

### Chain multiple servers

```bash
# Get Sentry errors, format them, post to Slack
errors=$(mcp sentry search_issues '{"query": "is:unresolved level:error"}' \
    | jq -r '.content[0].text')

message="Unresolved errors from Sentry:\n${errors}"
mcp slack send_message "$(jq -n --arg text "$message" \
    '{"channel": "#alerts", "text": $text}')"
```

### Cron job

```bash
#!/bin/bash
# /etc/cron.d/check-errors — run every hour

export SENTRY_TOKEN="your-token"
export MCP_CONFIG_PATH="/opt/mcp/servers.json"

count=$(mcp sentry search_issues '{"query": "is:unresolved level:error"}' \
    | jq '.content[0].text | length')

if [ "$count" -gt 0 ]; then
    echo "Found $count unresolved errors" | mail -s "Sentry Alert" team@example.com
fi
```

## CI/CD

### GitHub Actions

```yaml
- name: Check for critical Sentry issues
  env:
    MCP_CONFIG_PATH: ./ci/mcp-servers.json
    SENTRY_TOKEN: ${{ secrets.SENTRY_TOKEN }}
  run: |
    result=$(mcp sentry search_issues '{"query": "is:unresolved level:fatal"}')
    count=$(echo "$result" | jq '.content | length')
    if [ "$count" -gt 0 ]; then
      echo "::warning::Found unresolved fatal issues in Sentry"
    fi
```

## Tips

* **Always quote JSON arguments** — Shell metacharacters in JSON can cause issues
* **Use `jq -n`** — When building JSON arguments with variables, `jq -n` is safer than string interpolation
* **Redirect stderr** — Use `2>/dev/null` to suppress auth messages in scripts
* **Set `MCP_TIMEOUT`** — Increase for slow servers: `MCP_TIMEOUT=120 mcp slack --list`
* **Non-interactive stdin** — When piping or in cron, stdin is not a terminal, so `mcp` reads from it. Pass `{}` explicitly if the tool needs no arguments: `echo '{}' | mcp server tool`


# CLI as MCP

Any command-line tool can become an MCP server. No code, no wrapper — just config.

```json
{
  "mcpServers": {
    "kubectl": {
      "command": "kubectl",
      "cli": true
    }
  }
}
```

That's it. `mcp` runs `kubectl --help`, discovers subcommands and flags, and exposes them as MCP tools automatically.

## Why

MCP is becoming the standard protocol for AI tool integration. But most software ships as a CLI, not an MCP server. This bridge closes the gap: any CLI becomes accessible to GPT, Claude, Cursor, or any MCP-compatible client — without writing a single line of integration code.

```
You / AI agent  -->  mcp CLI  -->  CliTransport  -->  kubectl / docker / terraform / ...
                         |
                    servers.json
```

## How discovery works

When you add a CLI server, `mcp` automatically:

1. Runs `<command> --help` to discover subcommands
2. Runs `<command> <subcommand> --help` for each subcommand to discover flags
3. Generates MCP tool definitions with proper `inputSchema`

Each subcommand becomes a tool named `<command>_<subcommand>` (e.g. `kubectl_get`, `kubectl_describe`).

```bash
$ mcp kubectl --list
kubectl_get        Display one or many resources
kubectl_describe   Show details of a specific resource
kubectl_version    Print the client and server version information
...
```

Flags are parsed into typed schema properties:

```bash
$ mcp kubectl --info
# kubectl_get has: output (string), all_namespaces (boolean), selector (string), ...
```

## Calling tools

Tools accept a JSON object. Flags map to properties (dashes become underscores). Positional arguments go in `args`:

```bash
# kubectl get pods -n kube-system -o json
mcp kubectl kubectl_get '{"args": "pods", "namespace": "kube-system", "output": "json"}'

# kubectl version --client
mcp kubectl kubectl_version '{"client": true}'

# kubectl describe pod my-pod
mcp kubectl kubectl_describe '{"args": "pod my-pod"}'
```

The `args` field supports shell quoting for arguments with spaces:

```bash
# grep in a path with spaces
mcp grep grep '{"args": "pattern \"my directory/file.txt\""}'
```

Each call spawns the CLI process, captures stdout, and returns it as MCP content. No long-running process — each invocation is independent.

If the command writes to stderr on success (e.g. warnings), it's appended to the output under a `--- stderr ---` delimiter so nothing is lost silently.

## Configuration

### Minimal

```json
{
  "mcpServers": {
    "kubectl": {
      "command": "kubectl",
      "cli": true
    }
  }
}
```

### Full options

```json
{
  "mcpServers": {
    "kubectl": {
      "command": "kubectl",
      "cli": true,
      "cli_help": "--help",
      "cli_depth": 2,
      "cli_only": ["get", "describe", "logs", "version"],
      "args": [],
      "env": {
        "KUBECONFIG": "${HOME}/.kube/config"
      }
    }
  }
}
```

| Field       | Type      | Default    | Description                                                    |
| ----------- | --------- | ---------- | -------------------------------------------------------------- |
| `command`   | string    | *required* | CLI executable to wrap                                         |
| `cli`       | bool      | *required* | Must be `true` — marks this as a CLI server                    |
| `cli_help`  | string    | `"--help"` | Flag used to discover subcommands and options                  |
| `cli_depth` | number    | `2`        | How deep to recurse into subcommands for flag discovery        |
| `cli_only`  | string\[] | `[]` (all) | Whitelist of subcommands to expose — everything else is hidden |
| `args`      | string\[] | `[]`       | Base arguments prepended to every invocation                   |
| `env`       | object    | `{}`       | Environment variables for the CLI process                      |

### `cli_help`

Most CLIs use `--help`. Some don't:

```json
{
  "mcpServers": {
    "busybox": {
      "command": "busybox",
      "cli": true,
      "cli_help": "--list"
    }
  }
}
```

### `cli_only`

Limit exposure to safe, read-only commands:

```json
{
  "mcpServers": {
    "kubectl": {
      "command": "kubectl",
      "cli": true,
      "cli_only": ["get", "describe", "logs", "top", "version"]
    }
  }
}
```

This is important for security — you probably don't want an AI agent running `kubectl delete` or `kubectl exec`.

### `cli_depth`

Controls how deep the discovery goes:

* `1` — only parse the top-level `--help` (subcommand names + descriptions, no flag details)
* `2` (default) — also run `<subcommand> --help` to discover flags and build `inputSchema`

Values greater than `2` are accepted but currently behave the same as `2` (no additional recursion depth).

## Preset tools

If automatic discovery doesn't work for a specific CLI, you can define tools manually:

```json
{
  "mcpServers": {
    "custom": {
      "command": "my-tool",
      "cli": true,
      "tools": [
        {
          "name": "my_tool_export",
          "args": ["export", "--format", "json"],
          "description": "Export data as JSON"
        }
      ]
    }
  }
}
```

When `tools` is non-empty, automatic discovery is skipped. The `args` in each tool define the exact arguments passed to the CLI when that tool is called.

## Examples

### kubectl

```json
{
  "kubectl": {
    "command": "kubectl",
    "cli": true,
    "cli_only": ["get", "describe", "logs", "top", "version"]
  }
}
```

```bash
mcp kubectl kubectl_get '{"args": "pods -A", "output": "json"}'
mcp kubectl kubectl_logs '{"args": "deploy/api -n production", "tail": "100"}'
```

### docker

```json
{
  "docker": {
    "command": "docker",
    "cli": true,
    "cli_only": ["ps", "images", "logs", "inspect", "stats"]
  }
}
```

```bash
mcp docker docker_ps '{"all": true}'
mcp docker docker_logs '{"args": "my-container", "tail": "50"}'
```

### terraform

```json
{
  "terraform": {
    "command": "terraform",
    "cli": true,
    "cli_only": ["plan", "show", "state", "output", "validate"]
  }
}
```

```bash
mcp terraform terraform_plan '{}'
mcp terraform terraform_output '{"json": true}'
```

### git (read-only)

```json
{
  "git": {
    "command": "git",
    "cli": true,
    "cli_only": ["log", "diff", "status", "show", "branch"]
  }
}
```

```bash
mcp git git_log '{"args": "--oneline -20"}'
mcp git git_diff '{"args": "HEAD~1"}'
```

## How it works with proxy mode

CLI servers work with `mcp serve` just like any other server. Tools are namespaced the same way:

```bash
mcp serve
# Tools appear as: kubectl__kubectl_get, docker__docker_ps, etc.
```

Idle timeout applies: since each CLI call is a separate process spawn, the CLI transport itself has no persistent connection to shut down. The idle timeout controls when the discovered tool cache is dropped.

## Environment variables

| Variable                    | Default   | Description                                                 |
| --------------------------- | --------- | ----------------------------------------------------------- |
| `MCP_TIMEOUT`               | `60`      | Timeout in seconds for each CLI command execution           |
| `MCP_MAX_OUTPUT`            | `1048576` | Max output size in bytes (1 MB). Larger output is truncated |
| `MCP_DISCOVERY_CONCURRENCY` | `10`      | Max parallel `--help` calls during subcommand discovery     |

## Help format support

The discovery parser handles these common formats:

| CLI framework     | Example             | Supported                            |
| ----------------- | ------------------- | ------------------------------------ |
| Cobra (Go)        | kubectl, docker, gh | Yes                                  |
| Clap (Rust)       | ripgrep, fd         | Yes                                  |
| Click (Python)    | flask, black        | Yes                                  |
| Argparse (Python) | most Python CLIs    | Yes                                  |
| Custom            | varies              | Best-effort, fallback to single tool |

If a CLI's `--help` output doesn't follow standard patterns, discovery falls back to exposing the command as a single tool with a free-form `args` parameter.


# Proxy mode

`mcp serve` starts a single MCP server that aggregates all your configured backends. Any MCP-compatible client connects once and gets access to every tool from every server in your `servers.json`.

## The problem

Without proxy mode, every LLM tool (Claude Code, Cursor, Windsurf, etc.) needs its own copy of your MCP server configuration. Add a new server? Update it in 3 places. Change a token? Same. The config drifts, breaks, and wastes time.

There's another problem: **resource waste**. When you configure MCP servers with `command` in `mcpServers`, each client session spawns its own copy of every server process. Open 5 Claude Code sessions and you get 5 copies of every MCP server — easily 3-4 GB of RAM wasted on duplicate processes.

### Stdio vs HTTP: when to use each

| Approach                                   | How it works                                                | Trade-off                                          |
| ------------------------------------------ | ----------------------------------------------------------- | -------------------------------------------------- |
| `"command": "mcp", "args": ["serve"]`      | Each session spawns a new proxy (which spawns all backends) | Simple, but duplicates everything per session      |
| `"type": "sse", "url": "http://…/mcp/sse"` | All sessions share one persistent proxy                     | One process, one set of backends, zero duplication |

**Recommendation:** Run `mcp serve --http` as a persistent service (systemd, launchd, etc.) and point all your clients to it via SSE. This gives you a single set of backend connections shared across every session, every client, every terminal.

```mermaid
graph LR
    C1["Claude Code #1"] --> Proxy
    C2["Claude Code #2"] --> Proxy
    C3["Cursor"] --> Proxy
    Proxy["mcp serve --http<br/>(single process)"] --> Slack
    Proxy --> Sentry
    Proxy --> GitHub

    style Proxy fill:#4a9,color:#fff
```

## How it works

```mermaid
graph LR
    Client["LLM Client<br/>(Claude, Cursor)"] <-->|"stdio<br/>JSON-RPC 2.0"| Proxy["mcp serve<br/>(proxy)"]
    Proxy --> Sentry["sentry<br/>(http)"]
    Proxy --> Slack["slack<br/>(stdio)"]
    Proxy --> N["server N<br/>(stdio/http)"]
```

1. Client announces itself — either `server/discover` (MCP 2026-07-28) or `initialize` (every earlier revision). Both answer immediately with the same capabilities (tools, resources, prompts)
2. Client calls `tools/list`, `resources/list`, or `prompts/list` — the proxy returns items instantly from persistent cache (tools) or discovery (resources/prompts), aggregated across all backends. Each item is namespaced with `{server}__` prefix.
3. Client calls `tools/call`, `resources/read`, or `prompts/get` — the proxy reconnects the target backend on demand (if it was shut down), routes the request, and tracks usage for adaptive timeout

## Protocol revisions

`mcp serve` speaks MCP **2026-07-28** and every earlier revision it knows, on the same endpoint. It advertises, newest first: `2026-07-28`, `2025-11-25`, `2025-06-18`, `2025-03-26`, `2024-11-05`.

What it accepts:

* **`server/discover`** — the 2026-07-28 replacement for the handshake. Returns the list above as `supportedVersions`, plus `capabilities`, plus the proxy's `serverInfo` under `_meta["io.modelcontextprotocol/serverInfo"]` (not at the top level, where `initialize` puts it).
* **`initialize`** — still served, for every client that predates 2026-07-28. The proxy echoes back the revision *the client asked for* (when it's one we speak) instead of the newest one, so a 2025-06-18 client isn't told something it can't parse. A client that sends no `protocolVersion` gets the newest, exactly as before.
* **Per-request revision** — a 2026-07-28 client declares its revision in `params._meta` on each request. Absent means a legacy client and is never an error; present-but-unknown is rejected with `-32022`.
* **`MCP-Protocol-Version` / `Mcp-Method` / `Mcp-Name` headers** — validated **only when present**. Legacy clients send none of them and are not penalized. A header that contradicts the body is rejected with `-32020`, because that means a gateway routed or metered on a lie. Both `Mcp-Method` and `Mcp-Name` are decoded first when they arrive in the Base64 sentinel form `=?base64?…?=` (which is how a tool name or resource URI that isn't header-safe travels); a sentinel that won't decode is itself a rejection. A `MCP-Protocol-Version` naming a revision we don't speak is rejected with `-32022`, the same as one declared in `params._meta` — otherwise a future date would silently select 2026-07-28 semantics.

What it puts in responses **for a client that declared the new revision** — either in `params._meta` or via `MCP-Protocol-Version`. A client that declared neither gets exactly the bytes it got before this revision existed; these fields don't exist in the revision it negotiated, and a client validating results against a closed schema is a real client:

* `resultType` on every result, plus `serverInfo` under `_meta`.
* `ttlMs` and `cacheScope: "private"` on `tools/list`, `prompts/list`, `resources/list`, `resources/read` and `server/discover`. The scope is always private: those results are ACL-filtered per identity, so a shared cache would hand one identity another's tool list.
* Deterministic ordering of `tools/list`, `prompts/list` and `resources/list`, so the same set of primitives always serializes identically.
* HTTP status codes the 2026-07-28 transport pins: `-32020` and `-32022` answer `400`, and an unknown method answers `404` — the last one **only** for a client that declared the new revision, since every legacy client has always received `-32601` on a `200` and still does.

`tools/call`, `resources/read` and `prompts/get` are relayed to the backend, so a backend that answers with an interim `input_required` result (Multi Round-Trip Request) passes through intact, along with the `inputResponses` / `requestState` the client sends on the retry. The relay is not blind: whatever `ttlMs` / `cacheScope` a backend puts on such a result is stripped, because the proxy — not the backend — is the one that knows the answer was ACL-filtered. An `input_required` is preserved for every client, including a legacy one — dropping it would report an unfinished exchange as finished.

Backends are untouched by any of this: the proxy talks to each one with whatever revision *that* backend negotiated. A backend that never went past `initialize` gets no `_meta` and no `Mcp-Method` / `Mcp-Name` / `MCP-Protocol-Version` headers either — one hop cannot be half on each revision. The only exception is the `server/discover` probe that asks the backend which revision it speaks, a method that did not exist before 2026-07-28.

## Concurrency model

The proxy is built to be the **orchestration layer for N concurrent clients sharing the same set of backend processes**. This is the whole point of the project — without it, every editor session and every chat window spawns its own copy of every MCP server, multiplying RAM, CPU, and API rate-limit cost by the number of clients.

The guarantees the proxy makes:

* **One backend = one OS process**, regardless of how many clients are connected. 5 clients hitting `slack` share a single `slack-mcp-server` child.
* **Calls to different backends run in parallel.** Two clients calling `sentry__search_issues` and `github__list_repos` at the same time do not block each other.
* **Calls to the same backend also run in parallel.** The stdio transport multiplexes JSON-RPC requests on a single pipe via id matching, so 5 clients all hitting `slack__conversations_replies` simultaneously fan out and back through the same process — none of them serializes on the others.
* **A slow or hung backend only delays the requests targeting it.** The rest of the proxy keeps moving.
* **A dead client only loses its own request.** TCP keepalive (30s/10s) on the HTTP listener detects half-open sockets from crashed editors within \~60s, and `MCP_PROXY_REQUEST_TIMEOUT` (default 120s) is a final hard bound at the request boundary.
* **No orphan backends.** Every spawned child is registered with `kill_on_drop`, so a panicked task, a cancelled request, or a stalled graceful shutdown all converge on the same outcome: the child gets reaped.

You can verify the orchestration on a running proxy with `/health`:

```bash
curl -s http://127.0.0.1:7332/health | jq
```

```json
{
  "status": "ok",
  "backends_configured": 9,
  "backends_connected": 9,
  "active_clients": 5,
  "tools": 213,
  "version": "0.4.3"
}
```

`backends_connected` should never grow with `active_clients` — that's the win.

```mermaid
graph LR
    C1["claude code #1"] --> P
    C2["claude code #2"] --> P
    C3["opencode"] --> P
    C4["cursor"] --> P
    C5["windsurf"] --> P
    P["mcp serve --http<br/>(orchestrator)"] --> Slack["slack-mcp<br/>(1 process)"]
    P --> Sentry["sentry-mcp<br/>(1 process)"]
    P --> GH["github-mcp<br/>(1 process)"]
    P --> N["...N backends"]

    style P fill:#4a9,color:#fff
```

## Namespacing

Tools, resources, and prompts are prefixed with the server name using double underscore (`__`) as separator:

| Category | Server | Original        | Namespaced              |
| -------- | ------ | --------------- | ----------------------- |
| Tool     | sentry | `search_issues` | `sentry__search_issues` |
| Resource | sentry | `issue://123`   | `sentry__issue://123`   |
| Prompt   | slack  | `summarize`     | `slack__summarize`      |

Descriptions are also prefixed: `[sentry] Search for issues in Sentry`.

This prevents collisions when two servers expose items with the same name or URI.

## Stdio mode (default)

```bash
mcp serve
```

That's it. It reads the same `servers.json` (or `$MCP_CONFIG_PATH`) and connects to everything.

Diagnostics go to stderr:

```
[serve] discovering tools from sentry...
[serve] discovering tools from slack...
[serve] sentry: 8 tool(s)
[serve] slack: 12 tool(s)
[serve] ready — 2 backend(s), 20 tool(s)
[serve] shutting down idle backend: sentry (idle 74s, 1 reqs)
[serve] finalizing shutdown for sentry
[serve] connecting to sentry...
[serve] sentry: 8 tool(s) (reconnected)
```

A few things to note about reaping:

* A backend is **never reaped before its first request** until `max_idle_timeout` has elapsed since connect (warm-up grace). You won't see "shutting down idle backend: X (... 0 reqs)" inside the first few minutes after start anymore — the proxy waits for X to actually be used before considering it idle.
* When the reaper does fire, all eligible backends shut down **in parallel** — 8 idle backends take \~5s, not 8 × 5s.
* If a backend's graceful shutdown stalls past 5s, you'll see `shutdown timed out — force-killed via drop`. The child is guaranteed reaped via `kill_on_drop`; the proxy never leaks orphan processes.

## HTTP mode

Expose the proxy as an HTTP server so multiple developers can share a single MCP endpoint:

```bash
mcp serve --http
```

This starts an HTTP server on `127.0.0.1:8080` (localhost only, by default).

### Custom bind address

```bash
mcp serve --http 0.0.0.0:9090 --insecure
```

> **Security:** Non-loopback addresses require the `--insecure` flag. Without TLS, binding to `0.0.0.0` exposes the proxy to the network in plaintext. Use a reverse proxy (nginx, Caddy) with TLS in production.

### Endpoints

| Method | Path       | Description                                                 |
| ------ | ---------- | ----------------------------------------------------------- |
| `POST` | `/mcp`     | JSON-RPC 2.0 request/response (Streamable HTTP)             |
| `GET`  | `/mcp`     | SSE stream (same as `/mcp/sse`, for backward compatibility) |
| `GET`  | `/mcp/sse` | SSE stream (old HTTP+SSE transport)                         |
| `GET`  | `/health`  | Health check (JSON)                                         |

The proxy supports both the **Streamable HTTP** transport and the older **HTTP+SSE** transport for backward compatibility. See [Protocol revisions](#protocol-revisions) for which MCP revisions each speaks.

> **Deprecation:** MCP 2026-07-28 formally deprecates the HTTP+SSE transport with a 12-month removal window. The proxy keeps serving it unchanged and logs a warning on connect — nothing is gated. Move clients to `POST /mcp` when you can.

### POST /mcp

Send any MCP JSON-RPC request and get the response. Supports both requests (with `id`) and notifications (without `id`):

```bash
# Discover (MCP 2026-07-28 — no handshake needed afterwards)
curl -s http://localhost:8080/mcp \
  -H "Content-Type: application/json" \
  -d '{"jsonrpc":"2.0","id":1,"method":"server/discover"}'

# Initialize (every revision before 2026-07-28)
curl -s http://localhost:8080/mcp \
  -H "Content-Type: application/json" \
  -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-11-25","capabilities":{},"clientInfo":{"name":"test","version":"0.1"}}}'

# Send initialized notification (no id — returns 202)
curl -s http://localhost:8080/mcp \
  -H "Content-Type: application/json" \
  -d '{"jsonrpc":"2.0","method":"notifications/initialized"}'

# List tools
curl -s http://localhost:8080/mcp \
  -H "Content-Type: application/json" \
  -d '{"jsonrpc":"2.0","id":2,"method":"tools/list"}'

# Call a tool
curl -s http://localhost:8080/mcp \
  -H "Content-Type: application/json" \
  -d '{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"sentry__search_issues","arguments":{"query":"is:unresolved"}}}'
```

When used with an SSE session (`?session_id=<uuid>`), responses are delivered via the SSE stream and the POST returns `202 Accepted`.

### GET /mcp/sse

SSE endpoint for clients that use the old HTTP+SSE transport (protocol version `2024-11-05`). On connect, the server sends an `endpoint` event with the URL to POST requests to:

```
event: endpoint
data: /mcp?session_id=<uuid>
```

JSON-RPC responses are delivered as `message` events on the SSE stream. The connection stays open with periodic pings (every 15 seconds) to keep it alive. Sessions are cleaned up automatically when the client disconnects.

### GET /health

Returns the proxy status, including backend pool size and live client sessions:

```json
{
  "status": "ok",
  "backends_configured": 9,
  "backends_connected": 9,
  "active_clients": 5,
  "tools": 213,
  "version": "0.4.3"
}
```

`active_clients` is the number of SSE sessions currently registered. Combined with `backends_connected`, this is the metric that proves the proxy is doing its job: **N clients sharing M backends, not N × M processes**. If you ever see `backends_connected` grow proportionally to `active_clients`, something is wrong (clients should be hitting the proxy, not bypassing it to spawn their own backends).

### Graceful shutdown

The HTTP server shuts down cleanly on `SIGTERM` or `SIGINT` (Ctrl+C). It stops accepting new connections, finishes in-flight requests, and disconnects all backends.

### Team setup

Run one proxy server on shared infrastructure. Every developer connects to it:

```mermaid
graph TB
    subgraph Server["Private server (team infra)"]
        Proxy["mcp serve --http :8080"]
        Slack["Slack (token)"]
        Sentry["Sentry (token)"]
        GitHub["GitHub (token)"]
        Postgres["Postgres (token)"]
        Proxy --> Slack
        Proxy --> Sentry
        Proxy --> GitHub
        Proxy --> Postgres
    end

    D1["Dev 1"] -->|"mcp add --url http://mcp.internal:8080/mcp team"| Proxy
    D2["Dev 2"] -->|"mcp add --url http://mcp.internal:8080/mcp team"| Proxy

    style Proxy fill:#4a9,color:#fff
```

Tokens stay on the server. Developers just connect. For a deeper look at the enterprise use case, see [Enterprise token management](/guides/enterprise-token-management).

## Client configuration

### Claude Code (stdio)

In your Claude Code MCP settings (`.claude/mcp.json` or via Claude Code settings):

```json
{
  "mcpServers": {
    "all": {
      "command": "mcp",
      "args": ["serve"]
    }
  }
}
```

### Claude Code (HTTP — shared server)

Use the SSE transport type pointing to the `/mcp/sse` endpoint:

```json
{
  "mcpServers": {
    "team": {
      "type": "sse",
      "url": "http://localhost:8080/mcp/sse"
    }
  }
}
```

> **Note:** The Streamable HTTP transport (`type: "http"`) requires OAuth which is not yet supported. Use `type: "sse"` for now.

### Cursor (stdio)

In `.cursor/mcp.json`:

```json
{
  "mcpServers": {
    "mcp-proxy": {
      "command": "mcp",
      "args": ["serve"]
    }
  }
}
```

### Cursor (HTTP — shared server)

```json
{
  "mcpServers": {
    "team": {
      "url": "http://mcp.internal:8080/mcp"
    }
  }
}
```

### Windsurf

In your Windsurf MCP config:

```json
{
  "mcpServers": {
    "mcp-proxy": {
      "command": "mcp",
      "args": ["serve"]
    }
  }
}
```

### Any MCP client (generic stdio)

Any client that supports stdio transport can use it. The proxy speaks standard JSON-RPC 2.0 over MCP protocol on stdin/stdout.

```bash
# Manual test — list tools
echo '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-11-25","capabilities":{},"clientInfo":{"name":"test","version":"0.1"}}}' | mcp serve 2>/dev/null
```

### Any MCP client (HTTP)

Any client that supports HTTP transport can connect to the HTTP endpoint:

```bash
mcp add --url http://localhost:8080/mcp local-proxy
```

## Persistent tool cache

The proxy caches discovered tools in a local [ChronDB](https://chrondb.avelino.run/) database so that subsequent startups serve tools instantly — no need to wait for backends to connect.

### How it works

On startup, the proxy loads cached tools and serves them immediately. A background task then connects to real backends to refresh the cache. If a backend's configuration changes (detected via SHA-256 hash), its cached entry is invalidated and re-discovered.

First run with no cache: `tools/list` falls back to blocking full discovery (connecting to all backends before responding). However, `tools/call` uses per-server lazy discovery — it infers the target backend from the namespaced tool name and discovers only that server, so it avoids triggering full discovery of unrelated backends, though it can still be delayed if another discovery is already in progress.

### Cache invalidation

The cache is invalidated per-backend when:

* The backend's config in `servers.json` changes (command, args, url, env, etc.)
* The backend is removed from config (cache entry is ignored)

Cache location: `~/.config/mcp/db/` (shared database with audit logs, separated by key prefix).

## Lazy initialization and idle shutdown

The proxy does **not** keep all backends running permanently. It uses a lazy initialization strategy combined with adaptive idle shutdown to minimize resource usage.

### How it works

```mermaid
stateDiagram-v2
    [*] --> Disconnected: proxy starts (tools loaded from cache)
    Disconnected --> Connected: tools/call targeting this backend<br/>or background refresh
    Connected --> Disconnected: idle timeout exceeded
    Disconnected --> Connected: tools/call targeting this backend
```

1. **Startup** — No backends are connected. Cached tools are loaded from the local database and served immediately.
2. **Background refresh** — The proxy connects to all backends in the background, refreshes tool lists, and updates the cache. Clients are not blocked.
3. **Idle shutdown** — A background task checks every 30 seconds for idle backends. If a backend exceeds its idle timeout, it is shut down. Its tools remain visible in `tools/list`.
4. **On-demand reconnect** — When `tools/call` targets a disconnected backend, the proxy reconnects it transparently, refreshes the tool cache, and forwards the request.

Usage statistics (request count, frequency) are preserved across reconnections, so the adaptive timeout algorithm maintains continuity.

### Adaptive timeout tiers

The default idle timeout is `adaptive`. The proxy classifies each backend by its usage frequency:

| Tier     | Requests/hour | Idle timeout |
| -------- | ------------- | ------------ |
| **Hot**  | > 20          | 5 min        |
| **Warm** | 5–20          | 3 min        |
| **Cold** | < 5           | 1 min        |

Backends with fewer than 2 requests use the minimum timeout (default: 1 min).

### Configuring idle timeout

Per-backend in `servers.json`:

```json
{
  "mcpServers": {
    "slack": {
      "command": "npx",
      "args": ["@anthropic/mcp-slack"],
      "idle_timeout": "adaptive"
    },
    "sentry": {
      "url": "https://mcp.sentry.io",
      "idle_timeout": "never"
    },
    "github": {
      "command": "npx",
      "args": ["@modelcontextprotocol/server-github"],
      "idle_timeout": "2m",
      "min_idle_timeout": "30s",
      "max_idle_timeout": "5m"
    }
  }
}
```

| Value                  | Behavior                                           |
| ---------------------- | -------------------------------------------------- |
| `"adaptive"` (default) | Usage-based timeout with automatic tier assignment |
| `"never"`              | Keep alive forever (old behavior)                  |
| `"<duration>"`         | Fixed timeout (e.g. `"2m"`, `"30s"`, `"1h"`)       |

See the [config file reference](/reference/config-file#idle-timeout) for full details.

### Why this matters

With 10 MCP servers configured and 3 Claude Code sessions open:

* **Before:** 30 backend processes running permanently (\~3-4 GB RAM)
* **After:** Only the backends you're actively using stay alive. Idle ones are shut down within 1-5 minutes and reconnected on demand.

## Error handling

* **Backend fails to connect** — logged to stderr, skipped. Other backends still work.
* **Backend disconnected (idle shutdown)** — `tools/call` reconnects the backend transparently. If reconnection fails, returns an MCP error with context.
* **Backend disconnects mid-session** — `tools/call` returns an MCP error with context about which backend failed.
* **Unknown tool** — returns a JSON-RPC error with the unknown tool name.
* **Malformed JSON-RPC** — HTTP mode returns a parse error with details. Stdio mode silently ignores.

The proxy never crashes because one backend is down. It degrades gracefully.

## Authentication

The proxy supports server-side authentication for HTTP mode. Authentication is configured via `serverAuth` in `servers.json`.

### No auth (default)

By default, no authentication is required. This is suitable for local development and stdio mode.

> **Schema.** `serverAuth.providers` is a `Vec<String>`. List one or many — the proxy runs them as a chain, accepting the first identity that validates. Empty list (or omitted) = anonymous (`NoAuth`). The legacy `provider: "..."` single-string field is no longer accepted; configs that still carry it boot as `NoAuth`.

### Bearer token auth

Static token-to-user mapping. Each token maps to a subject identity:

```json
{
  "mcpServers": { ... },
  "serverAuth": {
    "providers": ["bearer"],
    "bearer": {
      "tokens": {
        "secret-token-abc": "alice",
        "secret-token-def": "bob"
      }
    }
  }
}
```

Clients pass the token in the `Authorization` header:

```bash
curl -s http://localhost:8080/mcp \
  -H "Authorization: Bearer secret-token-abc" \
  -H "Content-Type: application/json" \
  -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'
```

### Forwarded user auth

Trusts a reverse proxy header (e.g. `X-Forwarded-User`). Only use behind a trusted proxy that sets this header:

```json
{
  "mcpServers": { ... },
  "serverAuth": {
    "providers": ["forwarded"],
    "forwarded": {
      "header": "x-forwarded-user"
    }
  }
}
```

### OAuth Authorization Server

Lets Claude.ai, ChatGPT, Cursor and other AI clients connect as Custom Connectors via OAuth 2.0 + Dynamic Client Registration. Combine with `bearer` to keep static tokens working for local dev on the same instance:

```json
{
  "serverAuth": {
    "providers": ["bearer", "oauth_as"],
    "bearer": { "tokens": { "tok-local-dev": { "subject": "avelino", "roles": ["admin"] } } },
    "oauthAs": {
      "issuerUrl": "https://mcp.example.com",
      "jwtSecret": "${MCP_OAUTH_AS_JWT_SECRET}",
      "trustedSourceCidrs": ["10.0.0.0/8"],
      "redirectUriAllowlist": ["https://claude.ai/api/mcp/auth_callback"],
      "injectedRoles": ["oauth-user"]
    }
  }
}
```

Full setup, security notes, and troubleshooting in the [OAuth AS how-to](/how-to/oauth-as).

### Access control (ACL)

The ACL supports two schemas: a **role-based schema** (recommended) and a **legacy schema** (for backward compatibility). Detection is automatic based on the JSON keys present.

#### Role-based schema (recommended)

Define reusable roles with server-aware, read/write-aware grants:

```json
{
  "mcpServers": { ... },
  "serverAuth": {
    "providers": ["bearer"],
    "bearer": {
      "tokens": {
        "tok-alice": { "subject": "alice", "roles": ["admin"] },
        "tok-bob": { "subject": "bob", "roles": ["dev"] },
        "tok-charlie": { "subject": "charlie", "roles": ["readonly"] }
      }
    },
    "acl": {
      "default": "deny",
      "strictClassification": false,
      "roles": {
        "admin":    [{ "server": "*", "access": "*" }],
        "dev": [
          { "server": ["github", "grafana"], "access": "read" },
          { "server": "github", "access": "write", "tools": ["gh_pr", "gh_issue"] }
        ],
        "readonly": [{ "server": "*", "access": "read" }]
      },
      "subjects": {
        "charlie": {
          "roles": ["readonly"],
          "extra": [{ "server": "sentry", "access": "read" }]
        }
      }
    }
  }
}
```

**How it works:**

* **`roles`** — Map of role name to a list of grants. Roles are reusable and referenced by subjects.
* **`subjects`** — Map of subject identifier to `{ roles, extra }`. Roles reference entries in the top-level `roles` map. `extra` is a per-subject list of additional grants.
* **Evaluation is union-based** — collect all grants from all roles the identity has (token roles + subject config roles + extra). If any grant with `deny: true` matches, access is denied. Otherwise, if any allow grant matches, access is allowed. No match falls back to `default`.
* **Order doesn't matter** — unlike the legacy schema, rules are not position-sensitive.

**Grant fields:**

| Field       | Type                           | Description                                                                                                                                     |
| ----------- | ------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------- |
| `server`    | string or string\[]            | Server alias(es) to match. `"*"` matches any server.                                                                                            |
| `access`    | `"read"` \| `"write"` \| `"*"` | Access level (see below)                                                                                                                        |
| `tools`     | string\[]                      | Optional tool name globs to narrow the grant. Empty = all tools.                                                                                |
| `resources` | string\[]                      | Optional resource URI globs to narrow the grant. Empty = all resources. Enforced on `resources/list` (filtering) and `resources/read` (gating). |
| `prompts`   | string\[]                      | Optional prompt name globs to narrow the grant. Empty = all prompts. Enforced on `prompts/list` (filtering) and `prompts/get` (gating).         |
| `deny`      | bool                           | If `true`, turns this into an explicit deny that always wins over allows                                                                        |

**Access expansion:**

The `access` field interacts with the tool classifier (read/write/ambiguous):

| `access`  | Read tools | Write tools | Ambiguous tools |
| --------- | ---------- | ----------- | --------------- |
| `"read"`  | allowed    | denied      | denied          |
| `"write"` | denied     | allowed     | allowed         |
| `"*"`     | allowed    | allowed     | allowed         |

When `strictClassification: true`, ambiguous tools are blocked entirely — regardless of access level (including `"*"`). This forces explicit classification overrides in the server config before ambiguous tools can be used.

**Deny always wins:**

```json
{
  "roles": {
    "dev": [
      { "server": "github", "access": "*" },
      { "server": "github", "access": "write", "tools": ["gh_repo_delete"], "deny": true }
    ]
  }
}
```

The `dev` role has full access to github, except `gh_repo_delete` is explicitly denied.

#### Legacy schema

The original flat rules list, still fully supported. Detected when `rules` is present in the ACL config:

```json
{
  "acl": {
    "default": "allow",
    "rules": [
      { "subjects": ["bob"], "tools": ["sentry__*"], "policy": "deny" },
      { "subjects": ["bob"], "tools": ["*admin*"], "policy": "deny" },
      { "roles": ["admin"], "tools": ["*"], "policy": "allow" }
    ]
  }
}
```

Rules are evaluated in order — **first match wins**. If no rule matches, the default policy applies.

Legacy ACL fields:

* `subjects` — list of user subjects to match (supports `*` wildcard)
* `roles` — list of roles to match (supports `*` wildcard)
* `tools` — list of tool name patterns (supports `*` wildcards: prefix `sentry__*`, suffix `*_issues`, contains `*admin*`, multiple `sentry__*_admin__*`, exact match, or `*` for all)
* `policy` — `allow` or `deny`

Both `subjects` and `roles` must match for a rule to apply. Empty `subjects` or `roles` means "match all".

#### Schema detection

| JSON keys present                             | Schema used                 |
| --------------------------------------------- | --------------------------- |
| `roles` (as object) or `subjects` (as object) | Role-based                  |
| `rules` (as array)                            | Legacy                      |
| Both `rules` and `roles`/`subjects`           | Config error (fails loudly) |
| Neither                                       | Legacy with default allow   |

> **Note:** Stdio mode always uses anonymous identity. ACL rules still apply but the subject is always "anonymous".

### ACL enforcement points

The ACL is enforced at every dispatch point in the proxy:

1. **`tools/list`** — The response is **filtered** to only include tools the identity is allowed to call. A tool the identity cannot reach is invisible in the listing.
2. **`tools/call`** — The request is checked against the ACL before it reaches the backend. Denied requests return a JSON-RPC error (`-32603`) with the subject and tool name.
3. **`resources/list`** — Filtered to only include resources matching the identity's grants.
4. **`resources/read`** — Checked against the ACL before forwarding to the backend.
5. **`prompts/list`** — Filtered to only include prompts matching the identity's grants.
6. **`prompts/get`** — Checked against the ACL before forwarding to the backend.

This dual enforcement (listing filter + request gate) applies to all three categories. An unauthorized identity can't discover names via listing and can't bypass the filter by guessing names.

**Legacy schema behavior for resources/prompts:** Listing is always allowed regardless of the `default` policy. Only `resources/read` and `prompts/get` respect `default: deny`. This ensures legacy users migrating from tools-only configs don't lose visibility into what's available.

### Request timeout

Each client request has a hard upper bound of 120 seconds (configurable via `MCP_PROXY_REQUEST_TIMEOUT`). If a backend takes longer, the client gets a JSON-RPC error and the in-flight request is dropped. Other concurrent clients are unaffected.

```bash
MCP_PROXY_REQUEST_TIMEOUT=300 mcp serve --http
```

### Discovery retry with backoff

When a backend fails to connect during discovery, the proxy applies exponential backoff: 30s → 60s → 120s → 240s (capped at 300s). This prevents a flaky backend from repeatedly stealing the discovery lock and blocking healthy backends. After the backoff expires, the proxy retries on the next `tools/call` or discovery cycle. Success clears the backoff. Backoff is checked per-backend — a `tools/call` targeting a healthy server is never delayed by another server's backoff state.

## Security considerations

### Localhost-only by default

HTTP mode binds to `127.0.0.1` by default. This is safe for local development — only processes on the same machine can reach it.

### Non-loopback binding

To expose the proxy on the network, you must explicitly opt in:

```bash
mcp serve --http 0.0.0.0:8080 --insecure
```

The `--insecure` flag acknowledges the risk of plaintext HTTP on a network interface.

### Production deployment

For production, put a reverse proxy in front:

```
Internet → nginx/Caddy (TLS + auth) → mcp serve --http 127.0.0.1:8080
```

This gives you:

* TLS termination
* Authentication (bearer tokens or forwarded user)
* Rate limiting
* Access logging

### Token isolation

Backend tokens (Slack, GitHub, etc.) live in `servers.json` on the proxy server. They are never exposed to clients. Only tool results are forwarded.

## Environment variables

All standard `mcp` env vars apply:

| Variable                    | Default                                  | Effect                                                      |
| --------------------------- | ---------------------------------------- | ----------------------------------------------------------- |
| `MCP_CONFIG_PATH`           | `~/.config/mcp/servers.json`             | Custom config file path                                     |
| `MCP_TIMEOUT`               | `60`                                     | Timeout in seconds for backend connections                  |
| `MCP_PROXY_REQUEST_TIMEOUT` | `120`                                    | Hard upper bound (seconds) per client request in proxy mode |
| `MCP_CLASSIFIER_CACHE`      | `~/.config/mcp/tool-classification.json` | Path to the tool classification cache                       |

See [environment variables reference](/reference/environment-variables) for full details.

## Observability (OpenTelemetry)

`mcp serve` natively emits **traces** and **metrics** via OTLP when `OTEL_EXPORTER_OTLP_ENDPOINT` is set. Default-off; standard OTel env vars only. See [observability guide](/guides/observability) for span attributes, metrics list, Honeycomb / Tempo / collector recipes, and escape hatches for production rollback.

## When to use each mode

| Scenario                          | Mode                               |
| --------------------------------- | ---------------------------------- |
| Single session, quick test        | `mcp serve` (stdio)                |
| Multiple sessions on same machine | `mcp serve --http` + SSE clients   |
| Team sharing one MCP endpoint     | `mcp serve --http` + SSE clients   |
| CI/CD pipeline calling tools      | `mcp serve --http` + curl          |
| Production with auth & TLS        | `mcp serve --http` + reverse proxy |
| Calling one tool from a script    | `mcp <server> <tool>` directly     |

> **If you regularly open multiple Claude Code sessions**, use HTTP mode as a persistent service. Stdio mode spawns a full copy of every backend per session — HTTP mode shares one.


# Audit logging

Every operation that passes through `mcp` is logged — CLI commands, proxy requests, tool calls, registry searches. The audit log gives you full visibility into what happened, when, how long it took, and whether it succeeded.

## How it works

`mcp` writes audit entries to an embedded [ChronDB](https://chrondb.avelino.run/) database stored locally. Logging happens in a background thread via an async channel, so it never blocks your commands.

```
mcp <any command>  -->  AuditLogger (mpsc channel)  -->  ChronDB (background writer)
                                                             |
                                                    ~/.config/mcp/audit/
```

Every entry records:

| Field                       | Description                                                                                        |
| --------------------------- | -------------------------------------------------------------------------------------------------- |
| `timestamp`                 | ISO 8601 timestamp                                                                                 |
| `source`                    | Where it came from: `cli`, `serve:http`, `serve:stdio`                                             |
| `method`                    | What was called: `tools/call`, `tools/list`, `registry/search`, etc.                               |
| `tool_name`                 | Tool name (for `tools/call`)                                                                       |
| `server_name`               | Backend server name                                                                                |
| `identity`                  | Who called it: `local` for CLI, user subject for proxy                                             |
| `duration_ms`               | How long it took                                                                                   |
| `success`                   | Whether it worked                                                                                  |
| `error_message`             | Error details when it failed                                                                       |
| `acl_decision`              | `allow` or `deny` when ACL evaluation is performed                                                 |
| `acl_matched_rule`          | Which rule decided: `dev[1]`, `alice.extra[0]`, `default`, `legacy[3]`, `legacy:default`, `no-acl` |
| `acl_access_kind`           | Effective access evaluated: `read`, `write`, or `*`                                                |
| `classification_kind`       | Tool classification: `read`, `write`, or `ambiguous`                                               |
| `classification_source`     | How it was classified: `override`, `annotation`, `classifier`, or `fallback`                       |
| `classification_confidence` | Classifier confidence (0.00–1.00)                                                                  |

ACL/classification fields are present on entries that perform ACL checks: proxy `tools/call`, `tools/list:filtered`, and CLI `acl/check`. Other entries omit them (the fields are absent, not null).

## What gets logged

Everything:

| Command                     | Method                                                                                                      |
| --------------------------- | ----------------------------------------------------------------------------------------------------------- |
| `mcp --list`                | `servers/list`                                                                                              |
| `mcp search <query>`        | `registry/search`                                                                                           |
| `mcp add <name>`            | `config/add`                                                                                                |
| `mcp remove <name>`         | `config/remove`                                                                                             |
| `mcp update <name>`         | `config/update`                                                                                             |
| `mcp <server> --list`       | `tools/list`                                                                                                |
| `mcp <server> --info`       | `tools/info`                                                                                                |
| `mcp <server> <tool>`       | `tools/call`                                                                                                |
| Proxy: any JSON-RPC request | `initialize`, `tools/list`, `tools/call`, `resources/list`, `resources/read`, `prompts/list`, `prompts/get` |

The only command that doesn't log itself is `mcp logs` (that would be recursive).

## Querying logs

```bash
# Recent entries (default: last 50)
mcp logs

# Last 100 entries
mcp logs --limit 100

# Filter by backend server
mcp logs --server sentry

# Filter by tool name prefix
mcp logs --tool sentry__search

# Filter by JSON-RPC method
mcp logs --method tools/call

# Filter by caller identity (proxy mode)
mcp logs --identity alice

# Only failures
mcp logs --errors

# Time-based filter
mcp logs --since 5m       # last 5 minutes
mcp logs --since 1h       # last hour
mcp logs --since 24h      # last 24 hours
mcp logs --since 7d       # last 7 days

# Combine filters
mcp logs --server sentry --errors --since 24h
```

### Output formats

**Terminal** (interactive) — colored table:

```
Timestamp                         Source       Method           Tool                     Server   Identity  Duration  Status  Detail
2026-03-16T18:30:00+00:00         serve:http   tools/call       sentry__search_issues    sentry   alice     142ms     ok      -
2026-03-16T18:30:02+00:00         cli          registry/search  -                        -        local     630ms     ok      query=filesystem
2026-03-16T18:30:05+00:00         cli          tools/call       search_issues            sentry   local     27ms      error   MCP error -32602: Invalid arguments for tool search_issues:

3 entry(ies)
```

**JSON** (piped or `--json`) — composable with `jq`:

```bash
# Slow calls (>500ms)
mcp logs --json | jq '.[] | select(.duration_ms > 500)'

# Error messages only
mcp logs --errors --json | jq '.[].error_message'

# Count calls per server
mcp logs --json | jq 'group_by(.server_name) | map({server: .[0].server_name, count: length})'

# Denied write requests
mcp logs --json | jq '.[] | select(.acl_decision=="deny" and .acl_access_kind=="write")'

# Low-confidence classifications that were allowed
mcp logs --json | jq '.[] | select(.classification_confidence < 0.5 and .acl_decision=="allow")'

# Which rules are denying requests
mcp logs --json | jq '[.[] | select(.acl_decision=="deny")] | group_by(.acl_matched_rule) | map({rule: .[0].acl_matched_rule, count: length})'
```

## Follow mode

Stream new entries in real-time, like `tail -f`:

```bash
# Follow all entries
mcp logs -f

# Follow only errors
mcp logs -f --errors

# Follow filtered by server
mcp logs -f --server sentry
```

Follow mode uses polling (1s interval) on the ChronDB database, so it works even when `mcp serve` runs in a separate process.

## Configuration

Add an `audit` section to `~/.config/mcp/servers.json`:

```json
{
  "mcpServers": { ... },
  "audit": {
    "enabled": true,
    "log_arguments": false
  }
}
```

| Field           | Default                                                                                     | Description                                                                                                                                                                                                                                                                                                                                                           |
| --------------- | ------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `enabled`       | `true`                                                                                      | Enable/disable audit logging                                                                                                                                                                                                                                                                                                                                          |
| `output`        | unset (→ `file` for CLI, `file+stdout` for `serve --http`, `file+stderr` for `serve` stdio) | Output destination: `file` (ChronDB, queryable), `stdout`, `stderr` (JSON lines), `file+stdout`, `file+stderr` (ChronDB **and** JSON lines), or `none`. Internally `Option<AuditOutput>` — leaving it unset means "use the per-context default" (CLI safety vs serve visibility). Any explicit value (including `"file"`) bypasses the auto-promotion in `mcp serve`. |
| `log_arguments` | `false`                                                                                     | Log tool call arguments (may contain PII)                                                                                                                                                                                                                                                                                                                             |
| `path`          | `~/.config/mcp/audit/data`                                                                  | ChronDB data directory                                                                                                                                                                                                                                                                                                                                                |
| `index_path`    | `~/.config/mcp/audit/index`                                                                 | ChronDB index directory                                                                                                                                                                                                                                                                                                                                               |

### Logging arguments

By default, tool call arguments are **not** logged to avoid capturing sensitive data (API keys, personal info, query contents). Enable `log_arguments` only if you need it:

```json
{
  "audit": {
    "log_arguments": true
  }
}
```

With this enabled, `mcp logs --json` will include the full arguments:

```json
{
  "method": "tools/call",
  "tool_name": "search_issues",
  "arguments": {"query": "is:unresolved", "organizationSlug": "my-org"},
  ...
}
```

## Storage

Audit data lives in `~/.config/mcp/audit/` by default:

```
~/.config/mcp/audit/
  data/     # ChronDB git-based document store
  index/    # Lucene search index
```

Each entry is stored as a JSON document with key `audit:{timestamp_millis}-{uuid}`, which gives natural chronological ordering via prefix listing.

## Disabling audit logging

Via config file:

```json
{
  "audit": {
    "enabled": false
  }
}
```

Via environment variable (takes priority over config file):

```bash
MCP_AUDIT_ENABLED=false mcp serve --http 0.0.0.0:8080
```

When disabled, the logger is a no-op and the database is not initialized — zero overhead, no files created, no filesystem writes. This is the default in the Docker image.

## Output destinations

The `output` field controls where audit entries go.

The configuration distinguishes **explicit values from absent ones**. Internally `output` is `Option<AuditOutput>`: missing from the config and unset in `MCP_AUDIT_OUTPUT` means `None` (default); a present value means `Some(...)` (explicit). The distinction is what lets `mcp serve` auto-promote the default without ever overwriting a deliberate operator choice.

**CLI subcommands** (`mcp roam ...`, `mcp gh ...`, etc.) resolve `None` to `file` — entries are persisted to ChronDB and stdout stays clean (so pipelines like `mcp ... | jq` aren't corrupted by audit JSON interleaved with command output).

**`mcp serve`** resolves `None` to a dual-sink mode:

* **HTTP transport** (`mcp serve --http ...`): `None` → `file+stdout`. Audit is mirrored on stdout so it's visible in `docker logs`/`kubectl logs` without an extra command, while still persisting to ChronDB for `mcp logs` queries.
* **Stdio transport** (`mcp serve`): `None` → `file+stderr`. Stdout in stdio mode is the JSON-RPC channel, so the mirror goes to stderr instead.

Any explicit value in the config file or `MCP_AUDIT_OUTPUT` env var (**including `"file"`**) **bypasses** the auto-promotion. To force chrondb-only output in `mcp serve`, set `"output": "file"` explicitly — it survives the resolution untouched.

Each stdout/stderr line is a complete `AuditEntry` JSON object:

```json
{"timestamp":"2026-04-14T12:00:00-03:00","source":"serve:http","method":"tools/call","tool_name":"search_issues","server_name":"sentry","identity":"alice","duration_ms":142,"success":true}
```

The mirror is emitted **before** the ChronDB write, so entries stay visible even if the persistence layer fails.

### Choosing a mode

| `output`                                                                                  | ChronDB | stdout | stderr | `mcp logs`             |
| ----------------------------------------------------------------------------------------- | ------- | ------ | ------ | ---------------------- |
| *unset* → `file` (CLI default) / `file+stdout` (serve http) / `file+stderr` (serve stdio) | varies  | varies | varies | when ChronDB is active |
| `file` (explicit)                                                                         | ✅       | —      | —      | ✅                      |
| `file+stdout`                                                                             | ✅       | ✅      | —      | ✅                      |
| `file+stderr`                                                                             | ✅       | —      | ✅      | ✅                      |
| `stdout`                                                                                  | —       | ✅      | —      | —                      |
| `stderr`                                                                                  | —       | —      | ✅      | —                      |
| `none`                                                                                    | —       | —      | —      | —                      |

Pick `file` explicitly if you want chrondb-only **even in `mcp serve`** — explicit values skip the auto-promotion. Pick `stdout`/`stderr` only when you can't persist (read-only filesystem, ephemeral containers without a volume). Pick `file+stderr` if your transport is `stdio` or you want to keep stdout reserved for application output.

```bash
MCP_AUDIT_OUTPUT=file mcp serve --http 0.0.0.0:8080
```

```json
{ "audit": { "output": "file" } }
```

When using `stdout` or `stderr` alone (without `file`), `mcp logs` queries are not available — there's no database to query. Use your log aggregation pipeline instead.

> **stdio transport caveat**: in `mcp serve` without `--http` (stdio mode), stdout is the JSON-RPC channel. The default auto-promotion picks `file+stderr`. If the operator explicitly picks `stdout` or `file+stdout`, it's rewritten to the stderr variant with a warning so the JSON-RPC channel stays clean.

Set `output` to `none` to disable audit entirely without touching the `enabled` flag.

## Environment variable overrides

All audit settings can be overridden via environment variables, which take priority over the config file. This is useful for container deployments where editing the config JSON is impractical.

| Variable               | Overrides          | Description                                                         |
| ---------------------- | ------------------ | ------------------------------------------------------------------- |
| `MCP_AUDIT_ENABLED`    | `audit.enabled`    | Set to `false` or `0` to disable                                    |
| `MCP_AUDIT_OUTPUT`     | `audit.output`     | `file`, `stdout`, `stderr`, `file+stdout`, `file+stderr`, or `none` |
| `MCP_AUDIT_PATH`       | `audit.path`       | ChronDB data directory                                              |
| `MCP_AUDIT_INDEX_PATH` | `audit.index_path` | ChronDB index directory                                             |

Example: redirect audit to a mounted volume in Docker:

```bash
docker run -d \
  -e MCP_AUDIT_ENABLED=true \
  -e MCP_AUDIT_PATH=/data/audit/data \
  -e MCP_AUDIT_INDEX_PATH=/data/audit/index \
  -v audit-vol:/data/audit \
  ghcr.io/avelino/mcp serve --http 0.0.0.0:8080
```

Example: stream audit to container stdout (no volume needed):

```bash
docker run -d \
  -e MCP_AUDIT_OUTPUT=stdout \
  ghcr.io/avelino/mcp serve --http 0.0.0.0:8080
```

See the full list of variables in the [environment variables reference](/reference/environment-variables).


# Observability

`mcp serve` (proxy mode) emits native OTLP **traces** and **metrics**. Default-off: without an env var, behavior is byte-identical to 0.5.2 — structured logs on stderr + local audit trail in chrondb.

> **Want to try it now?** Jump to the [hands-on quickstart](/how-to/observability-quickstart) — it spins up Jaeger + `mcp serve` in 3 minutes and proves the full path end-to-end (including `traceparent` propagation and metrics).
>
> This page is the **reference** — what each attribute means, how to configure each vendor, and the escape hatches for when something goes sideways.

## Escape hatches (read this first)

Running `mcp serve` in production and nervous about turning OTel on? Two switches worth knowing:

* **Unset `OTEL_EXPORTER_OTLP_ENDPOINT`** → telemetry is **fully** disabled. Behavior is identical to 0.5.2. No rebuild, no rollback, just remove the env var from the deploy.
* **`MCP_OTEL_INJECT_TRACEPARENT=0`** → keeps traces and metrics on, **only** disables `traceparent` injection on outbound calls. Use this if some odd backend rejects unknown headers (W3C `traceparent` is standard, but bad servers exist).

## Configuration — standard OTel env vars

Everything is driven by OTel env vars. **Nothing** lives in `servers.json`.

| Variable                      | Effect                                         |
| ----------------------------- | ---------------------------------------------- |
| `OTEL_EXPORTER_OTLP_ENDPOINT` | **Sole activator.** Empty or unset = OTel off. |
| `OTEL_EXPORTER_OTLP_PROTOCOL` | `grpc` (default) or `http/protobuf`.           |
| `OTEL_EXPORTER_OTLP_HEADERS`  | CSV `k1=v1,k2=v2`. HTTP only per spec.         |
| `OTEL_SERVICE_NAME`           | Resource `service.name`. Default `mcp`.        |
| `OTEL_RESOURCE_ATTRIBUTES`    | Extra resource attributes, CSV format.         |
| `MCP_OTEL_INJECT_TRACEPARENT` | `0`/`false`/`no` disables outbound injection.  |

> **Quick diagnostic**: when OTel boots, the very first line on stderr is `[telemetry] OpenTelemetry initialized — endpoint=... protocol=...`. It prints directly to stderr and **does not** respect `MCP_LOG_LEVEL`. If you don't see it, OTel didn't start — check the env var.

## Vendor recipes

### Honeycomb

```bash
OTEL_EXPORTER_OTLP_ENDPOINT=https://api.honeycomb.io \
OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf \
OTEL_EXPORTER_OTLP_HEADERS="x-honeycomb-team=YOUR_API_KEY" \
OTEL_SERVICE_NAME=mcp-prod \
OTEL_RESOURCE_ATTRIBUTES="deployment.environment=production" \
mcp serve --http 0.0.0.0:7331 --insecure
```

The public Honeycomb ingest **only accepts HTTP/protobuf** — gRPC won't work. Don't forget the `x-honeycomb-team` header.

### Grafana Tempo (self-hosted)

```bash
OTEL_EXPORTER_OTLP_ENDPOINT=http://tempo:4317 \
OTEL_EXPORTER_OTLP_PROTOCOL=grpc \
OTEL_SERVICE_NAME=mcp \
mcp serve --http 0.0.0.0:7331 --insecure
```

`grpc` is the OTel SDK default — you can omit `OTEL_EXPORTER_OTLP_PROTOCOL` if you want.

### Datadog (via Agent)

```bash
OTEL_EXPORTER_OTLP_ENDPOINT=http://datadog-agent:4318 \
OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf \
OTEL_SERVICE_NAME=mcp \
OTEL_RESOURCE_ATTRIBUTES="env=prod,team=platform" \
mcp serve --http 0.0.0.0:7331 --insecure
```

The Datadog Agent exposes an OTLP receiver on 4317/4318 once enabled.

### Local (development)

To bring up Jaeger + see the whole flow on your machine, follow the [hands-on quickstart](/how-to/observability-quickstart).

## Span attributes

Every request produces a single root span `mcp.request` with:

| Attribute       | Meaning                                                                                  |
| --------------- | ---------------------------------------------------------------------------------------- |
| `otel.kind`     | `server`                                                                                 |
| `mcp.method`    | JSON-RPC method (`tools/call`, `tools/list`, `resources/read`, …)                        |
| `mcp.transport` | `serve:http` or `serve:stdio`                                                            |
| `mcp.identity`  | Authenticated subject (JWT `sub`, bearer-token name, or `anonymous`)                     |
| `mcp.server`    | Backend alias, **only** after routing resolves (tools/call, resources/read, prompts/get) |
| `mcp.tool`      | Backend tool name                                                                        |
| `mcp.status`    | `ok` or `error`                                                                          |

Span duration = total time inside `dispatch_request`. Includes ACL evaluation, the brief proxy lock, the backend connection, and the backend call itself — useful to see where latency is going.

Inbound `traceparent` (from the client) is honored: the `mcp` span becomes a child of the client's span, no new trace is created. Outbound, `mcp` injects `traceparent`/`tracestate` automatically — an instrumented backend continues the trace.

## Metrics

| Metric                              | Type      | Unit | Attributes                                                                              |
| ----------------------------------- | --------- | ---- | --------------------------------------------------------------------------------------- |
| `mcp.proxy.requests`                | counter   | —    | `mcp.method`, `mcp.transport`, `mcp.status`, `mcp.identity`, `mcp.server`*, `mcp.tool`* |
| `mcp.proxy.request.duration`        | histogram | ms   | same as the counter                                                                     |
| `mcp.proxy.classifier.cache.hits`   | counter   | —    | `mcp.server`                                                                            |
| `mcp.proxy.classifier.cache.misses` | counter   | —    | `mcp.server`                                                                            |
| `mcp.proxy.backends.connected`      | gauge     | —    | —                                                                                       |
| `mcp.proxy.sessions.active`         | gauge     | —    | —                                                                                       |

\* `mcp.server` and `mcp.tool` only appear when the request resolves to a backend (absent on `auth/failure`, unknown methods, malformed payload). The `mcp.tool` label carries the backend tool name (un-namespaced) — the namespace lives in `mcp.server`.

The PeriodicReader exports every 60s (OTel SDK default). When testing locally, wait \~65s after the first request before checking the exporter.

## Cardinality — heads-up

`mcp.identity` discriminates per authenticated subject. If your subjects are per-user UUID JWTs, you'll generate many series. Honeycomb / Tempo / Datadog handle it, but it's a deliberate trade-off — if you only care about role-level breakdowns, drop the label upstream (in your collector config).

## What it does NOT do

* **No Sentry / panic tracking.** Errors flow as `mcp.status=error` on the span. Sentry is tracked in a separate issue.
* **No continuous profiling.**
* **No OTLP logs signal.** The audit trail stays in chrondb (`mcp logs`); the structured log keeps going to stderr, controlled by `MCP_LOG_LEVEL` / `MCP_LOG_FORMAT`.

## Troubleshooting

If something went wrong, [the quickstart](/how-to/observability-quickstart#troubleshooting) has a longer checklist. In short:

* **No `[telemetry] OpenTelemetry initialized` on stderr** = the env var wasn't read. Check `OTEL_EXPORTER_OTLP_ENDPOINT`.
* **It initialized but nothing arrives** = wrong endpoint, firewall, or a vendor that only accepts HTTP (`OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf`).
* **`traceparent` not reaching the backend** = either telemetry didn't initialize, or `MCP_OTEL_INJECT_TRACEPARENT=0` is set.


# Enterprise token management

## The problem

A company with 50 engineers using AI tools (Claude Code, Cursor, Windsurf) needs access to Sentry, Slack, GitHub, Grafana, and a few internal services. Each service requires an API token. Each engineer needs their own tokens.

The math gets ugly fast:

| Engineers | Services | Tokens to manage |
| --------- | -------- | ---------------- |
| 10        | 5        | 50               |
| 50        | 8        | 400              |
| 200       | 12       | 2,400            |

Every token is a secret. Every secret is a liability. Every engineer's laptop is an attack surface.

Now multiply by the number of AI tools each engineer uses. Claude Code needs a `servers.json`. Cursor needs another. Windsurf needs a third. Same tokens, copied across machines, across config files, across tools.

**What actually goes wrong:**

* **Onboarding takes hours** — new engineer joins, needs tokens for 8 services. Opens 8 dashboards, generates 8 tokens, pastes them into 3 config files. One typo somewhere. Debug time.
* **Offboarding is incomplete** — engineer leaves, someone revokes their GitHub token but forgets the Sentry one. The Grafana token lives on a laptop image that gets recycled.
* **Token rotation is a myth** — security policy says rotate every 90 days. Nobody does it because it means updating tokens across every developer machine, every config file, every tool.
* **Audit is impossible** — "who has access to what?" requires checking every engineer's local config. There's no central log. No visibility.
* **Scope creep** — engineers generate broad-access tokens because it's easier than figuring out the minimum permissions. One leaked token exposes everything.

## The fix: one proxy, zero tokens on developer machines

Instead of distributing tokens to every engineer, run one MCP proxy on internal infrastructure. The proxy holds the service tokens. Engineers connect to the proxy.

**Before** — every dev holds tokens for every service:

```mermaid
graph LR
    D1[Dev 1] -->|token| Sentry
    D1 -->|token| Slack
    D1 -->|token| GitHub
    D2[Dev 2] -->|token| Sentry
    D2 -->|token| Slack
    D2 -->|token| GitHub
    D3[Dev 3] -->|token| Sentry
    D3 -->|token| Slack
    D3 -->|token| GitHub

    style Sentry fill:#f96
    style Slack fill:#f96
    style GitHub fill:#f96
```

> 50 devs × 8 services = **400 tokens** scattered across laptops.

**After** — one proxy holds the tokens, devs connect once:

```mermaid
graph LR
    D1[Dev 1] -->|auth| Proxy
    D2[Dev 2] -->|auth| Proxy
    D3[Dev 3] -->|auth| Proxy

    Proxy["mcp serve<br/>(proxy)"] -->|token| Sentry
    Proxy -->|token| Slack
    Proxy -->|token| GitHub

    style Proxy fill:#4a9,color:#fff
    style Sentry fill:#f96
    style Slack fill:#f96
    style GitHub fill:#f96
```

> 50 devs × 1 connection = **50 auth credentials**, centrally managed.

Service tokens live in one place. If you need to rotate the Sentry token, you update it once on the proxy. Zero touch on developer machines.

## How to set it up

### 1. Deploy the proxy

On a shared server (VM, container, Kubernetes pod):

```bash
mcp serve --http 0.0.0.0:8080 --insecure
```

> In production, put a reverse proxy (nginx, Caddy) in front for TLS. See the [proxy mode guide](/guides/proxy-mode#production-deployment) for details.

### 2. Configure backend tokens on the proxy

The proxy's `servers.json` holds all service tokens:

```json
{
  "mcpServers": {
    "sentry": {
      "url": "https://mcp.sentry.dev/sse",
      "headers": { "Authorization": "Bearer ${SENTRY_TOKEN}" }
    },
    "slack": {
      "command": "npx",
      "args": ["-y", "@anthropic-ai/mcp-server-slack"],
      "env": { "SLACK_BOT_TOKEN": "${SLACK_TOKEN}" }
    },
    "github": {
      "command": "npx",
      "args": ["-y", "@modelcontextprotocol/server-github"],
      "env": { "GITHUB_PERSONAL_ACCESS_TOKEN": "${GITHUB_TOKEN}" }
    }
  }
}
```

All secrets stay on the server. Engineers never see them.

### 3. Add per-user authentication

Use bearer tokens to identify each engineer and control access:

```json
{
  "mcpServers": { ... },
  "serverAuth": {
    "providers": ["bearer"],
    "bearer": {
      "tokens": {
        "eng-alice-a1b2c3": "alice",
        "eng-bob-d4e5f6": "bob",
        "eng-carol-g7h8i9": "carol"
      }
    },
    "acl": {
      "default": "allow",
      "rules": [
        { "subjects": ["carol"], "tools": ["sentry__*"], "policy": "deny" }
      ]
    }
  }
}
```

Or, if you already have an identity provider behind a reverse proxy:

```json
{
  "serverAuth": {
    "providers": ["forwarded"],
    "forwarded": { "header": "x-forwarded-user" }
  }
}
```

Or, if engineers also want to connect via Claude.ai / ChatGPT / Cursor as Custom Connectors, run `bearer` and `oauth_as` together — same instance, same `/mcp`:

```json
{
  "serverAuth": {
    "providers": ["bearer", "oauth_as"],
    "bearer": { "tokens": { "eng-alice-a1b2c3": { "subject": "alice", "roles": ["dev"] } } },
    "oauthAs": {
      "issuerUrl": "https://mcp.internal:8443",
      "jwtSecret": "${MCP_OAUTH_AS_JWT_SECRET}",
      "trustedSourceCidrs": ["10.0.0.0/8"],
      "redirectUriAllowlist": ["https://claude.ai/api/mcp/auth_callback"],
      "injectedRoles": ["ai-client"]
    }
  }
}
```

See the [proxy mode authentication docs](/guides/proxy-mode#authentication) for all provider options.

### 4. Engineers connect — one line

Each engineer adds one entry to their local config:

```bash
mcp add --url https://mcp.internal:8443/mcp team
```

That's it. Every AI tool on their machine connects through this one endpoint. No service tokens on their laptop. No per-service config.

```json
{
  "mcpServers": {
    "team": {
      "url": "https://mcp.internal:8443/mcp",
      "headers": { "Authorization": "Bearer eng-alice-a1b2c3" }
    }
  }
}
```

## What you gain

**Onboarding in minutes** — new engineer gets one proxy token. Immediately has access to all approved tools. No 8-service token generation dance.

**Offboarding in seconds** — remove the engineer's token from the proxy config. Access to everything is revoked instantly. No forgotten tokens on decommissioned laptops.

**Token rotation without pain** — rotate a service token on the proxy, no engineer even notices. Zero coordination, zero downtime.

**Audit in one place** — proxy logs show who called which tool, when. One log stream, one place to look.

**Least privilege by default** — ACL rules control which engineers can use which tools. Carol from marketing can use Slack tools but not Sentry admin tools.

**Tool-agnostic** — engineers can use Claude Code, Cursor, Windsurf, or any MCP-compatible client. All connect to the same proxy. Add or remove AI tools without touching service credentials.

## Architecture

```mermaid
graph TB
    subgraph Developers["Developer machines (no service tokens)"]
        D1["Dev 1<br/>Claude Code"]
        D2["Dev 2<br/>Cursor"]
        D3["Dev 3<br/>Windsurf"]
    end

    subgraph Infra["Internal infrastructure"]
        LB["nginx / Caddy<br/>(TLS termination)"]
        Proxy["mcp serve --http<br/>(proxy + auth + ACL)"]

        subgraph Backends["Backend services (tokens stored here)"]
            Sentry
            Slack
            GitHub
            Grafana
        end
    end

    D1 -->|"Bearer token<br/>(HTTPS)"| LB
    D2 -->|"Bearer token<br/>(HTTPS)"| LB
    D3 -->|"Bearer token<br/>(HTTPS)"| LB
    LB --> Proxy
    Proxy --> Sentry
    Proxy --> Slack
    Proxy --> GitHub
    Proxy --> Grafana

    style Proxy fill:#4a9,color:#fff
    style LB fill:#69b,color:#fff
    style Sentry fill:#f96
    style Slack fill:#f96
    style GitHub fill:#f96
    style Grafana fill:#f96
```

## Further reading

* [Proxy mode](/guides/proxy-mode) — full technical guide on stdio/HTTP modes, endpoints, and configuration
* [Proxy mode authentication](/guides/proxy-mode#authentication) — bearer tokens, forwarded user, and ACL rules
* [Config file reference](/reference/config-file#server-authentication-serverauth) — serverAuth schema


# Docker

The `mcp` CLI is available as a multi-arch Docker image (amd64/arm64) on GitHub Container Registry.

## Pull the image

```bash
docker pull ghcr.io/avelino/mcp
```

## Available tags

| Tag      | Description                   |
| -------- | ----------------------------- |
| `latest` | Latest stable release         |
| `x.y.z`  | Pinned version (e.g. `0.1.0`) |
| `beta`   | Latest build from main branch |

## Basic usage

The CLI runs as the container entrypoint. Pass arguments directly:

```bash
docker run --rm ghcr.io/avelino/mcp --help
docker run --rm ghcr.io/avelino/mcp search github
```

## Using your config

There are two ways to provide configuration: **file mount** (traditional) or **inline JSON** (container-friendly).

### Option A: Inline config (recommended for containers)

Pass the entire config as an environment variable — no file mounts needed:

```bash
docker run --rm \
  -e MCP_SERVERS_CONFIG='{
    "mcpServers": {
      "sentry": {
        "url": "https://mcp.sentry.dev/sse",
        "headers": {"Authorization": "Bearer ${SENTRY_TOKEN}"}
      }
    }
  }' \
  -e SENTRY_TOKEN \
  ghcr.io/avelino/mcp sentry search_issues '{"query": "is:unresolved"}'
```

You can also read the JSON from an existing file with `$(cat ...)`:

```bash
docker run --rm \
  -e MCP_SERVERS_CONFIG="$(cat servers.json)" \
  -e SENTRY_TOKEN \
  ghcr.io/avelino/mcp sentry search_issues '{"query": "is:unresolved"}'
```

This is ideal for Docker Compose, Kubernetes, and CI/CD — the config lives in the orchestrator, not the filesystem.

### Option B: File mount

Mount your local config directory so the container can access your server definitions:

```bash
docker run --rm \
  -v ~/.config/mcp:/root/.config/mcp \
  ghcr.io/avelino/mcp --list
```

## Passing environment variables

Servers that need API tokens or other secrets require environment variables. Pass them with `-e`:

```bash
docker run --rm \
  -e MCP_SERVERS_CONFIG='{"mcpServers":{"github":{"url":"https://api.github.com/mcp","headers":{"Authorization":"Bearer ${GITHUB_TOKEN}"}}}}' \
  -e GITHUB_TOKEN \
  ghcr.io/avelino/mcp github list_repositories '{"query": "mcp"}'
```

You can also use an env file:

```bash
# .env
GITHUB_TOKEN=ghp_xxxx
SLACK_TOKEN=xoxb-xxxx
MCP_SERVERS_CONFIG={"mcpServers":{"github":{"url":"https://api.github.com/mcp","headers":{"Authorization":"Bearer ${GITHUB_TOKEN}"}}}}
```

```bash
docker run --rm \
  --env-file .env \
  ghcr.io/avelino/mcp github list_repositories '{"query": "mcp"}'
```

## Proxy mode (long-running)

Run the MCP proxy as a long-running service:

```bash
docker run -d \
  -e MCP_SERVERS_CONFIG='{
    "mcpServers": {
      "sentry": {"url": "https://mcp.sentry.dev/sse"}
    },
    "serverAuth": {
      "providers": ["bearer"],
      "bearer": {
        "tokens": { "my-secret-token": "ops" }
      }
    }
  }' \
  -p 8080:8080 \
  ghcr.io/avelino/mcp serve --http 0.0.0.0:8080 --insecure
```

### With audit logging

The default image disables audit logging (`MCP_AUDIT_ENABLED=false`) because `scratch` images have no writable filesystem. You have two options:

**Option A: Stream to stdout (no volume needed)**

```bash
docker run -d \
  -e MCP_SERVERS_CONFIG='{"mcpServers":{...}}' \
  -e MCP_AUDIT_OUTPUT=stdout \
  -p 8080:8080 \
  ghcr.io/avelino/mcp serve --http 0.0.0.0:8080 --insecure
```

Audit entries are emitted as JSON lines to stdout, captured by your container log driver (CloudWatch, Datadog, etc.).

**Option B: Persist to a volume**

```bash
docker run -d \
  -e MCP_SERVERS_CONFIG='{"mcpServers":{...}}' \
  -e MCP_AUDIT_ENABLED=true \
  -e MCP_AUDIT_PATH=/data/audit/data \
  -e MCP_AUDIT_INDEX_PATH=/data/audit/index \
  -v audit-data:/data/audit \
  -p 8080:8080 \
  ghcr.io/avelino/mcp serve --http 0.0.0.0:8080 --insecure
```

### Application logs (stderr, JSON for log drivers)

Tracing logs (startup, backend discovery, request errors) go to **stderr**. Two env vars tune them for production:

```bash
docker run -d \
  -e MCP_SERVERS_CONFIG='{"mcpServers":{...}}' \
  -e MCP_LOG_LEVEL='mcp=debug,hyper=warn,reqwest=warn,h2=warn' \
  -e MCP_LOG_FORMAT=json \
  -p 8080:8080 \
  ghcr.io/avelino/mcp serve --http 0.0.0.0:8080 --insecure
```

* `MCP_LOG_LEVEL` uses [`tracing` EnvFilter](https://docs.rs/tracing-subscriber/latest/tracing_subscriber/filter/struct.EnvFilter.html) syntax — global level (`info`/`debug`) or per-module (`mcp=debug,hyper=warn`). The example silences noisy HTTP-stack libraries while keeping the proxy at `debug`.
* `MCP_LOG_FORMAT=json` emits newline-delimited JSON, one event per line — drop straight into Datadog, CloudWatch, Loki, etc.

Pair with `MCP_AUDIT_OUTPUT=stdout` and you get a single Docker log stream where every line is JSON: app/tracing on stderr, audit on stdout — both captured by `docker logs`.

```bash
# Filter app errors:
docker logs mcp-proxy 2>&1 | jq -c 'select(.level=="ERROR")'
```

## Container environment variables

These variables are especially useful for container deployments. See the full list in the [environment variables reference](/reference/environment-variables).

| Variable               | Default                                       | Purpose                                                                                                                                                                                                 |
| ---------------------- | --------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `MCP_SERVERS_CONFIG`   | —                                             | Inline JSON config, no file mount needed                                                                                                                                                                |
| `MCP_CONFIG_DIR`       | `~/.config/mcp`                               | Override config directory                                                                                                                                                                               |
| `MCP_LOG_LEVEL`        | `info`                                        | Log verbosity: `trace`, `debug`, `info`, `warn`, `error`                                                                                                                                                |
| `MCP_LOG_FORMAT`       | `text`                                        | Log format: `text` or `json` (structured, for log drivers)                                                                                                                                              |
| `MCP_AUDIT_ENABLED`    | `false` (in Docker image)                     | Disable audit for read-only fs                                                                                                                                                                          |
| `MCP_AUDIT_OUTPUT`     | unset (→ `file+stdout` in `mcp serve --http`) | `stdout`/`stderr` for log driver only, `file` for ChronDB only (setting this env var is treated as explicit and skips auto-promotion in serve), `file+stdout`/`file+stderr` for both, `none` to disable |
| `MCP_AUDIT_PATH`       | `~/.config/mcp/db/data`                       | Override audit data path                                                                                                                                                                                |
| `MCP_AUDIT_INDEX_PATH` | `~/.config/mcp/db/index`                      | Override audit index path                                                                                                                                                                               |
| `MCP_AUTH_CONFIG`      | —                                             | Inline `auth.json` content (read-only, writes are no-ops). Same idea as `MCP_SERVERS_CONFIG`.                                                                                                           |
| `MCP_AUTH_PATH`        | `~/.config/mcp/auth.json`                     | Override OAuth token storage (file path)                                                                                                                                                                |
| `MCP_CLASSIFIER_CACHE` | `~/.config/mcp/tool-classification.json`      | Override classifier cache                                                                                                                                                                               |

## Kubernetes

See the dedicated [Kubernetes deployment guide](/how-to/kubernetes) for complete manifests with probes, security context, audit logging, and operational guidance.

Quick start:

```bash
kubectl apply -k deploy/kubernetes/
```

## Shell alias

For day-to-day use, create an alias so `mcp` works like a native command:

```bash
# bash / zsh — add to ~/.bashrc or ~/.zshrc
alias mcp='docker run --rm -v ~/.config/mcp:/root/.config/mcp --env-file ~/.config/mcp/.env ghcr.io/avelino/mcp'

# fish — add to ~/.config/fish/config.fish
alias mcp 'docker run --rm -v ~/.config/mcp:/root/.config/mcp --env-file ~/.config/mcp/.env ghcr.io/avelino/mcp'
```

Then use it normally:

```bash
mcp --list
mcp sentry search_issues '{"query": "is:unresolved"}'
mcp search filesystem
```

## Piping JSON

Pipe input via stdin with `-i` (Docker's interactive flag):

```bash
echo '{"query": "is:unresolved"}' | docker run --rm -i \
  -e MCP_SERVERS_CONFIG='{"mcpServers":{"sentry":{"url":"https://mcp.sentry.dev/sse"}}}' \
  ghcr.io/avelino/mcp sentry search_issues
```

## Pinning a version

For CI/CD or reproducible environments, pin to a specific version:

```bash
docker run --rm ghcr.io/avelino/mcp:0.1.0 --help
```

## Limitations

* **Stdio servers only work if the runtime is available inside the container.** The default image includes only the `mcp` binary and `ca-certificates`. Servers that require `npx`, `python`, or other runtimes won't work unless you build a custom image. HTTP servers (configured with `url`) work out of the box.
* **OAuth browser flow doesn't work in Docker.** For HTTP servers that need OAuth, run `mcp add <server>` on your host first to complete authentication, then either mount the config directory (which includes `auth.json`), set `MCP_AUTH_PATH` to a mounted volume, or pass the JSON inline via `MCP_AUTH_CONFIG` (read-only — useful for read-only containers and Kubernetes Secrets).
* **Audit logging is disabled by default** in the Docker image because `scratch` images have no writable filesystem. Use `MCP_AUDIT_OUTPUT=stdout` to stream to the container log driver, or mount a volume and set `MCP_AUDIT_ENABLED=true`.


# Kubernetes

Reference manifests for running the MCP proxy in a Kubernetes cluster.

## Quick start

```bash
# 1. Edit the ConfigMap with your server configuration
vim deploy/kubernetes/configmap.yaml

# 2. Create the Secret with your API tokens (not included in kustomize)
kubectl create namespace mcp
kubectl -n mcp create secret generic mcp-secrets \
  --from-literal=sentry-token=sntrys_...

# 3. Apply the manifests
kubectl apply -k deploy/kubernetes/
```

This creates:

* `mcp` namespace
* `mcp-proxy` Deployment (1 replica)
* `mcp-proxy` ClusterIP Service on port 8080
* `mcp-config` ConfigMap with your server configuration

The Secret must be created separately (step 2) to avoid committing real tokens to the repo.

## Configuration

### Server config via ConfigMap

Edit `deploy/kubernetes/configmap.yaml` with your MCP servers:

```yaml
data:
  servers.json: |
    {
      "mcpServers": {
        "sentry": {
          "url": "https://mcp.sentry.dev/sse",
          "headers": {
            "Authorization": "Bearer ${SENTRY_TOKEN}"
          }
        },
        "grafana": {
          "url": "https://grafana.internal/api/mcp/sse",
          "headers": {
            "Authorization": "Bearer ${GRAFANA_TOKEN}"
          }
        }
      }
    }
```

The proxy resolves `${VAR}` placeholders from environment variables at startup. This keeps tokens out of the ConfigMap.

### Secrets for tokens

Create the secret with your real tokens:

```bash
kubectl -n mcp create secret generic mcp-secrets \
  --from-literal=sentry-token=sntrys_abc123 \
  --from-literal=grafana-token=glsa_xyz789
```

Then reference each token in the Deployment env:

```yaml
env:
  - name: SENTRY_TOKEN
    valueFrom:
      secretKeyRef:
        name: mcp-secrets
        key: sentry-token
  - name: GRAFANA_TOKEN
    valueFrom:
      secretKeyRef:
        name: mcp-secrets
        key: grafana-token
```

### OAuth tokens via Secret

For backends that use OAuth (Sentry, Honeycomb, GitHub Copilot, etc.), `mcp` keeps issued access/refresh tokens and dynamic-client registrations in `auth.json`. In a pod with a read-only root filesystem, mounting a writable `auth.json` is awkward — instead, inject the contents directly via `MCP_AUTH_CONFIG`.

**1. Run the OAuth flow once on a workstation:**

```bash
mcp add sentry --remote https://mcp.sentry.dev
# completes browser flow, writes ~/.config/mcp/auth.json
```

**2. Push the resulting file into a Secret:**

```bash
kubectl -n mcp create secret generic mcp-auth \
  --from-file=auth.json=$HOME/.config/mcp/auth.json
```

> Use a Secret (not a ConfigMap) — the file contains live access tokens.

**3. Inject it as an env var in the Deployment:**

```yaml
env:
  - name: MCP_AUTH_CONFIG
    valueFrom:
      secretKeyRef:
        name: mcp-auth
        key: auth.json
```

The proxy reads the inline JSON at startup and keeps it in an in-memory store. OAuth refresh and dynamic-client registration update the in-memory copy so refreshed tokens stay coherent across requests within the pod's lifetime. **Nothing is ever written to disk** — one `warn` log is emitted on the first save attempt. On pod restart, the Secret is re-read and in-memory mutations are discarded.

**Refresh strategy.** When refresh tokens are about to expire, rotate the Secret externally (sealed-secrets, external-secrets-operator, a CronJob that re-runs `mcp add`, etc.) and let the rolling update pick it up. The proxy itself is not designed to write back to the Secret.

**Limitation.** Since `MCP_AUTH_CONFIG` is read-only, you cannot run `mcp add <server>` against a running pod and have the registration persist. Always pre-provision the auth store on a workstation or in a one-off Job, then ship it via the Secret.

### Pinning the image version

Edit `deploy/kubernetes/kustomization.yaml`:

```yaml
images:
  - name: ghcr.io/avelino/mcp
    newTag: "0.5.0"  # pin to a specific version
```

## Why `--insecure`?

The proxy refuses to bind non-loopback addresses without `--insecure`. In Kubernetes, the pod needs `0.0.0.0:8080` so the Service can route traffic to it. TLS termination happens at the Ingress or load balancer level, not at the proxy.

## Health probes

The proxy exposes `GET /health` returning:

```json
{
  "status": "ok",
  "backends_configured": 3,
  "backends_connected": 2,
  "active_clients": 5,
  "tools": 42,
  "version": "0.5.0"
}
```

### Why the probes are configured this way

**Startup probe** — gives 30s (`failureThreshold: 6 * periodSeconds: 5`) for the process to start and begin backend discovery. Discovery is async, so the proxy serves immediately but backends connect in the background.

**Liveness probe** — checks every 30s that the process responds to HTTP. Backend failures are **degraded state**, not a reason to restart the pod. If sentry is down, the proxy still serves grafana tools fine.

**Readiness probe** — checks every 10s. The proxy is ready to serve as soon as it starts because it lazy-connects backends on first request. A probe failure here means the process itself is unhealthy.

> **Do not** use `backends_connected > 0` as a readiness condition. The proxy is designed to start with zero connections and connect on demand.

## Application logs

Application logs (`tracing` events from the proxy itself — startup, backend discovery, request errors, OAuth flows) go to **stderr** by default and are captured by the kubelet, so `kubectl logs` just works. Two env vars tune this for production:

```yaml
env:
  # EnvFilter syntax — silence noisy library logs, keep mcp at debug.
  - name: MCP_LOG_LEVEL
    value: "mcp=debug,hyper=warn,reqwest=warn,h2=warn"
  # Newline-delimited JSON, one event per line. Drop in any log driver
  # (Loki, Datadog, CloudWatch, Fluentd) without parsing rules.
  - name: MCP_LOG_FORMAT
    value: "json"
```

`MCP_LOG_LEVEL` follows `tracing`'s [EnvFilter](https://docs.rs/tracing-subscriber/latest/tracing_subscriber/filter/struct.EnvFilter.html) syntax. Set the global level with `info`/`debug`/`trace`, or scope per module with `target=level` separated by commas. The example above keeps the proxy at `debug` while silencing `hyper`/`reqwest`/`h2` chatter — which dominates request volume otherwise.

`MCP_LOG_FORMAT=json` swaps the human-readable formatter for newline-delimited JSON. Each line is a complete event with `timestamp`, `level`, `message`, and structured fields. Pair with the audit stream below and you get a single tail-able log surface — every line is JSON, no mixed formats.

```bash
# Live tail, all events:
kubectl -n mcp logs deploy/mcp-proxy -f

# Only proxy errors via jq:
kubectl -n mcp logs deploy/mcp-proxy -f | jq -c 'select(.level=="ERROR")'
```

> **Why stderr, not stdout, for app logs?** In `mcp serve`, audit logs go to **stdout** by default (auto-promotion of `file` to `file+stdout`) and they're the structured product surface. Application/tracing logs go to **stderr** as the diagnostic surface. Kubernetes captures both in the same `kubectl logs` stream by default — split them downstream with `jq` (audit lines have `method`/`identity`; tracing lines have `level`/`target`).

## Audit logging

By default, audit logging is disabled (`MCP_AUDIT_ENABLED=false`) because the scratch-based image has no writable filesystem.

**Option A: Stream to stdout (no PVC needed)**

Set `MCP_AUDIT_OUTPUT=stdout` in the Deployment env. Audit entries are emitted as JSON lines to stdout and captured by your cluster's log pipeline (Fluentd, Loki, CloudWatch, etc.). No persistent storage required.

> If you want **both** PVC persistence (queryable via `mcp logs` in `kubectl exec`) and the cluster log pipeline, leave `MCP_AUDIT_OUTPUT` unset — `mcp serve --http` auto-promotes the default `file` to `file+stdout` for exactly this case. Setting `MCP_AUDIT_OUTPUT=file+stdout` explicitly also works (and is honored verbatim).

**Option B: Persist to a PVC**

1. Set `MCP_AUDIT_ENABLED=true` in the Deployment env
2. Mount persistent storage at `/data`:

```yaml
# In deployment.yaml, replace the emptyDir volume:
volumes:
  - name: data
    persistentVolumeClaim:
      claimName: mcp-audit-data
```

3. Uncomment `pvc.yaml` in `kustomization.yaml`:

```yaml
resources:
  # ...
  - pvc.yaml
```

4. Apply:

```bash
kubectl apply -k deploy/kubernetes/
```

Audit logs are written to `/data/audit/data` and indexed at `/data/audit/index` (controlled by `MCP_AUDIT_PATH` and `MCP_AUDIT_INDEX_PATH`).

## Security context

The manifests include a hardened security context:

```yaml
securityContext:
  readOnlyRootFilesystem: true
  allowPrivilegeEscalation: false
  capabilities:
    drop: ["ALL"]
```

The image is based on `scratch` — a static binary with no shell, no package manager, no libc. The process runs as UID 0 by default (the Dockerfile doesn't set `USER`), but `scratch` itself does not require root. The attack surface is minimal regardless of UID: no shell to exec into, no tools to exploit, read-only filesystem.

If your cluster policy requires `runAsNonRoot: true`, set a numeric `runAsUser` and ensure mounted volumes (`/tmp`, `/data`) are writable for that UID — either via `fsGroup` or an initContainer:

```yaml
securityContext:
  runAsNonRoot: true
  runAsUser: 65534
  runAsGroup: 65534
  fsGroup: 65534
```

## Scaling

Each replica is fully independent — own backend pool, own tool cache, own connections. There's no shared state, no leader election, no coordination needed.

Scaling to N replicas means:

* N independent connections to each backend
* N copies of the tool/resource/prompt cache in memory
* Clients are load-balanced across replicas by the Service

This is fine for most deployments. Be aware that stdio-based backends (which spawn child processes) will have N copies of each process running across the cluster.

## Graceful shutdown

When Kubernetes sends `SIGTERM` (during rolling updates or scale-down):

1. The proxy stops accepting new connections
2. In-flight requests finish normally
3. Backend clients are shut down in parallel (5s timeout each)
4. Total internal cleanup is bounded to \~10s

`terminationGracePeriodSeconds: 30` in the Deployment gives enough headroom. After 30s, Kubernetes sends `SIGKILL`.

## Environment variables reference

| Variable                    | Manifest value                                | Description                                                                                                                                                                                                                         |
| --------------------------- | --------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `MCP_SERVERS_CONFIG`        | (from ConfigMap)                              | Inline JSON config (highest priority)                                                                                                                                                                                               |
| `MCP_AUTH_CONFIG`           | (from Secret, optional)                       | Inline OAuth tokens (`auth.json`). Read-only — writes are no-ops.                                                                                                                                                                   |
| `MCP_PROXY_REQUEST_TIMEOUT` | `120` (app default)                           | Max seconds per JSON-RPC request                                                                                                                                                                                                    |
| `MCP_LOG_LEVEL`             | `info`                                        | `tracing` `EnvFilter` (e.g. `mcp=debug,hyper=warn,reqwest=warn,h2=warn`)                                                                                                                                                            |
| `MCP_LOG_FORMAT`            | `text`                                        | `json` for newline-delimited JSON to stderr (log drivers)                                                                                                                                                                           |
| `MCP_AUDIT_ENABLED`         | `false`                                       | Enable audit logging                                                                                                                                                                                                                |
| `MCP_AUDIT_OUTPUT`          | unset (→ `file+stdout` in `mcp serve --http`) | `stdout` for cluster log pipeline only, `file` for PVC only (setting this env var is treated as explicit and skips auto-promotion), `file+stdout` for both PVC and pipeline (the auto-promoted default in serve), `none` to disable |
| `MCP_AUDIT_PATH`            | `/data/audit/data`                            | Audit data directory (app default: `~/.config/mcp/db/data`)                                                                                                                                                                         |
| `MCP_AUDIT_INDEX_PATH`      | `/data/audit/index`                           | Audit index directory (app default: `~/.config/mcp/db/index`)                                                                                                                                                                       |
| `MCP_CLASSIFIER_CACHE`      | `/tmp/tool-classification.json`               | Tool classification cache (app default: `~/.config/mcp/tool-classification.json`)                                                                                                                                                   |

Full reference: [Environment variables](/reference/environment-variables)

## Exposing outside the cluster

The Service is `ClusterIP` by default. To expose externally, add an Ingress:

```yaml
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: mcp-proxy
  namespace: mcp
  annotations:
    # TLS termination at the ingress
    cert-manager.io/cluster-issuer: letsencrypt-prod
spec:
  tls:
    - hosts: ["mcp.example.com"]
      secretName: mcp-tls
  rules:
    - host: mcp.example.com
      http:
        paths:
          - path: /
            pathType: Prefix
            backend:
              service:
                name: mcp-proxy
                port:
                  name: http
```

## Troubleshooting

### Pod starts but backends never connect

Check the ConfigMap config is valid JSON:

```bash
kubectl -n mcp get configmap mcp-config -o jsonpath='{.data.servers\.json}' | jq .
```

Check the proxy logs:

```bash
kubectl -n mcp logs deploy/mcp-proxy
```

Look for `[serve] discovering tools from ...` lines. If you see `failed to discover`, the backend URL or token is wrong.

### Health probe fails on startup

Increase the startup probe threshold:

```yaml
startupProbe:
  failureThreshold: 12  # 60s instead of 30s
  periodSeconds: 5
```

### Token not resolving

Ensure the Secret key matches what the Deployment env references, and that the `${VAR_NAME}` in the ConfigMap matches the env var name exactly. If a referenced env var is missing, the placeholder is replaced with an empty string silently — verify the resolved config by checking the proxy logs for authentication failures on backend connections.

### Read-only filesystem errors

If you see permission errors, make sure the `tmp` and `data` volumes are mounted. The scratch image has no writable paths without explicit volume mounts.


# OAuth Authorization Server (Claude.ai, ChatGPT, Cursor)

`mcp serve` ships an OAuth 2.0 Authorization Server with Dynamic Client Registration ([RFC 7591](https://www.rfc-editor.org/rfc/rfc7591)). It lets you plug a self-hosted proxy directly into Claude.ai, ChatGPT, Cursor and other AI clients that consume the [MCP authorization spec](https://modelcontextprotocol.io/specification/2025-06-18/basic/authorization) — no static bearer token to share, no extra OAuth infra in the middle.

## Why this exists

Before `oauth_as`:

* `BearerTokenAuth` (static `subject → token` map) is fine for local dev and CI but Claude.ai-style clients refuse to connect to it.
* `ForwardedUserAuth` works only when a separate IdP-aware reverse proxy is in front. That's a heavy lift just to expose a fleet of MCP servers to your AI tools.

`oauth_as` makes `mcp serve` itself the Authorization Server — but delegates *user* authentication to a trusted reverse proxy (oauth2-proxy, Cloudflare Access, Pomerium, anything that sets `X-Forwarded-User`). MCP never handles passwords. The OAuth flow just wraps the SSO session that already exists.

## Architecture

```
+----------+    +----------------+    +-------------------+
| Claude   |--->|  oauth2-proxy  |--->|    mcp serve      |
|  / etc.  |    |  (your IdP)    |    | OAuth AS + /mcp   |
+----------+    +----------------+    +-------------------+
   ^  user          ^ SSO session         ^
   | OAuth          | sets headers        | validates JWT
   | flow           | X-Forwarded-User    | on every request
                    | X-Forwarded-Groups
```

The reverse proxy authenticates the human. `mcp serve` reads the trusted headers at `/authorize` and emits a short-lived authorization code, then a JWT access token that subsequent `/mcp` requests carry in `Authorization: Bearer …`.

## Two providers, one endpoint

The most common deployment runs **`oauth_as` and `bearer` in parallel** on the same instance:

* Local dev / CI uses a static bearer token.
* Claude.ai web uses the OAuth flow.

Both kinds of `Authorization: Bearer …` hit the same `/mcp`. The [`ProviderChain`](https://github.com/avelino/mcp/blob/main/src/server_auth/providers.rs) tries each provider in order and the first one that accepts wins. ACL discriminates per role (see below), so static-bearer and OAuth identities can have completely different permissions on the same set of backends.

## Configure `serverAuth`

Drop this into `servers.json` (the working `mcp serve` config file):

```json
{
  "serverAuth": {
    "providers": ["bearer", "oauth_as"],

    "bearer": {
      "tokens": {
        "tok-local-dev": { "subject": "avelino", "roles": ["admin"] }
      }
    },

    "oauthAs": {
      "issuerUrl": "https://mcp.example.com",
      "jwtSecret": "${MCP_OAUTH_AS_JWT_SECRET}",
      "trustedUserHeader": "x-forwarded-user",
      "trustedGroupsHeader": "x-forwarded-groups",
      "trustedSourceCidrs": ["127.0.0.1/32", "10.0.0.0/8"],
      "accessTokenTtlSeconds": 3600,
      "refreshTokenTtlSeconds": 2592000,
      "authorizationCodeTtlSeconds": 60,
      "scopesSupported": ["mcp"],
      "redirectUriAllowlist": [
        "https://claude.ai/api/mcp/auth_callback",
        "https://chat.openai.com/aip/*/oauth/callback"
      ],
      "injectedRoles": ["oauth-user"]
    },

    "acl": {
      "default": "deny",
      "rules": [
        { "roles": ["admin"],      "tools": ["*"],         "policy": "allow" },
        { "roles": ["oauth-user"], "tools": ["sentry__*"], "policy": "allow" }
      ]
    }
  }
}
```

Field-by-field for `oauthAs`:

| Field                         | Required | Default              | Notes                                                                                                                        |
| ----------------------------- | -------- | -------------------- | ---------------------------------------------------------------------------------------------------------------------------- |
| `issuerUrl`                   | yes      | —                    | Public HTTPS URL the AS advertises. Must match what clients reach.                                                           |
| `jwtSecret`                   | yes      | —                    | HMAC-SHA256 signing key. **≥ 32 bytes.** Boot fails otherwise.                                                               |
| `trustedUserHeader`           | no       | `x-forwarded-user`   | The header your reverse proxy sets.                                                                                          |
| `trustedGroupsHeader`         | no       | `x-forwarded-groups` | Comma-separated → JWT `groups` claim.                                                                                        |
| `trustedSourceCidrs`          | yes      | —                    | CIDRs allowed to reach `/authorize`. **Empty list rejected at boot** — without it any client could spoof `X-Forwarded-User`. |
| `accessTokenTtlSeconds`       | no       | `3600`               | JWT lifetime.                                                                                                                |
| `refreshTokenTtlSeconds`      | no       | `2592000` (30d)      | Refresh token lifetime.                                                                                                      |
| `authorizationCodeTtlSeconds` | no       | `60`                 | Code lifetime.                                                                                                               |
| `scopesSupported`             | no       | `[]`                 | Advertised in metadata.                                                                                                      |
| `redirectUriAllowlist`        | yes      | —                    | Patterns clients may register. Trailing `*` for ChatGPT-style URIs.                                                          |
| `injectedRoles`               | no       | `[]`                 | Roles always added to issued JWTs. Marker for ACL discrimination.                                                            |

## How `injectedRoles` filters which mcpServers an AI client can use

Tools are routed to backends by prefix: `sentry__list_issues` lives in the `sentry` backend, `github__create_issue` in `github`, and so on. `injectedRoles: ["oauth-user"]` stamps every OAuth-issued JWT with the `oauth-user` role. Combine that with an ACL rule like `{"roles": ["oauth-user"], "tools": ["sentry__*"], "policy": "allow"}` and Claude.ai web sees only `sentry` tools, while your local-dev admin token still sees everything.

There's no special "OAuth user" path in the dispatcher. The same `is_tool_allowed` evaluator that gates static-bearer requests gates JWT requests too.

## Run it

1. **Generate the JWT secret** (32+ random bytes, kept out of the config file):

   ```bash
   export MCP_OAUTH_AS_JWT_SECRET=$(openssl rand -hex 32)
   ```
2. **Front it with oauth2-proxy** (or Cloudflare Access, Pomerium, etc.) so all traffic to `mcp serve` already has `X-Forwarded-User` set. The [oauth2-proxy quickstart](https://oauth2-proxy.github.io/oauth2-proxy/) walks through pointing it at Google / GitHub / Okta.
3. **Boot the proxy**:

   ```bash
   mcp serve --bind 127.0.0.1:8080
   ```

   Bind to loopback so only the reverse proxy can reach it. Anything else needs `--insecure`.
4. **Validate the discovery endpoints** before pointing a client at it:

   ```bash
   curl https://mcp.example.com/.well-known/oauth-protected-resource
   curl https://mcp.example.com/.well-known/oauth-authorization-server
   ```

   Both must return JSON. Empty bodies or HTML pages mean the provider isn't enabled or the proxy isn't routing the path.
5. **Connect from Claude.ai**:
   * Settings → Connectors → "Add custom connector"
   * URL: `https://mcp.example.com/mcp`
   * Authenticate with whatever IdP your reverse proxy uses
   * Tools should appear once the OAuth flow completes

The same URL works in ChatGPT (admin → connectors) and Cursor (settings → MCP).

## State persistence

`oauth_as` persists registered clients and refresh tokens to `auth_server.json` in the config dir. Inflight authorization codes are *not* persisted — restart drops them, which is the safer default than letting captured codes resume post-restart.

Override the location with `MCP_AUTH_SERVER_PATH=/path/to/file`, or inline the whole content with `MCP_AUTH_SERVER_CONFIG='{"clients":{}, "refresh_tokens":{}}'`. The inline mode is for read-only Secret mounts in Kubernetes — same contract as `MCP_AUTH_CONFIG` for the client store.

## Security notes

* **`trustedSourceCidrs` is mandatory.** With an empty list, any client could send a request directly to `mcp serve` carrying a forged `X-Forwarded-User` and walk away with an authorization code. The boot path refuses to start without at least one CIDR.
* **HTTPS issuer.** Setting `issuerUrl` to plain `http://` works technically but means tokens flow in cleartext. Document this for your auditors if you intentionally chose plain HTTP for an internal-only deployment.
* **JWT secret rotation invalidates all existing tokens.** v1 has no in-place rotation. Plan for a forced re-login when you change the secret.
* **PKCE S256 only.** The metadata advertises only `S256`. Clients attempting `plain` are rejected at `/authorize`. This is the OAuth 2.1 / MCP authorization spec baseline.
* **Refresh tokens rotate** on every successful refresh. A captured refresh token is valid for one use at most.
* **Authorization responses carry `iss`** ([RFC 9207](https://www.rfc-editor.org/rfc/rfc9207)). The redirect back to the client appends `iss=<issuerUrl>` next to `code` and `state`, normalized the same way the metadata endpoint normalizes it (trailing slash trimmed), so the client's string comparison matches. This closes the AS mix-up attack: a client running flows against several authorization servers can prove the code came from the one it started with.

  The `mcp` client validates `iss` **when it is present** and fails the flow on a mismatch. An authorization server that sends no `iss` keeps working unchanged.
* **Discovery documents are validated against where they came from** ([RFC 8414](https://www.rfc-editor.org/rfc/rfc8414) §3.3). The `issuer` inside `/.well-known/oauth-authorization-server` must be identical to the origin the document was fetched from, or the client aborts. Without that check, a hostile server could declare any issuer it liked — and the issuer is exactly what the `iss` comparison above and the client registration key below are built on. A document that omits `issuer` altogether is still accepted: hand-rolled metadata routinely does, and there is no identity to reject on.

## Client registrations are bound to the issuer

On the client side, `mcp` stores the `client_id` it obtained from Dynamic Client Registration keyed by the **authorization server's `issuer`**, not by the MCP server URL. One AS's `client_id` is never replayed against another, even for the same MCP server. Access and refresh tokens stay keyed by MCP server URL — they are scoped to the resource server, not the issuer.

Stores written by older builds keyed registrations by MCP server URL. They are migrated in place on first read: `auth.json` gains a `version` field and the old entries move to `legacy_clients`, where they keep working. Each one is re-keyed under an issuer only once a token exchange with that issuer has actually succeeded — a server merely *claiming* an issuer never gets to adopt a credential, which would let it overwrite that issuer's registration. **Nothing has to be re-registered and nobody has to re-login.**

Registration requests now also send `application_type: "native"` (SEP-837), which is what stops OIDC-flavored authorization servers from rejecting localhost redirect URIs. `mcp serve`'s own AS accepts the field, echoes it back per RFC 7591 §3.2.1 when sent, and imposes no redirect constraints from it — clients that omit it are equally fine.

## DCR is deprecated (but still supported)

MCP 2026-07-28 deprecates Dynamic Client Registration in favour of Client ID Metadata Documents (CIMD), with a 12-month window before removal. `mcp serve` **keeps its `/register` endpoint working** and the client keeps using it — nothing in this guide changes today. CIMD is not implemented yet.

## Troubleshooting

| Symptom                                                                  | Likely cause                                                                                                                                             |
| ------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `403 /authorize must originate from a trusted reverse proxy`             | Peer IP is not in `trustedSourceCidrs`.                                                                                                                  |
| `400 redirect_uri rejected: …`                                           | URI not in `redirectUriAllowlist` *or* not registered by the client.                                                                                     |
| `400 invalid_grant` on `/token`                                          | Code expired (60s default), already used, or PKCE verifier doesn't match.                                                                                |
| Claude.ai: "couldn't connect" with no error                              | The discovery endpoints returned non-JSON or 5xx. Curl them.                                                                                             |
| `issuer mismatch in OAuth callback`                                      | The AS returned an `iss` that doesn't match its advertised `issuer`. Check `issuerUrl` against what `/.well-known/oauth-authorization-server` publishes. |
| `authorization server metadata declares issuer … but was fetched from …` | RFC 8414 §3.3 violation: `issuerUrl` doesn't match the origin serving the metadata. Usually a reverse proxy rewriting the host.                          |
| Boot fails: `oauthAs.jwtSecret must be at least 32 bytes`                | The env var is empty or shorter.                                                                                                                         |
| Boot fails: `oauthAs.trustedSourceCidrs must list at least one CIDR`     | The anti-spoof list was left empty.                                                                                                                      |

## References

* MCP authorization spec: <https://modelcontextprotocol.io/specification/2025-06-18/basic/authorization>
* RFC 7591 — Dynamic Client Registration: <https://www.rfc-editor.org/rfc/rfc7591>
* RFC 8414 — Authorization Server Metadata: <https://www.rfc-editor.org/rfc/rfc8414>
* RFC 9728 — Protected Resource Metadata: <https://www.rfc-editor.org/rfc/rfc9728>
* RFC 9207 — Authorization Server Issuer Identification: <https://www.rfc-editor.org/rfc/rfc9207>
* RFC 7636 — PKCE: <https://www.rfc-editor.org/rfc/rfc7636>
* Claude.ai Custom Connectors: <https://support.claude.com/en/articles/11175166-get-started-with-custom-connectors-using-remote-mcp>


# Observability quickstart (Jaeger + OTel collector)

Copy-paste tutorial to bring up traces + metrics in \~3 minutes. Runs locally with Jaeger (nice UI) and then shows how to inspect metrics with the OTel Collector. Ends with the regression test (default-off).

Prerequisites: Docker + a build of `mcp` that includes native OTel support (any post-OpenTelemetry release; check `mcp --version`).

> Why bother running this? Before pointing `mcp serve` at a production Honeycomb / Datadog / Tempo, you want to **prove locally** that the trace flows, that `traceparent` propagates, and that without the env var nothing changes. This walkthrough proves all three.

***

## 1. Start Jaeger

Jaeger v2 speaks OTLP natively (gRPC on `:4317`, HTTP on `:4318`) and ships a UI on `:16686`. One container does it:

```bash
docker run -d --rm --name mcp-jaeger \
  -p 16686:16686 -p 4317:4317 -p 4318:4318 \
  jaegertracing/jaeger:latest
```

Wait until it's up:

```bash
until curl -fsS http://127.0.0.1:16686/api/services >/dev/null; do sleep 1; done
echo READY
```

UI: <http://localhost:16686>.

***

## 2. Start `mcp serve` with OTel enabled

Point it at Jaeger's HTTP receiver (port 4318). `OTEL_EXPORTER_OTLP_ENDPOINT` is the **sole** activator — every other env var below is optional:

```bash
OTEL_EXPORTER_OTLP_ENDPOINT=http://127.0.0.1:4318 \
OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf \
OTEL_SERVICE_NAME=mcp-local \
mcp serve --http 127.0.0.1:7331
```

On stderr, **the very first line** must be:

```
[telemetry] OpenTelemetry initialized — endpoint=http://127.0.0.1:4318 protocol=HttpProto
```

> Without that line, OTel did **not** start. Double-check the env var.

Leave it running.

***

## 3. Fire some requests

In another terminal:

```bash
# tools/list — span without mcp.tool/mcp.server (no backend resolved)
for i in 1 2 3; do
  curl -s -X POST http://127.0.0.1:7331/mcp \
    -H 'Content-Type: application/json' \
    -d "{\"jsonrpc\":\"2.0\",\"id\":$i,\"method\":\"tools/list\"}" \
    -o /dev/null -w "tools/list $i: %{http_code}\n"
done

# tools/call — span with mcp.tool and mcp.server resolved
# (replace filesystem__list_allowed_directories with one of your own tools)
curl -s -X POST http://127.0.0.1:7331/mcp \
  -H 'Content-Type: application/json' \
  -d '{"jsonrpc":"2.0","id":99,"method":"tools/call",
       "params":{"name":"filesystem__list_allowed_directories","arguments":{}}}' \
  -o /dev/null -w "tools/call: %{http_code}\n"
```

Wait for the batch to flush (default \~5s):

```bash
sleep 8
```

***

## 4. Inspect the spans in Jaeger

Open <http://localhost:16686>:

1. **Service:** pick `mcp-local`
2. **Operation:** `mcp.request`
3. Click **Find Traces**

You'll see N traces, each with one `mcp.request` span. Click any of them — under **Tags** you should find:

```
otel.kind       = server
mcp.method      = tools/list (or tools/call)
mcp.transport   = serve:http
mcp.identity    = anonymous
mcp.status      = ok
mcp.server      = filesystem      ← only on resolved tools/call
mcp.tool        = list_allowed_directories
```

Span duration is the total time `dispatch_request` took — includes ACL, the proxy lock, backend connection, and the backend call itself.

### Sanity check via API (no clicking)

```bash
curl -s "http://127.0.0.1:16686/api/traces?service=mcp-local&limit=3" \
  | python3 -c '
import json, sys
for t in json.load(sys.stdin)["data"]:
    for s in t["spans"]:
        tags = {tg["key"]: tg.get("value") for tg in s["tags"]}
        mcp = {k:v for k,v in tags.items() if k.startswith("mcp.")}
        print(f"{s[\"operationName\"]:14} dur={s[\"duration\"]:>7}us {mcp}")
'
```

***

## 5. Test parent context (inbound `traceparent`)

When the client is OTel-aware (Claude.ai, an instrumented gateway, an OTel SDK), it sends `traceparent` in the headers. `mcp serve` should **continue the trace** — its span becomes a child of the client's span instead of starting a new trace.

Send a fake `traceparent` and check stitching:

```bash
TRACEPARENT='00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01'

curl -s -X POST http://127.0.0.1:7331/mcp \
  -H 'Content-Type: application/json' \
  -H "traceparent: $TRACEPARENT" \
  -d '{"jsonrpc":"2.0","id":42,"method":"tools/list"}' \
  -o /dev/null -w "HTTP %{http_code}\n"

sleep 5

# Pull the trace by the traceID we injected
curl -s "http://127.0.0.1:16686/api/traces/0af7651916cd43dd8448eb211c80319c" \
  | python3 -c '
import json, sys
data = json.load(sys.stdin)["data"]
if not data:
    print("FAIL: parent context did not work")
else:
    for s in data[0]["spans"]:
        refs = s.get("references", [])
        parent = refs[0]["spanID"] if refs else "ROOT"
        print(f"  span {s[\"operationName\"]} parent={parent}")
'
```

Expected output:

```
  span mcp.request parent=b7ad6b7169203331
```

`parent=b7ad6b7169203331` confirms the `mcp.request` span became a child of the span we sent in `traceparent`. Trace stitching ✓.

***

## 6. Inspect the metrics

Jaeger v2 only stores traces. To see counters / histograms / gauges, swap it out for an OTel Collector with a debug exporter:

```bash
docker stop mcp-jaeger

cat > /tmp/otel-debug.yaml <<'EOF'
receivers:
  otlp:
    protocols:
      grpc: { endpoint: 0.0.0.0:4317 }
      http: { endpoint: 0.0.0.0:4318 }
exporters:
  debug:
    verbosity: detailed
service:
  pipelines:
    traces:  { receivers: [otlp], exporters: [debug] }
    metrics: { receivers: [otlp], exporters: [debug] }
EOF

docker run -d --rm --name mcp-otelcol \
  -v /tmp/otel-debug.yaml:/etc/otelcol-contrib/config.yaml \
  -p 4317:4317 -p 4318:4318 \
  otel/opentelemetry-collector-contrib:latest
```

Keep firing requests at `mcp serve`. **Important:** the PeriodicReader exports metrics every 60s (OTel SDK default), so **wait \~65 seconds** after the first request.

After 65s:

```bash
docker logs mcp-otelcol 2>&1 | grep -E "Name:|Value:|mcp\." | head -40
```

Expected output:

```
     -> Name: mcp.proxy.requests
     -> mcp.method: Str(tools/call)
     -> mcp.server: Str(filesystem)
     -> mcp.tool: Str(list_allowed_directories)
Value: 6
     -> Name: mcp.proxy.classifier.cache.hits
     -> mcp.server: Str(filesystem)
Value: 28
     -> Name: mcp.proxy.backends.connected
Value: 6
     -> Name: mcp.proxy.sessions.active
Value: 0
```

> The metrics `mcp` emits, in one line:
>
> * `mcp.proxy.requests` (counter) and `mcp.proxy.request.duration` (histogram, ms) — one per request, with labels `method/server/tool/status/transport/identity`
> * `mcp.proxy.classifier.cache.hits/misses` per server
> * `mcp.proxy.backends.connected` and `mcp.proxy.sessions.active` (gauges) refreshed at every export

***

## 7. Regression test — no OTel = 0.5.2 behavior

This is the step that **proves your current production version won't break**. Kill the running `mcp serve` and bring it back up **without any OTel env var**:

```bash
# from another terminal
pkill -f "mcp serve"
sleep 1

mcp serve --http 127.0.0.1:7331
```

On stderr you **must not** see `[telemetry] OpenTelemetry initialized`. Boot should look exactly like 0.5.2:

```
INFO database opened idle_timeout=120s
INFO HTTP server listening addr=127.0.0.1:7331
```

`tools/list` keeps responding:

```bash
curl -s -X POST http://127.0.0.1:7331/mcp \
  -H 'Content-Type: application/json' \
  -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}' \
  -o /dev/null -w "no-otel HTTP %{http_code}\n"
# no-otel HTTP 200
```

This is the **fail-safe**: if OTel ever goes wrong in production, just **unset** `OTEL_EXPORTER_OTLP_ENDPOINT` in the deploy and the system reverts to the previous behavior. No rebuild, no image rollback.

***

## 8. Cleanup

```bash
pkill -f "mcp serve"
docker stop mcp-otelcol mcp-jaeger 2>/dev/null
rm -f /tmp/otel-debug.yaml
```

***

## Next steps

* In production, swap the endpoint for Honeycomb / Tempo / Datadog — examples in the [observability reference guide](/guides/observability).
* If a backend MCP rejects `traceparent` (rare, it's a W3C standard header), enable the escape hatch without touching anything else: `MCP_OTEL_INJECT_TRACEPARENT=0`.
* Sampling lives at the exporter side — `mcp serve` always creates the span; Honeycomb / Tempo / your collector decides what to keep. Configure it there, not here.

## Troubleshooting

**Nothing shows up in Jaeger or in the collector logs.** Check the very first line of `mcp serve` stderr — if `[telemetry] OpenTelemetry initialized` is missing, the env var wasn't read. The line goes **straight to stderr**, before the logging subscriber wires up, and **does not** respect `MCP_LOG_LEVEL`.

**It said "OpenTelemetry initialized" but nothing reaches the collector.** Endpoint is probably wrong. `mcp` accepts the **base URL** (e.g. `http://host:4318`), not the full path (`/v1/traces`) — but it also tolerates a pre-suffixed value. If you swapped receivers and didn't clean up the old env var, you might still be sending elsewhere.

**Span without `mcp.server`/`mcp.tool`.** Normal for `tools/list`, `auth/failure`, unknown methods, or requests with malformed payloads — those don't resolve to a specific backend. On a resolved `tools/call` they always show up.

**`tonic` connection refused on an HTTPS endpoint.** Honeycomb (and a few other vendors) **only accept HTTP/protobuf** on the public ingest. Set `OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf` and use `https://` in the endpoint.

**Metrics don't show up even after 60s.** PeriodicReader is "best effort": if the process dies before the first flush, the export is gone. The `TelemetryGuard` in `main`'s `Drop` performs a graceful shutdown (which forces a flush). In production this is covered by a normal SIGTERM — but a `kill -9` may drop the last batch.


# Supported services

Step-by-step setup guides for popular services. Each section gets you from zero to working.

## Sentry

Track errors, search issues, and inspect events.

**Add the server:**

```bash
mcp add --url https://mcp.sentry.dev/sse sentry
```

**Authenticate:**

```bash
mcp sentry --list
```

Sentry supports OAuth 2.0. Your browser will open to authorize the app. After approval, the token is saved automatically.

**Example usage:**

```bash
# Search for unresolved errors
mcp sentry search_issues '{"query": "is:unresolved level:error"}'

# Get issue details
mcp sentry get_issue_details '{"issue_id": "12345"}'

# Search events in a project
mcp sentry search_events '{"project": "my-project", "query": "transaction:/api/users"}'
```

## Slack

Send messages, list channels, search conversations.

**Add the server:**

```bash
mcp add slack
```

**Set environment variables:**

You need a Slack app with a bot token. Go to [api.slack.com/apps](https://api.slack.com/apps), create an app, and get the OAuth token.

```bash
export SLACK_MCP_XOXP_TOKEN="xoxp-your-token"
export SLACK_MCP_TEAM_ID="T12345678"
```

**Test:**

```bash
mcp slack --list
```

> **Note:** Slack's MCP server can take 30+ seconds to initialize on first run (npm install). If you get a timeout, increase it: `MCP_TIMEOUT=120 mcp slack --list`

**Example usage:**

```bash
# List channels
mcp slack list_channels

# Send a message
mcp slack send_message '{"channel": "#general", "text": "Hello from mcp!"}'
```

## Grafana

Search dashboards, query Prometheus, check alerts.

**Add the server (self-hosted Grafana):**

```json
{
  "mcpServers": {
    "grafana": {
      "url": "https://grafana.example.com/mcp",
      "headers": {
        "Authorization": "Bearer ${GRAFANA_TOKEN}"
      }
    }
  }
}
```

Create a Service Account Token in Grafana: Administration → Service Accounts → Add token.

```bash
export GRAFANA_TOKEN="glsa_..."
```

**Example usage:**

```bash
# Search dashboards
mcp grafana search_dashboards '{"query": "api-latency"}'

# Query Prometheus
mcp grafana query_prometheus '{"query": "rate(http_requests_total[5m])"}'

# List alert rules
mcp grafana list_alert_groups
```

## GitHub

Search repositories, manage issues, read files.

**Add the server:**

```bash
mcp add github
```

**Set environment variable:**

Create a [Personal Access Token](https://github.com/settings/tokens) with the scopes you need.

```bash
export GITHUB_TOKEN="ghp_..."
```

**Example usage:**

```bash
# Search repositories
mcp github search_repositories '{"query": "mcp language:rust"}'

# Get file contents
mcp github get_file_contents '{"owner": "anthropics", "repo": "mcp", "path": "README.md"}'
```

## Honeycomb

Query datasets, explore traces, manage columns.

**Add the server:**

```bash
mcp add --url https://mcp.honeycomb.io/mcp honeycomb
```

**Authenticate:**

On first connect, `mcp` will prompt for your API key:

```
This server requires a Honeycomb API Key.

  How: Go to Account → API Keys → Create API Key
  URL: https://ui.honeycomb.io/account

Enter access token for https://mcp.honeycomb.io:
>
```

Or use OAuth if your Honeycomb setup supports it.

**Example usage:**

```bash
mcp honeycomb --list
```

## Roam Research

Read and write to your Roam graph.

**Add the server:**

```bash
mcp add roam
```

**Set environment variable:**

Get a Graph API Token from your Roam settings.

```bash
export ROAM_GRAPH_API_TOKEN="roam-graph-token-..."
```

**Example usage:**

```bash
# Search pages
mcp roam search '{"query": "project ideas"}'

# Get a page
mcp roam get_page '{"title": "Daily Notes"}'

# Create a block
mcp roam create_block '{"page": "Inbox", "content": "New idea from CLI"}'
```

## Adding any server

The pattern is always the same:

1. **Find it** — `mcp search <name>` or check the server's documentation
2. **Add it** — `mcp add <name>` or `mcp add --url <url> <name>` or edit `servers.json`
3. **Set credentials** — Environment variables or let OAuth handle it
4. **Explore** — `mcp <name> --list` to see available tools
5. **Use it** — `mcp <name> <tool> '{"arg": "value"}'`

Any MCP-compatible server works. If it speaks JSON-RPC 2.0 over stdio or HTTP, `mcp` can talk to it.


# Troubleshooting

Common issues and how to fix them.

## Connection errors

### "server closed stdout (EOF)"

The server process exited unexpectedly. Common causes:

* **Missing dependencies** — The server needs npm packages that aren't installed. Try running the command manually to see the error:

  ```bash
  npx -y @anthropic/fs-mcp-server
  ```
* **Bad arguments** — Check `args` in your config. Some servers need specific flags.
* **Environment variables** — A required env var might be missing or empty.

### "timeout waiting for server response"

The server took too long to respond.

**Fix:** Increase the timeout:

```bash
MCP_TIMEOUT=120 mcp slack --list
```

Some servers (especially npm-based ones) take a long time on first run because they need to download packages. Subsequent runs are faster.

### "failed to spawn process: "

The command in your config doesn't exist or isn't in your PATH.

**Check:**

```bash
which npx          # Is npx installed?
which node         # Is Node.js installed?
```

For `npx` servers, make sure Node.js is installed.

## Authentication errors

### "Server returned 401"

The token is invalid, expired, or missing.

**Fixes:**

1. **Clear saved tokens** — Delete the entry from `~/.config/mcp/auth.json` or the whole file:

   ```bash
   rm ~/.config/mcp/auth.json
   ```

   Next request will trigger a fresh auth flow.
2. **Check config headers** — If you have an `Authorization` header in config, make sure the env var is set:

   ```bash
   echo $MY_TOKEN   # Should print your token
   ```
3. **Re-authenticate** — Just call any command, the auth flow will start:

   ```bash
   mcp sentry --list
   ```

### "OAuth registration not available"

The server doesn't support OAuth Dynamic Client Registration. `mcp` will fall back to asking for a manual token. Follow the instructions it prints.

### "could not bind to any port in range 8085-8099"

Another process is using the ports `mcp` needs for the OAuth callback. Close any other `mcp` instances or processes on those ports.

## Config errors

### "server not found in config"

The server name you used doesn't match any entry in `servers.json`.

```bash
mcp --list    # See what's configured
```

Check for typos. Server names are case-sensitive.

### "conflicts with a reserved command name"

You named a server with a reserved name. Rename it in `servers.json`:

```
warning: server "search" conflicts with a reserved command name
```

Reserved names: `search`, `add`, `remove`, `list`, `help`, `version`.

### "failed to parse config file"

Your `servers.json` has invalid JSON. Common issues:

* Trailing comma after the last entry
* Missing quotes around keys
* Unescaped special characters

Validate your JSON:

```bash
python3 -m json.tool ~/.config/mcp/servers.json
```

## Proxy mode errors

### Backend stuck in discovery retry

When a backend fails to connect during `mcp serve`, the proxy applies exponential backoff before retrying: 30s → 60s → 120s → 240s (capped at 300s). This prevents a flaky backend from stealing the discovery lock and blocking healthy backends.

If you see repeated discovery failures in stderr:

```
[serve] backend "slack" discovery failed: timeout waiting for server response
```

**Fixes:**

1. **Check the backend command works standalone:**

   ```bash
   mcp slack --list
   ```
2. **Increase timeout for slow backends:**

   ```bash
   MCP_TIMEOUT=120 mcp serve --http
   ```
3. **Check credentials** — a backend stuck on an auth prompt will hang until timeout.

After fixing the issue, restart `mcp serve` — the backoff state is in-memory and resets on restart.

### "access denied" on tools/call

The ACL blocks both `tools/call` requests **and** filters `tools/list` responses. If a tool doesn't appear in `tools/list`, the identity doesn't have access to it. If a tool appears but `tools/call` returns access denied, the ACL rules may have changed between the list and the call, or the tool's read/write classification doesn't match the identity's access level.

**Debug:** Check what the classifier thinks about the tool:

```bash
mcp acl classify --server <backend>
```

Tools marked `[!]` (ambiguous) are treated as write by default. Add explicit `tool_acl` overrides in `servers.json` if the classifier is wrong.

### Request timeout in proxy mode

Each client request has a hard timeout of 120 seconds (configurable via `MCP_PROXY_REQUEST_TIMEOUT`). If a backend takes longer than this, the client gets a JSON-RPC error with code `-32000`. Other concurrent requests are unaffected.

```bash
MCP_PROXY_REQUEST_TIMEOUT=300 mcp serve --http
```

## Tool errors

### "tools/call failed: ..."

The tool returned an error. This is a server-side error — the tool itself failed. Check:

* **Arguments** — Use `mcp <server> --info` to see the expected input schema
* **Permissions** — Your token might not have the required scopes
* **Server-specific** — Check the server's documentation

### Response has `"isError": true`

The tool executed but returned an error result. This is different from a protocol error — the tool ran but the operation failed. Read the `content[0].text` for details.

## Debug tips

### See what config is loaded

```bash
mcp --list
```

### Check if a server is reachable

```bash
mcp sentry --list 2>&1
```

Watch stderr for auth messages and connection errors.

### Run the server command manually

For stdio servers, run the command directly to see what happens:

```bash
npx -y @anthropic/fs-mcp-server /home/me
```

If it prints errors to stderr, that's your problem.

### Check env var resolution

If you suspect env vars aren't being set, add a test server:

```json
{
  "mcpServers": {
    "debug": {
      "command": "echo",
      "args": [],
      "env": {
        "MY_TOKEN": "${MY_TOKEN}"
      }
    }
  }
}
```

```bash
mcp --list   # Will show the config with resolved values
```

### Network issues with HTTP servers

Check if you can reach the server:

```bash
curl -I https://mcp.sentry.dev/sse
```

If you get a 401, that's expected — auth will be handled by `mcp`. If you get a connection error, it's a network problem.


# CLI reference

Complete reference for all `mcp` commands.

## Output format

By default, `mcp` detects the output context:

* **Interactive terminal** — human-readable tables with colors
* **Piped or redirected** — JSON (for scripting with `jq`, etc.)

Use `--json` anywhere to force JSON output regardless of context:

```bash
mcp --list --json          # JSON even in terminal
mcp sentry --list --json   # JSON tool list
```

## Global commands

### `mcp --help`, `mcp -h`

Show usage information.

### `mcp --list`

List all configured servers.

```bash
mcp --list
```

Interactive output:

```
Server     Type   Endpoint
sentry     http   https://mcp.sentry.dev/sse
slack      stdio  npx -y slack-mcp-server@latest
grafana    stdio  uvx mcp-grafana

3 server(s) configured
```

JSON output (`--json` or piped):

```json
[
  { "name": "sentry", "type": "http", "url": "https://mcp.sentry.dev/sse" },
  { "name": "slack", "type": "stdio", "command": "npx", "args": ["-y", "slack-mcp-server@latest"] }
]
```

## Global flags

### `--json`

Force JSON output. Can be placed anywhere in the command:

```bash
mcp --json --list
mcp sentry --list --json
mcp sentry search_issues '{"query": "..."}' --json
```

## Server commands

### `mcp <server> --list`

Connect to the server and list available tools.

```bash
mcp sentry --list
```

Interactive output:

```
Tool                  Description
search_issues         Search for issues in Sentry
get_issue_details     Get details of a specific issue
search_events         Search events in a project

3 tool(s) available
```

If the server name alone is provided (no flags, no tool), this is the default behavior:

```bash
mcp sentry          # same as mcp sentry --list
```

### `mcp <server> --info`

Like `--list`, but includes parameter details for each tool.

```bash
mcp sentry --info
```

Interactive output:

```
search_issues
  Search for issues in Sentry
  Parameters:
    query string — The search query (required)
    project string — Project slug
    sort string — Sort order

get_issue_details
  Get details of a specific issue
  Parameters:
    issue_id string — The issue ID (required)

2 tool(s) available
```

JSON output includes full JSON Schema for each tool's input parameters.

### `mcp <server> --health`

Connect to the server and disconnect. Exits non-zero if the connection fails.

```bash
mcp sentry --health
```

Interactive output:

```
sentry: ok
```

JSON output also reports the MCP protocol revision the two sides agreed on — the quickest way to see whether a backend negotiated `2026-07-28` or fell back to the legacy `initialize` handshake:

```bash
mcp sentry --health --json
```

```json
{"server":"sentry","status":"ok","protocolVersion":"2025-11-25"}
```

With `MCP_PROXY_URL` set, the connection goes through the running `mcp serve` proxy, so `protocolVersion` is the revision negotiated with the *proxy*, not with the backend behind it.

### `mcp <server> <tool> [json]`

Call a tool on the server. The optional `json` argument is a JSON object with the tool's parameters.

```bash
mcp sentry search_issues '{"query": "is:unresolved"}'
```

If `json` is omitted:

* **Interactive terminal** — Uses `{}` (empty object)
* **Piped input** — Reads JSON from stdin

Interactive output prints text content directly:

```
Found 23 issues matching query "is:unresolved level:error"
```

Errors are prefixed with `error:` on stderr.

JSON output wraps content in the MCP protocol format:

```json
{
  "content": [
    { "type": "text", "text": "Found 23 issues..." }
  ],
  "isError": false
}
```

Content items have a `type` field:

* `"text"` — Text content in the `text` field
* `"image"` — Base64-encoded image in `data` field, with `mimeType`
* `"resource"` — Embedded resource content

## Proxy commands

### `mcp serve`

Start a proxy server that aggregates all configured backends into a single MCP endpoint.

```bash
mcp serve               # stdio mode (default)
mcp serve --http        # HTTP mode on 127.0.0.1:8080
mcp serve --http :9090  # HTTP mode on custom port
mcp serve --http 0.0.0.0:8080 --insecure  # HTTP on all interfaces
```

The proxy connects to every server in `servers.json`, merges their tool lists with namespaced names (`server__tool`), and routes `tools/call` requests to the correct backend.

#### Stdio mode (default)

Designed to be used as a stdio transport in any MCP client:

```json
{
  "mcpServers": {
    "all": {
      "command": "mcp",
      "args": ["serve"]
    }
  }
}
```

Diagnostics are logged to stderr. Protocol messages use stdin/stdout.

#### HTTP mode (`--http`)

Exposes the proxy over HTTP with the following endpoints:

| Method | Path       | Description                                   |
| ------ | ---------- | --------------------------------------------- |
| `POST` | `/mcp`     | JSON-RPC 2.0 request/response endpoint        |
| `GET`  | `/mcp/sse` | SSE endpoint for streaming (per MCP spec)     |
| `GET`  | `/health`  | Health check (returns JSON status, see below) |

Default bind address is `127.0.0.1:8080` (localhost only). To bind to a different address:

```bash
mcp serve --http 127.0.0.1:9090
mcp serve --http :3000              # shorthand for 0.0.0.0:3000 — requires --insecure
```

#### `--insecure`

Allow binding to non-loopback addresses without TLS. Required when using addresses like `0.0.0.0`, `192.168.x.x`, etc. Without this flag, the server refuses to start on non-loopback interfaces to prevent accidental plaintext exposure.

#### Health check (`GET /health`)

Returns the proxy status as JSON:

```json
{
  "status": "ok",
  "backends_configured": 9,
  "backends_connected": 3,
  "active_clients": 5,
  "tools": 213,
  "version": "0.4.3"
}
```

| Field                 | Description                                                                |
| --------------------- | -------------------------------------------------------------------------- |
| `status`              | Always `"ok"`                                                              |
| `backends_configured` | Total servers in `servers.json`                                            |
| `backends_connected`  | Backends currently running (others are idle-shutdown or not yet connected) |
| `active_clients`      | Number of SSE sessions currently registered                                |
| `tools`               | Total tools across all backends (including idle ones — tools are cached)   |
| `version`             | `mcp` binary version                                                       |

`backends_connected` should **not** grow with `active_clients` — that's the proxy doing its job (N clients sharing M backends). If they grow together, clients may be bypassing the proxy.

#### Graceful shutdown

The HTTP server handles `SIGTERM` and `SIGINT` (Ctrl+C) gracefully: stops accepting new connections, finishes in-flight requests, and disconnects all backends.

See [**Proxy mode guide**](/guides/proxy-mode) for full details, client configuration examples, and team setup.

## Registry commands

### `mcp search <query>`

Search the MCP server registry.

```bash
mcp search filesystem
mcp search "database sql"
```

Interactive output shows a table of matching servers. JSON output returns full server metadata including repository URL and install instructions.

### `mcp add <name>`

Add a server from the registry. Looks up the server by name, generates a config entry, and writes it to `servers.json`.

```bash
mcp add filesystem
```

Fails if:

* Server not found in registry
* Server already exists in config
* Name is reserved (`search`, `add`, `remove`, `update`, `list`, `help`, `version`, `serve`, `logs`, `acl`, `config`, `completions`, `healthcheck`)

### `mcp add --url <url> <name>`

Add an HTTP server manually.

```bash
mcp add --url https://api.example.com/mcp my-server
```

### `mcp remove <name>`

Remove a server from the config file.

```bash
mcp remove filesystem
```

Fails if the server is not in the config.

### `mcp update <name>`

Refresh a server's config entry from the registry, preserving your customizations.

```bash
mcp update github
```

Use this when the registry metadata for a server changed (new package version, new env vars, updated args) and you want to pull the changes without losing what you customized locally.

**What gets refreshed (from the registry):**

* `command` and `args`
* `url` (HTTP servers)
* `env` schema — new vars are added as `${VAR_NAME}` placeholders, vars removed from the registry are dropped

**What is preserved (your customizations):**

* Filled-in `env` values (anything that isn't a `${VAR_NAME}` placeholder)
* `idle_timeout`, `min_idle_timeout`, `max_idle_timeout`
* `headers` (HTTP servers)
* `cli`, `cli_help`, `cli_depth`, `cli_only`, `tools`

If the entry already matches the registry, the file is not rewritten and `mcp` reports `already up to date`. New env vars introduced by the update are listed at the end so you know what to fill in.

Fails if:

* Server is not in the local config (use `mcp add <name>` first)
* Server is not in the registry

> If the server changed type in the registry (stdio ↔ http), `mcp update` warns and drops type-specific fields that no longer apply.

## Config commands

### `mcp config path`

Print the resolved path to the config file (`servers.json`).

```bash
mcp config path
# /Users/you/.config/mcp/servers.json
```

With `--json`, includes metadata:

```json
{
  "path": "/Users/you/.config/mcp/servers.json",
  "exists": true,
  "dir": "/Users/you/.config/mcp"
}
```

### `mcp config edit`

Open the config file in your editor. Uses `$EDITOR`, falls back to `$VISUAL`, then `vi` (or `notepad` on Windows).

```bash
mcp config edit
```

If the config file doesn't exist, it's created with `{}` before opening.

## Shell completions

### `mcp completions <shell>`

Generate shell completion scripts. Supported shells: `bash`, `zsh`, `fish`.

```bash
# Fish (recommended for the current session)
mcp completions fish | source

# Fish (persistent)
mcp completions fish > ~/.config/fish/completions/mcp.fish

# Bash
mcp completions bash >> ~/.bashrc
source ~/.bashrc

# Zsh
mcp completions zsh > "${fpath[1]}/_mcp"
autoload -Uz compinit && compinit
```

Completions cover all subcommands, flags, and nested subcommands (`config path|edit`, `acl classify|check`, `logs` flags, etc.).

## ACL commands

### `mcp acl classify`

Classify every tool of every configured backend as `read`, `write`, or `ambiguous`, using the automatic classifier combined with manual `tool_acl` overrides from `servers.json`. This is metadata only — no enforcement path changes yet.

```bash
mcp acl classify                      # all servers, table output
mcp acl classify --server grafana     # one server
mcp acl classify --format json        # machine-readable
```

**Flags:**

* `--server <alias>` — restrict to one backend
* `--format table|json` — override the auto-detected output format

**Table columns:**

| Column    | Meaning                                                               |
| --------- | --------------------------------------------------------------------- |
| `SERVER`  | Backend alias                                                         |
| `TOOL`    | Upstream tool name (not namespaced)                                   |
| `KIND`    | `read`, `write`, or `ambiguous`                                       |
| `CONF`    | Classifier confidence (0.00–1.00)                                     |
| `SOURCE`  | `override`, `annotation`, `classifier`, or `fallback`                 |
| `reasons` | Which signals fired (name tokens, description patterns, schema hints) |

Rows prefixed with `[!]` are **ambiguous** — the classifier could not decide. They are treated as `write` at runtime (fail-safe). Add a `tool_acl` entry in `servers.json` to pin them explicitly.

**Example (JSON):**

```bash
mcp acl classify --server databricks --format json | jq '.[] | {tool, kind}'
```

The JSON form is an array of `{server, tool, kind, confidence, source, reasons}` objects, suitable for scripting or diffing across config changes.

See [Tool ACL overrides](/reference/config-file#tool-acl-overrides) for how to pin tools manually, and [`docs/acl-redesign-plan.md`](https://github.com/avelino/mcp/tree/main/docs/acl-redesign-plan.md) for the full redesign context.

### `mcp acl check`

Test an ACL decision without starting the proxy. Useful for validating policy changes before rolling them out.

```bash
mcp acl check --subject alice --server grafana --tool query_prometheus
mcp acl check --subject bob --server databricks --tool execute_sql --access write
mcp acl check --role dev --server github --all-tools
mcp acl check --subject alice --server github --all-tools --format json
mcp acl check --subject alice --server sentry --resource "issue://123"
mcp acl check --subject bob --server ai --prompt summarize
```

**Flags:**

* `--subject <name>` — subject to check (looks up roles from `subjects` map in ACL config)
* `--server <alias>` — backend server alias (required)
* `--tool <name>` — single tool to check
* `--resource <uri>` — resource URI to check (mutually exclusive with `--tool` and `--prompt`)
* `--prompt <name>` — prompt name to check (mutually exclusive with `--tool` and `--resource`)
* `--access read|write` — override the tool classification (if omitted, the CLI connects to the backend to classify the tool automatically; not applicable to resources/prompts)
* `--role <name>` — check a hypothetical role (creates a synthetic identity)
* `--all-tools` — connect to the backend, list all tools, and check each one
* `--format table|json` — override the auto-detected output format

One of `--tool`, `--resource`, `--prompt`, or `--all-tools` is required.

**Single-tool output:**

```
ALLOW  via dev[0]  access=read  classification=classifier:read (confidence 0.72)
```

**Multi-tool output (`--all-tools`):**

```
TOOL                                     DECISION RULE                      ACCESS KIND       SOURCE      CONF
query_prometheus                         ALLOW  dev[0]                    read   read       classifier  0.72
update_dashboard                         DENY   default                   -      write      classifier  0.81
```

**Exit code:** `0` for allow, `1` for deny (single-tool mode only). `--all-tools` always exits `0` since mixed results are expected.

**Examples:**

```bash
# CI pre-deploy check: ensure dev role can read grafana
mcp acl check --role dev --server grafana --tool query_prometheus || echo "BLOCKED"

# Audit what a role can reach on a server
mcp acl check --role dev --server github --all-tools --format json | jq '.[] | select(.decision=="DENY")'
```


# Config file reference

## Location

Default: `~/.config/mcp/servers.json`

Override: `MCP_CONFIG_PATH` environment variable.

## Schema

```json
{
  "mcpServers": {
    "<name>": <ServerConfig>,
    ...
  }
}
```

## ServerConfig

Three variants, distinguished by their fields:

### Stdio server

```json
{
  "command": "npx",
  "args": ["-y", "package-name"],
  "env": {
    "KEY": "value"
  }
}
```

| Field              | Type      | Default      | Description                                                                                |
| ------------------ | --------- | ------------ | ------------------------------------------------------------------------------------------ |
| `command`          | string    | *required*   | Executable to spawn                                                                        |
| `args`             | string\[] | `[]`         | Arguments passed to the command                                                            |
| `env`              | object    | `{}`         | Environment variables for the process                                                      |
| `tool_acl`         | object    | `null`       | Manual read/write classification overrides (see [Tool ACL overrides](#tool-acl-overrides)) |
| `idle_timeout`     | string    | `"adaptive"` | Idle shutdown policy (see [Idle timeout](#idle-timeout))                                   |
| `min_idle_timeout` | string    | `"1m"`       | Minimum idle timeout for adaptive mode                                                     |
| `max_idle_timeout` | string    | `"5m"`       | Maximum idle timeout for adaptive mode                                                     |

### HTTP server

```json
{
  "url": "https://example.com/mcp",
  "headers": {
    "Authorization": "Bearer token"
  }
}
```

| Field              | Type   | Default      | Description                                                                                |
| ------------------ | ------ | ------------ | ------------------------------------------------------------------------------------------ |
| `url`              | string | *required*   | Server endpoint URL                                                                        |
| `headers`          | object | `{}`         | HTTP headers for every request                                                             |
| `tool_acl`         | object | `null`       | Manual read/write classification overrides (see [Tool ACL overrides](#tool-acl-overrides)) |
| `idle_timeout`     | string | `"adaptive"` | Idle shutdown policy (see [Idle timeout](#idle-timeout))                                   |
| `min_idle_timeout` | string | `"1m"`       | Minimum idle timeout for adaptive mode                                                     |
| `max_idle_timeout` | string | `"5m"`       | Maximum idle timeout for adaptive mode                                                     |

### CLI server

```json
{
  "command": "kubectl",
  "cli": true,
  "cli_help": "--help",
  "cli_depth": 2,
  "cli_only": ["get", "describe", "logs"]
}
```

| Field              | Type      | Default      | Description                                                                                |
| ------------------ | --------- | ------------ | ------------------------------------------------------------------------------------------ |
| `command`          | string    | *required*   | CLI executable to wrap                                                                     |
| `cli`              | bool      | *required*   | Must be `true` — marks this as a CLI server                                                |
| `cli_help`         | string    | `"--help"`   | Flag used to discover subcommands and options                                              |
| `cli_depth`        | number    | `2`          | How deep to recurse into subcommands for flag discovery                                    |
| `cli_only`         | string\[] | `[]` (all)   | Whitelist of subcommands to expose                                                         |
| `args`             | string\[] | `[]`         | Base arguments prepended to every invocation                                               |
| `env`              | object    | `{}`         | Environment variables for the CLI process                                                  |
| `tools`            | array     | `[]`         | Preset tool definitions (skips auto-discovery when set)                                    |
| `tool_acl`         | object    | `null`       | Manual read/write classification overrides (see [Tool ACL overrides](#tool-acl-overrides)) |
| `idle_timeout`     | string    | `"adaptive"` | Idle shutdown policy (see [Idle timeout](#idle-timeout))                                   |
| `min_idle_timeout` | string    | `"1m"`       | Minimum idle timeout for adaptive mode                                                     |
| `max_idle_timeout` | string    | `"5m"`       | Maximum idle timeout for adaptive mode                                                     |

See the [CLI as MCP guide](/guides/cli-as-mcp) for discovery details and examples.

## Idle timeout

Controls when the proxy shuts down idle backend connections to reclaim resources. Applies to both stdio and HTTP backends in proxy mode (`mcp serve`).

### Policy values

| Value                  | Behavior                                                                              |
| ---------------------- | ------------------------------------------------------------------------------------- |
| `"adaptive"` (default) | Timeout adjusts based on usage frequency — frequently used backends stay alive longer |
| `"never"`              | Never shut down — backend stays connected for the entire proxy lifetime               |
| `"<duration>"`         | Fixed timeout (e.g. `"3m"`, `"30s"`, `"1h"`)                                          |

Duration format: number followed by `s` (seconds), `m` (minutes), or `h` (hours). Plain numbers are treated as seconds.

### Adaptive mode

When `idle_timeout` is `"adaptive"` (the default), the proxy tracks how often each backend is used and assigns a timeout tier:

| Usage tier | Requests/hour | Idle timeout |
| ---------- | ------------- | ------------ |
| Hot        | > 20          | 5 min        |
| Warm       | 5–20          | 3 min        |
| Cold       | < 5           | 1 min        |

The tier is computed from the backend's total request count divided by its uptime. The result is clamped between `min_idle_timeout` (default `1m`) and `max_idle_timeout` (default `5m`).

When a backend is shut down due to inactivity, its tools remain visible in `tools/list`. On the next `tools/call`, the proxy reconnects automatically (lazy initialization). Usage history is preserved across reconnections so the adaptive algorithm has continuity.

### Examples

```json
{
  "mcpServers": {
    "slack": {
      "command": "npx",
      "args": ["@anthropic/mcp-slack"],
      "idle_timeout": "adaptive",
      "min_idle_timeout": "30s",
      "max_idle_timeout": "5m"
    },
    "sentry": {
      "url": "https://mcp.sentry.io",
      "idle_timeout": "never"
    },
    "github": {
      "command": "npx",
      "args": ["@modelcontextprotocol/server-github"],
      "idle_timeout": "2m"
    }
  }
}
```

## Tool ACL overrides

The proxy ships with an automatic classifier that labels every tool of every upstream MCP as `read`, `write`, or `ambiguous` (treated as write, fail-safe). The classifier is auditable — run `mcp acl classify` to see the verdict, confidence, source, and reasons for each tool.

When the classifier is wrong (or when you just want to be explicit), add `tool_acl` to any server and pin individual tools to `read` or `write` using the same glob syntax as the ACL rules (`*`, prefix, suffix, contains).

```json
{
  "mcpServers": {
    "grafana": {
      "command": "mcp-grafana",
      "tool_acl": {
        "read":  ["get_*", "list_*", "search_*", "find_*", "query_*", "generate_deeplink"],
        "write": ["update_dashboard", "create_*", "alerting_manage_*"]
      }
    },
    "databricks": {
      "command": "databricks-mcp",
      "tool_acl": {
        "read":  ["execute_sql_read_only", "poll_sql_result"],
        "write": ["execute_sql"]
      }
    }
  }
}
```

Semantics:

* Both `read` and `write` are optional — omit either or both.
* Overrides run **before** the classifier. A tool that matches an override is never scored.
* The same pattern string may not appear in both `read` and `write` for the same server — that fails loudly at load time.
* If two different globs on the same server both match a single tool name (e.g. `get_*` in `read` and `*_thing` in `write` both match `get_thing`), the proxy fails safe to `write` at classification time. Narrow your globs to avoid this.
* Overrides are **never cached** — they are re-read from the config on every startup.

For the full redesign plan and the token/description dictionaries the classifier uses, see [`docs/acl-redesign-plan.md`](https://github.com/avelino/mcp/blob/main/docs/acl-redesign-plan.md).

## Type detection

The config uses serde's untagged enum deserialization. The type is inferred from the fields:

* Has `command` + `cli: true` → CLI
* Has `command` (without `cli`) → Stdio
* Has `url` → HTTP

CLI is checked first, then Stdio, then HTTP.

## Environment variable substitution

Any `${VAR_NAME}` in a string value is replaced with the env var's value at load time.

```json
{
  "env": { "TOKEN": "${MY_SECRET}" },
  "headers": { "Authorization": "Bearer ${API_KEY}" },
  "url": "https://${HOST}/mcp"
}
```

Missing env vars resolve to empty string `""`.

## Reserved names

These names cannot be used as server names:

* `search`
* `add`
* `remove`
* `list`
* `help`
* `version`

Using a reserved name won't break the config, but you'll get a warning and the server may be shadowed by built-in commands.

## Server authentication (`serverAuth`)

Optional. Configures authentication for `mcp serve --http`. Ignored for direct CLI usage.

```json
{
  "mcpServers": { ... },
  "serverAuth": {
    "providers": ["<provider>", "<provider>"],
    "bearer": { ... },
    "forwarded": { ... },
    "oauthAs": { ... },
    "acl": { ... }
  }
}
```

### Providers

`providers` is an array of provider names, evaluated in order as a chain. The first provider that accepts the request wins; if all reject, the chain returns the error of the *first* provider configured (oracle-resistant). Empty array (or omitted) is equivalent to anonymous access.

| Value         | Description                                                                                                                                                                                         |
| ------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `"none"`      | Anonymous identity (`subject: "anonymous"`, no roles). Useful for testing.                                                                                                                          |
| `"bearer"`    | Static bearer token validation. Reads from `bearer` sub-config.                                                                                                                                     |
| `"forwarded"` | Trust reverse proxy header (e.g. `X-Forwarded-User`). Reads from `forwarded` sub-config.                                                                                                            |
| `"oauth_as"`  | OAuth 2.0 Authorization Server with Dynamic Client Registration — the route required by Claude.ai / ChatGPT / Cursor. Reads from `oauthAs` sub-config. See the [OAuth AS how-to](/how-to/oauth-as). |

> **Schema change.** Earlier versions used a single `provider: "..."` string. The new schema is the array `providers: [...]`. Configs carrying the legacy field deserialize into an empty providers list and boot as `NoAuth` — silently weakening auth, so an explicit migration is required. Booting with a missing sub-config (e.g. `"bearer"` listed without a `bearer` block) fails at startup rather than degrading.

### Bearer config

Required when `"bearer"` is in `providers`. Each entry in `tokens` accepts two shapes:

* **Legacy (string):** `"<token>": "<subject>"` — subject only, no roles.
* **Extended (object):** `"<token>": { "subject": "<subject>", "roles": ["<role>", ...] }` — subject plus roles used by ACL evaluation.

Both forms can coexist in the same file.

```json
{
  "bearer": {
    "tokens": {
      "secret-abc": "alice",
      "secret-def": { "subject": "bob", "roles": ["dev", "oncall"] }
    }
  }
}
```

| Field    | Type   | Description                                                    |
| -------- | ------ | -------------------------------------------------------------- |
| `tokens` | object | Map of token → subject string **or** `{subject, roles}` object |

Each extended entry:

| Field     | Type      | Default    | Description                                         |
| --------- | --------- | ---------- | --------------------------------------------------- |
| `subject` | string    | *required* | User identity for this token                        |
| `roles`   | string\[] | `[]`       | Roles assigned to this identity (used by ACL rules) |

### Forwarded config

Optional when `"forwarded"` is in `providers`. Reads the authenticated user from a header set by a trusted reverse proxy, and optionally reads a groups header to populate roles.

```json
{
  "forwarded": {
    "header": "x-forwarded-user",
    "groups_header": "x-forwarded-groups"
  }
}
```

| Field           | Type   | Default                | Description                                                               |
| --------------- | ------ | ---------------------- | ------------------------------------------------------------------------- |
| `header`        | string | `"x-forwarded-user"`   | Header name to read the authenticated user from                           |
| `groups_header` | string | `"x-forwarded-groups"` | Header name to read roles from (comma-separated, oauth2-proxy convention) |

Groups header value is parsed as a comma-separated list: each entry is trimmed and empty entries are dropped. Missing header yields empty roles (not an error). Role matching is case-sensitive.

> Only use `forwarded` behind a trusted reverse proxy. The proxy **must** strip these headers from incoming client requests — otherwise a client could forge identity and roles.

### OAuth AS config (`oauthAs`)

Required when `"oauth_as"` is in `providers`. Turns `mcp serve` into an OAuth 2.0 Authorization Server with Dynamic Client Registration so Claude.ai, ChatGPT, Cursor and other AI clients can connect. User authentication is delegated to a trusted reverse proxy (oauth2-proxy / Cloudflare Access / Pomerium) — `mcp serve` never handles passwords.

```json
{
  "oauthAs": {
    "issuerUrl": "https://mcp.example.com",
    "jwtSecret": "${MCP_OAUTH_AS_JWT_SECRET}",
    "trustedUserHeader": "x-forwarded-user",
    "trustedGroupsHeader": "x-forwarded-groups",
    "trustedSourceCidrs": ["10.0.0.0/8"],
    "accessTokenTtlSeconds": 3600,
    "refreshTokenTtlSeconds": 2592000,
    "authorizationCodeTtlSeconds": 60,
    "scopesSupported": ["mcp"],
    "redirectUriAllowlist": [
      "https://claude.ai/api/mcp/auth_callback",
      "https://chat.openai.com/aip/*/oauth/callback"
    ],
    "injectedRoles": ["oauth-user"]
  }
}
```

| Field                         | Type      | Required | Default                | Description                                                                                                                       |
| ----------------------------- | --------- | -------- | ---------------------- | --------------------------------------------------------------------------------------------------------------------------------- |
| `issuerUrl`                   | string    | yes      | —                      | Public origin the AS advertises in metadata and embeds as `iss` in JWTs. Must match what clients reach.                           |
| `jwtSecret`                   | string    | yes      | —                      | HMAC-SHA256 signing key. **Must be ≥ 32 bytes** — boot fails otherwise.                                                           |
| `trustedUserHeader`           | string    | no       | `"x-forwarded-user"`   | Header read at `/authorize` to identify the human.                                                                                |
| `trustedGroupsHeader`         | string    | no       | `"x-forwarded-groups"` | Comma-separated → JWT `groups` claim.                                                                                             |
| `trustedSourceCidrs`          | string\[] | yes      | —                      | CIDRs allowed to reach `/authorize`. **Empty list rejected at boot** — without it any client could spoof the trusted user header. |
| `accessTokenTtlSeconds`       | number    | no       | `3600`                 | JWT lifetime.                                                                                                                     |
| `refreshTokenTtlSeconds`      | number    | no       | `2592000` (30d)        | Refresh-token lifetime.                                                                                                           |
| `authorizationCodeTtlSeconds` | number    | no       | `60`                   | Authorization-code lifetime.                                                                                                      |
| `scopesSupported`             | string\[] | no       | `[]`                   | Scopes advertised in metadata.                                                                                                    |
| `redirectUriAllowlist`        | string\[] | yes      | —                      | Patterns clients may register. Trailing `*` allowed (for ChatGPT-style URIs).                                                     |
| `injectedRoles`               | string\[] | no       | `[]`                   | Roles always added to issued JWTs — useful as a "came in via OAuth" marker for ACL discrimination.                                |

State (registered clients via DCR + refresh tokens) persists to `auth_server.json` in the config directory; override with `MCP_AUTH_SERVER_PATH` or inline via `MCP_AUTH_SERVER_CONFIG`. See the [OAuth AS how-to](/how-to/oauth-as) for setup, security notes, and troubleshooting.

### ACL config

Optional. Controls which users can access which tools. Supports two schemas: **role-based** (recommended) and **legacy** (backward compatible). Detection is automatic — see [schema detection](#schema-detection).

#### Role-based schema (recommended)

```json
{
  "acl": {
    "default": "deny",
    "strictClassification": false,
    "roles": {
      "admin": [{ "server": "*", "access": "*" }],
      "dev": [
        { "server": ["github", "grafana"], "access": "read" },
        { "server": "github", "access": "write", "tools": ["gh_pr", "gh_issue"] }
      ],
      "readonly": [{ "server": "*", "access": "read" }]
    },
    "subjects": {
      "alice": { "roles": ["admin"] },
      "bob":   { "roles": ["dev"] },
      "charlie": {
        "roles": ["readonly"],
        "extra": [{ "server": "sentry", "access": "read" }]
      }
    }
  }
}
```

| Field                  | Type                  | Default   | Description                                                |
| ---------------------- | --------------------- | --------- | ---------------------------------------------------------- |
| `default`              | `"allow"` \| `"deny"` | `"allow"` | Policy when no grant matches                               |
| `strictClassification` | bool                  | `false`   | Block ambiguous tools entirely (require explicit override) |
| `roles`                | object                | `{}`      | Map of role name → list of grants                          |
| `subjects`             | object                | `{}`      | Map of subject → `{ roles, extra }`                        |

#### Grant

| Field       | Type                           | Default              | Description                                                       |
| ----------- | ------------------------------ | -------------------- | ----------------------------------------------------------------- |
| `server`    | string or string\[]            | *required*           | Server alias(es) to match (`"*"` = any)                           |
| `access`    | `"read"` \| `"write"` \| `"*"` | *required*           | Access level                                                      |
| `tools`     | string\[]                      | `[]` (all tools)     | Tool name globs to narrow the grant                               |
| `resources` | string\[]                      | `[]` (all resources) | Resource URI globs to narrow the grant. Same `*` syntax as tools. |
| `prompts`   | string\[]                      | `[]` (all prompts)   | Prompt name globs to narrow the grant. Same `*` syntax as tools.  |
| `deny`      | bool                           | `false`              | Turns grant into explicit deny (always wins over allows)          |

#### Subject config

| Field   | Type      | Default | Description                                              |
| ------- | --------- | ------- | -------------------------------------------------------- |
| `roles` | string\[] | `[]`    | Roles assigned to this subject (merged with token roles) |
| `extra` | Grant\[]  | `[]`    | Additional per-subject grants                            |

#### Access expansion

| `access`  | Read tools | Write tools | Ambiguous tools | Ambiguous (strict) |
| --------- | ---------- | ----------- | --------------- | ------------------ |
| `"read"`  | allowed    | denied      | denied          | denied             |
| `"write"` | denied     | allowed     | allowed         | **denied**         |
| `"*"`     | allowed    | allowed     | allowed         | **denied**         |

#### Evaluation model

1. Collect all grants from all roles (token roles + subject config roles) + `extra`
2. Filter to grants matching the target server and tool
3. If any matching grant has `deny: true` → **deny**
4. If any matching allow grant covers the access level → **allow**
5. No match → apply `default`

Union-based, order-independent. Deny always wins.

#### Legacy schema

```json
{
  "acl": {
    "default": "allow",
    "rules": [
      {
        "subjects": ["bob"],
        "roles": ["viewer"],
        "tools": ["sentry__*"],
        "policy": "deny"
      }
    ]
  }
}
```

| Field     | Type                  | Default   | Description                                  |
| --------- | --------------------- | --------- | -------------------------------------------- |
| `default` | `"allow"` \| `"deny"` | `"allow"` | Default policy when no rule matches          |
| `rules`   | array                 | `[]`      | Ordered list of ACL rules (first match wins) |

#### Legacy ACL rule

| Field      | Type                  | Default          | Description                                                                    |
| ---------- | --------------------- | ---------------- | ------------------------------------------------------------------------------ |
| `subjects` | string\[]             | `[]` (match all) | User subjects to match (`*` = any)                                             |
| `roles`    | string\[]             | `[]` (match all) | Roles to match (`*` = any)                                                     |
| `tools`    | string\[]             | *required*       | Tool name patterns (supports `*` wildcards — prefix, suffix, middle, multiple) |
| `policy`   | `"allow"` \| `"deny"` | *required*       | Action when rule matches                                                       |

Both `subjects` and `roles` must match for a rule to apply. Empty means "match all".

#### Schema detection

| JSON keys present                             | Schema used               |
| --------------------------------------------- | ------------------------- |
| `roles` (as object) or `subjects` (as object) | Role-based                |
| `rules` (as array)                            | Legacy                    |
| Both `rules` and `roles`/`subjects`           | Config error              |
| Neither                                       | Legacy with default allow |

#### Tool pattern glob syntax

The `tools` field supports glob patterns with `*` wildcards (both schemas):

| Pattern              | Matches                           | Example                                |
| -------------------- | --------------------------------- | -------------------------------------- |
| `sentry__*`          | Anything starting with `sentry__` | `sentry__search_issues`                |
| `*_issues`           | Anything ending with `_issues`    | `search_issues`, `sentry__list_issues` |
| `*admin*`            | Anything containing `admin`       | `admin_panel`, `user_admin_tools`      |
| `sentry__*_admin__*` | Multiple wildcards                | `sentry__team_admin__delete`           |
| `my_tool`            | Exact match (no wildcards)        | `my_tool`                              |
| `*`                  | Everything                        | any tool                               |

## Auth store

Tokens and OAuth client registrations are stored separately in:

```
~/.config/mcp/auth.json
```

```json
{
  "clients": {
    "https://server-url": {
      "client_id": "registered-client-id",
      "client_secret": "optional-secret"
    }
  },
  "tokens": {
    "https://server-url": {
      "access_token": "the-token",
      "refresh_token": "optional-refresh-token",
      "expires_at": 1710000000
    }
  }
}
```

Keys are normalized server URLs (trailing slash removed).


# Environment variables

## CLI variables

These variables configure `mcp` behavior:

| Variable                    | Default                                                                                     | Description                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                   |
| --------------------------- | ------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `MCP_SERVERS_CONFIG`        | —                                                                                           | Inline JSON config (entire `servers.json` content). Highest priority — skips file read entirely.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                              |
| `MCP_CONFIG_PATH`           | `~/.config/mcp/servers.json`                                                                | Path to the config file                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                       |
| `MCP_CONFIG_DIR`            | `~/.config/mcp`                                                                             | Config directory. Falls back to `/tmp/mcp` when `HOME` is not set.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                            |
| `MCP_TIMEOUT`               | `60`                                                                                        | Timeout in seconds for server responses (stdio, CLI, and HTTP transports)                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                     |
| `MCP_MAX_OUTPUT`            | `1048576`                                                                                   | Maximum output bytes from CLI server commands                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                 |
| `MCP_PROXY_REQUEST_TIMEOUT` | `120`                                                                                       | (proxy mode) Hard upper bound, in seconds, that any single client request can spend inside `mcp serve` before the proxy returns a JSON-RPC error. Acts as a belt-and-suspenders boundary on top of the per-transport `MCP_TIMEOUT`.                                                                                                                                                                                                                                                                                                                                                                                                                                           |
| `MCP_CLASSIFIER_CACHE`      | `~/.config/mcp/tool-classification.json`                                                    | Path to the persistent tool read/write classification cache (see [`mcp acl classify`](/reference/cli#mcp-acl-classify)). Override this in CI/containers that cannot write to `$HOME`.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                         |
| `MCP_DISCOVERY_CONCURRENCY` | `10`                                                                                        | Max parallel `--help` calls during CLI subcommand discovery (see [CLI as MCP](/guides/cli-as-mcp))                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                            |
| `MCP_AUDIT_OUTPUT`          | unset (→ `file` for CLI, `file+stdout` for `serve --http`, `file+stderr` for `serve` stdio) | Audit output destination: `file` (ChronDB, queryable via `mcp logs`), `stdout`, `stderr` (JSON lines for container log drivers), `file+stdout`, `file+stderr` (ChronDB **and** JSON lines for both `mcp logs` and a log driver), or `none` (disable). Setting this env var marks the value as **explicit** and skips the per-context auto-promotion (so `MCP_AUDIT_OUTPUT=file` reliably forces chrondb-only output, including in `mcp serve`). Leaving it unset (and the config field absent) lets each context pick its safe default — `file` for CLI to keep stdout clean for command output, dual-sink for `mcp serve` so audit shows up in `docker logs`/`kubectl logs`. |
| `MCP_AUDIT_ENABLED`         | `true`                                                                                      | Set to `false` or `0` to disable audit logging and database initialization. Overrides `audit.enabled` in the config file.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                     |
| `MCP_AUDIT_PATH`            | `~/.config/mcp/db/data`                                                                     | Override the ChronDB data directory. Overrides `audit.path` in the config file.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                               |
| `MCP_AUDIT_INDEX_PATH`      | `~/.config/mcp/db/index`                                                                    | Override the ChronDB index directory. Overrides `audit.index_path` in the config file.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                        |
| `MCP_LOG_LEVEL`             | `info`                                                                                      | Log verbosity: `trace`, `debug`, `info`, `warn`, `error`. Uses `tracing` `EnvFilter` syntax — you can also set per-module levels like `mcp=debug,hyper=warn`.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                 |
| `MCP_LOG_FORMAT`            | `text`                                                                                      | Log output format: `text` (human-readable) or `json` (structured, for container log drivers).                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                 |
| `MCP_OAUTH_CALLBACK_PORT`   | `8085-8099`                                                                                 | Port or range for the OAuth callback listener. Single port (`9000`), range (`9000-9010`), or `0` for OS-assigned.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                             |
| `MCP_AUTH_CONFIG`           | —                                                                                           | Inline JSON content of `auth.json` (read-only). Highest priority for auth — skips file read. Writes are no-ops with a single `warn` log. Intended for k8s/Docker Secrets.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                     |
| `MCP_AUTH_PATH`             | `~/.config/mcp/auth.json`                                                                   | Override the OAuth token storage location (file path)                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                         |
| `MCP_AUTH_SERVER_CONFIG`    | —                                                                                           | Inline JSON of the OAuth Authorization Server state (`auth_server.json`). Same precedence model as `MCP_AUTH_CONFIG`: read-only on disk, mutable in memory. Used when `serverAuth.providers` includes `"oauth_as"`.                                                                                                                                                                                                                                                                                                                                                                                                                                                           |
| `MCP_AUTH_SERVER_PATH`      | `~/.config/mcp/auth_server.json`                                                            | File path for AS state (registered DCR clients + refresh tokens). Used when `serverAuth.providers` includes `"oauth_as"`.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                     |
| `MCP_OAUTH_AS_JWT_SECRET`   | —                                                                                           | HMAC-SHA256 signing key for issued JWTs when running the OAuth Authorization Server. Must be ≥ 32 bytes. Typically referenced from `serverAuth.oauthAs.jwtSecret` via `${MCP_OAUTH_AS_JWT_SECRET}`. Rotation invalidates every previously issued token.                                                                                                                                                                                                                                                                                                                                                                                                                       |

### Config loading priority

`mcp` resolves its configuration in this order:

1. **`MCP_SERVERS_CONFIG`** — inline JSON string, parsed directly (no file read)
2. **`MCP_CONFIG_PATH`** — path to a config file
3. **`MCP_CONFIG_DIR`/servers.json** — config directory override
4. **`~/.config/mcp/servers.json`** — default file location
5. **`/tmp/mcp/servers.json`** — last-resort fallback when `HOME` is not set

Environment variable substitution (`${VAR_NAME}`) works in all cases, including inline config.

### `MCP_SERVERS_CONFIG`

Provide the entire config as a JSON string. This is the recommended approach for containers — no file mounts required:

```bash
export MCP_SERVERS_CONFIG='{
  "mcpServers": {
    "sentry": {
      "url": "https://mcp.sentry.dev/sse",
      "headers": {"Authorization": "Bearer ${SENTRY_TOKEN}"}
    }
  }
}'
mcp serve --http 0.0.0.0:8080
```

Load from an existing file with `$(cat ...)`:

```bash
MCP_SERVERS_CONFIG="$(cat servers.json)" mcp serve --http 0.0.0.0:8080
```

`MCP_SERVERS_CONFIG` takes priority over `MCP_CONFIG_PATH`. If both are set, the inline config wins.

### `MCP_CONFIG_PATH`

Override the default config file location. Useful for maintaining multiple configs or testing.

```bash
MCP_CONFIG_PATH=./test-servers.json mcp --list
```

### `MCP_CONFIG_DIR`

Override the base config directory (default `~/.config/mcp`). All default paths (`servers.json`, `auth.json`, `db/`, `tool-classification.json`) resolve relative to this directory.

```bash
MCP_CONFIG_DIR=/data/mcp mcp serve --http 0.0.0.0:8080
```

When `HOME` is not set (common in `scratch` and `distroless` containers), `mcp` falls back to `/tmp/mcp` with a warning.

### `MCP_TIMEOUT`

How long to wait for a server to respond, in seconds. Applies to all transports: stdio, CLI, and HTTP. Increase this for servers that take a long time to initialize (like some npm packages on first run) or slow HTTP backends.

```bash
MCP_TIMEOUT=120 mcp slack --list
```

One request is deliberately not covered by it: the `server/discover` probe `mcp` sends on connect to detect an MCP 2026-07-28 server. A backend that predates that revision usually just never answers, and waiting `MCP_TIMEOUT` for a reply we expect to fail would cost that much on *every* connection, so the probe is capped at 3 seconds and then falls back to the `initialize` handshake.

### `MCP_MAX_OUTPUT`

Maximum number of bytes to capture from a CLI server's stdout. Commands that exceed this limit have their output truncated. Default is 1 MB.

```bash
MCP_MAX_OUTPUT=5242880 mcp my-cli some-tool '{"query": "large dataset"}'
```

### `MCP_PROXY_REQUEST_TIMEOUT`

Only applies to `mcp serve`. Bounds how long the proxy will wait for any single client JSON-RPC request to complete end-to-end (auth + routing + backend I/O). If the bound is hit, the client receives a JSON-RPC error with code `-32000` and the in-flight request is dropped — other concurrent clients are unaffected. Set lower for tighter SLAs, higher for backends that legitimately take a long time.

```bash
MCP_PROXY_REQUEST_TIMEOUT=60 mcp serve --http :7332
```

### `MCP_CLASSIFIER_CACHE`

Override the path of the tool read/write classification cache. The cache is a JSON file populated lazily by `mcp serve` and `mcp acl classify`, keyed by `(server, tool, hash(description))`. If the description changes, that tool's entry is transparently invalidated.

Useful when `$HOME` is read-only (CI workers, containers) — point the cache at an ephemeral path:

```bash
MCP_CLASSIFIER_CACHE=/tmp/classify.json mcp acl classify
```

Corrupt or unreadable cache files are non-fatal: a warning is logged and the process proceeds with fresh in-memory classifications.

### `MCP_DISCOVERY_CONCURRENCY`

Only applies to CLI servers (`cli: true`). Limits how many `--help` calls run in parallel during subcommand discovery. Lower this if your machine struggles with many concurrent child processes, or raise it to speed up discovery for deeply nested CLIs.

```bash
MCP_DISCOVERY_CONCURRENCY=5 mcp kubectl --list
```

### `MCP_AUDIT_ENABLED`

Disable audit logging entirely. When set to `false` or `0`, the database is not initialized and no filesystem writes occur. This overrides `"enabled": true` in the config file's `audit` section.

The default Docker image sets `MCP_AUDIT_ENABLED=false` because `scratch` images have no writable filesystem. Override it when you mount a volume:

```bash
docker run --rm \
  -e MCP_AUDIT_ENABLED=true \
  -e MCP_AUDIT_PATH=/data/audit/data \
  -e MCP_AUDIT_INDEX_PATH=/data/audit/index \
  -v audit-data:/data/audit \
  ghcr.io/avelino/mcp serve --http 0.0.0.0:8080
```

### `MCP_AUDIT_PATH` / `MCP_AUDIT_INDEX_PATH`

Override the ChronDB data and index directories. These take priority over the `audit.path` and `audit.index_path` fields in the config file.

```bash
MCP_AUDIT_PATH=/var/lib/mcp/data MCP_AUDIT_INDEX_PATH=/var/lib/mcp/index mcp serve --http 0.0.0.0:8080
```

### `MCP_LOG_LEVEL`

Controls verbosity of `tracing` events emitted by the proxy and CLI. Output goes to **stderr** (so `stdout` stays clean for `mcp logs --json`, `MCP_AUDIT_OUTPUT=stdout`, and stdio JSON-RPC).

Accepts the full [`tracing` `EnvFilter`](https://docs.rs/tracing-subscriber/latest/tracing_subscriber/filter/struct.EnvFilter.html) syntax — not just a single level:

```bash
# Global level
MCP_LOG_LEVEL=debug mcp serve --http :7332

# Per-module: keep mcp at debug, silence noisy HTTP-stack libs
MCP_LOG_LEVEL='mcp=debug,hyper=warn,reqwest=warn,h2=warn' mcp serve --http :7332

# Trace a single module
MCP_LOG_LEVEL='mcp::server_auth=trace,info' mcp serve --http :7332
```

The per-module pattern is the one to reach for in containers. A bare `debug` floods logs with HTTP framing noise from `hyper`/`h2`/`reqwest` — scoping `mcp` to `debug` and the rest to `warn` keeps the signal-to-noise ratio usable.

Invalid filter strings fall back silently to `info`.

### `MCP_LOG_FORMAT`

Selects between human-readable and structured logging. Defaults to `text` (color, indented multiline events). Set to `json` for newline-delimited JSON — one complete event per line, drop-in for any log driver:

```bash
MCP_LOG_FORMAT=json mcp serve --http 0.0.0.0:8080
```

Each event is a self-contained JSON object:

```json
{"timestamp":"2026-04-14T12:00:00.123Z","level":"INFO","fields":{"message":"backend connected","server":"sentry"}}
{"timestamp":"2026-04-14T12:00:01.456Z","level":"WARN","fields":{"message":"discovery failed","server":"grafana","error":"connection refused"}}
```

Combined with [`MCP_AUDIT_OUTPUT=stdout`](#mcp_audit_enabled), every byte the container emits is JSON: tracing on stderr, audit on stdout. No mixed formats, no parse rules to maintain.

```bash
# Container-friendly logging recipe:
docker run -d \
  -e MCP_LOG_LEVEL='mcp=debug,hyper=warn,reqwest=warn,h2=warn' \
  -e MCP_LOG_FORMAT=json \
  -e MCP_AUDIT_OUTPUT=stdout \
  ghcr.io/avelino/mcp serve --http 0.0.0.0:8080 --insecure
```

### `MCP_AUTH_CONFIG`

Inline JSON content of `auth.json`, equivalent to [`MCP_SERVERS_CONFIG`](#mcp_servers_config) but for OAuth tokens and dynamic-client registrations. Highest priority — when set, the file at `MCP_AUTH_PATH` (or the default location) is **not** read.

```bash
export MCP_AUTH_CONFIG='{
  "clients": {
    "https://mcp.sentry.dev": {"client_id": "abc123"}
  },
  "tokens": {
    "https://mcp.sentry.dev": {
      "access_token": "${SENTRY_ACCESS_TOKEN}",
      "refresh_token": "${SENTRY_REFRESH_TOKEN}"
    }
  }
}'
mcp serve --http 0.0.0.0:8080
```

`${VAR}` placeholders are expanded the same way as in [`MCP_SERVERS_CONFIG`](#mcp_servers_config), so you can split tokens across multiple Secret keys.

**Read-only on disk; mutable in memory.** When `MCP_AUTH_CONFIG` is set, the env var seeds an in-memory auth store on first load. Subsequent OAuth flows — token refresh, dynamic-client registration — update the cache so refreshed tokens are visible to later calls within the same process. Nothing is ever written back to disk (the source of truth is the Secret), and a single `warn` log is emitted on the first save attempt. On pod restart, the Secret is read again — any in-memory mutations are discarded.

**Use cases:**

* Kubernetes deployments where the pod has a read-only filesystem and OAuth tokens come from a Secret (see [Deploying on Kubernetes](/how-to/kubernetes))
* Docker containers where mounting a writable `auth.json` is undesirable
* CI environments that need pre-provisioned tokens for ephemeral runs

**Not recommended for:**

* Local development on a workstation — use the default `auth.json` and let `mcp add` handle the OAuth flow.
* Any environment where you expect `mcp add <server>` to register a new client and persist the result. That flow requires a writable file.

`MCP_AUTH_CONFIG` takes priority over `MCP_AUTH_PATH`. If both are set, the inline content wins. An empty or whitespace-only value falls through to the file path.

### `MCP_AUTH_PATH`

Override the OAuth token storage location (file path). Default is `~/.config/mcp/auth.json`. Useful in containers where `$HOME` doesn't exist, or to share an auth store across multiple `mcp` invocations.

```bash
MCP_AUTH_PATH=/data/auth.json mcp add sentry --remote https://mcp.sentry.dev
```

For container deployments where the filesystem is read-only or you want tokens injected from a secret manager, use [`MCP_AUTH_CONFIG`](#mcp_auth_config) instead.

### Auth loading priority

`mcp` resolves the auth store in this order:

1. **`MCP_AUTH_CONFIG`** — inline JSON (read-only, no file I/O)
2. **`MCP_AUTH_PATH`** — file path override
3. **`MCP_CONFIG_DIR`/auth.json** — config directory override
4. **`~/.config/mcp/auth.json`** — default file location

### `MCP_AUTH_SERVER_CONFIG`

Inline JSON for the OAuth Authorization Server state — registered clients (Dynamic Client Registration / RFC 7591) and persisted refresh tokens. Equivalent to `MCP_AUTH_CONFIG` but for the *server-side* AS state file (`auth_server.json`), used only when `serverAuth.providers` contains `"oauth_as"`.

Same precedence model: inline content takes priority, writes during runtime stay in memory only (the on-disk source is treated as read-only, matching how Kubernetes Secrets behave).

```bash
export MCP_AUTH_SERVER_CONFIG='{
  "clients": {},
  "refresh_tokens": {}
}'
```

Authorization codes are intentionally **not** persisted — restart drops them, which is the safer default than letting captured codes resume post-restart.

### `MCP_AUTH_SERVER_PATH`

Override the file location for AS state. Default is `~/.config/mcp/auth_server.json`. The file is rewritten atomically via tempfile-then-rename, so a crash mid-write cannot leave a partial JSON behind.

```bash
MCP_AUTH_SERVER_PATH=/var/lib/mcp/auth_server.json mcp serve --http 0.0.0.0:8080
```

`MCP_AUTH_SERVER_CONFIG` takes priority over `MCP_AUTH_SERVER_PATH`. Both apply only when `oauth_as` is in `serverAuth.providers`; otherwise they're ignored.

### `MCP_OAUTH_AS_JWT_SECRET`

HMAC-SHA256 signing key for the JWTs issued by the OAuth Authorization Server. Must be at least 32 bytes — boot fails otherwise. Generate with:

```bash
export MCP_OAUTH_AS_JWT_SECRET=$(openssl rand -hex 32)
```

Reference it from `serverAuth.oauthAs.jwtSecret` via `${MCP_OAUTH_AS_JWT_SECRET}` so the secret never lives in the config file.

> Rotation invalidates every previously issued access and refresh token. v1 has no in-place rotation — plan for a forced re-login when changing the secret.

## Config variables

Environment variables referenced in `servers.json` with `${VAR_NAME}` syntax. These are user-defined and depend on which servers you've configured.

Common examples:

| Variable        | Service       | Description                                  |
| --------------- | ------------- | -------------------------------------------- |
| `GITHUB_TOKEN`  | GitHub        | Personal access token                        |
| `SLACK_TOKEN`   | Slack         | Bot or user OAuth token (`xoxb-` or `xoxp-`) |
| `SENTRY_TOKEN`  | Sentry        | Auth token                                   |
| `GRAFANA_TOKEN` | Grafana       | Service account token                        |
| `ROAM_TOKEN`    | Roam Research | Graph API token                              |

Set them in your shell profile (`~/.bashrc`, `~/.zshrc`, etc.):

```bash
export GITHUB_TOKEN="ghp_..."
export SLACK_TOKEN="xoxb-..."
```

Or pass them inline:

```bash
GITHUB_TOKEN="ghp_..." mcp github --list
```


# Architecture

How `mcp` works internally. Read this if you want to contribute, debug an issue, or just understand what happens when you run a command.

## The big picture

When you run `mcp sentry search_issues '{"query": "is:unresolved"}'`, this is what happens:

```
1. Parse CLI args → server="sentry", tool="search_issues", args={...}
2. Load config → find "sentry" in servers.json → it's an HTTP server
3. Create transport → HttpTransport with the server URL
4. Load saved auth token (if any)
5. Protocol negotiation:
   → Send "server/discover" (2026-07-28 probe)
   ← If it works: pick the newest revision both sides speak
   ← If it fails for any reason: fall back to the legacy handshake
     → Send "initialize" request with protocol version
     ← Receive server capabilities
     → Send "notifications/initialized"
6. Send "tools/call" request with tool name and arguments
   ← If 401: start OAuth flow → retry with new token
   ← Receive tool result
7. Print JSON result to stdout
8. Close transport
```

The whole thing is a single async pipeline. No daemon, no background process, no state between runs (except saved tokens).

## Transport: the core abstraction

The most important design decision is the `Transport` trait:

```rust
trait Transport: Send + Sync {
    async fn request(&self, msg: &JsonRpcRequest) -> Result<JsonRpcResponse>;
    async fn notify(&self, msg: &JsonRpcNotification) -> Result<()>;
    async fn close(&self) -> Result<()>;
}
```

Note the `&self` (not `&mut self`) and the `Sync` bound. This is what makes the proxy non-blocking under load: a single transport instance can be shared across many concurrent tasks via `Arc<dyn Transport>`, and each implementation uses interior mutability (channels, atomics, mutexes) for the small amount of state it needs to mutate. There is no global lock around the client.

Three implementations:

**StdioTransport** — Spawns a child process and runs it as a multiplexed pipe. A dedicated **writer task** owns the child's stdin and serializes outbound writes. A dedicated **reader task** consumes the child's stdout line-by-line and dispatches each response to its caller via a `oneshot` channel keyed by JSON-RPC `id`. The result: **multiple in-flight requests can run concurrently on the same backend process** — callers only block waiting for their own response. The child is spawned with `kill_on_drop(true)` so it is reaped on any cleanup path (graceful shutdown, panic, task abort, error). On `close()` the child gets a brief grace period and is then force-killed.

**HttpTransport** — Sends HTTP POST requests with JSON-RPC bodies. Handles SSE (Server-Sent Events) responses by extracting the last `data:` line. Manages session IDs via `Mcp-Session-Id` headers — but only while the negotiated revision is a legacy one; 2026-07-28 removed sessions, so the header is neither sent nor captured once that revision is agreed. Every POST also carries `Mcp-Method` and (when the request targets a single primitive) `Mcp-Name`, so gateways can route and meter without parsing the body. `Mcp-Method` mirrors the JSON-RPC method verbatim; `Mcp-Name` carries a tool name or resource URI, which is not constrained to header-safe characters, so a value that isn't printable ASCII — or that would be ambiguous — is wrapped in the spec's Base64 sentinel `=?base64?…?=`, which the receiving server decodes before comparing it to the body. Either header is dropped rather than truncated when the encoded value exceeds 1 KiB. When the body's `_meta` declares a protocol version, the same value goes out as the `MCP-Protocol-Version` header, and `Mcp-Param-*` headers are added from the called tool's `x-mcp-header` annotations. On 401 responses, triggers the authentication flow and retries once. Mutable state (`session_id`, `bearer_token`, `headers` after a 401) lives behind small `Mutex`es; `reqwest::Client` is already `Send + Sync`, so concurrent requests fan out at the HTTP layer.

**CliTransport** — Wraps any command-line tool as an MCP server (see [CLI as MCP](/guides/cli-as-mcp)). Discovery state lives behind an `RwLock` with double-checked locking, and each tool invocation spawns a fresh `Command` with `kill_on_drop(true)` so cancellation reaps the child instead of leaking it.

`McpClient` wraps the transport in `Arc<dyn Transport>` and uses an `AtomicU64` for request id generation, so a single `Arc<McpClient>` is safe to share across any number of tasks. Adding a new transport (WebSocket, for example) means implementing the three trait methods — nothing else changes.

## Authentication: layered fallbacks

Auth only applies to HTTP servers. The strategy is a cascade:

1. **Config headers** — If `servers.json` has an `Authorization` header with a non-empty token, use it
2. **Saved token** — On connect, load token from `auth.json` (if valid and not expired)
3. **OAuth 2.0** — On 401 response:
   * Discover the authorization server (RFC 9728 Protected Resource Metadata → `.well-known/oauth-authorization-server`), rejecting a metadata document whose `issuer` doesn't match where it was fetched from (RFC 8414 §3.3); an absent `issuer` stays allowed, since hand-rolled documents routinely omit it
   * Register as a client (Dynamic Client Registration, `application_type: native`)
   * Run Authorization Code flow with PKCE (S256)
   * Open browser, listen for callback on localhost:8085-8099
   * Validate the callback's `iss` against the discovered issuer when present (RFC 9207)
   * Exchange code for tokens, save them
4. **Manual prompt** — If OAuth registration fails, ask the user for a token interactively. Show service-specific hints for known services (Sentry, GitHub, Slack, etc.)

Tokens are stored per server URL (normalized, trailing slash stripped) — they are scoped to the resource server. Client *registrations* are keyed by the authorization server's `issuer` instead, so one AS's `client_id` is never reused with another. Stores written by older builds keyed registrations by MCP server URL; they are migrated in place (`version` + `legacy_clients`) and each entry is adopted under an issuer only *after* a token exchange with that issuer succeeds — a server naming an issuer is not enough to claim someone else's registration. Nothing has to be re-registered. Refresh tokens are used automatically when access tokens expire.

## Protocol: JSON-RPC 2.0 over MCP

`mcp` implements a subset of the [Model Context Protocol](https://spec.modelcontextprotocol.io/):

**Negotiation:**

* `server/discover` → Server lists the revisions it speaks (2026-07-28)
* `initialize` + `notifications/initialized` → Legacy handshake, for peers that don't answer the probe

**Tool operations:**

* `tools/list` → Returns available tools (with pagination via cursor)
* `tools/call` → Execute a tool with arguments, get results

**Resource operations:**

* `resources/list` → Returns available resources, aggregated across upstreams
* `resources/read` → Read a specific resource by URI

**Prompt operations:**

* `prompts/list` → Returns available prompts, aggregated across upstreams
* `prompts/get` → Get a specific prompt by name (with optional arguments)

All three categories use the same `{server}__{name}` aliasing to keep items from different upstreams distinguishable. Sampling and other MCP features are not implemented.

Responses follow the MCP content model: an array of content items, each with a type (`text`, `image`) and corresponding data. The `isError` flag indicates tool-level errors (distinct from protocol errors).

### Protocol revisions: the dual stack

`mcp` speaks two generations of MCP at once. The newest revision it implements is **2026-07-28**; the previous one, **2025-11-25**, is what every backend in the wild still speaks. Accepted revisions, newest first: `2026-07-28`, `2025-11-25`, `2025-06-18`, `2025-03-26`, `2024-11-05`. Anything outside that set is rejected with `-32022` (UnsupportedProtocolVersion).

**As a client**, `McpClient::negotiate()` probes `server/discover` first. The probe answers with `supportedVersions` (plus `capabilities`, and `serverInfo` inside `_meta`); the client picks the newest revision both sides list and — when that revision is stateless — skips the handshake entirely. The probe is bounded by its own **3-second** timeout rather than the request timeout, because the likeliest answer from today's backends is no answer at all and that is not worth a minute of startup. If it fails for *any* reason (method not found, some other JSON-RPC error, a timeout, a transport failure, or a result that isn't a discovery result), the client falls back to `initialize` at `2025-11-25` and behaves exactly as it did before, adopting whatever earlier revision the server echoes back. When the probe killed the transport outright — a stdio backend that exits on an unknown method — the transport is re-opened before the handshake runs, so a fatal probe still ends in a working connection. The agreed revision is fixed at connect time and surfaced by `McpClient::protocol_version()` (and by `mcp <server> --health --json`).

What "stateless" changes on the wire, once 2026-07-28 is agreed:

* No `initialize`/`notifications/initialized`, no `Mcp-Session-Id`.
* Every request carries its own `params._meta`: `io.modelcontextprotocol/protocolVersion`, `clientCapabilities`, `clientInfo`, plus W3C trace context (`traceparent`/`tracestate`/`baggage`) when telemetry is enabled — which is the only way a stdio backend ever sees a trace, since it has no headers.
* Results carry a `resultType` discriminator. A result that omits it reads as `"complete"`, which is what keeps older peers working. `input_required` is the interim result of a Multi Round-Trip Request (MRTR) — the typed accessors refuse it, and the raw paths (`request_raw`, `call_tool_raw`, `read_resource_raw`, `get_prompt_raw`) relay it untouched.
* A tool may annotate input properties with `x-mcp-header`, asking for an argument to be mirrored into an `Mcp-Param-{Name}` header on `tools/call`. Both halves are implemented: annotations that break the spec's constraints invalidate the whole tool definition, which is dropped from `tools/list` with a warning (the annotation names the header, so an unchecked one lets a backend aim at `Authorization` or smuggle a CRLF), and valid ones are mirrored on every call. The mirroring only happens once 2026-07-28 is agreed — a legacy peer never agreed to receive those headers — but the validation runs against every peer and every transport, because `mcp serve` re-exports a stdio backend's tools over HTTP one hop later.

On a legacy peer the client attaches **no** `_meta` at all. Absence is the legacy wire shape, and injecting keys a pre-2026-07-28 server never agreed to is exactly what trips strict schema validators.

**As a server** (`mcp serve`), the revision is negotiated per request rather than per connection: it is read from `params._meta`, and its absence means a legacy client — never an error. `server/discover` advertises the accepted set; `initialize` is still served and now echoes back a revision the *client* asked for rather than blindly the newest one. A result going to a client that *declared* the new revision is stamped with `resultType` and `_meta` serverInfo and, on the `*/list` methods, `ttlMs` + `cacheScope: "private"` (they are ACL-filtered per identity, so no shared intermediary may cache them); a client that declared nothing gets none of these, because they do not exist in the revision it negotiated. `*/list` results are sorted deterministically for everyone, and `tools/call`, `resources/read` and `prompts/get` are relayed raw so MRTR exchanges pass through intact — the client's `inputResponses` / `requestState` reach the backend, and the backend's interim result reaches the client. What the backend does *not* get to decide is caching: `ttlMs` / `cacheScope` on a relayed result are stripped, and re-stated by the proxy only where it has something to say (`resources/read`, at `ttlMs: 0`), since only the proxy knows the result was ACL-filtered.

Two more things change for a peer that declared 2026-07-28. The routing headers (`MCP-Protocol-Version`, `Mcp-Method`, `Mcp-Name`) are validated **only when present** — legacy clients send none and are not penalized — and a header contradicting the body is rejected with `-32020`, because that means a gateway routed or metered on a lie. And errors the transport pins to a status code get one: `-32020` and `-32022` answer `400`, and `-32601` answers `404` for a peer that declared the new revision (only for such a peer — legacy clients have always received `-32601` on `200` and still do). This matters because the spec's era-detection has clients inspect the body of a `400` to decide whether to fall back to `initialize`.

## Config: untagged enum deserialization

Server configs use serde's untagged enum:

```rust
enum ServerConfig {
    Stdio { command, args, env, idle_timeout, min_idle_timeout, max_idle_timeout },
    Http { url, headers, idle_timeout, min_idle_timeout, max_idle_timeout },
}
```

Serde tries each variant in order. If the JSON has `command`, it's Stdio. If it has `url`, it's HTTP. This means the config file doesn't need a `type` field — the structure itself determines the type.

Environment variable substitution (`${VAR}`) happens at config load time via regex replacement, before JSON parsing. Missing vars become empty strings.

## Registry: search and scaffold

The registry integration is straightforward:

* Search the [official MCP registry API](https://registry.modelcontextprotocol.io/v0.1/servers) by query
* Find a server by exact name
* Generate a config entry from registry metadata (command, args, env var placeholders)

When adding from registry, packages (stdio) take priority over remotes (HTTP). Environment variables get `${VAR}` placeholders so the user sets them in their shell.

## Output: dual format (Text + JSON)

Output adapts to the context automatically via `OutputFormat::detect()`:

* **Interactive terminal** — colored tables with `comfy-table`, styled text with `console` crate
* **Piped or redirected** — JSON for composability with `jq`
* **`--json` flag** — forces JSON output regardless of context

All output functions return `Result` and write to stdout. Errors and status messages go to stderr. This separation is critical for scripting — `stdout` is always valid structured data, `stderr` is for humans.

For tool call results, text content prints directly in interactive mode. Images show a `[image: mime/type]` placeholder. Validation errors from MCP servers (e.g. Sentry-style structured errors) are parsed and reformatted into readable per-field messages with colored highlighting. JSON mode wraps everything in the MCP protocol format (`content` array + `isError` flag).

## Proxy mode: `mcp serve`

The proxy inverts the CLI's role — instead of being a **client** that talks to one server, it becomes a **server** that talks to many.

```
MCP Client  ←stdin/stdout→  mcp serve  ←→  backend 1 (stdio)
                                        ←→  backend 2 (http)
                                        ←→  backend N
```

Backends are managed lazily with a persistent tool cache. On startup, the proxy loads previously discovered tools from a local [ChronDB](https://chrondb.avelino.run/) database and serves them immediately — no backend connections needed. A background task then connects to all backends to refresh the cache. On first run (no cache), `tools/list` blocks on full discovery as a fallback. On `tools/call`, the proxy infers the target backend from the namespaced name (`server__tool`) and discovers **only that backend** if it hasn't been seen yet — other backends are not touched. This means a call to `gh__issue` only waits for the `gh` backend, not for every other server to finish discovery. If the backend cannot be inferred (e.g. a non-namespaced tool name), the proxy falls back to discovering all pending backends.

Cache invalidation is per-backend via SHA-256 hash of the raw config JSON. If a backend's config changes in `servers.json`, its cached tools are discarded and re-discovered. The cache and audit log share a single [ChronDB](https://chrondb.avelino.run/) database (`~/.config/mcp/db/`), separated by key prefix (`cache:tools:*` vs `audit:*`).

Each backend tracks usage statistics: request count, first/last use timestamps, and an exponential moving average (EMA) of inter-request intervals. A background reaper task runs every 30 seconds and shuts down backends that exceed their idle timeout. The timeout is adaptive by default — frequently used backends (>20 req/h) get 5 minutes, moderately used (5-20 req/h) get 3 minutes, and rarely used (<5 req/h) get 1 minute. Users can override this per backend with fixed timeouts or `"never"`.

A **warm-up grace period** protects freshly-connected backends: a backend with `request_count == 0` is never reaped before its `max_idle_timeout` elapses, so the proxy doesn't kill a backend you haven't gotten around to using yet. Without this, the proxy would reap idle backends \~60 seconds after start and the very first real `tools/call` would always pay a full reconnect.

When the reaper does fire, it shuts down all eligible backends **in parallel** via a `tokio::task::JoinSet`. If a backend's graceful `shutdown()` doesn't finish within 5 seconds, the reaper drops the `Arc<McpClient>` and `kill_on_drop(true)` force-reaps the child — orphaned backend processes are not possible by construction.

When a backend is shut down, its tools remain in the tool list (cached in memory and on disk). On the next `tools/call` targeting that backend, the proxy transparently reconnects, refreshes the tool cache, and forwards the request. Usage stats are preserved across reconnections for adaptive timeout continuity.

The proxy reuses the same `McpClient` and `Transport` abstractions — no new protocol code was needed. It just listens on stdin instead of connecting to a server's stdin.

Error handling is partial-availability: if one backend fails to connect, the others still work. If a backend dies mid-session, the proxy returns an MCP-level error for that tool call without crashing.

### Concurrency model

The proxy is the orchestrator for **N concurrent clients sharing the same set of backends**. The whole pipeline is built so that no single client, request, or backend can wedge any of the others.

Backends are pooled by name in a `HashMap<String, BackendState>` inside `ProxyServer`, and each connected backend is held as `Arc<McpClient>`. A request flows through `dispatch_request` in three carefully scoped phases:

1. **Resolve (under a brief proxy lock)** — look up the namespaced tool in `tool_map`, run the ACL check, and clone the `Arc<McpClient>` out of `BackendState::Connected`. The lock is released before any I/O.
2. **Connect (without the proxy lock)** — if no client exists yet, `connect_backend()` spawns the child, runs the MCP handshake and `tools/list`, and only then briefly re-acquires the lock to install the new client (deduplicating against any concurrent connector).
3. **Invoke (without the proxy lock)** — `client.call_tool().await` runs entirely outside the proxy lock. Because `McpClient` and `Transport` are `&self`, the same `Arc<McpClient>` is invoked in parallel by every concurrent caller; the stdio multiplexer described above handles fan-in/fan-out by id.

Discovery — the act of connecting to a previously-unseen backend and listing its tools — used to run **under** the proxy lock, which meant a single slow backend (e.g. a 30-second OAuth handshake) could wedge every other client until it returned. That is fixed by a separate `discovery_lock: Arc<Mutex<()>>` on `ProxyServer`. Discovery batches now snapshot the pending set under a brief lock, drop the proxy lock, run all the connect attempts in parallel **without** holding the proxy mutex, and only re-acquire the lock briefly to commit each result. Two callers that both want to discover are serialized on the discovery lock (so they don't double-spawn), but request handlers targeting already-discovered backends fly through with zero contention while a discovery batch is in progress.

For single-item requests (`tools/call`, `resources/read`, `prompts/get`), the proxy uses **per-server lazy discovery**: it infers the target backend from the namespaced name and calls `discover_single_backend` instead of `discover_pending_backends`. This means the request discovers only the needed server rather than proactively discovering kubectl, grafana, or every other pending backend. However, `discover_single_backend` still runs under the same shared `discovery_lock`, so it can wait behind another discovery already in progress. Full batch discovery is reserved for listing operations (`tools/list`, `resources/list`, `prompts/list`) where the client expects the complete catalog.

The HTTP+SSE legacy transport has its own backpressure trap: each client session is fed by a bounded `mpsc` channel, and a slow consumer can fill the buffer. The POST handler bounds its `tx.send(...)` with a 5s timeout — on failure or timeout, the session is **evicted** from the session map and the client is expected to reconnect. The SSE keepalive ping background task uses `try_send` instead of `send().await` so a momentarily-full buffer never blocks it; after \~1 minute of consecutive full-buffer pings the session is also evicted as wedged.

Practical consequences:

* Calls to **different** backends are fully parallel.
* Calls to the **same** backend are also parallel — they fan out through one shared process via the stdio multiplexer (or through `reqwest`'s native concurrency for HTTP backends). One backend = one OS process, regardless of how many clients are connected.
* A slow or hung backend only delays the requests targeting it. Other clients keep moving.
* A slow discovery (e.g. an unreachable backend hitting its 30s timeout) blocks only other callers that also need discovery for the same backend. A `tools/call` for a different backend discovers only its target — it is not delayed by the slow one. Already-discovered backends keep serving requests normally.
* A dead client only loses its own request. The HTTP listener is bound with TCP keepalive (30s idle / 10s interval) so half-open sockets from crashed clients are detected within \~60s, and `MCP_PROXY_REQUEST_TIMEOUT` (default 120s) is a final hard bound at the proxy boundary.
* A client request that is cancelled mid-flight cleans up after itself: the future is dropped, any spawned child process is reaped via `kill_on_drop`, and the backend's pending-request map is cleared by the reader task on EOF.

### Server-side authentication

The proxy supports an optional authentication layer for HTTP mode, designed to be transport-independent:

```
HTTP headers → extract_credentials() → Credentials (HashMap)
                                            ↓
                                    AuthProvider.authenticate()
                                            ↓
                                    AuthIdentity { subject, roles }
                                            ↓
                                    ACL.is_tool_allowed()
```

The `AuthProvider` trait and `AuthIdentity` type are transport-agnostic — only `extract_credentials()` knows about HTTP headers. This means the same auth logic works across any transport. Stdio mode always uses `AuthIdentity::anonymous()`.

Three providers are available: `NoAuth` (default), `BearerTokenAuth` (static token mapping), and `ForwardedUserAuth` (reverse proxy header trust). The ACL system filters `tools/list` responses and blocks unauthorized `tools/call` requests before they reach backends.

## Design principles

* **No daemon** — Each invocation is independent. Start, connect, do the thing, exit. Tokens are persisted to disk, everything else is ephemeral.
* **Lazy by default** — In proxy mode, backends are only connected when needed and shut down when idle. No process runs longer than it has to. Tool lists are cached to disk so restarts don't pay the discovery cost again.
* **Protocol over implementation** — The Transport trait means the client code is completely decoupled from transport details. Adding WebSocket support is adding a file, not refactoring the client.
* **Fail loud** — Errors propagate up with context (`anyhow` chains). No silent failures, no swallowed errors, no default fallbacks that hide problems.
* **JSON in, JSON out** — The CLI is a pipe-friendly citizen. Structured input, structured output, errors on stderr.


