mcp-proxy
by DawidNowak
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.
This server cannot be deployed
Maintenance
ActivityMaintained
ResponsivenessNo issues