Skip to main content
Glama
README.md
# mcp-proxy

A **hardening MCP proxy** that sits in front of one or more upstream MCP servers
and exposes only the tools a given **profile** is allowed to see and call.

One config file describes your real servers (GitHub, filesystem, Slack, …) and the
profiles (reviewer, implementer, ci-bot, …). Each agent launches its own copy of the
proxy with `--profile <name>` — or, in `serve` mode, a single shared HTTP server
maps each connection to a profile via auth — and gets a **filtered**, **enforced**
view of those servers, saving context tokens and preventing dangerous tool calls by
construction.

---

## Why mcp-proxy?

Official MCP servers expose **all** of their tools to **every** agent. The client
fetches `tools/list` and injects every tool's schema into the prompt on *every*
turn, burning context tokens. And a tool that's visible is a tool that can be
called — there is no hard boundary.

mcp-proxy fixes both problems at once:

- **Token savings** — a profile only advertises the tools you explicitly allow, so
  only those schemas ever enter the agent's context.
- **Hard guardrail** — a tool that isn't allowed is neither *listed* nor *callable*:
  even a hallucinated call is rejected at execution time, not just hidden from the menu.

---

## Benefits

| Benefit | How it helps |
|---|---|
| 🔒 **Fail-closed guardrail** | `block` wins; unknown tools default to denied. Visibility and callability are kept in sync. |
| 📉 **Token savings** | Filtered `tools/list` means smaller prompts and cheaper, more focused sessions. |
| 👥 **One config, many agents** | Reviewer, implementer, and CI bot share the same `servers` block but get different profiles via `--profile`. |
| 🧩 **Multi-server aggregation** | Merge several upstreams (stdio + HTTP) behind a single MCP endpoint. |
| 🔐 **Secrets stay out of the repo** | `${VAR}` placeholders + `.env`; the loader fails fast on a missing variable. |
| ♻️ **Resilient** | Auto-reconnect with exponential backoff; live `tools/list_changed` updates are re-filtered and propagated downstream. |
| 🛡️ **Argument validation** | `tools/call` arguments are validated against the upstream `inputSchema` before forwarding. |
| 📊 **Observable** | `--verbose` emits structured JSON-lines logs with per-request correlation ids; the shared server also exposes Prometheus `/metrics`. |
| 🌐 **Shared server mode** | `serve` runs one Streamable HTTP server for many agents; per-connection auth maps tokens/headers to profiles. |
| 🏷️ **Collision-safe** | Tools that share a name across servers are auto-prefixed (`github__read_file`), others keep bare names. |

---

## How it works

### Architecture

```mermaid
flowchart TB
    subgraph agents["🤖 Agents (MCP clients)"]
        direction LR
        A1["reviewer agent<br/><code>--profile reviewer</code>"]
        A2["implementer agent<br/><code>--profile implementer</code>"]
    end

    subgraph proxy["mcp-proxy — one stdio process per agent"]
        direction TB
        D1["stdio transport"]
        D2["tool filter<br/>(allow/block · globs + regex)"]
        D3["call-time guardrail<br/>+ argument validation"]
        D4["upstream registry<br/>(discovery · reconnect · list_changed)"]
    end

    subgraph up["Upstream MCP servers"]
        direction LR
        U1["filesystem<br/>(stdio)"]
        U2["github<br/>(HTTP)"]
        U3["slack<br/>(HTTP)"]
    end

    A1 -->|"stdin/stdout"| D1
    A2 -->|"stdin/stdout"| D1
    D1 --> D2 --> D3 --> D4
    D4 -->|"spawn"| U1
    D4 -->|"connect"| U2
    D4 -->|"connect"| U3
```

Each agent spawns the proxy as a child process over stdio. The proxy connects to
every upstream listed in the selected profile, fetches each `tools/list`, applies
the profile's allow/block rules, and re-exposes only the surviving tools.

### Request flow

```mermaid
sequenceDiagram
    autonumber
    participant A as Agent
    participant P as mcp-proxy
    participant U as Upstream MCP server

    A->>P: tools/list
    P->>U: tools/list (every upstream in profile)
    U-->>P: full tool set
    P->>P: filter + collision resolve
    P-->>A: allowed tools only

    A->>P: tools/call (allowed tool)
    P->>P: guardrail re-check<br/>+ schema validation
    P->>U: forward call
    U-->>P: result
    P-->>A: result

    A->>P: tools/call (blocked tool)
    P-->>A: ❌ rejected with error

    U-->>P: notifications/tools/list_changed
    P->>U: re-fetch tools/list
    P->>P: re-filter
    P-->>A: notifications/tools/list_changed
```

### Filter decision

A tool is allowed only if it survives this precedence chain:

```mermaid
flowchart LR
    T["tool name"] --> B{"matches a<br/><code>block</code> pattern?"}
    B -- "yes" --> DENY["🔒 DENY"]
    B -- "no" --> A{"matches an<br/><code>allow</code> pattern?"}
    A -- "yes" --> OK["✅ ALLOW"]
    A -- "no" --> D["fallback:<br/>server <code>default</code><br/>→ profile <code>default</code><br/>→ <code>block</code>"]
    D --> F{"fallback is <code>allow</code>?"}
    F -- "yes" --> OK
    F -- "no" --> DENY
```

`block` always wins. Patterns are globs (`read_*`, `{get,list}_*`) or regexes
(`/.*delete.*/i`). A server omitted from a profile exposes none of its tools.

---

## Example: three profiles, measured live

The same proxy driven through three profiles, run against the real
`@modelcontextprotocol/server-filesystem` upstream (14 tools). A second
filesystem instance stood in for the HTTP GitHub server so the demo needs no
token — per-server filtering behaves identically for any upstream.

```yaml
# mcp-proxy.yaml (demo)
version: 1
servers:
  filesystem:
    type: stdio
    command: npx
    args: ["-y", "@modelcontextprotocol/server-filesystem", "C:/data"]
  github:                     # HTTP in real life; filesystem stand-in in this demo
    type: http
    url: https://api.github.com/mcp
    headers: { Authorization: "${GITHUB_TOKEN}" }

profiles:
  reviewer:
    default: block
    servers:
      filesystem:
        allow: ["read_file", "list_directory", "search_files", "directory_tree", "get_file_info"]
      github:
        block: ["**"]          # GitHub fully disabled for this agent

  implementer:
    default: allow
    servers:
      filesystem:
        block: ["/.*delete.*/i", "remove_*", "edit_file", "write_file"]
      github: {}               # all GitHub tools allowed

  noTools:
    default: block
    servers:
      filesystem: { block: ["**"] }
      github: { block: ["**"] }
```

Measured over a live `tools/list` handshake:

| Profile | Tools exposed | `tools/list` payload | ~tokens |
|---|---|---|---|
| `reviewer` | 5 | 2,926 chars | ~732 |
| `implementer` | 26 | 15,762 chars | ~3,941 |
| `noTools` | 0 | 2 chars | ~1 |

Tokens use a ~4 chars/token heuristic; the real saving is the schema surface the
agent re-loads into context on every turn.

**Tools each profile actually received:**

- **`reviewer`** (read-only, GitHub blocked):
  `read_file`, `list_directory`, `directory_tree`, `search_files`, `get_file_info`
- **`implementer`** (deny-list, GitHub allowed):
  `filesystem__read_file`, `github__read_file`, `filesystem__read_text_file`, `github__read_text_file`,
  `filesystem__read_media_file`, `github__read_media_file`, `filesystem__read_multiple_files`, `github__read_multiple_files`,
  `filesystem__create_directory`, `github__create_directory`, `filesystem__list_directory`, `github__list_directory`,
  `filesystem__list_directory_with_sizes`, `github__list_directory_with_sizes`, `filesystem__directory_tree`, `github__directory_tree`,
  `filesystem__move_file`, `github__move_file`, `filesystem__search_files`, `github__search_files`,
  `filesystem__get_file_info`, `github__get_file_info`, `filesystem__list_allowed_directories`, `github__list_allowed_directories`,
  `write_file`, `edit_file`
- **`noTools`** (everything blocked): *(none)*

Two details worth noting:

- **Collision auto-prefixing** — `read_file` exists on both servers, so it becomes
  `filesystem__read_file` and `github__read_file`. But `write_file`/`edit_file`
  keep their bare names because they're blocked on `filesystem`, leaving `github`
  as the only source.
- **An empty profile view is valid** — `noTools` (or any profile with
  `block: ["**"]`, or a server simply omitted) exposes **zero** tools; the agent
  still connects, it just has nothing to call.

---

## Quick start

**1. Install and build**

```sh
npm install
npm run build          # compiles TypeScript to dist/
```

**2. Put secrets in `.env` (never in the config)**

```sh
cp .env.example .env   # then fill in your tokens
```

**3. Write `mcp-proxy.yaml`**

```yaml
version: 1

servers:
  filesystem:
    type: stdio
    command: npx
    args: ["-y", "@modelcontextprotocol/server-filesystem", "C:/repo"]
    env:
      ROOT: "C:/repo"

  github:
    type: http
    url: https://api.github.com/mcp
    headers:
      Authorization: "${GITHUB_TOKEN}"   # env-var reference, not a literal secret

profiles:
  reviewer:                 # read-only, fail-closed
    description: "Read-only agent"
    default: block
    servers:
      filesystem:
        allow: ["read_file", "list_directory", "directory_tree", "get_file_info"]
      github:
        allow: ["get_*", "list_*", "search_*"]

  implementer:              # deny-list, fail-open minus dangerous ops
    description: "Full access minus destructive ops"
    default: allow
    servers:
      filesystem:
        block: ["/.*delete.*/i", "edit_file", "write_file"]
      github:
        block: ["merge_pull_request", "delete_*"]

defaultProfile: reviewer
```

**4. Run**

```sh
node dist/cli/index.js --profile reviewer
# add --verbose for structured debug logging
node dist/cli/index.js --profile reviewer --verbose
```

Profile precedence: `--profile` > `MCP_PROFILE` > `defaultProfile`.

---

## Configuration reference

### `servers` — upstream MCP servers

**stdio** (spawned as a child process):

```yaml
filesystem:
  type: stdio
  command: npx
  args: ["-y", "@modelcontextprotocol/server-filesystem", "C:/repo"]
  env: { ROOT: "C:/repo" }
  prefix: fs__          # optional: override collision-prefix namespace
```

**http** (Streamable HTTP):

```yaml
github:
  type: http
  url: https://api.github.com/mcp
  headers:
    Authorization: "${GITHUB_TOKEN}"
  prefix: gh__          # optional
```

### `profiles` — named tool views

```yaml
profiles:
  my-profile:
    description: "..."           # optional
    default: allow               # allow | block (fallback when no rule matches)
    servers:
      github:
        allow: ["get_*"]         # optional allow-list
        block: ["delete_*"]      # optional block-list (always wins)
        default: block           # optional per-server fallback override
      # filesystem omitted → none of its tools are exposed
```

### `http` — Streamable HTTP downstream (`serve` mode)

Optional top-level block that turns the proxy into a **shared HTTP server** serving
many agents from one process. See [Shared server (HTTP)](#shared-server-http).

```yaml
http:
  host: 0.0.0.0             # default 127.0.0.1
  port: 3000                # default 3000
  path: /mcp                # MCP endpoint (default /mcp)
  metricsPath: /metrics     # Prometheus metrics (default /metrics)
  healthPath: /health       # liveness (default /health)
  readyPath: /ready         # readiness (default /ready)
  auth:
    header: authorization   # selector header (default authorization)
    scheme: Bearer          # optional prefix to strip
    tokens:                 # token -> profile map (values may use ${VAR})
      tok-reviewer: reviewer
      tok-impl: implementer
    defaultProfile: reviewer # optional fallback (fail-closed without it)
```

When `tokens` is set, the scheme-stripped header value is looked up in the map.
Without `tokens`, the stripped header value is used directly as the profile name.
A missing/unknown selector falls back to `defaultProfile`, then is rejected
(`401`/`403`) if none applies.

### Secrets

`${VAR}` placeholders are resolved from the environment (or `.env`) at load time.
The YAML holds only the variable *name*, so it's safe to commit. A missing variable
makes the loader **fail fast** — no silently-empty headers.

---

## Wire it to your agent

The proxy *is* an MCP server over stdio. Point your agent at the proxy entrypoint
instead of the real server, passing the profile flag.

```jsonc
// .mcp.json — reviewer agent
{
  "mcpServers": {
    "proxy": {
      "command": "node",
      "args": ["C:/Dev/mcp-proxy/dist/cli/index.js", "--profile", "reviewer"]
    }
  }
}
```

```jsonc
// .mcp.json — implementer agent (same proxy, different profile)
{
  "mcpServers": {
    "proxy": {
      "command": "node",
      "args": ["C:/Dev/mcp-proxy/dist/cli/index.js", "--profile", "implementer"]
    }
  }
}
```

Each agent gets its own stdio process, so profiles are fully isolated per agent and
credentials never cross a process boundary.

### Shared server (HTTP)

For a central deployment, run `serve` to expose **one** Streamable HTTP server that
many agents share. Each connection is mapped to a profile from its auth header:

```sh
node dist/cli/index.js serve --config mcp-proxy.yaml
# options: --host, --port (override http.host/http.port)
```

Endpoints:

| Path | Purpose |
|---|---|
| `/mcp` | Streamable HTTP MCP endpoint (session-per-connection) |
| `/health` | Liveness — always `200` once the process is up |
| `/ready` | Readiness — `200` only when every profile's upstreams are connected |
| `/metrics` | Prometheus text metrics (tools listed/called/blocked, latency, upstream state) |

Per-connection profile resolution is **fail-closed**: a connection with no usable
selector is rejected (`401`) unless `http.auth.defaultProfile` is set, and a selector
that maps to an unknown profile is rejected (`403`).

Client config for a shared deployment (any Streamable-HTTP-capable client):

```jsonc
// .mcp.json — reviewer agent (token maps to the `reviewer` profile)
{
  "mcpServers": {
    "proxy": {
      "type": "http",
      "url": "https://proxy.example.com/mcp",
      "headers": { "Authorization": "Bearer ${PROXY_TOKEN}" }
    }
  }
}
```

```jsonc
// .mcp.json — implementer agent (same server, different token/profile)
{
  "mcpServers": {
    "proxy": {
      "type": "http",
      "url": "https://proxy.example.com/mcp",
      "headers": { "Authorization": "Bearer ${PROXY_TOKEN_IMPL}" }
    }
  }
}
```

The Copilot coding agent reads the repository's `.mcp.json`; for other agents use
their native MCP server field (see [context/AGENT-SETUP.md](context/AGENT-SETUP.md)
and [context/VENDOR-AGENTS.md](context/VENDOR-AGENTS.md)).

---

## Observability

Run with `--verbose` to emit structured JSON-lines logs to **stderr** (keeping the
MCP stdio channel on stdout clean):

```json
{"timestamp":"2026-08-23T17:22:26.976Z","level":"info","message":"connected to upstream","server":"filesystem","tools":14}
{"timestamp":"2026-08-23T17:22:26.980Z","level":"debug","message":"tools/call","correlationId":"42","tool":"read_file","server":"filesystem"}
```

Every `tools/list` and `tools/call` entry carries the MCP request's `correlationId`,
so a single request can be traced across the proxy and its upstreams.

To see the context cost of a profile, compare the tool count and `tools/list`
payload size across profiles (see the [measured example](#example-three-profiles-measured-live)
above): fewer advertised tools means fewer schemas injected into the prompt each turn.

In `serve` mode, scrape `/metrics` for Prometheus counters, gauges, and histograms:
`mcp_proxy_tools_listed_total`, `mcp_proxy_tools_called_total`,
`mcp_proxy_tools_blocked_total`, `mcp_proxy_tool_call_duration_seconds`, and
`mcp_proxy_upstream_connections` (all labelled by `profile`/`server`/`tool`).

---

## Resilience

- **Auto-reconnect** — if an upstream (especially a spawned stdio process) dies,
  the proxy reconnects with exponential backoff (500ms → 15s cap, infinite retries).
- **Live tool updates** — when an upstream emits `notifications/tools/list_changed`,
  the proxy re-fetches, re-filters, and forwards the change downstream, so agents
  always see an accurate tool list.
- **Argument validation** — `tools/call` arguments are checked against the
  upstream's `inputSchema` before forwarding; invalid calls are rejected locally.

---

## Development

```sh
npm run typecheck    # tsc --noEmit
npm test             # vitest (unit + integration + filesystem smoke)
npm run build        # tsc → dist/
```

---

## More

- [context/DESIGN.md](context/DESIGN.md) — full design, decisions, and tradeoffs.
- [context/SETUP.md](context/SETUP.md) — step-by-step setup for real stdio + HTTP servers.
- [context/ROADMAP.md](context/ROADMAP.md) — v1.0 shipped (HTTP downstream, per-connection profiles, observability, packaging).
- [`mcp-proxy.yaml`](mcp-proxy.yaml) — working example config.
- [`.env.example`](.env.example) — environment variable template.