Skip to main content
Glama
@qelos/better-mcp

@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.**

[![npm](https://img.shields.io/npm/v/@qelos/better-mcp?color=cb3837&logo=npm)](https://www.npmjs.com/package/@qelos/better-mcp)
[![node](https://img.shields.io/node/v/@qelos/better-mcp)](https://nodejs.org)
[![Docker](https://img.shields.io/badge/ghcr.io-qelos--io%2Fbetter--mcp-2496ed?logo=docker&logoColor=white)](https://github.com/qelos-io/better-mcp/pkgs/container/better-mcp)
[![license](https://img.shields.io/npm/l/@qelos/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

MIT

TDQS

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