tool-guardian 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 LuminariSoftwares
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,145 @@
1
+ Metadata-Version: 2.4
2
+ Name: tool-guardian
3
+ Version: 0.1.0
4
+ Summary: An MCP router that keeps tool definitions from filling the context window — it fronts your MCP servers behind three generic tools and discovers the rest on demand.
5
+ Author: LuminariSoftwares
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/LuminariSoftwares/tool-guardian
8
+ Project-URL: Repository, https://github.com/LuminariSoftwares/tool-guardian
9
+ Project-URL: Changelog, https://github.com/LuminariSoftwares/tool-guardian/blob/main/CHANGELOG.md
10
+ Project-URL: Issues, https://github.com/LuminariSoftwares/tool-guardian/issues
11
+ Keywords: mcp,model-context-protocol,context-window,tools,proxy,router,progressive-disclosure,local-llm,ollama,claude
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: License :: OSI Approved :: MIT License
15
+ Classifier: Operating System :: OS Independent
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Programming Language :: Python :: 3.9
18
+ Classifier: Programming Language :: Python :: 3.10
19
+ Classifier: Programming Language :: Python :: 3.11
20
+ Classifier: Programming Language :: Python :: 3.12
21
+ Classifier: Topic :: Software Development :: Libraries
22
+ Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
23
+ Requires-Python: >=3.9
24
+ Description-Content-Type: text/markdown
25
+ License-File: LICENSE
26
+ Dynamic: license-file
27
+
28
+ # Tool Guardian
29
+
30
+ ![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)
31
+ ![Python 3.9+](https://img.shields.io/badge/python-3.9%2B-blue.svg)
32
+
33
+ An MCP server that sits in front of your other MCP servers and exposes **three generic tools** instead of dozens of specific ones — discovering the rest **on demand** — so tool definitions stop eating your context window before the model reads a word.
34
+
35
+ Companion to [Context Guardian](https://github.com/LuminariSoftwares/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.
36
+
37
+ ## Why this exists
38
+
39
+ 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.
40
+
41
+ You have two ways to deal with that today, and both cost you something:
42
+
43
+ | Approach | The cost |
44
+ |---|---|
45
+ | Load fewer MCP servers | You lose the capability entirely |
46
+ | Live with it | Two-thirds of the window is gone before you type |
47
+
48
+ Tool Guardian is a third option that costs neither. It fronts all your servers and shows the model just three tools plus a one-line catalogue of server names (~300 tokens). The full schema for a tool is fetched only when the model asks for it:
49
+
50
+ ```
51
+ list_capabilities(server?) one line per tool — names and purpose
52
+ describe_tool(server, tool) the full argument schema for ONE tool
53
+ call_tool(server, tool, args) invoke it, return the result
54
+ ```
55
+
56
+ Same idea as a search index: cheap catalogue always visible, detail on demand.
57
+
58
+ ## Where it sits
59
+
60
+ ```
61
+ your CLI / agent (Claude Code, OpenClaude, any MCP client)
62
+ -> Tool Guardian (this project — one MCP server)
63
+ -> your real MCP servers (filesystem, git, n8n, database, ...)
64
+ ```
65
+
66
+ You point your client at **one** MCP server — Tool Guardian — and give Tool Guardian the same `mcpServers` config you'd have given the client. It starts your servers, keeps them warm, and proxies calls through on demand.
67
+
68
+ ## Install
69
+
70
+ ```bash
71
+ pip install tool-guardian
72
+ ```
73
+
74
+ Pure standard library — nothing else to install.
75
+
76
+ ## Configure
77
+
78
+ Tool Guardian reads the **standard** `mcpServers` block (the same shape Claude Desktop / Claude Code and most MCP clients use):
79
+
80
+ ```json
81
+ {
82
+ "mcpServers": {
83
+ "files": {
84
+ "command": "npx",
85
+ "args": ["-y", "@modelcontextprotocol/server-filesystem", "/data"]
86
+ },
87
+ "git": {
88
+ "command": "uvx",
89
+ "args": ["mcp-server-git"],
90
+ "description": "git status / diff / commit / log"
91
+ }
92
+ }
93
+ }
94
+ ```
95
+
96
+ 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.
97
+
98
+ Config is searched in order: `--config PATH`, `$TOOL_GUARDIAN_CONFIG`, `./mcp.json`, `./.mcp.json`, `~/.tool-guardian/mcp.json`.
99
+
100
+ ## Run
101
+
102
+ Point your MCP client at Tool Guardian as a single stdio server:
103
+
104
+ ```json
105
+ {
106
+ "mcpServers": {
107
+ "tool-guardian": {
108
+ "command": "tool-guardian",
109
+ "args": ["--config", "/path/to/your/mcp.json"]
110
+ }
111
+ }
112
+ }
113
+ ```
114
+
115
+ 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.
116
+
117
+ ## See what it saves
118
+
119
+ ```bash
120
+ tool-guardian --selftest
121
+ ```
122
+
123
+ Starts your configured servers, prints the catalogue, and reports the tokens the three router tools cost versus loading every server's tools directly — e.g. *"router tools cost ~310 tokens vs ~28,700 for the full set behind them → ~28,390 freed on every request."*
124
+
125
+ ## Design notes (the parts that matter)
126
+
127
+ - **Failure is loud, on purpose.** A router is a single point of failure: without one a broken server costs you that server; behind one it could cost you all of them. So an unreachable backend is reported as `UNKNOWN` with its real error, **never as an empty tool list**. A model that asks for a server and gets `[]` concludes the capability doesn't exist and quietly works around it — the exact failure this avoids.
128
+ - **Built for models, not just machines.** It accepts a tool's `args` as either an object or a JSON string, aliases the near-misses models actually send (`query`/`name` → `server`), and ends every result with the concrete **NEXT STEP** to call — because a model that receives a catalogue and no instruction tends to stop there instead of finishing the task.
129
+ - **The catalogue names your servers.** Three unnamed generic tools give a model no reason to believe any capability exists, so it improvises. Naming the servers in the tool description costs a few tokens and is the difference between a catalogue the model opens and three tools it ignores.
130
+
131
+ ## What it does *not* do (yet)
132
+
133
+ - **stdio servers only.** An HTTP/SSE server (a `"url"` entry) is reported `UNSUPPORTED` — load it directly rather than through here.
134
+ - It does not merge or rename tools; it proxies them faithfully. `call_tool(server, tool, args)` reaches the real tool unchanged.
135
+
136
+ ## Development
137
+
138
+ ```bash
139
+ pip install -r requirements-dev.txt
140
+ pytest
141
+ ```
142
+
143
+ ## License
144
+
145
+ MIT — see [LICENSE](LICENSE).
@@ -0,0 +1,118 @@
1
+ # Tool Guardian
2
+
3
+ ![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)
4
+ ![Python 3.9+](https://img.shields.io/badge/python-3.9%2B-blue.svg)
5
+
6
+ An MCP server that sits in front of your other MCP servers and exposes **three generic tools** instead of dozens of specific ones — discovering the rest **on demand** — so tool definitions stop eating your context window before the model reads a word.
7
+
8
+ Companion to [Context Guardian](https://github.com/LuminariSoftwares/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.
9
+
10
+ ## Why this exists
11
+
12
+ 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.
13
+
14
+ You have two ways to deal with that today, and both cost you something:
15
+
16
+ | Approach | The cost |
17
+ |---|---|
18
+ | Load fewer MCP servers | You lose the capability entirely |
19
+ | Live with it | Two-thirds of the window is gone before you type |
20
+
21
+ Tool Guardian is a third option that costs neither. It fronts all your servers and shows the model just three tools plus a one-line catalogue of server names (~300 tokens). The full schema for a tool is fetched only when the model asks for it:
22
+
23
+ ```
24
+ list_capabilities(server?) one line per tool — names and purpose
25
+ describe_tool(server, tool) the full argument schema for ONE tool
26
+ call_tool(server, tool, args) invoke it, return the result
27
+ ```
28
+
29
+ Same idea as a search index: cheap catalogue always visible, detail on demand.
30
+
31
+ ## Where it sits
32
+
33
+ ```
34
+ your CLI / agent (Claude Code, OpenClaude, any MCP client)
35
+ -> Tool Guardian (this project — one MCP server)
36
+ -> your real MCP servers (filesystem, git, n8n, database, ...)
37
+ ```
38
+
39
+ You point your client at **one** MCP server — Tool Guardian — and give Tool Guardian the same `mcpServers` config you'd have given the client. It starts your servers, keeps them warm, and proxies calls through on demand.
40
+
41
+ ## Install
42
+
43
+ ```bash
44
+ pip install tool-guardian
45
+ ```
46
+
47
+ Pure standard library — nothing else to install.
48
+
49
+ ## Configure
50
+
51
+ Tool Guardian reads the **standard** `mcpServers` block (the same shape Claude Desktop / Claude Code and most MCP clients use):
52
+
53
+ ```json
54
+ {
55
+ "mcpServers": {
56
+ "files": {
57
+ "command": "npx",
58
+ "args": ["-y", "@modelcontextprotocol/server-filesystem", "/data"]
59
+ },
60
+ "git": {
61
+ "command": "uvx",
62
+ "args": ["mcp-server-git"],
63
+ "description": "git status / diff / commit / log"
64
+ }
65
+ }
66
+ }
67
+ ```
68
+
69
+ 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.
70
+
71
+ Config is searched in order: `--config PATH`, `$TOOL_GUARDIAN_CONFIG`, `./mcp.json`, `./.mcp.json`, `~/.tool-guardian/mcp.json`.
72
+
73
+ ## Run
74
+
75
+ Point your MCP client at Tool Guardian as a single stdio server:
76
+
77
+ ```json
78
+ {
79
+ "mcpServers": {
80
+ "tool-guardian": {
81
+ "command": "tool-guardian",
82
+ "args": ["--config", "/path/to/your/mcp.json"]
83
+ }
84
+ }
85
+ }
86
+ ```
87
+
88
+ 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.
89
+
90
+ ## See what it saves
91
+
92
+ ```bash
93
+ tool-guardian --selftest
94
+ ```
95
+
96
+ Starts your configured servers, prints the catalogue, and reports the tokens the three router tools cost versus loading every server's tools directly — e.g. *"router tools cost ~310 tokens vs ~28,700 for the full set behind them → ~28,390 freed on every request."*
97
+
98
+ ## Design notes (the parts that matter)
99
+
100
+ - **Failure is loud, on purpose.** A router is a single point of failure: without one a broken server costs you that server; behind one it could cost you all of them. So an unreachable backend is reported as `UNKNOWN` with its real error, **never as an empty tool list**. A model that asks for a server and gets `[]` concludes the capability doesn't exist and quietly works around it — the exact failure this avoids.
101
+ - **Built for models, not just machines.** It accepts a tool's `args` as either an object or a JSON string, aliases the near-misses models actually send (`query`/`name` → `server`), and ends every result with the concrete **NEXT STEP** to call — because a model that receives a catalogue and no instruction tends to stop there instead of finishing the task.
102
+ - **The catalogue names your servers.** Three unnamed generic tools give a model no reason to believe any capability exists, so it improvises. Naming the servers in the tool description costs a few tokens and is the difference between a catalogue the model opens and three tools it ignores.
103
+
104
+ ## What it does *not* do (yet)
105
+
106
+ - **stdio servers only.** An HTTP/SSE server (a `"url"` entry) is reported `UNSUPPORTED` — load it directly rather than through here.
107
+ - It does not merge or rename tools; it proxies them faithfully. `call_tool(server, tool, args)` reaches the real tool unchanged.
108
+
109
+ ## Development
110
+
111
+ ```bash
112
+ pip install -r requirements-dev.txt
113
+ pytest
114
+ ```
115
+
116
+ ## License
117
+
118
+ MIT — see [LICENSE](LICENSE).
@@ -0,0 +1,45 @@
1
+ [build-system]
2
+ requires = ["setuptools>=61", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "tool-guardian"
7
+ version = "0.1.0"
8
+ description = "An MCP router that keeps tool definitions from filling the context window — it fronts your MCP servers behind three generic tools and discovers the rest on demand."
9
+ readme = "README.md"
10
+ requires-python = ">=3.9"
11
+ license = { text = "MIT" }
12
+ authors = [{ name = "LuminariSoftwares" }]
13
+ keywords = ["mcp", "model-context-protocol", "context-window", "tools", "proxy",
14
+ "router", "progressive-disclosure", "local-llm", "ollama", "claude"]
15
+ classifiers = [
16
+ "Development Status :: 4 - Beta",
17
+ "Intended Audience :: Developers",
18
+ "License :: OSI Approved :: MIT License",
19
+ "Operating System :: OS Independent",
20
+ "Programming Language :: Python :: 3",
21
+ "Programming Language :: Python :: 3.9",
22
+ "Programming Language :: Python :: 3.10",
23
+ "Programming Language :: Python :: 3.11",
24
+ "Programming Language :: Python :: 3.12",
25
+ "Topic :: Software Development :: Libraries",
26
+ "Topic :: Scientific/Engineering :: Artificial Intelligence",
27
+ ]
28
+ # Pure standard library at runtime — nothing to install.
29
+ dependencies = []
30
+
31
+ [project.urls]
32
+ Homepage = "https://github.com/LuminariSoftwares/tool-guardian"
33
+ Repository = "https://github.com/LuminariSoftwares/tool-guardian"
34
+ Changelog = "https://github.com/LuminariSoftwares/tool-guardian/blob/main/CHANGELOG.md"
35
+ Issues = "https://github.com/LuminariSoftwares/tool-guardian/issues"
36
+
37
+ [project.scripts]
38
+ tool-guardian = "tool_guardian:main"
39
+
40
+ [tool.setuptools]
41
+ py-modules = ["tool_guardian"]
42
+
43
+ [tool.pytest.ini_options]
44
+ testpaths = ["tests"]
45
+ addopts = "--strict-markers --strict-config"
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,175 @@
1
+ """Integration tests for tool_guardian.
2
+
3
+ These use a STUB MCP server (a tiny stdio server written to a temp file and run
4
+ with the same interpreter) so the whole path is exercised for real: config load
5
+ -> start backend -> initialize/tools/list handshake -> list/describe/call. No
6
+ network, no external deps.
7
+ """
8
+ import json
9
+ import sys
10
+ from pathlib import Path
11
+
12
+ import pytest
13
+
14
+ sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
15
+ import tool_guardian as tg # noqa: E402
16
+
17
+
18
+ # A minimal but real MCP stdio server: initialize, tools/list, tools/call(echo).
19
+ STUB_SERVER = '''
20
+ import json, sys
21
+ TOOLS = [
22
+ {"name": "echo", "description": "Echo back the text you send.",
23
+ "inputSchema": {"type": "object", "properties": {"text": {"type": "string"}},
24
+ "required": ["text"]}},
25
+ {"name": "ping", "description": "Return pong.",
26
+ "inputSchema": {"type": "object", "properties": {}}},
27
+ ]
28
+ for line in sys.stdin:
29
+ line = line.strip()
30
+ if not line:
31
+ continue
32
+ msg = json.loads(line)
33
+ method, mid = msg.get("method"), msg.get("id")
34
+ if method == "initialize":
35
+ res = {"protocolVersion": "2024-11-05", "capabilities": {"tools": {}},
36
+ "serverInfo": {"name": "stub", "version": "1"}}
37
+ elif method == "tools/list":
38
+ res = {"tools": TOOLS}
39
+ elif method == "tools/call":
40
+ p = msg.get("params") or {}
41
+ name = p.get("name"); args = p.get("arguments") or {}
42
+ if name == "echo":
43
+ res = {"content": [{"type": "text", "text": "echo: " + str(args.get("text", ""))}]}
44
+ elif name == "ping":
45
+ res = {"content": [{"type": "text", "text": "pong"}]}
46
+ else:
47
+ sys.stdout.write(json.dumps({"jsonrpc": "2.0", "id": mid,
48
+ "error": {"code": -32601, "message": "no tool " + str(name)}}) + "\\n")
49
+ sys.stdout.flush(); continue
50
+ elif mid is None:
51
+ continue
52
+ else:
53
+ res = {}
54
+ if mid is not None:
55
+ sys.stdout.write(json.dumps({"jsonrpc": "2.0", "id": mid, "result": res}) + "\\n")
56
+ sys.stdout.flush()
57
+ '''
58
+
59
+
60
+ @pytest.fixture
61
+ def router(tmp_path):
62
+ stub = tmp_path / "stub_server.py"
63
+ stub.write_text(STUB_SERVER, encoding="utf-8")
64
+ cfg = tmp_path / "mcp.json"
65
+ cfg.write_text(json.dumps({"mcpServers": {
66
+ "stub": {"command": sys.executable, "args": [str(stub)],
67
+ "description": "a test echo server"},
68
+ }}), encoding="utf-8")
69
+ r = tg.Router(str(cfg))
70
+ r.start()
71
+ tg.ROUTER_TOOLS = tg.build_router_tools(r.backends)
72
+ return r
73
+
74
+
75
+ def test_backend_starts_and_lists_tools(router):
76
+ b = router.backends["stub"]
77
+ assert b.status == "ok"
78
+ assert {t["name"] for t in b.tools} == {"echo", "ping"}
79
+
80
+
81
+ def test_catalogue_all_and_one(router):
82
+ all_cat = router.catalogue()
83
+ assert "[stub]" in all_cat and "echo" in all_cat
84
+ one = router.catalogue("stub")
85
+ assert "stub.echo" in one
86
+ assert "NEXT STEP" in one # the result must nudge the model to call_tool
87
+
88
+
89
+ def test_describe_tool(router):
90
+ out = router.handle("describe_tool", {"server": "stub", "tool": "echo"})
91
+ schema = json.loads(out)
92
+ assert schema["name"] == "echo"
93
+ assert "text" in schema["inputSchema"]["properties"]
94
+
95
+
96
+ def test_call_tool_roundtrip(router):
97
+ out = router.handle("call_tool", {"server": "stub", "tool": "echo",
98
+ "args": {"text": "hi"}})
99
+ assert "echo: hi" in out
100
+
101
+
102
+ def test_call_tool_accepts_json_string_args(router):
103
+ # models often send args as a JSON string, not an object
104
+ out = router.handle("call_tool", {"server": "stub", "tool": "echo",
105
+ "args": '{"text": "strung"}'})
106
+ assert "echo: strung" in out
107
+
108
+
109
+ def test_list_capabilities_accepts_query_alias(router):
110
+ # a model naming the server `query` instead of `server` must still work
111
+ out = router.handle("list_capabilities", {"query": "stub"})
112
+ assert "stub.echo" in out
113
+
114
+
115
+ def test_unknown_backend_is_loud_not_empty(tmp_path):
116
+ cfg = tmp_path / "mcp.json"
117
+ cfg.write_text(json.dumps({"mcpServers": {
118
+ "broken": {"command": "this_command_does_not_exist_xyz", "args": []},
119
+ }}), encoding="utf-8")
120
+ r = tg.Router(str(cfg))
121
+ r.start()
122
+ b = r.backends["broken"]
123
+ assert b.status == "UNKNOWN"
124
+ cat = r.catalogue("broken")
125
+ assert "UNKNOWN" in cat
126
+ assert "empty tool list" in cat # explicitly tells the model this is not []
127
+
128
+
129
+ def test_call_on_dead_backend_raises_loudly(tmp_path):
130
+ cfg = tmp_path / "mcp.json"
131
+ cfg.write_text(json.dumps({"mcpServers": {
132
+ "broken": {"command": "nope_xyz", "args": []}}}), encoding="utf-8")
133
+ r = tg.Router(str(cfg))
134
+ r.start()
135
+ # handle() deliberately does NOT swallow a dead-backend call: it raises, and
136
+ # serve() turns that into a visible isError tool result. The failure must be
137
+ # loud with UNKNOWN wording, never a fake success.
138
+ with pytest.raises(RuntimeError) as e:
139
+ r.handle("call_tool", {"server": "broken", "tool": "whatever"})
140
+ assert "UNKNOWN" in str(e.value)
141
+
142
+
143
+ def test_router_cost_is_small_and_fixed_and_scales_against_backends():
144
+ # The router's own tool payload is a small, ~constant cost regardless of how
145
+ # many servers hide behind it -- that fixed cost is the whole value prop.
146
+ # (With a toy 2-tool backend the router can cost MORE; the win is at scale,
147
+ # so we test the mechanism, not a false always-cheaper inequality.)
148
+ router_cost = tg.est_tokens(tg.build_router_tools({}))
149
+ assert 0 < router_cost < 1500
150
+ small = tg.est_tokens([{"name": "a", "description": "x"}])
151
+ big = tg.est_tokens([{"name": "t%d" % i,
152
+ "description": "a tool with a reasonably long description " * 3,
153
+ "inputSchema": {"type": "object", "properties": {
154
+ "p%d" % j: {"type": "string"} for j in range(8)}}}
155
+ for i in range(40)])
156
+ assert big > small
157
+ assert big > router_cost # 40 real tools dwarf the 3 router tools -> the win
158
+
159
+
160
+ def test_no_config_does_not_crash(tmp_path, monkeypatch):
161
+ monkeypatch.chdir(tmp_path) # no mcp.json here
162
+ monkeypatch.delenv("TOOL_GUARDIAN_CONFIG", raising=False)
163
+ r = tg.Router()
164
+ r.start()
165
+ assert r.backends == {}
166
+ assert r.catalogue() == "no backends configured"
167
+
168
+
169
+ def test_unsupported_url_backend(tmp_path):
170
+ cfg = tmp_path / "mcp.json"
171
+ cfg.write_text(json.dumps({"mcpServers": {
172
+ "remote": {"url": "https://example.com/mcp"}}}), encoding="utf-8")
173
+ r = tg.Router(str(cfg))
174
+ r.start()
175
+ assert r.backends["remote"].status == "UNSUPPORTED"
@@ -0,0 +1,145 @@
1
+ Metadata-Version: 2.4
2
+ Name: tool-guardian
3
+ Version: 0.1.0
4
+ Summary: An MCP router that keeps tool definitions from filling the context window — it fronts your MCP servers behind three generic tools and discovers the rest on demand.
5
+ Author: LuminariSoftwares
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/LuminariSoftwares/tool-guardian
8
+ Project-URL: Repository, https://github.com/LuminariSoftwares/tool-guardian
9
+ Project-URL: Changelog, https://github.com/LuminariSoftwares/tool-guardian/blob/main/CHANGELOG.md
10
+ Project-URL: Issues, https://github.com/LuminariSoftwares/tool-guardian/issues
11
+ Keywords: mcp,model-context-protocol,context-window,tools,proxy,router,progressive-disclosure,local-llm,ollama,claude
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: License :: OSI Approved :: MIT License
15
+ Classifier: Operating System :: OS Independent
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Programming Language :: Python :: 3.9
18
+ Classifier: Programming Language :: Python :: 3.10
19
+ Classifier: Programming Language :: Python :: 3.11
20
+ Classifier: Programming Language :: Python :: 3.12
21
+ Classifier: Topic :: Software Development :: Libraries
22
+ Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
23
+ Requires-Python: >=3.9
24
+ Description-Content-Type: text/markdown
25
+ License-File: LICENSE
26
+ Dynamic: license-file
27
+
28
+ # Tool Guardian
29
+
30
+ ![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)
31
+ ![Python 3.9+](https://img.shields.io/badge/python-3.9%2B-blue.svg)
32
+
33
+ An MCP server that sits in front of your other MCP servers and exposes **three generic tools** instead of dozens of specific ones — discovering the rest **on demand** — so tool definitions stop eating your context window before the model reads a word.
34
+
35
+ Companion to [Context Guardian](https://github.com/LuminariSoftwares/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.
36
+
37
+ ## Why this exists
38
+
39
+ 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.
40
+
41
+ You have two ways to deal with that today, and both cost you something:
42
+
43
+ | Approach | The cost |
44
+ |---|---|
45
+ | Load fewer MCP servers | You lose the capability entirely |
46
+ | Live with it | Two-thirds of the window is gone before you type |
47
+
48
+ Tool Guardian is a third option that costs neither. It fronts all your servers and shows the model just three tools plus a one-line catalogue of server names (~300 tokens). The full schema for a tool is fetched only when the model asks for it:
49
+
50
+ ```
51
+ list_capabilities(server?) one line per tool — names and purpose
52
+ describe_tool(server, tool) the full argument schema for ONE tool
53
+ call_tool(server, tool, args) invoke it, return the result
54
+ ```
55
+
56
+ Same idea as a search index: cheap catalogue always visible, detail on demand.
57
+
58
+ ## Where it sits
59
+
60
+ ```
61
+ your CLI / agent (Claude Code, OpenClaude, any MCP client)
62
+ -> Tool Guardian (this project — one MCP server)
63
+ -> your real MCP servers (filesystem, git, n8n, database, ...)
64
+ ```
65
+
66
+ You point your client at **one** MCP server — Tool Guardian — and give Tool Guardian the same `mcpServers` config you'd have given the client. It starts your servers, keeps them warm, and proxies calls through on demand.
67
+
68
+ ## Install
69
+
70
+ ```bash
71
+ pip install tool-guardian
72
+ ```
73
+
74
+ Pure standard library — nothing else to install.
75
+
76
+ ## Configure
77
+
78
+ Tool Guardian reads the **standard** `mcpServers` block (the same shape Claude Desktop / Claude Code and most MCP clients use):
79
+
80
+ ```json
81
+ {
82
+ "mcpServers": {
83
+ "files": {
84
+ "command": "npx",
85
+ "args": ["-y", "@modelcontextprotocol/server-filesystem", "/data"]
86
+ },
87
+ "git": {
88
+ "command": "uvx",
89
+ "args": ["mcp-server-git"],
90
+ "description": "git status / diff / commit / log"
91
+ }
92
+ }
93
+ }
94
+ ```
95
+
96
+ 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.
97
+
98
+ Config is searched in order: `--config PATH`, `$TOOL_GUARDIAN_CONFIG`, `./mcp.json`, `./.mcp.json`, `~/.tool-guardian/mcp.json`.
99
+
100
+ ## Run
101
+
102
+ Point your MCP client at Tool Guardian as a single stdio server:
103
+
104
+ ```json
105
+ {
106
+ "mcpServers": {
107
+ "tool-guardian": {
108
+ "command": "tool-guardian",
109
+ "args": ["--config", "/path/to/your/mcp.json"]
110
+ }
111
+ }
112
+ }
113
+ ```
114
+
115
+ 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.
116
+
117
+ ## See what it saves
118
+
119
+ ```bash
120
+ tool-guardian --selftest
121
+ ```
122
+
123
+ Starts your configured servers, prints the catalogue, and reports the tokens the three router tools cost versus loading every server's tools directly — e.g. *"router tools cost ~310 tokens vs ~28,700 for the full set behind them → ~28,390 freed on every request."*
124
+
125
+ ## Design notes (the parts that matter)
126
+
127
+ - **Failure is loud, on purpose.** A router is a single point of failure: without one a broken server costs you that server; behind one it could cost you all of them. So an unreachable backend is reported as `UNKNOWN` with its real error, **never as an empty tool list**. A model that asks for a server and gets `[]` concludes the capability doesn't exist and quietly works around it — the exact failure this avoids.
128
+ - **Built for models, not just machines.** It accepts a tool's `args` as either an object or a JSON string, aliases the near-misses models actually send (`query`/`name` → `server`), and ends every result with the concrete **NEXT STEP** to call — because a model that receives a catalogue and no instruction tends to stop there instead of finishing the task.
129
+ - **The catalogue names your servers.** Three unnamed generic tools give a model no reason to believe any capability exists, so it improvises. Naming the servers in the tool description costs a few tokens and is the difference between a catalogue the model opens and three tools it ignores.
130
+
131
+ ## What it does *not* do (yet)
132
+
133
+ - **stdio servers only.** An HTTP/SSE server (a `"url"` entry) is reported `UNSUPPORTED` — load it directly rather than through here.
134
+ - It does not merge or rename tools; it proxies them faithfully. `call_tool(server, tool, args)` reaches the real tool unchanged.
135
+
136
+ ## Development
137
+
138
+ ```bash
139
+ pip install -r requirements-dev.txt
140
+ pytest
141
+ ```
142
+
143
+ ## License
144
+
145
+ MIT — see [LICENSE](LICENSE).
@@ -0,0 +1,10 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ tool_guardian.py
5
+ tests/test_tool_guardian.py
6
+ tool_guardian.egg-info/PKG-INFO
7
+ tool_guardian.egg-info/SOURCES.txt
8
+ tool_guardian.egg-info/dependency_links.txt
9
+ tool_guardian.egg-info/entry_points.txt
10
+ tool_guardian.egg-info/top_level.txt
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ tool-guardian = tool_guardian:main
@@ -0,0 +1 @@
1
+ tool_guardian
@@ -0,0 +1,469 @@
1
+ """
2
+ tool_guardian.py
3
+ ================
4
+ An MCP server that fronts your other MCP servers and exposes THREE generic tools
5
+ instead of dozens of specific ones, discovering the rest on demand:
6
+
7
+ list_capabilities(server?) one line per tool -- names and purpose
8
+ describe_tool(server, tool) the full argument schema for ONE tool
9
+ call_tool(server, tool, args) invoke it, return the result
10
+
11
+ tool-guardian run as an MCP server (stdio)
12
+ tool-guardian --selftest start the backends, print the token saving
13
+ tool-guardian --config path.json use a specific mcpServers config
14
+
15
+ WHY THIS EXISTS
16
+ MCP tool definitions are re-sent on EVERY request, whether the model touches
17
+ them or not. A handful of servers routinely comes to tens of thousands of
18
+ tokens -- most of a small local model's context window -- before the first
19
+ user message. Loading fewer servers trades capability for room. This trades
20
+ neither: the model sees ~300 tokens of router tools and the full catalogue
21
+ only when it asks. Same progressive-disclosure idea as a search index --
22
+ cheap catalogue first, detail on demand.
23
+
24
+ Companion to Context Guardian (https://pypi.org/project/context-guardian/):
25
+ Context Guardian compacts the CONVERSATION before the window fills; Tool
26
+ Guardian keeps the TOOLS from filling it in the first place. Two halves of
27
+ the same problem.
28
+
29
+ FAILURE IS LOUD, ON PURPOSE
30
+ A router is a single point of failure: without one, a broken server costs
31
+ you that server; behind one it could cost you all of them. So an unreachable
32
+ backend is reported as UNKNOWN with its real error, NEVER as an empty tool
33
+ list. A model that asks for a server and gets `[]` concludes the capability
34
+ does not exist and quietly works around it -- the exact failure this avoids.
35
+
36
+ CONFIG
37
+ Standard MCP shape -- the same `mcpServers` block Claude Desktop / Claude
38
+ Code / most MCP clients use:
39
+
40
+ {"mcpServers": {
41
+ "files": {"command": "npx", "args": ["-y", "@modelcontextprotocol/server-filesystem", "/data"]},
42
+ "git": {"command": "uvx", "args": ["mcp-server-git"], "description": "git status/diff/commit"}
43
+ }}
44
+
45
+ An optional per-server "description" enriches the catalogue the model sees;
46
+ without it, the hint is derived from the server's own tool names at startup.
47
+ Searched in order: --config PATH, $TOOL_GUARDIAN_CONFIG, ./mcp.json,
48
+ ./.mcp.json, ~/.tool-guardian/mcp.json.
49
+
50
+ stdio servers only for now. An HTTP/SSE server (a "url" entry) is reported
51
+ UNSUPPORTED -- load it directly rather than through here.
52
+
53
+ MIT licensed.
54
+ """
55
+ from __future__ import annotations
56
+
57
+ import argparse
58
+ import json
59
+ import os
60
+ import subprocess
61
+ import sys
62
+ import threading
63
+ import time
64
+ from pathlib import Path
65
+
66
+ __version__ = "0.1.0"
67
+
68
+ PROTOCOL = "2024-11-05"
69
+ START_TIMEOUT = float(os.environ.get("TOOL_GUARDIAN_START_TIMEOUT", "90"))
70
+ CALL_TIMEOUT = float(os.environ.get("TOOL_GUARDIAN_CALL_TIMEOUT", "120"))
71
+ CHARS_PER_TOKEN = float(os.environ.get("TOOL_GUARDIAN_CHARS_PER_TOKEN", "3.5"))
72
+ LOG_PATH = os.environ.get("TOOL_GUARDIAN_LOG", "")
73
+
74
+
75
+ def log(msg: str) -> None:
76
+ """stderr and (optionally) a file. NEVER stdout -- stdout is the MCP channel
77
+ and one stray line there corrupts the protocol for the whole session."""
78
+ line = "%s %s" % (time.strftime("%H:%M:%S"), msg)
79
+ print(line, file=sys.stderr, flush=True)
80
+ if LOG_PATH:
81
+ try:
82
+ os.makedirs(os.path.dirname(LOG_PATH) or ".", exist_ok=True)
83
+ with open(LOG_PATH, "a", encoding="utf-8") as fh:
84
+ fh.write(line + "\n")
85
+ except OSError:
86
+ pass
87
+
88
+
89
+ def est_tokens(obj) -> int:
90
+ """Rough token estimate from serialized length (~3.5 chars/token). Not a real
91
+ tokenizer -- a safety-margin figure for the saving report, deliberately in
92
+ the conservative (slightly high) direction, same as Context Guardian."""
93
+ try:
94
+ text = obj if isinstance(obj, str) else json.dumps(obj, ensure_ascii=False)
95
+ except (TypeError, ValueError):
96
+ text = str(obj)
97
+ return int(len(text) / CHARS_PER_TOKEN)
98
+
99
+
100
+ # --------------------------------------------------------------- config ------
101
+ def _config_search_order(explicit: str = "") -> list:
102
+ order = []
103
+ if explicit:
104
+ order.append(Path(explicit))
105
+ env = os.environ.get("TOOL_GUARDIAN_CONFIG", "").strip()
106
+ if env:
107
+ order.append(Path(env))
108
+ order += [Path("mcp.json"), Path(".mcp.json"),
109
+ Path.home() / ".tool-guardian" / "mcp.json"]
110
+ return order
111
+
112
+
113
+ def load_backends(explicit: str = "") -> dict:
114
+ """Return {name: spec} from the first config file found. spec is the standard
115
+ MCP server object: command, args, env, optional description/url."""
116
+ for path in _config_search_order(explicit):
117
+ try:
118
+ if path.is_file():
119
+ data = json.loads(path.read_text(encoding="utf-8"))
120
+ servers = data.get("mcpServers") or data.get("servers") or {}
121
+ if not isinstance(servers, dict):
122
+ raise ValueError("`mcpServers` is not an object")
123
+ log("config: %d server(s) from %s" % (len(servers), path))
124
+ servers.pop("tool-guardian", None) # never front ourselves
125
+ servers.pop("router", None)
126
+ return servers
127
+ except Exception as exc: # noqa: BLE001
128
+ log("config %s unreadable: %s" % (path, exc))
129
+ log("no config found (looked for mcp.json / .mcp.json / "
130
+ "$TOOL_GUARDIAN_CONFIG / --config). Running with no backends.")
131
+ return {}
132
+
133
+
134
+ # -------------------------------------------------------------- backend ------
135
+ class Backend:
136
+ """One real MCP server, kept alive so tools/call does not pay a cold start."""
137
+
138
+ def __init__(self, name: str, spec: dict):
139
+ self.name = name
140
+ self.spec = spec or {}
141
+ self.proc = None
142
+ self.tools = []
143
+ self.status = "not started"
144
+ self.error = ""
145
+ self._id = 100
146
+ self._lock = threading.Lock()
147
+
148
+ def _send(self, obj) -> None:
149
+ self.proc.stdin.write(json.dumps(obj) + "\n")
150
+ self.proc.stdin.flush()
151
+
152
+ def _await(self, want_id: int, timeout: float):
153
+ """Read until the reply with this id. Servers emit notifications and log
154
+ lines on stdout, so anything else is skipped, not treated as the answer."""
155
+ deadline = time.time() + timeout
156
+ while time.time() < deadline:
157
+ line = self.proc.stdout.readline()
158
+ if not line:
159
+ raise RuntimeError("server closed stdout")
160
+ try:
161
+ msg = json.loads(line)
162
+ except ValueError:
163
+ continue
164
+ if msg.get("id") == want_id:
165
+ if "error" in msg:
166
+ raise RuntimeError(str(msg["error"])[:300])
167
+ return msg.get("result", {})
168
+ raise TimeoutError("no reply to id %d within %ss" % (want_id, timeout))
169
+
170
+ def _next(self) -> int:
171
+ self._id += 1
172
+ return self._id
173
+
174
+ def start(self) -> None:
175
+ if self.spec.get("url"):
176
+ self.status = "UNSUPPORTED"
177
+ self.error = ("HTTP/SSE backend -- this router speaks stdio only so "
178
+ "far. Load it directly rather than through here.")
179
+ return
180
+ command = self.spec.get("command")
181
+ if not command:
182
+ self.status, self.error = "UNKNOWN", "no `command` in config"
183
+ return
184
+ env = dict(os.environ)
185
+ env.update({k: str(v) for k, v in (self.spec.get("env") or {}).items()})
186
+ cmd = [command, *(self.spec.get("args") or [])]
187
+ try:
188
+ self.proc = subprocess.Popen(
189
+ cmd, stdin=subprocess.PIPE, stdout=subprocess.PIPE,
190
+ stderr=subprocess.DEVNULL, env=env, text=True,
191
+ encoding="utf-8", errors="replace", bufsize=1)
192
+ except Exception as exc: # noqa: BLE001
193
+ self.status, self.error = "UNKNOWN", "could not start: %s" % exc
194
+ return
195
+ try:
196
+ i = self._next()
197
+ self._send({"jsonrpc": "2.0", "id": i, "method": "initialize",
198
+ "params": {"protocolVersion": PROTOCOL, "capabilities": {},
199
+ "clientInfo": {"name": "tool-guardian",
200
+ "version": __version__}}})
201
+ self._await(i, START_TIMEOUT)
202
+ self._send({"jsonrpc": "2.0", "method": "notifications/initialized"})
203
+ i = self._next()
204
+ self._send({"jsonrpc": "2.0", "id": i, "method": "tools/list", "params": {}})
205
+ res = self._await(i, START_TIMEOUT)
206
+ self.tools = res.get("tools") or []
207
+ self.status = "ok"
208
+ log("backend %s: %d tools" % (self.name, len(self.tools)))
209
+ except Exception as exc: # noqa: BLE001
210
+ self.status, self.error = "UNKNOWN", "%s: %s" % (type(exc).__name__, exc)
211
+ log("backend %s: %s -- %s" % (self.name, self.status, self.error))
212
+
213
+ def hint(self) -> str:
214
+ """A one-line purpose for the catalogue: the config `description` if
215
+ given, else derived from the server's own tool names."""
216
+ desc = self.spec.get("description")
217
+ if desc:
218
+ return short(desc)
219
+ if self.tools:
220
+ names = ", ".join(t.get("name", "?") for t in self.tools[:6])
221
+ return names + ("..." if len(self.tools) > 6 else "")
222
+ return "see list_capabilities"
223
+
224
+ def find(self, tool: str):
225
+ for t in self.tools:
226
+ if t.get("name") == tool:
227
+ return t
228
+ return None
229
+
230
+ def call(self, tool: str, args: dict):
231
+ if self.status != "ok":
232
+ raise RuntimeError(
233
+ "server %r is %s: %s. That is UNKNOWN, not 'the tool does not "
234
+ "exist' -- do not work around it, report it."
235
+ % (self.name, self.status, self.error))
236
+ if not self.find(tool):
237
+ raise RuntimeError(
238
+ "server %r has no tool %r. Available: %s"
239
+ % (self.name, tool, ", ".join(t.get("name", "?") for t in self.tools)))
240
+ with self._lock:
241
+ i = self._next()
242
+ self._send({"jsonrpc": "2.0", "id": i, "method": "tools/call",
243
+ "params": {"name": tool, "arguments": args or {}}})
244
+ return self._await(i, CALL_TIMEOUT)
245
+
246
+
247
+ def short(desc: str, words: int = 12) -> str:
248
+ """A one-line purpose. The catalogue must stay cheap -- a full description per
249
+ tool would rebuild the very payload this exists to avoid."""
250
+ parts = " ".join((desc or "").split()).split(" ")
251
+ return " ".join(parts[:words]) + ("..." if len(parts) > words else "")
252
+
253
+
254
+ def build_router_tools(backends: dict) -> list:
255
+ """The 3 tools the model actually sees. The catalogue of server NAMES goes in
256
+ the description on purpose: three unnamed generic tools give the model no
257
+ reason to believe any capability exists, so it improvises instead of calling
258
+ them. Naming the servers costs ~a few tokens and is the difference between a
259
+ catalogue the model opens and one it ignores."""
260
+ live = [(n, b) for n, b in sorted(backends.items()) if b.status == "ok"]
261
+ catalogue = "; ".join("%s (%s)" % (n, b.hint()) for n, b in live) or "none reachable"
262
+ return [
263
+ {"name": "list_capabilities",
264
+ "description": ("List the tools on a connected MCP server. THESE SERVERS "
265
+ "ARE AVAILABLE AND YOU SHOULD USE THEM RATHER THAN "
266
+ "GUESSING OR WORKING AROUND THEM: " + catalogue + ". "
267
+ "If a request concerns any of those, call this FIRST to "
268
+ "find the right tool, then call_tool."),
269
+ "inputSchema": {"type": "object", "properties": {
270
+ "server": {"type": "string", "description": "server name, or omit for all"}}}},
271
+ {"name": "describe_tool",
272
+ "description": ("Get the full argument schema for one tool. Call after "
273
+ "list_capabilities and before call_tool if unsure of args."),
274
+ "inputSchema": {"type": "object", "properties": {
275
+ "server": {"type": "string"}, "tool": {"type": "string"}},
276
+ "required": ["server", "tool"]}},
277
+ {"name": "call_tool",
278
+ "description": ("Invoke a tool on a server. THIS IS THE STEP THAT ANSWERS "
279
+ "THE USER -- list_capabilities only finds the name. `args` "
280
+ "may be an object or a JSON string; omit it for tools that "
281
+ "take no arguments."),
282
+ "inputSchema": {"type": "object", "properties": {
283
+ "server": {"type": "string"}, "tool": {"type": "string"},
284
+ "args": {"description": "the tool's arguments -- object or JSON string"}},
285
+ "required": ["server", "tool"]}},
286
+ ]
287
+
288
+
289
+ ROUTER_TOOLS = [] # filled at startup by build_router_tools
290
+
291
+
292
+ class Router:
293
+ def __init__(self, config: str = ""):
294
+ self.config = config
295
+ self.backends = {}
296
+
297
+ def start(self) -> None:
298
+ for name, spec in load_backends(self.config).items():
299
+ b = Backend(name, spec)
300
+ b.start()
301
+ self.backends[name] = b
302
+
303
+ def catalogue(self, server: str = "") -> str:
304
+ if server:
305
+ b = self.backends.get(server)
306
+ if not b:
307
+ return ("no server named %r. Known: %s"
308
+ % (server, ", ".join(sorted(self.backends))))
309
+ if b.status != "ok":
310
+ return ("%s: %s -- %s\nThis is UNKNOWN, not an empty tool list."
311
+ % (server, b.status, b.error))
312
+ listing = "\n".join("%s.%s: %s" % (server, t.get("name"),
313
+ short(t.get("description")))
314
+ for t in b.tools)
315
+ # The next step goes in the RESULT, not only the tool description: a
316
+ # description is read once, before the model has the catalogue; a
317
+ # result is read at the moment the model decides what to do next.
318
+ return (listing + "\n\nNEXT STEP: you have not answered the user yet. "
319
+ "Pick the tool above that does the job and call it now:\n"
320
+ " call_tool(server=\"%s\", tool=\"<name>\", args={...})" % server)
321
+ out = []
322
+ for name, b in sorted(self.backends.items()):
323
+ if b.status != "ok":
324
+ out.append("[%s] %s: %s" % (name, b.status, b.error[:80]))
325
+ continue
326
+ out.append("[%s] %d tools: %s" % (name, len(b.tools),
327
+ ", ".join(t.get("name", "?") for t in b.tools)))
328
+ if out:
329
+ out.append("\nNEXT STEP: call list_capabilities(server=\"<name>\") for "
330
+ "one server's full tool list, then call_tool to invoke.")
331
+ return "\n".join(out) or "no backends configured"
332
+
333
+ @staticmethod
334
+ def _coerce(args: dict) -> dict:
335
+ """Accept what a model actually sends, not only what the schema says.
336
+ Models commonly send args as a JSON STRING, or name the server `query`/
337
+ `name`. A strict schema is right for a machine caller and wrong for a
338
+ model one -- it loses correct answers on JSON shape. Parse and alias;
339
+ refuse only what is genuinely ambiguous."""
340
+ a = dict(args or {})
341
+ for alias in ("query", "name", "server_name"):
342
+ if not a.get("server") and isinstance(a.get(alias), str):
343
+ a["server"] = a.pop(alias)
344
+ for alias in ("tool_name", "toolName"):
345
+ if not a.get("tool") and a.get(alias):
346
+ a["tool"] = a.pop(alias)
347
+ raw = a.get("args")
348
+ if isinstance(raw, str):
349
+ try:
350
+ a["args"] = json.loads(raw) if raw.strip() else {}
351
+ except ValueError:
352
+ a["args"] = {}
353
+ elif raw is None:
354
+ a["args"] = {}
355
+ return a
356
+
357
+ def handle(self, name: str, args: dict) -> str:
358
+ args = self._coerce(args)
359
+ if name == "list_capabilities":
360
+ return self.catalogue(str(args.get("server") or ""))
361
+ if name == "describe_tool":
362
+ b = self.backends.get(str(args.get("server") or ""))
363
+ if not b:
364
+ return ("no server named %r. Known: %s"
365
+ % (args.get("server"), ", ".join(sorted(self.backends))))
366
+ t = b.find(str(args.get("tool") or ""))
367
+ if not t:
368
+ return ("%s has no tool %r. Available: %s"
369
+ % (b.name, args.get("tool"),
370
+ ", ".join(x.get("name", "?") for x in b.tools)))
371
+ return json.dumps(t, indent=2)
372
+ if name == "call_tool":
373
+ b = self.backends.get(str(args.get("server") or ""))
374
+ if not b:
375
+ return ("no server named %r. Known: %s"
376
+ % (args.get("server"), ", ".join(sorted(self.backends))))
377
+ res = b.call(str(args.get("tool") or ""), args.get("args") or {})
378
+ return json.dumps(res, indent=2)[:20000]
379
+ return "unknown router tool %r" % name
380
+
381
+
382
+ def serve(router: Router) -> int:
383
+ """Speak MCP on stdio. stdout is the protocol -- see log()."""
384
+ out = sys.stdout
385
+ for line in sys.stdin:
386
+ line = line.strip()
387
+ if not line:
388
+ continue
389
+ try:
390
+ msg = json.loads(line)
391
+ except ValueError:
392
+ continue
393
+ method, mid = msg.get("method"), msg.get("id")
394
+ if method == "initialize":
395
+ reply = {"protocolVersion": PROTOCOL, "capabilities": {"tools": {}},
396
+ "serverInfo": {"name": "tool-guardian", "version": __version__}}
397
+ elif method == "tools/list":
398
+ reply = {"tools": ROUTER_TOOLS}
399
+ elif method == "tools/call":
400
+ p = msg.get("params") or {}
401
+ try:
402
+ text = router.handle(p.get("name", ""), p.get("arguments") or {})
403
+ reply = {"content": [{"type": "text", "text": text}]}
404
+ except Exception as exc: # noqa: BLE001
405
+ # As tool output, not a protocol error: the model must SEE the
406
+ # failure and say so rather than silently retry.
407
+ reply = {"content": [{"type": "text",
408
+ "text": "ROUTER ERROR: %s: %s"
409
+ % (type(exc).__name__, exc)}],
410
+ "isError": True}
411
+ elif mid is None:
412
+ continue # a notification
413
+ else:
414
+ reply = {}
415
+ if mid is not None:
416
+ out.write(json.dumps({"jsonrpc": "2.0", "id": mid, "result": reply}) + "\n")
417
+ out.flush()
418
+ return 0
419
+
420
+
421
+ def selftest(config: str = "") -> int:
422
+ r = Router(config)
423
+ r.start()
424
+ global ROUTER_TOOLS
425
+ ROUTER_TOOLS = build_router_tools(r.backends)
426
+ print(r.catalogue())
427
+ ok = [b for b in r.backends.values() if b.status == "ok"]
428
+ bad = [b for b in r.backends.values() if b.status != "ok"]
429
+ router_cost = est_tokens(ROUTER_TOOLS)
430
+ full_cost = sum(est_tokens(b.tools) for b in ok)
431
+ print("\nrouter tools cost ~%d tokens vs ~%d for the full set behind them"
432
+ % (router_cost, full_cost))
433
+ if full_cost > router_cost:
434
+ print("-> ~%d tokens freed on every request (%.0f%% smaller)"
435
+ % (full_cost - router_cost, 100.0 * (1 - router_cost / max(full_cost, 1))))
436
+ else:
437
+ print("-> at this size the router's own tools cost about as much as the "
438
+ "servers behind it. The win grows with more / larger servers -- it "
439
+ "pays off exactly when tool bloat is actually a problem.")
440
+ print("\n%d backend(s) up, %d tools reachable."
441
+ % (len(ok), sum(len(b.tools) for b in ok)))
442
+ for b in bad:
443
+ print(" %s: %s -- %s" % (b.name, b.status, b.error[:100]))
444
+ return 0 if ok else 1
445
+
446
+
447
+ def main(argv=None) -> int:
448
+ ap = argparse.ArgumentParser(description="Tool Guardian -- an MCP router that "
449
+ "keeps tool definitions from filling the context window")
450
+ ap.add_argument("--config", default="", help="path to an mcpServers JSON config")
451
+ ap.add_argument("--selftest", action="store_true",
452
+ help="start backends, print the token saving, exit")
453
+ ap.add_argument("--version", action="store_true")
454
+ a = ap.parse_args(argv)
455
+ if a.version:
456
+ print(__version__)
457
+ return 0
458
+ if a.selftest:
459
+ return selftest(a.config)
460
+ r = Router(a.config)
461
+ r.start()
462
+ global ROUTER_TOOLS
463
+ ROUTER_TOOLS = build_router_tools(r.backends)
464
+ log("tool-guardian v%s up with %d backend(s)" % (__version__, len(r.backends)))
465
+ return serve(r)
466
+
467
+
468
+ if __name__ == "__main__":
469
+ raise SystemExit(main())