|
1 | 1 | # Toolint |
2 | 2 |
|
3 | | -**Structural linter for MCP-compatible, zero-dependency Python agent tool packages.** |
| 3 | +[](https://github.com/PlateerLab/Toolint/actions/workflows/ci.yml) |
| 4 | +[](https://pypi.org/project/toolint/) |
| 5 | +[](https://pypi.org/project/toolint/) |
| 6 | +[](https://opensource.org/licenses/MIT) |
4 | 7 |
|
5 | | -`toolint` enforces architectural rules that ensure your Python package works correctly as: |
6 | | -- A **standalone library** (`from my_tool import MyTool`) |
7 | | -- A **CLI tool** (`my-tool search "query"`) |
8 | | -- An **MCP server** (`my-tool serve --source spec.json`) |
9 | | -- An **SDK middleware** (OpenAI / Anthropic client patches) |
| 8 | +**Structural linter for Python agent tool packages.** |
10 | 9 |
|
11 | | -Inspired by the architecture of [graph-tool-call](https://github.com/SonAIengine/graph-tool-call). |
| 10 | +Ensures your package works as a **library**, **CLI**, and **MCP server** simultaneously — with zero-dependency core and proper facade separation. |
12 | 11 |
|
13 | | -## Why? |
| 12 | +## The Problem |
14 | 13 |
|
15 | | -Building agent-compatible tools is easy to get wrong: |
| 14 | +AI agent tools need to work in multiple contexts at once: |
16 | 15 |
|
17 | | -| Mistake | Consequence | |
18 | | -|---------|------------| |
19 | | -| `core/` imports `numpy` without guard | Users without numpy get `ImportError` on `import my_tool` | |
20 | | -| MCP server has business logic | Can't use the same functionality as a library | |
21 | | -| `__version__` != `pyproject.toml` version | PyPI shows wrong version | |
22 | | -| Tool function has no docstring | LLM can't understand what the tool does | |
23 | | -| Optional dep not in `extras` | `pip install my-tool[mcp]` doesn't install MCP SDK | |
| 16 | +```python |
| 17 | +# As a library |
| 18 | +from my_tool import MyTool |
| 19 | +tg = MyTool() |
| 20 | +tg.search("query") |
24 | 21 |
|
25 | | -`toolint` catches all of these **before** they reach users. |
| 22 | +# As a CLI |
| 23 | +$ my-tool search "query" |
| 24 | + |
| 25 | +# As an MCP server |
| 26 | +$ my-tool serve --source spec.json |
| 27 | +``` |
| 28 | + |
| 29 | +Getting this right requires strict architectural discipline. Without it: |
| 30 | + |
| 31 | +- `core/` imports `numpy` → users get `ImportError` just from `import my_tool` |
| 32 | +- MCP server has business logic → can't reuse the same functionality as a library |
| 33 | +- CLI calls internal modules directly → refactoring breaks everything |
| 34 | +- Tool function has no docstring → LLM can't select the right tool |
| 35 | +- `__version__` doesn't match `pyproject.toml` → PyPI shows wrong version |
| 36 | + |
| 37 | +**Toolint catches all of these statically, before they reach users.** |
26 | 38 |
|
27 | 39 | ## Installation |
28 | 40 |
|
29 | 41 | ```bash |
30 | 42 | pip install toolint |
31 | 43 |
|
32 | | -# or with uv |
33 | | -uv pip install toolint |
34 | | - |
35 | | -# or as a tool |
| 44 | +# or run without installing |
36 | 45 | uvx toolint check . |
37 | 46 | ``` |
38 | 47 |
|
39 | | -## Quick Start |
| 48 | +## Usage |
40 | 49 |
|
41 | 50 | ```bash |
42 | | -# Lint current project |
| 51 | +# Lint a project |
43 | 52 | toolint check . |
44 | 53 |
|
45 | | -# Lint with specific rules only |
46 | | -toolint check . --select ATL101,ATL102 |
| 54 | +# Select specific rules |
| 55 | +toolint check . --select ATL101,ATL201 |
47 | 56 |
|
48 | | -# Ignore specific rules |
49 | | -toolint check . --ignore ATL105 |
| 57 | +# Ignore rules |
| 58 | +toolint check . --ignore ATL105,ATL501 |
50 | 59 |
|
51 | | -# JSON output (for CI integration) |
| 60 | +# JSON output for CI |
52 | 61 | toolint check . --format json |
53 | 62 |
|
54 | | -# Show all available rules |
| 63 | +# List all rules |
55 | 64 | toolint rules |
56 | 65 | ``` |
57 | 66 |
|
58 | | -## Example Output |
| 67 | +## Real-World Example |
| 68 | + |
| 69 | +Running `toolint` against [graph-tool-call](https://github.com/SonAIengine/graph-tool-call) (248-tool search engine, 1K+ stars worth of architecture): |
59 | 70 |
|
60 | 71 | ``` |
61 | | -my_tool/core/engine.py:3:0 ATL101 (error) |
62 | | - Hard import of 'numpy' in core module — core must be stdlib-only. |
63 | | - Use try/except ImportError guard or move to a non-core module. |
| 72 | +graph_tool_call/core/graph.py:9:4 ATL101 (error) |
| 73 | + Third-party import 'networkx' in core module — core/ must be stdlib-only. |
| 74 | + Move this module outside of core/, or add 'networkx' to |
| 75 | + core_allowed_imports in [tool.toolint]. |
| 76 | +
|
| 77 | +graph_tool_call/mcp_server.py:128:0 ATL201 (warning) |
| 78 | + 'mcp_server.py' imports internal module 'graph_tool_call.retrieval.engine' |
| 79 | + instead of using facade 'ToolGraph'. |
64 | 80 |
|
65 | | -my_tool/retrieval/embedding.py:5:0 ATL102 (error) |
66 | | - Optional import 'sentence_transformers' missing try/except ImportError guard. |
| 81 | +graph_tool_call/tool_graph.py:410:0 ATL501 (warning) |
| 82 | + Facade method 'ToolGraph.add_domain()' has no docstring. |
| 83 | +
|
| 84 | +11 issues found (1 error, 10 warnings) |
| 85 | +``` |
67 | 86 |
|
68 | | -my_tool/__init__.py:1:0 ATL004 (error) |
69 | | - Version mismatch: __init__.py has '0.2.0' but pyproject.toml has '0.2.1' |
| 87 | +## Architecture Enforced |
70 | 88 |
|
71 | | -my_tool/mcp_server.py:42:8 ATL503 (error) |
72 | | - MCP tool function 'process_data' has no docstring. |
73 | | - LLMs rely on tool descriptions to select the right tool. |
| 89 | +Toolint validates this package structure: |
74 | 90 |
|
75 | | -4 issues found (4 errors, 0 warnings) |
| 91 | +``` |
| 92 | +my_package/ |
| 93 | +├── __init__.py # __version__, __all__, lazy imports |
| 94 | +├── __main__.py # CLI — calls facade only |
| 95 | +├── core/ # stdlib ONLY — no external deps |
| 96 | +│ ├── protocol.py # Abstract interfaces (Protocol) |
| 97 | +│ └── models.py # Domain models (dataclass) |
| 98 | +├── feature_a/ # Business logic (optional deps with guards) |
| 99 | +├── facade.py # Single public API class |
| 100 | +├── mcp_server.py # MCP server — wraps facade |
| 101 | +└── middleware.py # SDK patches — wraps facade |
76 | 102 | ``` |
77 | 103 |
|
78 | | -## Rules |
| 104 | +Four principles: |
79 | 105 |
|
80 | | -### Layer 1: Structure (ATL0xx) |
| 106 | +1. **Core is stdlib-only** — `import my_tool` always works, no extras needed |
| 107 | +2. **Facade is the single API** — CLI, MCP, middleware all go through one class |
| 108 | +3. **Optional deps use import guards** — graceful degradation, not crashes |
| 109 | +4. **Interface layers are thin** — no business logic in MCP server or CLI |
81 | 110 |
|
82 | | -| Rule | Severity | Description | |
83 | | -|------|----------|-------------| |
84 | | -| `ATL001` | error | Package must have a single public facade class | |
85 | | -| `ATL002` | error | `__main__.py` must exist and be registered in pyproject.toml scripts | |
86 | | -| `ATL003` | warning | `__init__.py` must define `__all__` including the facade class | |
87 | | -| `ATL004` | error | `__version__` in `__init__.py` must match pyproject.toml version | |
| 111 | +## Rules |
88 | 112 |
|
89 | | -### Layer 2: Dependency Rules (ATL1xx) |
| 113 | +### Structure (ATL0xx) |
90 | 114 |
|
91 | | -| Rule | Severity | Description | |
92 | | -|------|----------|-------------| |
93 | | -| `ATL101` | error | No third-party imports in `core/` directory (stdlib only) | |
94 | | -| `ATL102` | error | Optional dependencies must use `try/except ImportError` guard | |
95 | | -| `ATL103` | warning | Import guard must include install hint (e.g. `pip install pkg[extra]`) | |
96 | | -| `ATL104` | error | Optional imports must be registered in pyproject.toml `extras` | |
97 | | -| `ATL105` | warning | `__init__.py` should not eagerly import optional-dep modules | |
| 115 | +| Rule | Sev | What it checks | |
| 116 | +|------|-----|----------------| |
| 117 | +| `ATL001` | error | Facade class exists in the package | |
| 118 | +| `ATL002` | error | `__main__.py` exists | |
| 119 | +| `ATL003` | warn | `__init__.py` has `__all__` with facade class | |
| 120 | +| `ATL004` | error | `__version__` matches `pyproject.toml` | |
98 | 121 |
|
99 | | -### Layer 3: Layer Separation (ATL2xx) |
| 122 | +### Dependencies (ATL1xx) |
100 | 123 |
|
101 | | -| Rule | Severity | Description | |
102 | | -|------|----------|-------------| |
103 | | -| `ATL201` | warning | Interface files should not call internal business logic directly (type/enum/constant imports are allowed) | |
104 | | -| `ATL202` | warning | CLI command handlers should invoke functionality through the facade class | |
105 | | -| `ATL203` | warning | Interface layer should not import `core/` internals directly (except types/constants) | |
| 124 | +| Rule | Sev | What it checks | |
| 125 | +|------|-----|----------------| |
| 126 | +| `ATL101` | error | `core/` has no third-party imports | |
| 127 | +| `ATL102` | error | Optional deps use `try/except ImportError` | |
| 128 | +| `ATL103` | warn | Import guard has install hint message | |
| 129 | +| `ATL104` | error | Guarded imports are in `pyproject.toml` extras | |
| 130 | +| `ATL105` | warn | `__init__.py` doesn't eagerly import optional deps | |
106 | 131 |
|
107 | | -### Layer 4: pyproject.toml Consistency (ATL3xx) |
| 132 | +### Layer Separation (ATL2xx) |
108 | 133 |
|
109 | | -| Rule | Severity | Description | |
110 | | -|------|----------|-------------| |
111 | | -| `ATL301` | error | CLI entry point must be registered in `[tool.poetry.scripts]` or `[project.scripts]` | |
112 | | -| `ATL302` | error | If MCP server exists, `mcp` extras group must be defined | |
113 | | -| `ATL303` | warning | `all` extras group must include all dependencies from other extras groups | |
| 134 | +| Rule | Sev | What it checks | |
| 135 | +|------|-----|----------------| |
| 136 | +| `ATL201` | warn | Interface files go through facade, not internal modules | |
| 137 | +| `ATL202` | warn | CLI references the facade class | |
| 138 | +| `ATL203` | warn | Interface doesn't import `core/` directly (types allowed) | |
114 | 139 |
|
115 | | -### Layer 5: Tool Schema Quality (ATL5xx) |
| 140 | +### pyproject.toml (ATL3xx) |
116 | 141 |
|
117 | | -| Rule | Severity | Description | |
118 | | -|------|----------|-------------| |
119 | | -| `ATL501` | warning | Facade public methods must have docstrings | |
120 | | -| `ATL502` | warning | Facade public methods must have parameter + return type hints | |
121 | | -| `ATL503` | error | MCP tool functions must have docstrings (min 10 chars) | |
122 | | -| `ATL504` | warning | MCP tool function docstrings should describe each parameter | |
| 142 | +| Rule | Sev | What it checks | |
| 143 | +|------|-----|----------------| |
| 144 | +| `ATL301` | error | CLI scripts entry registered | |
| 145 | +| `ATL302` | error | MCP server present → `mcp` extras defined | |
| 146 | +| `ATL303` | warn | `all` extras includes everything | |
123 | 147 |
|
124 | | -### Layer 6: Agent Compatibility (ATL6xx) |
| 148 | +### Schema Quality (ATL5xx) |
125 | 149 |
|
126 | | -| Rule | Severity | Description | |
127 | | -|------|----------|-------------| |
128 | | -| `ATL601` | warning | Facade public methods should return JSON-serializable types | |
129 | | -| `ATL602` | error | MCP tool functions must return `str` (MCP protocol requirement) | |
130 | | -| `ATL603` | warning | Facade/MCP tools should not silently swallow exceptions | |
| 150 | +| Rule | Sev | What it checks | |
| 151 | +|------|-----|----------------| |
| 152 | +| `ATL501` | warn | Facade public methods have docstrings | |
| 153 | +| `ATL502` | warn | Facade public methods have type hints | |
| 154 | +| `ATL503` | error | MCP tool functions have docstrings (min 10 chars) | |
| 155 | +| `ATL504` | warn | MCP tool docstrings describe parameters | |
131 | 156 |
|
132 | 157 | ## Configuration |
133 | 158 |
|
134 | | -Add to `pyproject.toml`: |
135 | | - |
136 | 159 | ```toml |
| 160 | +# pyproject.toml |
137 | 161 | [tool.toolint] |
138 | | -# Package root (auto-detected from pyproject.toml) |
139 | | -package = "my_tool" |
140 | | - |
141 | | -# Facade class name (auto-detected if single prominent class exists) |
142 | | -facade_class = "MyTool" |
143 | | - |
144 | | -# Core directory (default: "core") |
145 | | -core_dir = "core" |
146 | | - |
147 | | -# Interface files (default: auto-detected) |
148 | | -interface_files = ["mcp_server.py", "mcp_proxy.py", "middleware.py", "__main__.py"] |
149 | | - |
150 | | -# Extra stdlib-like packages allowed in core (escape hatch) |
151 | | -core_allowed_imports = [] |
152 | | - |
153 | | -# Rules to ignore |
154 | | -ignore = ["ATL105"] |
| 162 | +package = "my_tool" # auto-detected |
| 163 | +facade_class = "MyTool" # auto-detected |
| 164 | +core_dir = "core" # default |
| 165 | +interface_files = [ # default |
| 166 | + "mcp_server.py", "mcp_proxy.py", |
| 167 | + "middleware.py", "__main__.py" |
| 168 | +] |
| 169 | +core_allowed_imports = [] # escape hatch for core/ |
| 170 | +ignore = ["ATL105"] # rules to skip |
155 | 171 | ``` |
156 | 172 |
|
157 | | -Or use a standalone file `.toolint.toml` with the same structure (without the `[tool.toolint]` nesting). |
158 | | - |
159 | | -## The Architecture This Enforces |
160 | | - |
161 | | -``` |
162 | | -my_package/ |
163 | | -├── __init__.py # __version__, __all__, lazy imports |
164 | | -├── __main__.py # CLI (argparse) — calls facade only |
165 | | -├── core/ # ZERO external dependencies (stdlib only) |
166 | | -│ ├── protocol.py # Abstract interfaces (Protocol classes) |
167 | | -│ └── models.py # Domain models (dataclasses) |
168 | | -├── feature_a/ # Business logic modules |
169 | | -│ └── ... # May use optional deps with import guards |
170 | | -├── facade.py # Single public API class |
171 | | -├── mcp_server.py # MCP server — wraps facade only |
172 | | -└── middleware.py # SDK patches — wraps facade only |
173 | | -``` |
174 | | - |
175 | | -**Key principles:** |
176 | | -1. **Core is stdlib-only** — anyone can `import my_tool` without installing extras |
177 | | -2. **Facade is the single API surface** — all interfaces (CLI, MCP, middleware) go through it |
178 | | -3. **Optional deps use import guards** — graceful degradation, not crashes |
179 | | -4. **Interface layers are thin wrappers** — no business logic in MCP server or CLI |
| 173 | +Or use `.toolint.toml` as a standalone config file. |
180 | 174 |
|
181 | 175 | ## CI Integration |
182 | 176 |
|
183 | 177 | ### GitHub Actions |
184 | 178 |
|
185 | 179 | ```yaml |
186 | | -- name: Lint agent tool structure |
| 180 | +- name: Structural lint |
187 | 181 | run: uvx toolint check . |
188 | 182 | ``` |
189 | 183 |
|
190 | 184 | ### Pre-commit |
191 | 185 |
|
192 | 186 | ```yaml |
193 | 187 | repos: |
194 | | - - repo: https://github.com/PlateerLab/toolint |
| 188 | + - repo: https://github.com/PlateerLab/Toolint |
195 | 189 | rev: v0.1.0 |
196 | 190 | hooks: |
197 | 191 | - id: toolint |
198 | 192 | ``` |
199 | 193 |
|
200 | | -## Technical Details |
| 194 | +## How It Works |
201 | 195 |
|
202 | | -- **Python 3.10+** |
203 | | -- **Zero dependencies** — uses only `ast` and `tomllib` from stdlib |
204 | | -- **Fast** — AST parsing, no runtime imports of the target package |
205 | | -- **Self-validating** — `toolint` follows the same architecture it enforces |
| 196 | +- **Zero dependencies** — stdlib only (`ast`, `tomllib`, `pathlib`) |
| 197 | +- **AST-based** — parses Python files without importing them |
| 198 | +- **Fast** — 66 tests run in 0.1s, real projects lint in milliseconds |
| 199 | +- **Python 3.10+** — uses `sys.stdlib_module_names` for accurate stdlib detection |
206 | 200 |
|
207 | | -## License |
| 201 | +## Links |
208 | 202 |
|
209 | | -MIT |
| 203 | +- [PyPI](https://pypi.org/project/toolint/) |
| 204 | +- [GitHub](https://github.com/PlateerLab/Toolint) |
| 205 | +- [graph-tool-call](https://github.com/SonAIengine/graph-tool-call) — reference architecture this linter validates |
210 | 206 |
|
211 | | -## Links |
| 207 | +## License |
212 | 208 |
|
213 | | -- [GitHub](https://github.com/PlateerLab/toolint) |
214 | | -- [PyPI](https://pypi.org/project/toolint/) (coming soon) |
215 | | -- [graph-tool-call](https://github.com/SonAIengine/graph-tool-call) — the reference implementation this linter is based on |
| 209 | +MIT |
0 commit comments