@qelos/better-mcp
This server provides filesystem tools for reading, writing, editing, searching, and managing files and directories within allowed directories.
Read text files, including first/last N lines, via
read_text_fileand deprecatedread_file.Read image or audio files as base64 data with MIME type.
Read multiple files in one call.
Write or overwrite files with text content.
Make line-based edits to files and preview changes with a dry run.
Create directories, including nested paths.
List directory contents, optionally with file sizes and sorting.
View a recursive directory tree with exclusion patterns.
Move or rename files and directories.
Search recursively for files/directories using glob-style patterns.
Retrieve detailed file/directory metadata such as size, timestamps, permissions, and type.
List the directories the server is allowed to access.
Provides tools for interacting with GitHub repositories via the GitHub MCP server, enabling operations such as listing issues, managing pull requests, and other repository actions.
Click on "Deploy Server".
Wait a few minutes for the server to deploy. Once ready, it will show a "Started" state.
In the chat, type
@followed by the MCP server name and your instructions, e.g., "@@qelos/better-mcp list files in /tmp"
That's it! The server will respond to your query, and you can continue using it as needed.
Here is a step-by-step guide with screenshots.
better-mcp
One MCP endpoint for all your MCP servers — with login, a marketplace, and a token diet.
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_mcpsand 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/afterhooks.Run it anywhere.
npx, Docker, or expose it over Streamable HTTP for teams.
Related MCP server: mcp-toolmux
Quick start
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" } } }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.jsonClaude Desktop / Cursor / VS Code (
mcpServersin the client's config){ "mcpServers": { "better-mcp": { "command": "npx", "args": ["-y", "@qelos/better-mcp", "-c", "/abs/path/to/mcp.json"] } } }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.jsonOption B — Docker
The image is published to GitHub Container Registry:
docker pull ghcr.io/qelos-io/better-mcp:latestdocker 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:latestFlag | Why |
| The proxy speaks MCP over stdio (no |
| Your config. Mount it read-write (no |
| The management app ( |
| The OAuth callback. Remote-server logins redirect your browser to |
| Optional. Persists OAuth state when you use |
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.jsonmust be runnable inside the container. The image hasnodeandnpx; 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}inmcp.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.dirto 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.jsonRun
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-mcpConfig location
Resolved in this order — the first hit wins:
-c <path>/--config <path>CLI flagMCP_PROXY_CONFIGenv var (inline JSON, or a path)mcp.jsonnext to the entry script (e.g.dist/mcp.json, or/app/dist/mcp.jsoninside Docker)mcp.jsonone level up from the entry script (e.g. project root, or/app/mcp.jsoninside Docker)mcp.jsonin 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 (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.describeprints the description and input schema;callruns the tool and prints its result as JSON (after the middleware pipeline;--rawprints 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(aliaslist) 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}'orcat body.json | better-mcp tools call github__get_issue. Connection logs are hidden so stdout stays clean JSON (--verboseshows 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 runningbetter-mcp appis reused too.--no-daemonconnects in the command itself. The helper's log is in~/.better-mcp/daemons/.better-mcp help tools(ortools --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 managemcp.jsonoutside 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 ifmanage.enabledisfalse.
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 tomcp.json(overrides discovery + env).--no-namespace— disable the<server>__<tool>prefix at runtime.--offload-resources— also offloadresources/readresponses (same as settingmiddleware.offload.includeResources: true, orMCP_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_mcpsopens 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).authenticatestarts 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.afterUser 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,$commenttitle,examples,defaultrequired: []andenum: []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
firstSeentimestamp — 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:
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.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 asexcludeelsewhere).Object value =
Partial<{ thresholdBytes, chapterMarkdown, inferArrayShape, previewRows }>merged on top of the global config for matching calls. Unspecified knobs inherit from the global.falsesentinel = 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.mdThis 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
# H1line if one exists, elseintro. 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 tochapterwhen nothing usable remains.Set
chapterMarkdown: falseto keep today's behavior (full file saved as a.jsonwrapper for everything that isn't a JSON array).If a chapter write fails mid-flight, the full
.mdfile 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). Defaultdiris<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
aftersteps. 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 withmaxBodyBytesif that matters.Redaction: the tracer sees raw, pre-redaction upstream data, so it scrubs independently using
trace.redact(falling back tomiddleware.redact). Unlike theredactmiddleware, it also descends into JSON embedded in strings — the commoncontent[].textwrapper — 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>unlessnamespace: 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/SIGTERMcloses 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-tagspublishConfig.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 toolsfs__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
Scored across 14 tools
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.
All tools follow a consistent 'fs__' prefix with clear, descriptive snake_case names. No mixing of conventions.
14 tools is a reasonable number for a file system server. Each tool covers a specific operation without excessive fragmentation.
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
Related MCP Connectors
Toolstile is a managed MCP gateway that connects remote MCP servers behind one endpoint, with per-agent permissions, shared accounts and activity logs. Connect at https://mcp.toolstile.com/mcp over Streamable HTTP with OAuth; sign in at https://toolstile.com, select a workspace and connect your upstream servers. Available tools, resources and prompts depend on that workspace and the agent's granted access.
Nifty's MCP server — exposes tasks, projects, messages, and files as tools for AI agents.
Talk to your public-facing AI from any MCP client — Claude, ChatGPT, Cursor, Cline, Windsurf.
MCP server for your apps' tools and custom tools, plus hosted AI agents and approval-gated workflows
Related MCP Servers
- AlicenseBqualityBmaintenanceA 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.31MIT
- AlicenseNot gradedqualityDmaintenanceProxy-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
- AlicenseNot gradedqualityDmaintenanceA 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.111MIT
- FlicenseNot gradedqualityCmaintenanceA 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.-