@qelos/better-mcp
by qelos-io
README.md
<div align="center">
# better-mcp
**One MCP endpoint for all your MCP servers — with login, a marketplace, and a token diet.**
[](https://www.npmjs.com/package/@qelos/better-mcp)
[](https://nodejs.org)
[](https://github.com/qelos-io/better-mcp/pkgs/container/better-mcp)
[](https://github.com/qelos-io/better-mcp/blob/main/LICENSE)
</div>
better-mcp sits between your AI client (Claude Code, Claude Desktop, Cursor,
VS Code…) and your MCP servers. You configure **one** server in the client;
better-mcp connects to all the others — local (stdio) or remote (HTTP) — and
exposes their tools, resources and prompts through a single endpoint, wrapped in
a middleware pipeline that cuts token usage and keeps secrets out of the model.
```
┌──────────┐ stdio / HTTP ┌────────────┐ stdio ┌──────────────────┐
│ Client │ ─────────────▶ │ better-mcp │ ───────▶ │ filesystem │
│ (Claude, │ │ middleware │ HTTP ├──────────────────┤
│ Cursor, │ ◀───────────── │ + manager │ ───────▶ │ Linear (OAuth) │
│ etc.) │ list_changed └────────────┘ ├──────────────────┤
└──────────┘ │ …more upstreams │
└──────────────────┘
```
## Why
- **Log in once.** Remote servers that need OAuth (Linear, Notion, ClickUp,
Sentry…) open a browser login, just like Claude Code. The login is saved and
shared by every client and session that uses your config.
- **Manage from the chat.** Ask your agent to run `manage_mcps` and a local app
opens: see what's connected, log in, and install, disable or remove servers
from a **marketplace** — no restart, no JSON editing.
- **Spend fewer tokens.** Slim `tools/list`, compact JSON, strip ANSI noise,
de-duplicate repeated responses, and offload oversize output to disk with a
short pointer.
- **Keep secrets out.** Redact fields by name in both requests and responses.
- **See everything.** Per-tool JSONL traces of the full request → upstream →
response flow, plus your own `before` / `after` hooks.
- **Run it anywhere.** `npx`, Docker, or expose it over Streamable HTTP for
teams.
## Quick start
1. Create an `mcp.json` (same shape as Cursor / Claude Desktop):
```json
{
"mcpServers": {
"filesystem": {
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-filesystem", "/path/to/allowed/root"]
},
"linear": { "url": "https://mcp.linear.app/mcp" }
}
}
```
2. Point your client at better-mcp instead of at each server.
**Claude Code**
```bash
claude mcp add better-mcp -- npx -y @qelos/better-mcp -c /abs/path/to/mcp.json
```
**Claude Desktop / Cursor / VS Code** (`mcpServers` in the client's config)
```json
{
"mcpServers": {
"better-mcp": {
"command": "npx",
"args": ["-y", "@qelos/better-mcp", "-c", "/abs/path/to/mcp.json"]
}
}
}
```
3. In your client, ask: *"open manage_mcps"* — log in to `linear`, or browse the
[marketplace](https://github.com/qelos-io/better-mcp/tree/main/marketplace)
and install more servers with one click.
Tools are exposed as `<server>__<tool>` (e.g. `filesystem__read_file`) so
servers never collide.
## Install
### Option A — npm
Requires Node.js 18 or newer.
```bash
# Run without installing (recommended for MCP clients)
npx -y @qelos/better-mcp -c /abs/path/to/mcp.json
# Or install globally
npm install -g @qelos/better-mcp
better-mcp -c ./mcp.json
```
### Option B — Docker
The image is published to GitHub Container Registry:
```bash
docker pull ghcr.io/qelos-io/better-mcp:latest
```
```bash
docker run --rm -i \
-v "$PWD/mcp.json:/app/mcp.json" \
-v better-mcp-data:/data \
-p 127.0.0.1:33419:33419 \
-p 127.0.0.1:33418:33418 \
ghcr.io/qelos-io/better-mcp:latest
```
| Flag | Why |
|------|-----|
| `-i` | The proxy speaks MCP over stdio (no `-t`). |
| `-v "$PWD/mcp.json:/app/mcp.json"` | Your config. **Mount it read-write** (no `:ro`) so the management app can install, remove and disable servers and keep logins in it. |
| `-p 127.0.0.1:33419:33419` | The **management app** (`manage_mcps`). Without this port the app's link won't open from your browser. |
| `-p 127.0.0.1:33418:33418` | The **OAuth callback**. Remote-server logins redirect your browser to `http://localhost:33418/callback`. |
| `-v better-mcp-data:/data` | Optional. Persists OAuth state when you use `"authStorage": "home"`. |
Publish the ports on `127.0.0.1` as shown — the app is protected by a random
URL token, but it can install programs, so don't expose it to your network.
Inside the container better-mcp binds `0.0.0.0` (via `BETTER_MCP_MANAGE_HOST`,
`BETTER_MCP_MANAGE_PORT` and `BETTER_MCP_CALLBACK_HOST`) so the published ports
reach it. A browser can't be launched from inside a container, so
`manage_mcps` returns the app URL in its reply — click it. For logins, the
app shows an **Open login page** link while a login is in progress.
Wire it into a host:
```json
{
"mcpServers": {
"better-mcp": {
"command": "docker",
"args": [
"run", "--rm", "-i",
"-v", "/abs/path/to/mcp.json:/app/mcp.json",
"-p", "127.0.0.1:33419:33419",
"-p", "127.0.0.1:33418:33418",
"ghcr.io/qelos-io/better-mcp:latest"
]
}
}
}
```
Notes for Docker:
- Upstream servers in `mcp.json` must be runnable **inside the container**. The
image has `node` and `npx`; servers that need Python (`uvx`), a browser, or
host paths require a derived image or a remote (`url`) server.
- Pass per-server secrets with `-e GITHUB_PERSONAL_ACCESS_TOKEN=…` and reference
them as `${GITHUB_PERSONAL_ACCESS_TOKEN}` in `mcp.json`.
- To keep the config in a directory instead of a single file, mount the
directory and pass `-c`: `-v "$PWD/config:/config" … -c /config/mcp.json`.
- For offload output, mount a writable volume and set `middleware.offload.dir`
to it (e.g. `/exports`).
- Different ports? Set `-e BETTER_MCP_MANAGE_PORT=…` and
`"oauth": { "callbackPort": … }` per server, and publish those instead.
### Option C — Build from source
```bash
git clone https://github.com/qelos-io/better-mcp.git
cd better-mcp
npm install
npm run build
node dist/index.js -c ./mcp.json
```
## Run
The proxy uses the same `mcp.json` shape as Cursor and Claude Desktop. By
default it looks for one automatically; you only need `-c` to override.
```bash
# Auto-discover mcp.json (see "Config location" below)
better-mcp
# Or point at a specific file
better-mcp -c ./examples/mcp.json
better-mcp --config /abs/path/to/mcp.json
# Or pass via env (inline JSON or a path)
MCP_PROXY_CONFIG=./examples/mcp.json better-mcp
MCP_PROXY_CONFIG='{"mcpServers":{"fs":{"command":"npx","args":["-y","@modelcontextprotocol/server-filesystem","/tmp"]}}}' better-mcp
```
### Config location
Resolved in this order — the first hit wins:
1. `-c <path>` / `--config <path>` CLI flag
2. `MCP_PROXY_CONFIG` env var (inline JSON, or a path)
3. `mcp.json` next to the entry script (e.g. `dist/mcp.json`, or `/app/dist/mcp.json` inside Docker)
4. `mcp.json` one level up from the entry script (e.g. project root, or `/app/mcp.json` inside Docker)
5. `mcp.json` in the current working directory
In practice: drop your `mcp.json` next to `package.json` (or mount it at
`/app/mcp.json` in Docker) and it just works.
## Popular MCP servers
Browse the [marketplace](https://github.com/qelos-io/better-mcp/tree/main/marketplace) (registry: [`marketplace/servers.yaml`](https://github.com/qelos-io/better-mcp/blob/main/marketplace/servers.yaml) — contributions welcome) for one-click installs.
Copy-paste configs for filesystem, GitHub, Postgres, Brave Search, Playwright,
Context7, memory, and more — plus a ready-made `[examples/mcp.popular.json](https://github.com/qelos-io/better-mcp/blob/main/examples/mcp.popular.json)`.
See **[docs/popular-mcps.md](https://github.com/qelos-io/better-mcp/blob/main/docs/popular-mcps.md)**.
## Config
```jsonc
{
"mcpServers": {
"<name>": {
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"],
"env": { "FOO": "bar" }, // optional
"cwd": "./somewhere", // optional
"enabled": true, // optional, default true
"disabledTools": ["delete_repo"] // optional: hide these tools and refuse to call them (also editable in the management app)
}
},
// Prefix every tool/prompt with "<serverName>__". Default true.
// Resources keep their original URIs (they're already namespaced by scheme).
"namespace": true,
"middleware": {
// true = log to stderr, or pass { level, file } for control.
// level "info" logs name + duration; "debug" also logs params + result.
"log": { "level": "info" },
// Field-name substrings (case-insensitive) whose values get replaced with
// "[REDACTED]" in responses — useful for masking secrets in tool output.
"redact": ["token", "password", "api_key", "authorization"],
// Slim down `tools/list` by stripping JSON-Schema noise (`$schema`,
// `title`, `examples`, `default`, empty `required`/`enum`, …) from
// every tool's inputSchema. ON by default; set `false` to disable.
// See "Slimming tools/list" below for the full set of knobs.
"slim": true,
// Compact responses: drop null-valued fields and minify JSON-in-text.
// Affects the WIRE response only — the file written by `offload` keeps
// full fidelity. ON by default; see "Compacting responses" below.
"compact": {
"dropNull": true, // default true
"dropEmptyString": false, // default false
"dropEmptyArray": false, // default false
"dropEmptyObject": false, // default false
"roundFloats": 0, // 0 = off; e.g. 4 → 0.1235
"exclude": ["server-name", "server__tool"] // skip per-server or per-tool
},
// Clean terminal noise from text blocks: ANSI escape sequences and
// trailing whitespace per line. ON by default; see "Cleaning text" below.
"cleantext": {
"stripAnsi": true, // default true
"trimTrailingWhitespace": true, // default true
"collapseBlankLines": false, // default false (risky for markdown)
"exclude": []
},
// Response dedup cache. When the same `(server, tool)` returns identical
// bytes within TTL, replace the response with a short pointer. Useful for
// polling tools that re-emit unchanged data. OPT-IN — see "Dedup" below.
"dedup": {
"ttlSeconds": 300, // default 300 (5 min)
"maxEntries": 1000, // LRU cap
"minBytes": 200, // skip dedup below this
"includeResources": false, // resources opt-in
"exclude": []
},
// Offload oversize responses to a file and return a short pointer.
// `true` enables defaults; pass an object to tune.
"offload": {
"thresholdBytes": 16384, // default 16 KB
"dir": "./exports", // default <os.tmpdir()>/better-mcp
"includeResources": false, // default false (tools only)
"inferArrayShape": true, // default true
"previewRows": 3, // first-N preview line; 0 disables
"chapterMarkdown": true, // split long markdown on H2 into chapters
"perTool": { // per-server / per-tool overrides
"fs__list_directory": { "thresholdBytes": 0 }, // always offload
"github": { "thresholdBytes": 32768 }, // higher cutoff for the whole server
"weird-server": false // never offload this server
}
},
// Per-tool JSONL trace of the ENTIRE pipeline flow (request → each
// middleware before → upstream → each middleware after → response).
// `true` enables defaults; pass an object to tune.
"trace": {
"dir": "/abs/path/to/logs", // default <os.tmpdir()>/better-mcp/trace
"maxBodyBytes": 0, // 0 = full bodies (no cap)
"redact": ["token", "password"], // default: the `redact` list above
"includeResources": false // default false (tools only)
},
// Path to a JS/MJS module exporting `{ before, after }` hooks.
// Relative paths resolve against the config file's directory.
"hooks": "./middleware.js"
}
}
```
CLI commands (print and exit; they never start the stdio server):
- `better-mcp version` (or `--version`, `-v`) — print the version, Node and platform.
- `better-mcp help` (or `--help`, `-h`) — usage, options and environment variables.
- `better-mcp tools ls [filters] | describe <tool> | call <tool> [json]` — use tools
from the shell. `describe` prints the description and input schema; `call`
runs the tool and prints its result as JSON (after the middleware pipeline;
`--raw` prints the upstream's own response) and exits 1 on a tool error.
`<tool>` is the public name (`server__tool`) or a bare name unique to one
server. `ls` (alias `list`) takes optional filters, case-insensitive substrings of
the name or description, keeping tools that match any. Arguments are a JSON object, inline or piped:
`better-mcp tools call github__get_issue '{"number": 5}'` or
`cat body.json | better-mcp tools call github__get_issue`.
Connection logs are hidden so stdout stays clean JSON (`--verbose` shows them).
The first command starts a background helper that keeps your servers
connected and exits after 10 idle minutes (`BETTER_MCP_DAEMON_IDLE_MS`), so
later commands take milliseconds; it restarts when the config file changes,
and a running `better-mcp app` is reused too. `--no-daemon` connects in the
command itself. The helper's log is in `~/.better-mcp/daemons/`.
`better-mcp help tools` (or `tools --help`) documents all three; the same
reference, with your launch command filled in, is the **Command line** page
of the management app.
- `better-mcp app [-c file.json]` — open **only** the management app for that
config (installs, logins, tool toggles, settings), with no stdio server and no
HTTP listener, so you can manage `mcp.json` outside any agent client. It
prints the app's URL, opens your browser, and runs until Ctrl+C. It connects
the configured servers so their status and tools show up, and works even if
`manage.enabled` is `false`.
Any other bare word is rejected with usage and exit code 2, instead of silently
starting the proxy.
CLI flags:
- `-c <path>` / `--config <path>` — path to `mcp.json` (overrides discovery + env).
- `--no-namespace` — disable the `<server>__<tool>` prefix at runtime.
- `--offload-resources` — also offload `resources/read` responses (same as
setting `middleware.offload.includeResources: true`, or
`MCP_PROXY_OFFLOAD_RESOURCES=1`).
### OAuth login for remote (HTTP) servers
Remote upstreams (`"url": ...`) that require authentication work like they do
in Claude Code: when you start a login (the `authenticate` tool or the
management app, below), better-mcp opens your browser to the server's login
page, and the provider redirects back to
`http://localhost:33418/callback`. Tokens and the dynamically registered
client are saved (see below) and refreshed automatically, so later starts need
no login. Startup never opens a browser by itself: servers that need a login
show as "needs login" until you start one.
```json
{
"mcpServers": {
"linear": { "url": "https://mcp.linear.app/mcp" },
"custom": {
"url": "https://example.com/mcp",
"oauth": { "clientId": "...", "scopes": ["read"], "callbackPort": 40000 }
},
"static-token": {
"url": "https://example.com/mcp",
"headers": { "Authorization": "Bearer ${TOKEN}" }
}
}
}
```
OAuth is on by default for `url` servers unless you set an `Authorization`
header; `"oauth": false` turns it off. If the callback port is taken, set
`oauth.callbackPort`. Under Docker, publish the callback port (see
[Option B](#option-b--docker)); the app shows an **Open login page** link when no
browser can be launched.
#### Where logins are stored
By default logins live **in your `mcp.json`**, under a top-level `auth` key
next to `mcpServers`, so every client, session and container that uses the file
shares one login:
```json
{
"mcpServers": { "linear": { "url": "https://mcp.linear.app/mcp" } },
"auth": { "linear-1a2b3c4d": { "clientInformation": { "...": "..." }, "tokens": { "...": "..." } } }
}
```
better-mcp manages `auth` itself — don't edit it by hand. It contains access and
refresh tokens, so **don't commit that file** (or set `"authStorage": "home"`
to keep logins in `~/.better-mcp/auth/` instead — `/data/auth` in Docker, see
`BETTER_MCP_HOME`). Inline `MCP_PROXY_CONFIG` configs always use the home
directory.
### Management app (`manage_mcps`, `authenticate`)
better-mcp adds two tools of its own (they are not namespaced):
- **`manage_mcps`** opens the management app in your browser and returns its URL
and each server's status. The app lists every server (connected, needs login,
error, disabled) with Log in / Reconnect / Disable / Remove buttons, and has a
**Marketplace** tab with one-click Install (you can edit the config, e.g. paths
and secrets, before saving).
- **`authenticate`** starts the browser login for one server (`server`) or for
every server that needs it.
Installing, removing, enabling and disabling take effect immediately: better-mcp
rewrites `mcp.json` (only `mcpServers`; `${VAR}` placeholders are preserved),
reconnects just that server, and sends `tools/list_changed` so the client
refreshes without a restart. If the config was passed inline via
`MCP_PROXY_CONFIG`, changes last only until restart.
The **Settings** tab edits `namespace` and the middleware (slim, compact, clean
text, dedup, offload, redact, log, trace) and applies them to the running proxy
on save: a fresh pipeline replaces the old one (dedup cache and trace state
start empty), calls already in flight finish on the old one, and clients get
`tools/list_changed` when namespacing or slimming changes tool names or
schemas. If the saved config can't be loaded (for example a broken `hooks`
module), the previous settings stay active and the app says a restart is
needed. `hooks` is not editable from the app.
The app listens on `127.0.0.1` only, behind an unguessable URL token. The
marketplace catalog is fetched from this repo's `docs/marketplace/servers.json`
(falling back to a bundled copy offline), so new contributions show up without a
release. Only install servers you trust: an entry runs a local command.
```json
{ "manage": { "port": 33419, "catalogUrl": false, "enabled": true } }
```
`port` pins the app's port (default: a free one), `catalogUrl: false` stays
offline, and `enabled: false` removes both tools and the app.
## Middleware
Every upstream call passes through a small stack. Registration order is
`logger → offloader → redactor → user`, so the after-chain runs from inside
out:
```
client ─▶ logger.before ─▶ user.before ─▶ upstream
│
▼
client ◀─ logger.after ◀─ offloader.after ◀─ redactor.after ◀─ user.after
```
User hooks see the raw request and the raw upstream response. Redaction cleans
that data, the offloader decides whether to write it to disk and replace the
response with a pointer, and the logger records what the client will ultimately
see.
The `**log**` middleware is itself a hook at the outermost position, so it only
sees the request before anyone touched it and the response after everyone did.
The `**trace**` feature is different: it's wired into the pipeline itself, not
into the hook chain, so it can record what *each* middleware changed. Use `log`
for a light one-line-per-call record; use `trace` when you need the full
per-tool flow.
### Slimming `tools/list`
`tools/list` is resent to the model every conversation turn, so trimming the
catalog pays out per turn. By default the proxy strips a small set of
JSON-Schema annotations that the model doesn't need at call time:
- `$schema`, `$id`, `$comment`
- `title`, `examples`, `default`
- `required: []` and `enum: []` when empty (always, regardless of strip list)
Set `middleware.slim: false` to disable, or pass an object to tune:
```jsonc
"middleware": {
"slim": {
// Override the default strip list. Walk is recursive (descends into
// `properties`, `items`, `anyOf`/`oneOf`/`allOf`, `patternProperties`, …).
"stripSchemaFields": ["$schema", "title", "examples", "default"],
// Drop a property's `description` when it's a short paraphrase of its name
// (e.g. property `userId` with description "the user ID"). Off by default.
"stripPropertyDescriptions": false,
// Truncate each tool's top-level description to this many chars
// (trailing `…`). 0 disables. Off by default — descriptions stay full.
"maxDescriptionLength": 0
}
}
```
What's *not* stripped by default: `additionalProperties`, `format`, per-property
`description`, `pattern`, `minimum`/`maximum`. Those carry real semantics that
the model can use.
### Compacting responses
For every tool/resource/prompt response, the proxy walks `content[].text`
blocks; when a text block holds parseable JSON (object or array), it drops
empty-valued fields and re-stringifies minified. Substitution only happens
when the result is strictly shorter, so this is idempotent on already-clean
payloads. Free-form text (prose, code, offload pointers) is never touched —
the parse-as-JSON precondition is the safety rail.
Compact runs **after** the offloader in the response chain, so the file written
to disk keeps the full original payload. Compact only changes what the client
receives.
```jsonc
"middleware": {
"compact": {
"dropNull": true, // default true — drop fields whose value is `null`
"dropEmptyString": false, // `""` vs missing is often meaningful
"dropEmptyArray": false, // `[]` usually means "no results", not missing
"dropEmptyObject": false, // `{}` can be a deliberate empty container
// Round JSON numbers to N decimal places. `0` (default) disables.
// Integers and NaN/Infinity are untouched. Useful for floats from ML
// scores, timestamps, lat/lng — but lossy, so opt in deliberately.
// 0.123456789 → 0.1235 at precision 4 (≈45% byte saving per number)
"roundFloats": 0,
// Skip compaction for noisy servers/tools whose text payloads must
// round-trip byte-identical (e.g. an html_dump or code_snippet tool).
// Match by `"<server>"` (whole server) or `"<server>__<tool>"`.
"exclude": ["weird-server", "weird-server__raw_html"]
}
}
```
Set `compact: false` to disable entirely. The minification step (whitespace
stripping) is always on when compact is enabled — even with every `drop*`
knob off, a pretty-printed JSON response will round-trip to its minified form.
Note on array elements: compact **never drops elements from arrays** —
array length is treated as load-bearing. The drop-* knobs apply only to
object FIELDS.
### Cleaning text
For every tool/resource/prompt response, the proxy walks `content[].text`
blocks (whether or not they hold JSON) and strips terminal-style noise:
- **ANSI / CSI / OSC escape sequences** (`\x1b[31m…\x1b[0m`, terminal-title
setters, cursor moves, …). Useless to a model, pure tokens.
- **Trailing whitespace per line** — `[ \t]+$` per line. No semantic value
unless you're writing a markdown trailing-double-space line break.
```jsonc
"middleware": {
"cleantext": {
"stripAnsi": true, // default true
"trimTrailingWhitespace": true, // default true
"collapseBlankLines": false, // default false — risky for markdown that
// uses blank lines structurally
"exclude": ["weird-server", "weird-server__raw_terminal"]
}
}
```
Cleantext runs **after compact** in the response chain (so minified JSON
text — which is already trim — passes through unchanged), and **after
offload** (so the on-disk file keeps the original ANSI codes for forensic
value). Set `cleantext: false` to disable entirely.
### Dedup
Hash-based response cache for polling-style tools. When the proxy sees the
same `(server, tool, response-bytes)` within `ttlSeconds`, it replaces the
response with a short pointer instead of re-sending the full payload:
```
same response as 5s ago (sha:abc12345)
```
OFF by default — enable explicitly when you know you're polling. The pointer
changes what the client receives; most LLMs handle it fine, but it's a
behavioral change worth opting into.
```jsonc
"middleware": {
"dedup": {
"ttlSeconds": 300, // default 300 (5 min)
"maxEntries": 1000, // LRU cap; oldest evicted on insert
"minBytes": 200, // skip dedup when response is smaller than this
"includeResources": false, // resources opt-in (prompts never deduped)
"exclude": ["weird-server", "weird-server__sometimes_caches_wrong"]
}
}
```
How it works:
- Runs **after** compact + cleantext, so the hash covers the bytes the client
actually receives (deterministic transforms don't invalidate cache hits).
- Cache key is `<server>__<tool>__<sha256-of-result-prefix>` — same content
on different tools doesn't collide.
- Hash is `sha256(JSON.stringify(result))`, displayed as the first 8 hex
chars. 32 bits of entropy is plenty for in-session dedup.
- TTL is enforced lazily on each access (no background sweep). Entries past
TTL are dropped before the lookup; LRU eviction kicks in at `maxEntries`.
- A HIT bumps the entry to most-recent in LRU order but does NOT reset its
`firstSeen` timestamp — the pointer's age reflects when the content was
first observed.
- Per-process cache (per-pipeline). Shared across HTTP sessions naturally.
Cleared on restart.
**Caveat:** tools whose responses embed timestamps, request IDs, or any
non-deterministic field will never dedup — bytes differ → not the same
response. That's correct behavior, but worth knowing before turning dedup on
for a tool that "feels like" it should hit and never does.
### Offloading oversize responses
When a tool response's JSON-serialized size exceeds `thresholdBytes`
(default 16 KB), the offloader:
1. Writes the full response to `<dir>/<server>__<tool>__<timestamp>.json`.
If the response is the typical `{ content: [{ type: "text", text: "<JSON>" }] }`
shape, the *parsed* inner JSON is saved instead of the wrapper.
2. Replaces the response with a short text message like:
```
response exported to: /tmp/better-mcp/github__list_issues__2026-05-17T12-34-56-789Z.json
size: 142.3 KB (145708 bytes)
length: 1024
interface: Array<{ id: number; number: number; title: string; state: string; labels: Array<{ name: string; color: string }>; assignee: { login: string } | null }>
preview: {"cols":["id","number","title","state"],"rows":[[1,1,"First issue","open"],[2,2,"Second","closed"],[3,3,"Third","open"]]}
```
`length`, `interface`, and `preview` only appear when the saved data is an
array. The interface is inferred from a sample of up to 200 elements and
depth-capped at 4 to keep it lean. The `preview` line shows the first
`previewRows` items (default 3): homogeneous object arrays render as
`{cols, rows}`; primitive or mixed arrays render as a JSON sample. Cell
values are capped at 80 chars; tables wider than 12 columns are skipped.
Set `previewRows: 0` to disable.
Tool responses are always considered. Resource reads are skipped by default;
flip `includeResources: true` (or pass `--offload-resources`) to include them.
Prompts are never offloaded.
#### Per-tool / per-server overrides
Some tools always cross the threshold and never benefit from inline text
(`fs__list_directory`, log dumps); others should never offload (small,
format-sensitive tools). Use `perTool` to override the global behaviour for
specific servers or tools without touching anyone else:
```jsonc
"offload": {
"thresholdBytes": 16384,
"perTool": {
"fs__list_directory": { "thresholdBytes": 0 }, // always offload
"github__get_repo": { "chapterMarkdown": false }, // disable chaptering here
"github": { "thresholdBytes": 32768 }, // higher cutoff for whole server
"weird-server": false, // never offload
"weird-server__keepme": { "thresholdBytes": 0 } // …except this one tool
}
}
```
Rules:
- **Keys** use the `<server>__<tool>` / `<server>` convention (same as
`exclude` elsewhere).
- **Object value** = `Partial<{ thresholdBytes, chapterMarkdown,
inferArrayShape, previewRows }>` merged on top of the global config for
matching calls. Unspecified knobs inherit from the global.
- **`false` sentinel** = skip offload entirely for that server/tool. Use
this instead of `{ thresholdBytes: Infinity }`.
- **Specificity:** `<server>__<tool>` wins over `<server>` when both match.
This lets you disable a whole server then re-enable one tool inside it.
- **Storage knobs (`dir`, `includeResources`) stay global** — they're not
per-tool concerns.
`{ thresholdBytes: 0 }` means "every response length is `> 0`, so every
response offloads." Use it for tools whose output is always too large to be
useful inline.
#### Markdown chaptering
When an oversize response is a single text block (didn't parse as JSON) and
contains at least one H2 heading (`^## `), the offloader switches to markdown
mode: it writes the full text to `<base>.md` AND one `<base>__NN_<slug>.md`
sidecar per chapter, and returns a TOC pointer instead of the standard line:
```
markdown exported to: /tmp/better-mcp/jira__get_page__2026-05-17T….md
size: 142.3 KB (145708 bytes)
chapters:
- 00 - page_title: /tmp/…__00_page_title.md
- 01 - overview: /tmp/…__01_overview.md
- 02 - api_reference: /tmp/…__02_api_reference.md
```
This costs slightly more pointer bytes than the single-file version, but the
model can `read_file` just the chapter it needs on follow-up turns instead of
the whole document.
- The splitter is **code-block aware**: a `## ` line inside ```` ``` ```` or
`~~~` won't trigger a split.
- Chapter 00 is the content before the first H2. Its slug comes from the
first `# H1` line if one exists, else `intro`. Empty intros are skipped.
- Each chapter file includes its own heading line for context.
- Slugs are lowercase, diacritics stripped, non-alphanumeric → `_`, capped
at 40 chars; falls back to `chapter` when nothing usable remains.
- Set `chapterMarkdown: false` to keep today's behavior (full file saved as
a `.json` wrapper for everything that isn't a JSON array).
- If a chapter write fails mid-flight, the full `.md` file is still on disk
and the pointer simply omits the failed entries — graceful degradation.
### Tracing the full pipeline (per-tool logs)
Set `middleware.trace` and every tool call is recorded to its own append-only
JSONL file:
```jsonc
"middleware": {
"redact": ["token", "password", "api_token"],
"trace": true // or { dir, maxBodyBytes, redact, includeResources }
}
```
- **One file per tool**: `<dir>/<server>__<tool>.jsonl`
(e.g. `jira__search_issues.jsonl`). Default `dir` is
`<os.tmpdir()>/better-mcp/trace`.
- **No per-tool setup** — it's automatic for every tool that gets called.
Resources/prompts are excluded unless `includeResources: true`.
#### What a trace looks like
One JSON object per line. Every line carries `ts`, `callId`, `seq`, `server`,
`tool`, `kind`, and `phase`. One call's lifecycle (here: a user hook that
mutated the request, then the `redact` middleware that cleaned the response):
```jsonc
{"ts":"…","callId":"a1f…","seq":0,"server":"jira","tool":"search_issues","kind":"tool","phase":"request","params":{"jql":"project = DEMO"}}
{… "seq":1,"phase":"before","mw":"user","changed":true,"durationMs":0,"params":{"jql":"project = DEMO","injectedByUser":true}}
{… "seq":2,"phase":"upstream","durationMs":214,"ok":true,"result":{"content":[{"type":"text","text":"{…}"}]}}
{… "seq":3,"phase":"after","mw":"user","changed":false,"durationMs":0}
{… "seq":4,"phase":"after","mw":"redact","changed":true,"durationMs":1,"result":{"content":[{"type":"text","text":"{…redacted…}"}]}}
{… "seq":5,"phase":"response","totalMs":216,"result":{…}}
```
Phases, in order: `request` → one `before` per middleware that has a `before`
hook → `upstream` → one `after` per middleware that has an `after` hook →
`response`. Each middleware step reports `mw` (`log`/`offload`/`redact`/`user`),
`changed` (did it return a modified request/response), and `durationMs`. A body
is included on a step **only when that step changed it**; `request`, `upstream`,
and `response` always carry the body. If a hook throws, its step is recorded
with an `error` field and the call still aborts as before.
#### Concurrency
Concurrent calls to the same tool write to the same file. Every event is a
self-contained line tagged with `callId` + a per-call `seq`, and writes are
serialized per file, so lines never tear. Reconstruct one flow with:
```bash
grep '"callId":"a1f…"' jira__search_issues.jsonl | jq -s 'sort_by(.seq)'
```
#### What to expect — important behaviors
- **Bodies are full by default** (`maxBodyBytes: 0`). Set a byte cap and larger
bodies become a `{ "truncated": true, "bytes", "sha256", "head" }`
placeholder instead.
- **Trace vs. offload**: the trace captures the **pre-offload** payload at the
inner `after` steps. With full bodies, a response that offload would shrink
still lands in the trace file at full size — that's the point (full fidelity
for debugging), but it means trace files can grow large. Cap with
`maxBodyBytes` if that matters.
- **Redaction**: the tracer sees raw, pre-redaction upstream data, so it scrubs
independently using `trace.redact` (falling back to `middleware.redact`).
Unlike the `redact` middleware, it also descends into **JSON embedded in
strings** — the common `content[].text` wrapper — so secrets there are caught.
It's still key-based: a secret that isn't the value of a key matching a
pattern won't be masked. Set your patterns deliberately, and treat the trace
directory as sensitive.
- **No rotation**: per-tool files append indefinitely. Rotate/prune them
yourself if volume is a concern.
- **Cost**: bodies are redacted and serialized on the call path before the
async write. It's a debugging/observability feature — leave it off in
latency-sensitive setups, or use `maxBodyBytes`.
- A config change (including enabling `trace`) only takes effect when the proxy
restarts — restart your MCP host after editing it.
### User hooks
```js
// middleware.js
export default {
async before(req) {
// req: { server, kind: "tool"|"resource"|"prompt", name, params, meta? }
if (req.kind === "tool" && req.name === "write_file") {
if (req.params?.path?.includes("/etc/")) {
throw new Error("writes under /etc are blocked");
}
}
return req; // or void to leave unchanged
},
async after(req, res) {
// res: { result, durationMs, error? }
if (res.durationMs > 2000) {
process.stderr.write(`slow: ${req.server}/${req.name}\n`);
}
return res;
},
};
```
Both hooks may be sync or async. Return the modified request/response, or
nothing to leave it as-is. Throwing inside `before` cancels the call; throwing
inside `after` surfaces as an MCP error to the client.
## What's proxied
- **tools/list** — aggregated from every connected server. Names are namespaced
as `<server>__<tool>` unless `namespace: false`.
- **tools/call** — routed to the right upstream based on the namespaced name.
- **resources/list** / **resources/read** — aggregated; URIs are kept as-is.
- **prompts/list** / **prompts/get** — aggregated; names are namespaced.
If two upstreams expose the same name (or resource URI), the proxy logs a
collision warning and the later one wins. Use `namespace: true` (the default)
to avoid this.
## Notes
- Logs go to **stderr** — stdout is reserved for the MCP protocol.
- A server that fails to start is logged and skipped; the rest still come up.
- Servers that need a browser login show as "needs login" until you run
`authenticate` (or use the management app); startup never opens a browser.
- `SIGINT` / `SIGTERM` closes every upstream cleanly before exit.
## Publishing
### npm (manual)
```bash
npm version <patch|minor|major> # bumps + tags + commits
npm publish --access public # runs build + tests first (prepublishOnly)
git push --follow-tags
```
`publishConfig.access: "public"` is set in `package.json`, so the
`--access public` flag is just belt-and-suspenders.
### Docker (automated)
Every push to `main` and every `v*.*.*` tag triggers
`.github/workflows/docker-publish.yml`, which builds a multi-arch image
(`linux/amd64` + `linux/arm64`) and pushes to `ghcr.io/qelos-io/better-mcp`.
Tags pushed:
- branch pushes → `:main`, `:sha-<short>`
- semver tags → `:latest`, `:vX.Y.Z`, `:X.Y`, `:X`
The workflow also flips the package to public on first push (no-op afterwards).
## License
MITTDQS
A3.7/5.0
Scored across 14 tools
Disambiguation4/5
Most tools have distinct purposes, but 'list_directory' and 'list_directory_with_sizes' overlap significantly. Also, 'read_file' is deprecated and duplicates 'read_text_file', though the deprecation note mitigates confusion.
Naming Consistency5/5
All tools follow a consistent 'fs__' prefix with clear, descriptive snake_case names. No mixing of conventions.
Tool Count5/5
14 tools is a reasonable number for a file system server. Each tool covers a specific operation without excessive fragmentation.
Completeness3/5
Covers essential file system operations but lacks a delete or copy tool, which are common needs. Also missing append functionality. These gaps could hinder some workflows.
Maintenance
ActivityMaintained
ResponsivenessNo recent community issues