Skip to main content
Glama
@qelos/better-mcp

@qelos/better-mcp

by qelos-io

better-mcp

One MCP endpoint for all your MCP servers — with login, a marketplace, and a token diet.

npm node Docker license

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.

Related MCP server: mcp-toolmux

Quick start

  1. Create an mcp.json (same shape as Cursor / Claude Desktop):

    {
      "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

    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)

    {
      "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 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.

# 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:

docker pull ghcr.io/qelos-io/better-mcp:latest
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:

{
  "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

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.

# 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.

Browse the marketplace (registry: 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.

Config

{
  "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.

{
  "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); 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:

{
  "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.

{ "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:

"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.

"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.

"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.

"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:

"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:

"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):

{"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:

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

// 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)

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

Available Tools

14 tools
fs__create_directoryA

Create a new directory or ensure a directory exists. Can create multiple nested directories in one operation. If the directory already exists, this operation will succeed silently. Perfect for setting up directory structures for projects or ensuring required paths exist. Only works within allowed directories.

Inputs: path (required).

fs__directory_treeB

Get a recursive tree view of files and directories as a JSON structure. Each entry includes 'name', 'type' (file/directory), and 'children' for directories. Files have no children array, while directories always have a children array (which may be empty). The output is formatted with 2-space indentation for readability. Only works within allowed directories.

Inputs: path (required), excludePatterns.

fs__edit_fileA

Make line-based edits to a text file. Each edit replaces exact line sequences with new content. Returns a git-style diff showing the changes made. Only works within allowed directories.

Inputs: path (required), edits (required), dryRun.

fs__get_file_infoA

Retrieve detailed metadata about a file or directory. Returns comprehensive information including size, creation time, last modified time, permissions, and type. This tool is perfect for understanding file characteristics without reading the actual content. Only works within allowed directories.

Inputs: path (required).

fs__list_allowed_directoriesA

Returns the list of directories that this server is allowed to access. Subdirectories within these allowed directories are also accessible. Use this to understand which directories and their nested paths are available before trying to access files.

Inputs: no named parameters.

fs__list_directoryA

Get a detailed listing of all files and directories in a specified path. Results clearly distinguish between files and directories with [FILE] and [DIR] prefixes. This tool is essential for understanding directory structure and finding specific files within a directory. Only works within allowed directories.

Inputs: path (required).

fs__list_directory_with_sizesA

Get a detailed listing of all files and directories in a specified path, including sizes. Results clearly distinguish between files and directories with [FILE] and [DIR] prefixes. This tool is useful for understanding directory structure and finding specific files within a directory. Only works within allowed directories.

Inputs: path (required), sortBy.

fs__move_fileA

Move or rename files and directories. Can move files between directories and rename them in a single operation. If the destination exists, the operation will fail. Works across different directories and can be used for simple renaming within the same directory. Both source and destination must be within allowed directories.

Inputs: source (required), destination (required).

fs__read_fileA

Read the complete contents of a file as text. DEPRECATED: Use read_text_file instead.

Inputs: path (required), tail, head.

fs__read_media_fileB

Read an image or audio file. Returns the base64 encoded data and MIME type. Only works within allowed directories.

Inputs: path (required).

fs__read_multiple_filesA

Read the contents of multiple files simultaneously. This is more efficient than reading files one by one when you need to analyze or compare multiple files. Each file's content is returned with its path as a reference. Failed reads for individual files won't stop the entire operation. Only works within allowed directories.

Inputs: paths (required).

fs__read_text_fileA

Read the complete contents of a file from the file system as text. Handles various text encodings and provides detailed error messages if the file cannot be read. Use this tool when you need to examine the contents of a single file. Use the 'head' parameter to read only the first N lines of a file, or the 'tail' parameter to read only the last N lines of a file. Operates on the file as text regardless of extension. Only works within allowed directories.

Inputs: path (required), tail, head.

fs__search_filesA

Recursively search for files and directories matching a pattern. The patterns should be glob-style patterns that match paths relative to the working directory. Use pattern like '.ext' to match files in current directory, and '**/.ext' to match files in all subdirectories. Returns full paths to all matching items. Great for finding files when you don't know their exact location. Only searches within allowed directories.

Inputs: path (required), pattern (required), excludePatterns.

fs__write_fileA

Create a new file or completely overwrite an existing file with new content. Use with caution as it will overwrite existing files without warning. Handles text content with proper encoding. Only works within allowed directories.

Inputs: path (required), content (required).

Tool Schema Changelog

Recent tool additions, removals, and schema changes observed during successful MCP inspections.

Loading tool schema changelog…

    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

    Related MCP Connectors

    Related MCP Servers

    • A
      license
      B
      quality
      B
      maintenance
      A flexible proxy server that aggregates multiple backend MCP servers into a single interface using STDIO or SSE transports. It supports dynamic server management via an HTTP API and utilizes namespacing to prevent tool conflicts across connected services.
      3
      1
      MIT
    • A
      license
      Not graded
      quality
      D
      maintenance
      Proxy-style MCP tool multiplexer that aggregates multiple downstream stdio MCP servers into one, offering meta-tools for status, search, call, parallel, batch, and pipeline operations with concurrency control and caching.
      MIT
    • A
      license
      Not graded
      quality
      D
      maintenance
      A flexible MCP proxy server that connects to and routes between multiple backend MCP servers over STDIO or SSE, enabling dynamic management and namespacing of tools.
      111
      MIT
    • F
      license
      Not graded
      quality
      C
      maintenance
      A local stdio MCP server that bridges remote Streamable HTTP MCP servers to MCP clients, enabling transport normalization and optional outbound HTTP proxy support for restricted networks.
      -