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.
- tool_guardian-0.1.0/LICENSE +21 -0
- tool_guardian-0.1.0/PKG-INFO +145 -0
- tool_guardian-0.1.0/README.md +118 -0
- tool_guardian-0.1.0/pyproject.toml +45 -0
- tool_guardian-0.1.0/setup.cfg +4 -0
- tool_guardian-0.1.0/tests/test_tool_guardian.py +175 -0
- tool_guardian-0.1.0/tool_guardian.egg-info/PKG-INFO +145 -0
- tool_guardian-0.1.0/tool_guardian.egg-info/SOURCES.txt +10 -0
- tool_guardian-0.1.0/tool_guardian.egg-info/dependency_links.txt +1 -0
- tool_guardian-0.1.0/tool_guardian.egg-info/entry_points.txt +2 -0
- tool_guardian-0.1.0/tool_guardian.egg-info/top_level.txt +1 -0
- tool_guardian-0.1.0/tool_guardian.py +469 -0
|
@@ -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
|
+

|
|
31
|
+

|
|
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
|
+

|
|
4
|
+

|
|
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,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
|
+

|
|
31
|
+

|
|
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 @@
|
|
|
1
|
+
|
|
@@ -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())
|