DeepSeek Harness 插件

LuminariSoftwares/tool-guardian

Star 数 ★ 2 下载量(近 30 天) 2,841 分类 工具与能力 收录于 2026-09-21 npm dsh-tool-guardian

用三个路由工具(list_capabilities、describe_tool、call_tool)代理 stdio MCP 服务器,schema 按需加载,不再随每次请求发送;所有工具结果经过确定性输出阶梯压缩,有损处理前先归档原文(retrieve_spill 可读回);按 token 为工具分组标价,并可按 agent 隐藏内置分组。桥接到纯标准库的 Python 路由器,需要 Python 3.9+。

安装

# npm 包(预构建)

dsh plugin --profile web add dsh-tool-guardian

# GitHub 源码(首次需按提示配置 allowBuilds 构建授权后重试)

dsh plugin --profile web add github:LuminariSoftwares/tool-guardian

装任何插件都等于在你的机器上跑第三方代码,权限和你本人一样大——能读你的文件、用你的凭据、访问网络,工具审批管不到它。GitHub 来源的插件还会在安装时执行构建脚本——pnpm 默认拦截,所以安装可能停在 ERR_PNPM_GIT_DEP_PREPARE_NOT_ALLOWED 或 ERR_PNPM_IGNORED_BUILDS;dsh 会打印出需要添加的确切键名,把它加进该 profile 的 pnpm-workspace.yaml 的 allowBuilds 下,重跑一次即可装上。放行构建本身就是一次信任判断:请只安装可信来源,并尽量锁定 commit(github:owner/repo#sha)。

README

该插件的 README 只有英文版本。

mcp-name: io.github.LuminariSoftwares/tool-guardian

The headline is this repo's bench, run against the recorded tools/list answers of seven public MCP servers (no network, no model; abridged where marked):

$ python bench/bench_tokens.py
# Tool-Guardian Token Benchmark

Token counting method: estimate:chars/3.5

| server | tools | tokens (full schemas) |
...
| **Total** | **95** | **20005** |

**Every request:** 20005 tokens of tool schemas without tool-guardian, 1084 with it (94.6% less).

An MCP server that sits in front of your other MCP servers and exposes 6 router tools (8 with skills) instead of dozens of specific ones: four generic ones to list, search, describe and call the tools behind it, plus two for tool-group costs and archived results (see Tools) — discovering the rest on demand — so tool definitions stop eating your context window before the model reads a word.

The router never talks to the model backend, so it does not matter whether Ollama, LM Studio, vLLM or llama.cpp serves your model; what matters is that your client's model calls tools (run with Ollama; the others expected, not yet tested).

Companion to Context Guardian: Context Guardian compacts the conversation before the window fills; Tool Guardian keeps the tools from filling it in the first place. Two halves of the same problem.

Without Tool Guardian With it
7 MCP servers on a 32K model (bench) 20,005 tokens of schemas on every request (61 % of the window) 1,084 tokens (3 %); a schema is fetched only when the model asks
A 3,000-line build log with one error (bench) 115,945 characters land in the conversation 4,540 characters, the full original archived and one call away
A backend that fails to start an empty tool list the model silently works around status UNKNOWN plus the real error (e.g. could not start: …), never an empty list

Quick start (60 seconds)

Or tell your coding agent: Install tool-guardian (docs: https://raw.githubusercontent.com/LuminariSoftwares/tool-guardian/main/llms.txt) and route my MCP servers through it.

Nothing to install: uvx runs it from PyPI. The example server below is the MCP filesystem server, which runs with npx, so that one line needs Node.js — the router itself is Python 3.9+ and standard library only.

uvx tool-guardian --version
uvx --from tool-guardian tool-guardian-setup add files -- npx -y @modelcontextprotocol/server-filesystem "$HOME"
uvx tool-guardian --selftest
claude mcp add --scope user tool-guardian -- uvx tool-guardian   # or any client: see "Run" below

Prefer it installed? pip install tool-guardian, then drop the uvx prefixes (tool-guardian-setup add ..., tool-guardian --selftest).

Success looks like added 'files' to ~/.tool-guardian/mcp.json, then from --selftest 1 backend(s) up, 14 tools reachable. and a router tools cost ~N tokens vs ~M line. Already have servers in Claude Desktop, Cursor or Windsurf? tool-guardian-setup import copies them instead of add. In the client, ask for something one of your servers does; the model should call list_capabilities (or search_capabilities) and then call_tool.

What it looks like in practice

A real session against two servers (files and git), abridged only where marked. The model asks for the catalogue, narrows to one server, checks one tool's arguments, then calls it:

> list_capabilities()
[files] 14 tools: read_file, read_text_file, read_media_file, read_multiple_files, write_file,
        edit_file, create_directory, list_directory, list_directory_with_sizes, directory_tree,
        move_file, search_files, get_file_info, list_allowed_directories
[git] 12 tools: git_status, git_diff_unstaged, git_diff_staged, git_diff, git_commit, git_add,
      git_reset, git_log, git_create_branch, git_checkout, git_show, git_branch

NEXT STEP: use search_capabilities with a keyword (or list_capabilities with one server name) to
see a tool's args, then call_tool.

> list_capabilities(server="files")
files.read_file: Read the complete contents of a file as text. DEPRECATED: Use read_text_file...
    args: path:string, ?tail:number, ?head:number
files.read_text_file: Read the complete contents of a file from the file system as...
    args: path:string, ?tail:number, ?head:number
... 11 more tools ...
files.list_allowed_directories: Returns the list of directories that this server is allowed to access....
    args: (no arguments)

NEXT STEP: you have not answered the user yet. Pick the tool above that does the job and use
call_tool with server files, that tool's name, and the args listed under it.

> describe_tool(server="files", tool="read_text_file")
files.read_text_file: Read the complete contents of a file from the file system as...
required args: path:string

NEXT STEP: use call_tool with server files, tool read_text_file, args: path:string, ?tail:number, ?head:number.

> call_tool(server="files", tool="read_text_file", args={"path": "project/README.txt"})
hello from tool-guardian

NEXT STEP: answer the user from this result.

Only the router's own six tools were in the model's context the whole time; the 26 backend tools were never sent.

When not to use the router

It is the wrong tool in two cases, and saying so is cheaper than finding out:

  • Your client already searches tools for you. Some clients (and some model APIs) load tool definitions on demand themselves. Putting a router under that adds a hop and a second catalogue for no saving.
  • You run a handful of small servers. The router's own tools cost about 1,100 tokens of tools/list; one small server behind it is close to break-even (--selftest prints the real arithmetic for your servers before you commit to it). The saving grows with every server added and is largest where it matters most — a 32K local model with half a dozen servers.

It also does not help if your model will not call tools reliably in the first place: see Model requirement.

Works with

pip install tool-guardian, then one entry in your client. Every example below starts the same command, tool-guardian, which reads ~/.tool-guardian/mcp.json: tool-guardian-setup import fills it from the servers you already use.

Client Example Where it goes Tested
Claude Code project.mcp.json, hook: settings.json, notes claude mcp add --scope user tool-guardian -- uvx tool-guardian, or copy to .mcp.json in a project (Claude Code asks once to approve a project server — that file is the client's config, not the router's) ; the hook goes in ~/.claude/settings.json or .claude/settings.json 2.1.296: full turn; hook in a live session
OpenCode opencode.json opencode.json in the project, or ~/.config/opencode/opencode.json 1.18.35: full turn
Cline cline_mcp_settings.json VS Code: Cline → MCP Servers → Configure MCP Servers; CLI: ~/.cline/data/settings/cline_mcp_settings.json CLI 3.0.70: full turn
Continue tool-guardian.yaml .continue/mcpServers/ in the workspace (IDE), or its mcpServers entry in config.yaml cn 1.5.47: full turn (entry in config.yaml)

What "full turn" means. On Linux, 2026-10-10, each client got the example's tool-guardian entry unchanged (Claude Code through claude mcp add-json; OpenCode and Continue with a test model provider added beside it; Cline the file as is) and talked to a scripted model (tests/clients/mock_model.py, no real model, no API key). The client sent tool-guardian's router tools to the model. The model's list_capabilities call went through tool-guardian to a stub MCP server, and the answer came back to the model. tool-guardian's call log recorded the client by name (claude-code, opencode, @cline/core, continue-cli-client). Re-run it: pip install -e . then python tests/clients/run_clients.py. This proves the wiring, not that your model will use the router: see Model requirement.

The Claude Code hook in a live session. The hook command from settings.json with --mode deny added (wrapped to record its input) blocked a Bash call naming a router tool inside a running Claude Code 2.1.296 session. Claude Code showed the hook's reason, Use call_tool(server="probe", tool="tg_live_probe_tool", args={...}) instead of the shell, and the call log got its bypass row. The payload Claude Code sent is a test fixture now (tests/test_claude_code_hook.py), next to the example from Claude Code's hook docs.

Not tested yet: the Cline VS Code extension (same file name and shape; tested through the Cline CLI), Continue's .continue/mcpServers/ folder (read by the IDE extension; cn reads only config.yaml), and client runs on Windows or macOS (the unit tests run on all three in CI). On Windows, if a client cannot find tool-guardian, put the full path from where tool-guardian in the command.

DeepSeek Harness results

Tool Guardian also ships as a native DeepSeek Harness bundle, dsh-tool-guardian (what it adds). These two figures come from DSH runs only and are not reproduced by anything in this repo:

  • Sessions that ended on a tool call written as text instead of made: 7/16 → 0/32 — DeepSeek Harness (version not recorded), qwen3-coder:30b, 8 tasks; 16 sessions (8 × 2) on dsh-tool-guardian 0.4.0-beta.2, 32 sessions (8 × 4) on 0.5.0-beta.1; the repair runs in the DSH bundle only; measured by the author, script and raw runs not yet published (see the 0.5.0-beta.1 entry of the CHANGELOG); not yet measured on other setups.
  • DSH first request: 46 tools and 37,154 characters of schema without the bundle, 22 tools and 18,503 characters with it — one DSH setup, measured by the author, script not yet published.

Install both (DeepSeek Harness, about 5 minutes)

Context Guardian and Tool Guardian are two halves of one problem: Tool Guardian keeps tool schemas and tool results from filling the window, and Context Guardian compacts the conversation before it fills and keeps what matters. They share no files and install separately. You need DSH 0.1.2-alpha.2 or later, Node.js ^22.19 or >=24, and Python 3.9+ on PATH for Tool Guardian's router.

# 1. Add both bundles to the DSH profile you use (`web` is the one `dsh web` uses)
dsh plugin --profile web add dsh-tool-guardian
dsh plugin --profile web add dsh-context-guardian

# 2. Tool Guardian: copy in the MCP servers you already use (Claude Desktop / Cursor / Windsurf / .mcp.json), then check them
cd ~/.dsh/profiles/web/node_modules/dsh-tool-guardian          # Windows: cd %USERPROFILE%\.dsh\profiles\web\node_modules\dsh-tool-guardian
npm run setup                  # asks before it writes ~/.tool-guardian/mcp.json, then prints a doctor report

# 3. Context Guardian: add its compaction row to your agent preset (a dry run until --apply)
cd ~/.dsh/profiles/web/node_modules/dsh-context-guardian       # Windows: cd %USERPROFILE%\.dsh\profiles\web\node_modules\dsh-context-guardian
npm run setup
npm run setup -- --apply

# 4. Start DSH and open a NEW session with the preset setup named
dsh web

In that session, type /guardian. It shows Context Guardian's engine revision and your model's window. Then type /toolguardian. It shows each MCP server Tool Guardian started, the tokens the router saves on every request, and whether an update is out.

Not on DSH? Context Guardian's proxy (python context_guardian.py, see its README) works with any OpenAI-compatible CLI. Tool Guardian runs as a plain MCP server for Claude Code, Cursor or any MCP client (see its README).

New to it? Your first 5 minutes goes from nothing installed to proof that your model uses the router, on DSH or Claude Code.

Why this exists

MCP tool definitions are re-sent on every single request, whether the model touches them or not. A handful of servers routinely comes to tens of thousands of tokens — often most of a small local model's window — before the first user message. On one real setup, seven MCP servers came to 28,689 tokens, 87.6% of a 32K window, as a fixed floor under everything else.

You have two ways to deal with that today, and both cost you something:

Approach The cost
Load fewer MCP servers You lose the capability entirely
Live with it Two-thirds of the window is gone before you type

Tool Guardian is a third option that costs neither. It fronts all your servers and shows the model just a handful of small tools plus a one-line catalogue of server names (1,084 tokens with seven servers behind it, measured below). The full schema for a tool is fetched only when the model asks for it:

list_capabilities(server?)      one line per tool — names and purpose
describe_tool(server, tool)     the full argument schema for ONE tool
call_tool(server, tool, args)   invoke it, return the result

Same idea as a search index: cheap catalogue always visible, detail on demand.

Every tool carries the MCP annotations (title, readOnlyHint, destructiveHint, idempotentHint, openWorldHint). All of them are read-only except call_tool, which is marked destructive and open-world because it runs whatever the backend tool does; the DSH bundle's tg__* direct tools carry the same values.

Model requirement (read this before you switch)

The whole design rests on one behaviour: the model must proactively call list_capabilities (then call_tool) when it needs a tool. Capable/frontier models do this reliably. Small local models are less certain — and, as the two measurements below show, the harness the model runs in matters as much as the model.

Measurement 1 — August 2026, OpenClaude, router mounted over MCP. Against a real multi-server stack, gpt-oss:20b and qwen3-30b-a3b both bypassed the router on ordinary tasks — even with the NEXT STEP nudge in every result and a dedicated router sub-agent priming them. They treated a tool name as a shell command or scripted their way around it. Part of that was the harness, not the models: gpt-oss:20b did call list_capabilities correctly but could not carry the discovery into call_tool inside a general task, and the one approach that completed the sequence (the sub-agent) was blocked by the harness's own agent-tool argument validation before it ever reached the router.

Measurement 2 — 2026-09-19 and 2026-09-21, DeepSeek Harness, router tools registered natively by the DSH bundle. The same model family (qwen3:30b-a3b-instruct-2507, 32K window) used the router with no bypass. 09-19, two sessions whose prompts named the tools: list_capabilities once, then list_capabilities ×8 → call_tool(ops-scripts, service_status). 09-21, one session, three ordinary prompts that named no tool ("Which of the project's services are running right now?", "How many n8n workflows do we have, and which were edited most recently?", "What did we learn last time a bridge commit wrote stale bytes?"): the call log shows list_capabilities(jobs) → call_tool(jobs, pipeline_status), then call_tool(n8n, list) (wrong name, ok: false) → call_tool(n8n, n8n_list_workflows) (ok: true, 12,963 chars shaped by the ladder) — the model corrected itself from the router's error — and zero bypass rows; the third prompt was answered with the companion plugin's recall/search plus built-in glob/read, which is the right tool, not a bypass. That is three sessions and one model: enough to show Measurement 1 is not a verdict on these models, not enough to promise yours will behave.

So: --selftest proves the saving and that your backends start — it does not prove your model will drive the router in your harness. Test discovery→call with your actual model and harness before committing, and measure rather than guess: every router call is appended to ~/.tool-guardian/calls.jsonl (TOOL_GUARDIAN_CALL_LOG) with a session id, and under DSH a shell call that does a router tool's job is logged there as kind: "bypass" (and can be nudged or denied). After a few real tasks, /toolguardian bypass (DSH) or tool-guardian --bypass-summary (any client) reads that file for you: router calls, every bypass with the exact call_tool(...) it should have been, and sessions that never touched the router. Over plain MCP, see bypass detection and its limits. If it won't, expose a small curated, visible subset of servers instead of routing everything behind a catalogue the model never opens.

Two ways to run it

It is one repo and one Python router. Pick the front door that matches your harness — both stay supported.

MCP server (any MCP client) Native DSH bundle
Works with Claude Code, OpenCode, Cline, Continue (tested configs), Claude Desktop, OpenClaude, Cursor, anything that speaks MCP over stdio DeepSeek Harness
Install pip install tool-guardian dsh plugin --profile <name> add dsh-tool-guardian
Hides MCP schemas behind 6 router tools (8 with skills) yes yes, registered natively
Output ladder on results results of call_tool every tool's result (bash, grep, web_fetch, ...)
Tool groups with token prices list_groups_with_costs plus activate_group, and DSH's own built-in tools can be grouped and hidden too
Notices a shell call doing a router tool's job — logs, nudges or denies it
Configured by tool-guardian.json + TOOL_GUARDIAN_* env the tool-guardian patch row or DSH settings; the same env vars win
flowchart LR
    A["Your agent<br/>(Claude Code, DSH, any MCP client)"] -->|"6 tools, ~1,700 tokens for 7 servers"| B["Tool Guardian"]
    B -->|"on demand"| C["filesystem"]
    B -->|"on demand"| D["git"]
    B -->|"on demand"| E["n8n, database, ..."]
    B -. "big result" .-> F[("archive<br/>retrieve_spill")]
    B -->|"shaped result"| A

Tools

The router exposes these tools. The first four are always there; list_groups_with_costs and retrieve_spill (while the output ladder is on) are the other two of the 6 router tools; the two skill tools appear only when a skill folder is configured.

tool arguments what it does
list_capabilities server (optional) tool names with one-line summaries for one server, or all; start here
search_capabilities query, server and limit (optional) find a tool by keyword across servers
describe_tool server, tool, detail (optional) the arguments of one tool (detail=full: its whole schema)
call_tool server, tool, args (optional) run a backend tool and return its (shaped) result; this is the step that answers the user
list_groups_with_costs none tool groups and the context-token cost of loading each
retrieve_spill id, grep, start_line, max_lines (optional) read back the full original of a shortened result
list_skills none one line per skill (only with TOOL_GUARDIAN_SKILLS)
read_skill name the full text of one skill (only with TOOL_GUARDIAN_SKILLS)

All but call_tool only report (read-only, no network: the catalogue is in memory, the archive and skills on local disk); call_tool does whatever the backend tool does. Each tool's description says what it is for, when a sibling is the better call, what it touches and what comes back — in about one clause each, because the whole tools/list payload is capped at 1,100 tokens by a test. Every parameter is typed and explained exactly once, by its own description, by its enum, or by the tool description naming it; tests/test_tool_definitions.py checks that, and checks this table's argument names against the schemas the server really lists.

Search by meaning (optional)

search_capabilities matches words. Ask it to "persist a memo" and it finds nothing, because no tool says "persist" or "memo"; the tool is add_note. Search by meaning fixes that with an embedding model you run yourself. It is off by default: with TOOL_GUARDIAN_EMBED_URL unset the output is byte for byte what it was and nothing is sent anywhere.

Turn it on by pointing it at an embeddings endpoint. The URL path tells tool-guardian which shape to speak:

# Ollama (the path ends in /api/embed)
export TOOL_GUARDIAN_EMBED_URL=http://127.0.0.1:11434/api/embed
# or any OpenAI-compatible server (the path ends in /embeddings)
export TOOL_GUARDIAN_EMBED_URL=http://127.0.0.1:8080/v1/embeddings
variable default meaning
TOOL_GUARDIAN_EMBED_URL unset (off) the embeddings endpoint; a path that ends in neither /api/embed nor /embeddings is refused and search stays on keywords
TOOL_GUARDIAN_EMBED_MODEL nomic-embed-text the model name sent in the request
TOOL_GUARDIAN_EMBED_MIN 0.5 the cosine similarity a tool needs to be listed at all
TOOL_GUARDIAN_EMBED_CACHE ~/.tool-guardian/embed_cache where the tool vectors are kept, one <hash>.json per model and catalogue

What changes: a tool is listed only when its meaning is close enough to the query (cosine at or above the minimum), so an unrelated query can answer "no tools match". The listed tools are ranked by cosine, with the old keyword score counted at 1/100 only to break ties. The header, the args: lines and the NEXT STEP line are unchanged. If the endpoint is down, times out or answers badly, search_capabilities returns the keyword answer, byte for byte, and tool-guardian logs one search by meaning off line to stderr per run, not one per search.

Measured with python bench/bench_search.py (seven bench catalogues, 95 tools; 40 queries: 20 paraphrases, 10 exact names, 10 that nothing should match; minimum 0.5; 2026-10-31). A query counts when an expected tool is in the first three lines, or, for the ten negative ones, when nothing is listed:

nomic-embed-text   recall@3 keyword=35.0 semantic=80.0 blended=80.0 over 40 queries
mxbai-embed-large  recall@3 keyword=35.0 semantic=85.0 blended=82.5 over 40 queries

Run it on your own model: python bench/bench_search.py --url http://127.0.0.1:11434/api/embed --model <name> --min 0.5.

Why meaning first: a blend that let keywords outweigh meaning scored only 45 to 47.5 on real embeddings, because a substring hit ("store" inside "storemanager") lists tools for unrelated queries and ranks them above the paraphrase match. So a keyword hit alone lists nothing, and keywords only order tools that already passed on meaning.

Cost: one embedding call per search (the query), plus one call for the whole catalogue whenever a tool is added, removed or re-described. Nothing else is sent, and the tool descriptions go only to the endpoint you chose.

Architecture

flowchart LR
    client["MCP client<br/>(Claude Code, Claude Desktop, ...)"] -- "stdio JSON-RPC" --> serve
    dsh["DSH bundle<br/>index.js"] -- "line JSON<br/>tg_bridge.py" --> router
    subgraph tg["tool-guardian (Python, standard library only)"]
        serve["serve()<br/>MCP over stdio"] --> router["Router<br/>catalogue · search · arg checks · loop guard"]
        router --> ladder["tg_ladder<br/>shape result by type"]
        ladder -- "lossy?" --> spill[("tg_spill archive<br/>~/.tool-guardian/spill")]
        router --> groups["tg_groups<br/>token price per group"]
        router --> state[("calls.jsonl · state.json")]
    end
    router -- "stdio, started at launch,<br/>respawned once" --> b1["backend MCP server"]
    router --> b2["backend MCP server"]

You point your client at one MCP server, Tool Guardian, and give it the same mcpServers config you would have given the client. It starts every backend as a child process at launch, reads each tools/list once, and from then on answers list_capabilities, search_capabilities and describe_tool from memory. Only call_tool reaches a backend. Its arguments are checked against the backend tool's schema first, and the result goes through the output ladder before the model sees it. The DSH bundle reaches the same Router through a private bridge process instead of MCP.

Key decisions, each with its trade-off (full records in docs/adr/):

Decision What it buys What it costs
Generic router tools, schemas on demand (0001) 91 % fewer schema tokens on every request (bench) every capability is 2–3 calls away; the model must choose to look
Loud failure, named catalogue (0002) a dead server is never mistaken for "no such tool" the description grows a few tokens per server
Descriptions budgeted and pinned (0003) tools a model can choose between without guessing 1,090 tokens of tools/list with no backends, pinned at ≤ 1,100 by a test
Lenient inputs, strict backend checks (0004) small models' near-miss calls still work; impossible calls cost no round trip schemas are looser than what the router enforces
One stdlib Python core; DSH bundle is a bridge (0005) installs anywhere; one router to fix MCP stdio is hand-written; the npm package needs a Python
Archive before shortening (0006) big results stop flooding the window, nothing is lost originals are kept on local disk

Install

pip install tool-guardian

Pure standard library — nothing else to install.

PyPI and npm now ship the same code: pip install tool-guardian (0.3.0+) for the MCP server, dsh-tool-guardian on npm for DSH.

Which install is which: the Python package tool-guardian 0.9.1 is the MCP server (pip install tool-guardian, or uvx tool-guardian); the npm package dsh-tool-guardian 0.9.1-beta.1 is the DeepSeek Harness bundle. They are versioned separately.

Configure

Quickest way: you probably already have your servers set up in another MCP client.

tool-guardian-setup import    # copies them from Claude Desktop / Cursor / Windsurf / .mcp.json, asks before writing
tool-guardian-setup doctor    # checks the config, each server's command, your .env and ${VARS}, and says how to fix each problem

Adding one more server is one command -- everything after -- is the server's command line, exactly as you would type it:

tool-guardian-setup add git -- uvx mcp-server-git
tool-guardian-setup add github --env GITHUB_TOKEN='${GITHUB_TOKEN}' --description "issues and PRs" -- npx -y @modelcontextprotocol/server-github
tool-guardian-setup list      # name, command, and the group each server's tools land in
tool-guardian-setup remove git

add writes to the same config doctor finds (under DSH that is $TOOL_GUARDIAN_CONFIG), backs it up first, refuses a name that already exists unless you pass --replace, checks the command is on your PATH, warns about any ${VAR} that is not set in your environment or .env, and ends with the doctor result for that one server. Then restart your MCP client (or start a new DSH session) to load it. Every server you add is automatically behind the router's 6 tools: with no groups config it gets its own group named after the server; with a custom groups config it lands in other until you add it to a group.

import writes ~/.tool-guardian/mcp.json (backing up any existing file) and skips "url" servers (it does not copy them yet; add one to ~/.tool-guardian/mcp.json by hand, see Remote servers). For each skipped server it prints the one entry to put in your client instead. Inside DSH (npm install) run the same thing as python <plugin folder>/tg_setup.py doctor.

Or write the config by hand:

Tool Guardian reads the standard mcpServers block (the same shape Claude Desktop / Claude Code and most MCP clients use):

{
  "mcpServers": {
    "files": {
      "command": "npx",
      "args": ["-y", "@modelcontextprotocol/server-filesystem", "/data"]
    },
    "git": {
      "command": "uvx",
      "args": ["mcp-server-git"],
      "description": "git status / diff / commit / log"
    }
  }
}

An optional per-server "description" enriches the catalogue the model sees. Without one, the hint is derived from that server's own tool names at startup.

Config is searched in order: --config PATH, $TOOL_GUARDIAN_CONFIG, ~/.tool-guardian/mcp.json. Whichever file is used is printed to stderr at startup, with the .env that was loaded beside it.

A config in the current directory needs your say-so. ./mcp.json and ./.mcp.json are not loaded automatically. A repository you cloned can carry one, and up to 0.7.0 starting the router inside that directory launched its servers — with no approval prompt from your client. When one is found and skipped you get a single stderr line naming it and the opt-in; to use it, pass --config ./mcp.json, set TOOL_GUARDIAN_CONFIG=./mcp.json, or set TOOL_GUARDIAN_PROJECT_CONFIG=1.

A ./.mcp.json whose only server is tool-guardian itself (what the Claude Code setup below writes, to register the router with the client) is never treated as the router's own config: it is skipped and the search carries on to ~/.tool-guardian/mcp.json.

Environment and .env

Tool Guardian loads a .env itself and expands variables in your backend args, so secrets don't have to be exported into the environment by whatever launches it.

  • .env autoload. On startup it looks for a .env in this order: an explicit path, $TOOL_GUARDIAN_ENV, then an upward search — starting at the config file's directory (or the cwd) and walking up to 5 parent directories, loading the first .env it finds. This lets your config live in a nested folder while the .env sits at the project root. Values already in the real environment win over the file, and the TOOL_GUARDIAN_* settings themselves can live there too; a missing .env is a no-op, never an error.

    Example — config nested under the project, .env at the root:

    myproject/
    ├── .env                 <- (3) found here, loaded, search stops
    └── config/
        └── dsh/
            └── mcp.json      <- $TOOL_GUARDIAN_CONFIG points here
    

    The search walks upward from the config's directory:

    1. myproject/config/dsh/.env    -> not found
    2. myproject/config/.env        -> not found
    3. myproject/.env               -> FOUND  (stops here)
    
  • Small-window preset. TOOL_GUARDIAN_PRESET=small is for 8k-context local models. It sets exactly two values:

    setting default small what it does
    TOOL_GUARDIAN_CATALOGUE_CHARS 0 (no cap) 1500 each server's block in list_capabilities is cut at this many characters, with a [+N more tools not shown ...] line pointing at search_capabilities
    TOOL_GUARDIAN_DESC_WORDS 12 6 words of a tool, server or skill description shown in catalogue and search lines

    small also turns on TOOL_GUARDIAN_TERSE (below). Set either variable to override the preset. Search ranking still scores 12 description words, the output ladder and every other setting are unchanged, and describe_tool is compact in both. On the seven bench catalogues the seven per-server listings total 9,582 characters with small against 16,274 without.

  • Terse router tools. TOOL_GUARDIAN_TERSE=1 serves the router's own tools with one-sentence descriptions (at most 160 characters), no per-parameter descriptions, and without list_groups_with_costs. Names, argument types, enums, defaults, required arguments and the MCP annotation hints are unchanged, and list_capabilities still names the live servers. tools/list drops from 1,090 to about 617 tokens. It is on under TOOL_GUARDIAN_PRESET=small unless you set TOOL_GUARDIAN_TERSE=0; unset or 0 leaves the default payload exactly as it was.

  • Variable expansion. ${VAR}, $VAR and %VAR% are expanded in each backend's args and in its env values from the environment; unknown variables are left as-is, so the backend's own error names the one you missed. Keep a secret in .env and reference it in a backend arg:

    "args": ["-y", "mcp-remote", "https://app.openseo.so/mcp",
             "--header", "Authorization: Bearer ${OPENSEO_API_KEY}"]
    

    or in a backend's environment:

    "env": {"GITHUB_TOKEN": "${GITHUB_TOKEN}"}
    

    (Up to 0.7.0 only args were expanded, so an env value like that reached the server as the literal text ${GITHUB_TOKEN}.)

Remote servers

A "url" entry is a remote MCP server over Streamable HTTP, routed like any stdio server:

{"mcpServers": {
  "docs": {"url": "https://mcp.example.org/mcp", "headers": {"Authorization": "Bearer ${MY_TOKEN}"}},
  "local": {"url": "http://127.0.0.1:8080/mcp"}
}}
  • headers values take ${VAR} exactly as env does (from the environment or .env). type may be "http" or "streamable-http", or left out; "sse" forces the older HTTP+SSE transport (below).
  • https:// always verifies the certificate. http:// is allowed to 127.0.0.1, localhost and ::1 only; any other host fails with plaintext http to a non-loopback host; set allowInsecure true for this server. Add "allowInsecure": true to that server's entry to accept it.
  • HTTPS_PROXY / HTTP_PROXY are honoured (HTTPS goes through a CONNECT tunnel) and so is NO_PROXY.
  • The router asks each server which revision it speaks, once, when it starts: the stateless 2026-07-28 revision (server/discover), else the stateful 2025-11-25, 2025-06-18 or 2025-03-26 (initialize, with a session id if the server issues one, ended with DELETE when the router stops). tool-guardian --selftest prints one line per remote server that is up:
[remote] stub: legacy 2025-06-18 2 tools
  • HTTP+SSE (2024-11-05) is spoken too. Old servers still exist, and the transport is Deprecated upstream (it is still allowed for a twelve-month window), so the router speaks it rather than leave them out. Set "type": "sse" and the router opens one GET stream, takes the endpoint event as the URL to POST to (a path is resolved against the server's origin; an endpoint on another origin is refused), and reads every reply from the stream. Without a type, the router falls back to it, once and in this order, when server/discover and then initialize are both refused with 400, 404 or 405 and a GET answers with an endpoint event. headers go on the GET and on every POST. If the stream closes, or the endpoint answers 404, the router reconnects once (a new GET, a new initialize) and re-sends the request with a new id; a second failure is reported as for any other server. Closing sends no DELETE. --selftest prints [remote] <name>: sse 2024-11-05 <n> tools.

Failures take the path a stdio server does: one reconnect after a short wait (a legacy server's session is re-initialized), then DOWN on a second failure within a minute. A 401 shows the server as AUTH_REQUIRED with its WWW-Authenticate header, which carries the resource_metadata URL.

Not done: OAuth (wrap the server in mcp-remote as in the args example under "Variable expansion"; it does the sign-in and the router sees a plain stdio command); resuming a dropped stream (Last-Event-ID) and the standalone GET stream of Streamable HTTP; subscriptions/listen; and server requests for input (elicitation).

Run

Point your MCP client at Tool Guardian as a single stdio server:

{
  "mcpServers": {
    "tool-guardian": {
      "command": "tool-guardian",
      "args": ["--config", "/path/to/your/mcp.json"]
    }
  }
}

Claude Desktop (claude_desktop_config.json) without installing anything first, using uv:

{
  "mcpServers": {
    "tool-guardian": {
      "command": "uvx",
      "args": ["tool-guardian", "--config", "/path/to/your/mcp.json"]
    }
  }
}

Claude Code, from a terminal (user scope, every project):

claude mcp add --scope user tool-guardian -- tool-guardian --config /path/to/your/mcp.json

In a container: docker build -t tool-guardian . then docker run -i --rm -v "$HOME/.tool-guardian:/root/.tool-guardian" tool-guardian. The backends run inside the container too, so the image needs whatever they are started with (npx, uvx, ...); the bundled Dockerfile has Python only.

Everything your servers can do is still reachable — the model just discovers it in two steps (list_capabilities → call_tool) instead of paying for all of it up front.

A backend that exits or stops answering

When a backend's process exits, or a call gets no reply within TOOL_GUARDIAN_CALL_TIMEOUT, the router stops it and starts it again before the next call: after 1 s the first time, 5 s on any later respawn, re-sending the initialize handshake. A second failure within 60 s marks the backend DOWN and it is not restarted again. The count is backend_respawns in the bridge's stats and the backend respawns: N since load line of /toolguardian.

See what it saves

tool-guardian --selftest

Starts your configured servers, prints the catalogue, and reports the tokens the router tools cost versus loading every server's tools directly — e.g. with one filesystem server behind it: "router tools cost ~840 tokens vs ~3943 for the full set behind them -> ~3103 tokens freed on every request (79% smaller)". One small server is close to break-even; the saving grows with every server added. Below the totals it prints one [server] N tools, ~M tokens line per server, and it exits non-zero if any configured server failed to start. It ends with one update: line (a newer release, "none", or "could not check"); tool-guardian-setup doctor ends with the same line. The check runs at most once a day, is silent on failure, and is off with GUARDIAN_NO_UPDATE_CHECK=1.

Token counts use tiktoken (cl100k_base) when it is importable and the characters ÷ 3.5 estimate otherwise; the report prints which, e.g. token counts: estimate (chars / 3.5; tiktoken not available). pip install tiktoken switches it; nothing else needs it.

Count what it saved in real use

tool-guardian --usage [--since 7d|24h] [--json]

--selftest measures once, on a clean start. --usage counts afterwards, from the call log (~/.tool-guardian/calls.jsonl, on by default): per server, the router calls, errors and result characters in and out; the catalogue tokens avoided; and the servers you configured but never called. Without --since it reads the whole log; --json prints the same figures as one JSON object. A scripted run of seven servers (the bench's demo catalogues) over four sessions:

tool-guardian usage (whole call log): 4 session(s), 7 router call(s), 1 error(s)
  filesystem  calls 4  errors 1  chars in 27,900  out 9,200
  github      calls 2  errors 0  chars in 50,800  out 4,000
  memory      calls 1  errors 0  chars in 420  out 420
result shaping saved 65,500 chars
catalogue tokens avoided: ~97,112
unused servers: everything, memory, n8n-mcp, playwright, sequential-thinking
dropping the unused servers would shrink the full catalogue by ~16,553 tokens (65% of ~25,304)
  • Catalogue tokens avoided is the number of sessions started in the window × (what every live server's tools/list costs − what the router's own costs). Both costs are written to state.json each time the router starts, so a log from before 0.8.3 prints unknown until the router has started once. The ~ marks a characters ÷ 3.5 estimate; --usage always uses it, even when tiktoken is installed.
  • Unused servers have no call_tool in the window; describe_tool or a search alone does not count as use.
  • Nothing in the report is an argument value: the log holds only server and tool names, argument key names and character counts. With the log off (TOOL_GUARDIAN_CALL_LOG="") there is nothing to read and it says so; an empty window prints no calls in window.

Prove your model drives the router

TOOL_GUARDIAN_PROVE_URL=http://127.0.0.1:11434/v1 tool-guardian --prove

Sends three bundled prompts to an OpenAI-compatible chat endpoint with the router tools attached, runs the model's tool calls through a real router in front of a bundled stub server, and prints one PASS/FAIL line per prompt with the prompt and completion tokens the endpoint reported: list_describe (list or search, then describe_tool), nested_call (call_tool with a nested filter), stalled (the model must reach call_tool, not only search). Exit 0 when all pass, 1 on any FAIL, 2 when TOOL_GUARDIAN_PROVE_URL is not set. Optional: TOOL_GUARDIAN_PROVE_MODEL (default: the first id from GET <url>/models), TOOL_GUARDIAN_PROVE_KEY (bearer token). The servers in your mcp.json are not started, and nothing is written to the call log or state.json. Your tool policy file is ignored (it prints prove: policy off): this is a capability test on the bundled stub server, and a deny or readOnly rule would fail it for the wrong reason.

Under DSH, type /toolguardian in a session for the same report from the running plugin: each server's status and tool count, the tokens freed on every request, active groups, ladder and bypass counters, and the update line.

It keeps tool results small too (0.3.0)

Definitions are half the problem; one 40 KB build log is the other half. Every call_tool result now goes down a deterministic output ladder before the model sees it:

result what the model gets
under ~1.2k chars, or from a read-class tool untouched, byte for byte
an error over 300 chars head + tail summary
JSON array / CSV ≥ 10k keys, first and last items, counts
shell-style output ≥ 8k head, evenly spaced samples (with line numbers), tail — [exit code: …] always kept
a unified diff every change, plus the context right next to it
anything else ≥ 1.2k cleaned losslessly: ANSI stripped, blank runs collapsed, repeated lines counted

Nothing is lost silently. Before any lossy step the full original is archived, the result says so in one line, and the model can call retrieve_spill(id, grep=…) to read it back. If the archive cannot be written, the original is returned instead. Same input, same output, always — so provider prompt caches keep hitting. TOOL_GUARDIAN_LADDER=0 turns it off.

list_groups_with_costs prices each tool group in context tokens, and every router call is logged (argument values never are) to ~/.tool-guardian/calls.jsonl so you can measure whether your model actually uses the router.

Skills

The same catalogue-on-demand idea, applied to SKILL.md files. Point TOOL_GUARDIAN_SKILLS at one or more folders (separated by : on Linux/macOS, ; on Windows); every SKILL.md one or two levels below a root is a skill, named by its front-matter name (or its folder).

  • list_skills — one line per skill: name and purpose. Its description names every skill, so the model knows they exist.
  • read_skill(name) — the full text of one skill. A name is a name, never a path: /, \ and .. are refused.

The two tools appear only when at least one skill is found. tool-guardian --skills-report prints what the catalogue saves against loading every skill up front.

Measured savings

Reproduce it yourself: python bench/bench_tokens.py (no network, no model; --json for machine output). The catalogs are the real tools/list answers of seven public MCP servers, recorded in bench/fixtures/catalogs/.

server tools tokens of tool schemas
everything 13 1,610
filesystem 14 2,500
github 26 5,094
memory 9 1,347
n8n-mcp 7 2,826
playwright 25 5,443
sequential-thinking 1 1,185
all seven 95 20,005

Every request: 20,005 tokens of tool schemas without tool-guardian, 1,084 with it (94.6 % less). On a 32K-context local model that is the difference between a window that is 61 % full before the conversation starts and one that is 3 % full. (The 2026-10-21 description rewrite had pushed this to 1,790; ADR 0003 records the budget that brought it back.)

The output ladder on four typical tool results:

output tool before (chars) after (chars) how
3,000-line build log with one error bash 115,945 4,540 head, samples, the error line, tail, exit code
400-item JSON array http_get 29,521 730 keys, first and last items, counts
3-hunk unified diff git_diff 41,764 1,363 every change plus 2 lines of context
1,500-row CSV query 44,882 354 header, first and last rows, counts

Every shortened result is archived first and can be read back with retrieve_spill. Token counts use tiktoken (cl100k) when it is installed and a conservative characters ÷ 3.5 estimate otherwise; the table above is the estimate.

W

…

内容来自项目 README(GitHub)↗

链接

同类插件

查看整个分类 →

社区评论

评论公开保存在 GitHub Discussions。加载评论会连接 GitHub 和 Giscus;发表内容需要 GitHub 账号。