autotool-mcp 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.
Files changed (42) hide show
  1. autotool_mcp-0.1.0/LICENSE +21 -0
  2. autotool_mcp-0.1.0/PKG-INFO +285 -0
  3. autotool_mcp-0.1.0/README.md +246 -0
  4. autotool_mcp-0.1.0/autotool/__init__.py +13 -0
  5. autotool_mcp-0.1.0/autotool/__main__.py +3 -0
  6. autotool_mcp-0.1.0/autotool/agent.py +106 -0
  7. autotool_mcp-0.1.0/autotool/cli.py +214 -0
  8. autotool_mcp-0.1.0/autotool/clients/__init__.py +0 -0
  9. autotool_mcp-0.1.0/autotool/clients/dynamic_client.py +198 -0
  10. autotool_mcp-0.1.0/autotool/core/__init__.py +0 -0
  11. autotool_mcp-0.1.0/autotool/core/credentials.py +117 -0
  12. autotool_mcp-0.1.0/autotool/core/llm.py +434 -0
  13. autotool_mcp-0.1.0/autotool/core/orchestrator.py +201 -0
  14. autotool_mcp-0.1.0/autotool/core/policy.py +151 -0
  15. autotool_mcp-0.1.0/autotool/core/registry.py +161 -0
  16. autotool_mcp-0.1.0/autotool/core/schema.py +142 -0
  17. autotool_mcp-0.1.0/autotool/core/toolenv.py +190 -0
  18. autotool_mcp-0.1.0/autotool/guard.py +213 -0
  19. autotool_mcp-0.1.0/autotool/main.py +111 -0
  20. autotool_mcp-0.1.0/autotool/server.py +336 -0
  21. autotool_mcp-0.1.0/autotool/setup.py +147 -0
  22. autotool_mcp-0.1.0/autotool/synthesis/__init__.py +5 -0
  23. autotool_mcp-0.1.0/autotool/synthesis/generator.py +135 -0
  24. autotool_mcp-0.1.0/autotool/synthesis/repair.py +84 -0
  25. autotool_mcp-0.1.0/autotool/synthesis/verifier.py +252 -0
  26. autotool_mcp-0.1.0/autotool_mcp.egg-info/PKG-INFO +285 -0
  27. autotool_mcp-0.1.0/autotool_mcp.egg-info/SOURCES.txt +40 -0
  28. autotool_mcp-0.1.0/autotool_mcp.egg-info/dependency_links.txt +1 -0
  29. autotool_mcp-0.1.0/autotool_mcp.egg-info/entry_points.txt +3 -0
  30. autotool_mcp-0.1.0/autotool_mcp.egg-info/requires.txt +16 -0
  31. autotool_mcp-0.1.0/autotool_mcp.egg-info/top_level.txt +1 -0
  32. autotool_mcp-0.1.0/pyproject.toml +71 -0
  33. autotool_mcp-0.1.0/setup.cfg +4 -0
  34. autotool_mcp-0.1.0/tests/test_credentials.py +130 -0
  35. autotool_mcp-0.1.0/tests/test_guard.py +365 -0
  36. autotool_mcp-0.1.0/tests/test_library.py +49 -0
  37. autotool_mcp-0.1.0/tests/test_openai_compatible.py +141 -0
  38. autotool_mcp-0.1.0/tests/test_openai_provider.py +120 -0
  39. autotool_mcp-0.1.0/tests/test_server.py +397 -0
  40. autotool_mcp-0.1.0/tests/test_setup.py +124 -0
  41. autotool_mcp-0.1.0/tests/test_synthesis.py +292 -0
  42. autotool_mcp-0.1.0/tests/test_toolenv.py +453 -0
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Varad Ganjoo
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,285 @@
1
+ Metadata-Version: 2.4
2
+ Name: autotool-mcp
3
+ Version: 0.1.0
4
+ Summary: A self-extending tool layer for any AI agent: your agent writes the tools it needs; AutoTool verifies, guards and mounts them, and gives them API keys by name only.
5
+ Author-email: Varad Ganjoo <varadganjoo@gmail.com>
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/varadganjoo/Autotool
8
+ Project-URL: Repository, https://github.com/varadganjoo/Autotool
9
+ Project-URL: Issues, https://github.com/varadganjoo/Autotool/issues
10
+ Keywords: mcp,model context protocol,agents,llm,tools,claude code,codex,cursor,ollama
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: Operating System :: OS Independent
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.11
16
+ Classifier: Programming Language :: Python :: 3.12
17
+ Classifier: Programming Language :: Python :: 3.13
18
+ Classifier: Programming Language :: Python :: 3.14
19
+ Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
20
+ Classifier: Topic :: Software Development :: Libraries
21
+ Requires-Python: >=3.11
22
+ Description-Content-Type: text/markdown
23
+ License-File: LICENSE
24
+ Requires-Dist: mcp<3,>=2.2
25
+ Requires-Dist: httpx2<3,>=2.13
26
+ Requires-Dist: pydantic<3,>=2.13
27
+ Requires-Dist: anyio<5,>=4.15
28
+ Requires-Dist: python-dotenv<2,>=1.2
29
+ Requires-Dist: keyring<26,>=25.7
30
+ Provides-Extra: models
31
+ Requires-Dist: openai<4,>=3.19; extra == "models"
32
+ Requires-Dist: anthropic<2,>=1.8; extra == "models"
33
+ Provides-Extra: dev
34
+ Requires-Dist: autotool-mcp[models]; extra == "dev"
35
+ Requires-Dist: pytest<10,>=9.1; extra == "dev"
36
+ Requires-Dist: pytest-asyncio<2,>=1.4; extra == "dev"
37
+ Requires-Dist: ruff<0.17,>=0.16; extra == "dev"
38
+ Dynamic: license-file
39
+
40
+ # AutoTool
41
+
42
+ **A tool layer that grows, for any AI agent.** Connect AutoTool to Claude Code, Codex, Cursor,
43
+ Claude Desktop, OpenClaw or an agent you are building. When your agent needs something it has no tool for,
44
+ such as a web API, live data or your company's internal service, it writes the tool. AutoTool then:
45
+
46
+ - **verifies** it: lint, launch in a subprocess, smoke test;
47
+ - **guards** it: the tool runs in its own process with only the credentials it declares;
48
+ - **mounts** it, live, with no restart;
49
+ - **keeps** it for every agent on your machine.
50
+
51
+ The model is told your keys' **names** (`TAVILY_API_KEY`), never their values, and values that a tool
52
+ echoes back are redacted before the model sees them.
53
+
54
+ ```
55
+ your agent (Claude Code, Codex, Cursor, your own) ──MCP──▶ autotool serve
56
+ ├─ create_tool the agent writes a tool
57
+ ├─ run_tool call any created tool
58
+ ├─ weather_tool__current ─▶ own process, gets WEATHER_API_KEY only
59
+ └─ tavily_tool__search ─▶ own process, gets TAVILY_API_KEY only
60
+ ```
61
+
62
+ ## Quick start
63
+
64
+ Python 3.11+. Pick the way you work:
65
+
66
+ ### Build your own agent (Python)
67
+
68
+ ```bash
69
+ pip install "autotool-mcp[models]"
70
+ export OPENAI_API_KEY=sk-... # your agent's model (or ANTHROPIC_API_KEY)
71
+ ```
72
+
73
+ ```python
74
+ import asyncio, autotool
75
+ from autotool.core.llm import default_provider
76
+
77
+ answer = asyncio.run(
78
+ autotool.run_agent(
79
+ "What are the top 3 stories on Hacker News right now?",
80
+ default_provider(),
81
+ on_consent=lambda question: input(question + " [y/N] ") == "y", # asked before a tool gets a key
82
+ )
83
+ )
84
+ print(answer)
85
+ ```
86
+
87
+ The agent has no Hacker News tool. It writes one, AutoTool verifies and mounts it, and the agent
88
+ uses it. Run it again and the tool is already there.
89
+
90
+ **Local or other models.** Any OpenAI-compatible endpoint works: Ollama, LM Studio, vLLM,
91
+ OpenRouter, and others. Set a base URL and a model name; local servers need no key.
92
+
93
+ ```bash
94
+ export OPENAI_BASE_URL=http://localhost:11434/v1 # Ollama; LM Studio: http://localhost:1234/v1
95
+ export OPENAI_LLM=qwen2.5:14b # the model name on that server
96
+ ```
97
+
98
+ `default_provider()` then talks to that endpoint through the Chat Completions API. Pick a model
99
+ with tool calling. Writing a working tool is real coding, so small models fail more often than
100
+ frontier ones. `AUTOTOOL_PROVIDER=openai` keeps OpenAI's Responses API even with a custom base URL.
101
+
102
+ **Already have an agent loop?** Connect it to AutoTool and pass tool calls through:
103
+
104
+ ```python
105
+ async with autotool.connect() as client: # starts AutoTool as an MCP server
106
+ listed = await client.list_tools() # create_tool, run_tool, every tool created so far
107
+ tools = autotool.openai_tools(listed) # or autotool.anthropic_tools(listed)
108
+ result = await client.call_tool(name, arguments) # whatever your model asked for
109
+ ```
110
+
111
+ Frameworks with stdio MCP support (OpenAI Agents SDK `MCPServerStdio`, LangGraph via
112
+ `langchain-mcp-adapters`) can also launch `autotool serve` directly.
113
+
114
+ ### Add it to Claude Code, Codex, Cursor, Claude Desktop or OpenClaw
115
+
116
+ ```bash
117
+ pip install autotool-mcp # or: pipx install autotool-mcp / uv tool install autotool-mcp
118
+ autotool setup # registers AutoTool with every agent app it finds
119
+ autotool keys add TAVILY_API_KEY # stored in your OS keychain; add as many as you like
120
+ ```
121
+
122
+ Restart your agent app and ask for something it can't do yet: *"Search the web for this week's
123
+ James Webb news"*. It writes a Tavily tool. AutoTool asks whether that tool may use
124
+ `TAVILY_API_KEY`, verifies it, hands it that key and nothing else, and the answer comes back.
125
+ Next time, the tool is already there.
126
+
127
+ `autotool setup --dry-run` shows what it would change first. JSON configs (Claude Desktop, Cursor)
128
+ are backed up to `.bak` before editing. Existing entries keep their `env` block, and an existing
129
+ Claude Code or Codex entry is left untouched.
130
+
131
+ ## Adding credentials
132
+
133
+ You add environment variables. AutoTool offers their **names** to your agent, and a created tool
134
+ gets a value only if it declares that name in its code (`REQUIRED_ENV = ["TAVILY_API_KEY"]`).
135
+ Pick any of these places (the first match wins):
136
+
137
+ | Where | How |
138
+ |---|---|
139
+ | Your host's MCP config | `"env": {"AUTOTOOL_KEY_TAVILY_API_KEY": "tvly-..."}`. The prefix makes sharing opt-in. |
140
+ | OS keychain | `autotool keys add TAVILY_API_KEY` (hidden prompt; `--stdin` for scripts) |
141
+ | `~/.autotool/.env` | `TAVILY_API_KEY=tvly-...`, one per line |
142
+ | Any file | `autotool serve --env-file path/to/.env` |
143
+
144
+ A new key can be used by the next tool your agent creates, with no restart. Your agent's tool list
145
+ shows the new name after its next refresh, and a cached tool that was skipped for a missing key
146
+ loads on the next restart. `autotool keys list` shows names. `autotool tools list` shows
147
+ which tools hold which credentials. If a tool needs a key you don't have, your agent is told
148
+ exactly what to run (`autotool keys add STRIPE_API_KEY`).
149
+
150
+ ## Let AutoTool write the tools
151
+
152
+ If your agent is small or not good at writing code, give AutoTool a model of its own in
153
+ `~/.autotool/.env`: `OPENAI_API_KEY` (default model `gpt-6-sol`), `ANTHROPIC_API_KEY` (default
154
+ `claude-opus-5`), or an OpenAI-compatible endpoint (`OPENAI_BASE_URL` + `OPENAI_LLM`, e.g. a local
155
+ Ollama model). Only a key in that file counts: a key your host happens to have in its environment
156
+ is never used, so nothing gets billed by surprise. A `synthesize_tool` tool then appears: your
157
+ agent describes the capability, and AutoTool writes, verifies, repairs and mounts it.
158
+
159
+ ## What a created tool looks like
160
+
161
+ ```python
162
+ import os
163
+
164
+ import httpx2
165
+ from mcp.server.mcpserver import MCPServer
166
+ from mcp.server.mcpserver.exceptions import ToolError
167
+
168
+ REQUIRED_ENV = ["TAVILY_API_KEY"] # the only credential this process will receive
169
+ mcp = MCPServer("tavily_tool")
170
+ API_BASE = os.environ.get("TAVILY_API_BASE", "https://api.tavily.com")
171
+
172
+
173
+ @mcp.tool()
174
+ def search(query: str, max_results: int = 5) -> str:
175
+ """Search the web with Tavily."""
176
+ with httpx2.Client(timeout=10) as client:
177
+ r = client.post(
178
+ f"{API_BASE}/search",
179
+ json={"query": query, "max_results": max_results},
180
+ headers={"Authorization": f"Bearer {os.environ['TAVILY_API_KEY']}"},
181
+ )
182
+ if r.status_code != 200:
183
+ raise ToolError(f"Tavily returned HTTP {r.status_code}: {r.text[:200]}")
184
+ return r.text
185
+
186
+
187
+ if __name__ == "__main__":
188
+ mcp.run()
189
+ ```
190
+
191
+ A tool is a plain [MCP](https://modelcontextprotocol.io) server built on the official Python SDK
192
+ (v2). `ToolError` messages reach the agent; an unexpected exception's traceback goes to the
193
+ verifier, which feeds it back so the tool can be fixed.
194
+
195
+ ## Security
196
+
197
+ **Consent.** The first time a tool asks for credentials, AutoTool asks *you*, through your agent
198
+ app's approval prompt: "Allow `tavily_tool` to use TAVILY_API_KEY? It can only send them to
199
+ api.tavily.com." Nothing runs with the key until you say yes. You are asked again only when the risk changes:
200
+ - **Key with a host allowlist** (and the guard on): rewrites of the tool don't ask again. A
201
+ rewrite can't send the key anywhere new. It asks again only if the tool wants more keys or you
202
+ widen the allowlist.
203
+ - **Key without an allowlist:** every new version of the tool asks, because a rewrite could send
204
+ the key anywhere. Setting allowlists means fewer prompts.
205
+
206
+ Removing a tool revokes its approval. If your app can't show prompts, the
207
+ agent is told to ask you to run the approval yourself:
208
+
209
+ ```bash
210
+ autotool tools approve tavily_tool TAVILY_API_KEY
211
+ autotool tools revoke tavily_tool
212
+ autotool serve --dangerously-allow-all-tools # never ask (or AUTOTOOL_CONSENT=dangerously-allow-all)
213
+ ```
214
+
215
+ `--dangerously-allow-all-tools` gives every tool the credentials it declares without asking.
216
+ Use it for CI, or if you'd rather not approve tools. The guard and host allowlists still apply,
217
+ and the server logs a warning at startup.
218
+
219
+ **Per-key host allowlists.** Say where a key may be sent. A tool holding that key can then only
220
+ reach those hosts (plus localhost, which never leaves your machine):
221
+
222
+ ```bash
223
+ autotool keys allow TAVILY_API_KEY api.tavily.com
224
+ autotool keys allow GITHUB_TOKEN api.github.com "*.githubusercontent.com"
225
+ autotool keys allow TAVILY_API_KEY # no hosts: remove the limit
226
+ ```
227
+
228
+ **The guard (on by default, optional).** Every tool runs under a Python audit hook that the tool
229
+ cannot remove. It enforces the allowlists and blocks:
230
+ - subprocesses and shell commands;
231
+ - loading native code;
232
+ - the OS keychain and credential stores;
233
+ - reading AutoTool's credential files and your agent apps' MCP configs;
234
+ - creating, changing or deleting anything outside the temp directory.
235
+
236
+ This applies to every tool, including ones that declare no keys.
237
+
238
+ It is a guard, not a jail: native code or interpreter bugs can get past it. If AutoTool already
239
+ runs inside a sandbox (a container, a VM, a devcontainer), turn it off:
240
+ `autotool serve --sandbox off` (or `AUTOTOOL_SANDBOX=off` in your host's MCP config). Only an
241
+ explicit `off`, `0`, `false` or `no` turns it off; anything else keeps it on. Host
242
+ allowlists are then not enforced; consent still is.
243
+
244
+ **Always on:**
245
+ - The model is only ever *told* credential **names**. Values a tool echoes back (raw, URL-encoded
246
+ or JSON-escaped) are redacted. A tool written to disguise a key (e.g. base64) could still reveal
247
+ it; that is what consent and host allowlists are for.
248
+ - Each tool process gets only the variables in its `REQUIRED_ENV`, plus an allowlist of OS
249
+ basics: PATH, temp dirs, locale, proxy and CA settings, and `*_API_BASE` overrides. Nothing else
250
+ from your host's environment reaches it (no `AWS_*`, no `DATABASE_URL`).
251
+ - Before anything a tool produces reaches the model (output, errors, tracebacks, schemas), secret
252
+ values are replaced with `[REDACTED:NAME]`. This covers raw, URL-encoded and JSON-escaped forms.
253
+
254
+ For a hard boundary, run AutoTool in a container with the guard off.
255
+
256
+ The design and its evaluation are written up in [the paper](https://github.com/varadganjoo/Autotool/blob/main/paper/autotool.md).
257
+
258
+ ## Hosts
259
+
260
+ | Host | Connect | Notes |
261
+ |---|---|---|
262
+ | Claude Code | `autotool setup` (runs `claude mcp add --scope user`) | Headless `claude -p`: set `MCP_CONNECTION_NONBLOCKING=0` so the first turn waits for AutoTool. |
263
+ | Codex | `autotool setup` (runs `codex mcp add`) | Headless `codex exec` auto-rejects MCP calls: add `-c mcp_servers.autotool.default_tools_approval_mode="approve"`. |
264
+ | Claude Desktop, Cursor | `autotool setup` (edits their `mcpServers` config) | Restart the app. |
265
+ | OpenClaw | `autotool setup` (`openclaw mcp set` on releases that have it) | Older releases: setup prints the config snippet to paste. |
266
+ | Anything else | point it at `autotool serve` (stdio) | |
267
+
268
+ AutoTool speaks both MCP protocol eras: hosts on the 2026-07-28 revision get consent through an
269
+ input-required round trip and hear about new tools on `subscriptions/listen`; older hosts get a
270
+ form elicitation and `notifications/tools/list_changed`.
271
+
272
+ ## Development
273
+
274
+ ```bash
275
+ python -m venv venv && venv/bin/pip install -e ".[dev]" # Windows: venv\Scripts\pip
276
+ pytest # offline suite
277
+ ruff check . && ruff format --check . # lint
278
+ autotool run "What is the top story on Hacker News?" # demo agent: needs [models] + a key in ./.env;
279
+ # no consent prompts or allowlists, uses ./tools
280
+ python scripts/auth_benchmark.py --trials 3 # credentials benchmark
281
+ python scripts/host_benchmark.py --trials 3 # Claude Code + custom-agent benchmark
282
+ ```
283
+
284
+ Built on the MCP Python SDK 2.x, `httpx2`, the OpenAI SDK 3.x (Responses API) and the Anthropic
285
+ SDK 1.x.
@@ -0,0 +1,246 @@
1
+ # AutoTool
2
+
3
+ **A tool layer that grows, for any AI agent.** Connect AutoTool to Claude Code, Codex, Cursor,
4
+ Claude Desktop, OpenClaw or an agent you are building. When your agent needs something it has no tool for,
5
+ such as a web API, live data or your company's internal service, it writes the tool. AutoTool then:
6
+
7
+ - **verifies** it: lint, launch in a subprocess, smoke test;
8
+ - **guards** it: the tool runs in its own process with only the credentials it declares;
9
+ - **mounts** it, live, with no restart;
10
+ - **keeps** it for every agent on your machine.
11
+
12
+ The model is told your keys' **names** (`TAVILY_API_KEY`), never their values, and values that a tool
13
+ echoes back are redacted before the model sees them.
14
+
15
+ ```
16
+ your agent (Claude Code, Codex, Cursor, your own) ──MCP──▶ autotool serve
17
+ ├─ create_tool the agent writes a tool
18
+ ├─ run_tool call any created tool
19
+ ├─ weather_tool__current ─▶ own process, gets WEATHER_API_KEY only
20
+ └─ tavily_tool__search ─▶ own process, gets TAVILY_API_KEY only
21
+ ```
22
+
23
+ ## Quick start
24
+
25
+ Python 3.11+. Pick the way you work:
26
+
27
+ ### Build your own agent (Python)
28
+
29
+ ```bash
30
+ pip install "autotool-mcp[models]"
31
+ export OPENAI_API_KEY=sk-... # your agent's model (or ANTHROPIC_API_KEY)
32
+ ```
33
+
34
+ ```python
35
+ import asyncio, autotool
36
+ from autotool.core.llm import default_provider
37
+
38
+ answer = asyncio.run(
39
+ autotool.run_agent(
40
+ "What are the top 3 stories on Hacker News right now?",
41
+ default_provider(),
42
+ on_consent=lambda question: input(question + " [y/N] ") == "y", # asked before a tool gets a key
43
+ )
44
+ )
45
+ print(answer)
46
+ ```
47
+
48
+ The agent has no Hacker News tool. It writes one, AutoTool verifies and mounts it, and the agent
49
+ uses it. Run it again and the tool is already there.
50
+
51
+ **Local or other models.** Any OpenAI-compatible endpoint works: Ollama, LM Studio, vLLM,
52
+ OpenRouter, and others. Set a base URL and a model name; local servers need no key.
53
+
54
+ ```bash
55
+ export OPENAI_BASE_URL=http://localhost:11434/v1 # Ollama; LM Studio: http://localhost:1234/v1
56
+ export OPENAI_LLM=qwen2.5:14b # the model name on that server
57
+ ```
58
+
59
+ `default_provider()` then talks to that endpoint through the Chat Completions API. Pick a model
60
+ with tool calling. Writing a working tool is real coding, so small models fail more often than
61
+ frontier ones. `AUTOTOOL_PROVIDER=openai` keeps OpenAI's Responses API even with a custom base URL.
62
+
63
+ **Already have an agent loop?** Connect it to AutoTool and pass tool calls through:
64
+
65
+ ```python
66
+ async with autotool.connect() as client: # starts AutoTool as an MCP server
67
+ listed = await client.list_tools() # create_tool, run_tool, every tool created so far
68
+ tools = autotool.openai_tools(listed) # or autotool.anthropic_tools(listed)
69
+ result = await client.call_tool(name, arguments) # whatever your model asked for
70
+ ```
71
+
72
+ Frameworks with stdio MCP support (OpenAI Agents SDK `MCPServerStdio`, LangGraph via
73
+ `langchain-mcp-adapters`) can also launch `autotool serve` directly.
74
+
75
+ ### Add it to Claude Code, Codex, Cursor, Claude Desktop or OpenClaw
76
+
77
+ ```bash
78
+ pip install autotool-mcp # or: pipx install autotool-mcp / uv tool install autotool-mcp
79
+ autotool setup # registers AutoTool with every agent app it finds
80
+ autotool keys add TAVILY_API_KEY # stored in your OS keychain; add as many as you like
81
+ ```
82
+
83
+ Restart your agent app and ask for something it can't do yet: *"Search the web for this week's
84
+ James Webb news"*. It writes a Tavily tool. AutoTool asks whether that tool may use
85
+ `TAVILY_API_KEY`, verifies it, hands it that key and nothing else, and the answer comes back.
86
+ Next time, the tool is already there.
87
+
88
+ `autotool setup --dry-run` shows what it would change first. JSON configs (Claude Desktop, Cursor)
89
+ are backed up to `.bak` before editing. Existing entries keep their `env` block, and an existing
90
+ Claude Code or Codex entry is left untouched.
91
+
92
+ ## Adding credentials
93
+
94
+ You add environment variables. AutoTool offers their **names** to your agent, and a created tool
95
+ gets a value only if it declares that name in its code (`REQUIRED_ENV = ["TAVILY_API_KEY"]`).
96
+ Pick any of these places (the first match wins):
97
+
98
+ | Where | How |
99
+ |---|---|
100
+ | Your host's MCP config | `"env": {"AUTOTOOL_KEY_TAVILY_API_KEY": "tvly-..."}`. The prefix makes sharing opt-in. |
101
+ | OS keychain | `autotool keys add TAVILY_API_KEY` (hidden prompt; `--stdin` for scripts) |
102
+ | `~/.autotool/.env` | `TAVILY_API_KEY=tvly-...`, one per line |
103
+ | Any file | `autotool serve --env-file path/to/.env` |
104
+
105
+ A new key can be used by the next tool your agent creates, with no restart. Your agent's tool list
106
+ shows the new name after its next refresh, and a cached tool that was skipped for a missing key
107
+ loads on the next restart. `autotool keys list` shows names. `autotool tools list` shows
108
+ which tools hold which credentials. If a tool needs a key you don't have, your agent is told
109
+ exactly what to run (`autotool keys add STRIPE_API_KEY`).
110
+
111
+ ## Let AutoTool write the tools
112
+
113
+ If your agent is small or not good at writing code, give AutoTool a model of its own in
114
+ `~/.autotool/.env`: `OPENAI_API_KEY` (default model `gpt-6-sol`), `ANTHROPIC_API_KEY` (default
115
+ `claude-opus-5`), or an OpenAI-compatible endpoint (`OPENAI_BASE_URL` + `OPENAI_LLM`, e.g. a local
116
+ Ollama model). Only a key in that file counts: a key your host happens to have in its environment
117
+ is never used, so nothing gets billed by surprise. A `synthesize_tool` tool then appears: your
118
+ agent describes the capability, and AutoTool writes, verifies, repairs and mounts it.
119
+
120
+ ## What a created tool looks like
121
+
122
+ ```python
123
+ import os
124
+
125
+ import httpx2
126
+ from mcp.server.mcpserver import MCPServer
127
+ from mcp.server.mcpserver.exceptions import ToolError
128
+
129
+ REQUIRED_ENV = ["TAVILY_API_KEY"] # the only credential this process will receive
130
+ mcp = MCPServer("tavily_tool")
131
+ API_BASE = os.environ.get("TAVILY_API_BASE", "https://api.tavily.com")
132
+
133
+
134
+ @mcp.tool()
135
+ def search(query: str, max_results: int = 5) -> str:
136
+ """Search the web with Tavily."""
137
+ with httpx2.Client(timeout=10) as client:
138
+ r = client.post(
139
+ f"{API_BASE}/search",
140
+ json={"query": query, "max_results": max_results},
141
+ headers={"Authorization": f"Bearer {os.environ['TAVILY_API_KEY']}"},
142
+ )
143
+ if r.status_code != 200:
144
+ raise ToolError(f"Tavily returned HTTP {r.status_code}: {r.text[:200]}")
145
+ return r.text
146
+
147
+
148
+ if __name__ == "__main__":
149
+ mcp.run()
150
+ ```
151
+
152
+ A tool is a plain [MCP](https://modelcontextprotocol.io) server built on the official Python SDK
153
+ (v2). `ToolError` messages reach the agent; an unexpected exception's traceback goes to the
154
+ verifier, which feeds it back so the tool can be fixed.
155
+
156
+ ## Security
157
+
158
+ **Consent.** The first time a tool asks for credentials, AutoTool asks *you*, through your agent
159
+ app's approval prompt: "Allow `tavily_tool` to use TAVILY_API_KEY? It can only send them to
160
+ api.tavily.com." Nothing runs with the key until you say yes. You are asked again only when the risk changes:
161
+ - **Key with a host allowlist** (and the guard on): rewrites of the tool don't ask again. A
162
+ rewrite can't send the key anywhere new. It asks again only if the tool wants more keys or you
163
+ widen the allowlist.
164
+ - **Key without an allowlist:** every new version of the tool asks, because a rewrite could send
165
+ the key anywhere. Setting allowlists means fewer prompts.
166
+
167
+ Removing a tool revokes its approval. If your app can't show prompts, the
168
+ agent is told to ask you to run the approval yourself:
169
+
170
+ ```bash
171
+ autotool tools approve tavily_tool TAVILY_API_KEY
172
+ autotool tools revoke tavily_tool
173
+ autotool serve --dangerously-allow-all-tools # never ask (or AUTOTOOL_CONSENT=dangerously-allow-all)
174
+ ```
175
+
176
+ `--dangerously-allow-all-tools` gives every tool the credentials it declares without asking.
177
+ Use it for CI, or if you'd rather not approve tools. The guard and host allowlists still apply,
178
+ and the server logs a warning at startup.
179
+
180
+ **Per-key host allowlists.** Say where a key may be sent. A tool holding that key can then only
181
+ reach those hosts (plus localhost, which never leaves your machine):
182
+
183
+ ```bash
184
+ autotool keys allow TAVILY_API_KEY api.tavily.com
185
+ autotool keys allow GITHUB_TOKEN api.github.com "*.githubusercontent.com"
186
+ autotool keys allow TAVILY_API_KEY # no hosts: remove the limit
187
+ ```
188
+
189
+ **The guard (on by default, optional).** Every tool runs under a Python audit hook that the tool
190
+ cannot remove. It enforces the allowlists and blocks:
191
+ - subprocesses and shell commands;
192
+ - loading native code;
193
+ - the OS keychain and credential stores;
194
+ - reading AutoTool's credential files and your agent apps' MCP configs;
195
+ - creating, changing or deleting anything outside the temp directory.
196
+
197
+ This applies to every tool, including ones that declare no keys.
198
+
199
+ It is a guard, not a jail: native code or interpreter bugs can get past it. If AutoTool already
200
+ runs inside a sandbox (a container, a VM, a devcontainer), turn it off:
201
+ `autotool serve --sandbox off` (or `AUTOTOOL_SANDBOX=off` in your host's MCP config). Only an
202
+ explicit `off`, `0`, `false` or `no` turns it off; anything else keeps it on. Host
203
+ allowlists are then not enforced; consent still is.
204
+
205
+ **Always on:**
206
+ - The model is only ever *told* credential **names**. Values a tool echoes back (raw, URL-encoded
207
+ or JSON-escaped) are redacted. A tool written to disguise a key (e.g. base64) could still reveal
208
+ it; that is what consent and host allowlists are for.
209
+ - Each tool process gets only the variables in its `REQUIRED_ENV`, plus an allowlist of OS
210
+ basics: PATH, temp dirs, locale, proxy and CA settings, and `*_API_BASE` overrides. Nothing else
211
+ from your host's environment reaches it (no `AWS_*`, no `DATABASE_URL`).
212
+ - Before anything a tool produces reaches the model (output, errors, tracebacks, schemas), secret
213
+ values are replaced with `[REDACTED:NAME]`. This covers raw, URL-encoded and JSON-escaped forms.
214
+
215
+ For a hard boundary, run AutoTool in a container with the guard off.
216
+
217
+ The design and its evaluation are written up in [the paper](https://github.com/varadganjoo/Autotool/blob/main/paper/autotool.md).
218
+
219
+ ## Hosts
220
+
221
+ | Host | Connect | Notes |
222
+ |---|---|---|
223
+ | Claude Code | `autotool setup` (runs `claude mcp add --scope user`) | Headless `claude -p`: set `MCP_CONNECTION_NONBLOCKING=0` so the first turn waits for AutoTool. |
224
+ | Codex | `autotool setup` (runs `codex mcp add`) | Headless `codex exec` auto-rejects MCP calls: add `-c mcp_servers.autotool.default_tools_approval_mode="approve"`. |
225
+ | Claude Desktop, Cursor | `autotool setup` (edits their `mcpServers` config) | Restart the app. |
226
+ | OpenClaw | `autotool setup` (`openclaw mcp set` on releases that have it) | Older releases: setup prints the config snippet to paste. |
227
+ | Anything else | point it at `autotool serve` (stdio) | |
228
+
229
+ AutoTool speaks both MCP protocol eras: hosts on the 2026-07-28 revision get consent through an
230
+ input-required round trip and hear about new tools on `subscriptions/listen`; older hosts get a
231
+ form elicitation and `notifications/tools/list_changed`.
232
+
233
+ ## Development
234
+
235
+ ```bash
236
+ python -m venv venv && venv/bin/pip install -e ".[dev]" # Windows: venv\Scripts\pip
237
+ pytest # offline suite
238
+ ruff check . && ruff format --check . # lint
239
+ autotool run "What is the top story on Hacker News?" # demo agent: needs [models] + a key in ./.env;
240
+ # no consent prompts or allowlists, uses ./tools
241
+ python scripts/auth_benchmark.py --trials 3 # credentials benchmark
242
+ python scripts/host_benchmark.py --trials 3 # Claude Code + custom-agent benchmark
243
+ ```
244
+
245
+ Built on the MCP Python SDK 2.x, `httpx2`, the OpenAI SDK 3.x (Responses API) and the Anthropic
246
+ SDK 1.x.
@@ -0,0 +1,13 @@
1
+ """AutoTool: a self-extending tool layer for any AI agent, served over MCP."""
2
+
3
+ __version__ = "0.1.0"
4
+ __all__ = ["anthropic_tools", "connect", "openai_tools", "run_agent"]
5
+
6
+
7
+ def __getattr__(name: str):
8
+ # Lazy, so tool processes that import autotool.guard don't load the MCP client stack.
9
+ if name in __all__:
10
+ from autotool import agent
11
+
12
+ return getattr(agent, name)
13
+ raise AttributeError(f"module 'autotool' has no attribute {name!r}")
@@ -0,0 +1,3 @@
1
+ from autotool.cli import main
2
+
3
+ raise SystemExit(main())
@@ -0,0 +1,106 @@
1
+ """Build with AutoTool from Python.
2
+
3
+ import asyncio, autotool
4
+ from autotool.core.llm import default_provider # needs autotool-mcp[models] + OPENAI_API_KEY
5
+
6
+ print(asyncio.run(autotool.run_agent("What's the top story on Hacker News?", default_provider())))
7
+
8
+ Or bring your own agent loop:
9
+
10
+ async with autotool.connect() as session: # AutoTool running as an MCP server
11
+ tools = autotool.openai_tools(await session.list_tools()) # or anthropic_tools(...)
12
+ result = await session.call_tool(name, arguments) # whatever your model asks for
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import os
18
+ import sys
19
+ from collections.abc import AsyncIterator, Callable
20
+ from contextlib import asynccontextmanager
21
+ from pathlib import Path
22
+ from typing import Any
23
+
24
+ from mcp import Client, StdioServerParameters, types
25
+
26
+ from autotool.core.llm import LLMProvider
27
+ from autotool.core.schema import ToolResult
28
+
29
+ SYSTEM = (
30
+ "You are a helpful agent. Use your tools for anything that needs live data or an external API. "
31
+ "If no tool fits, write one with create_tool, then call it. Never invent data a tool should provide."
32
+ )
33
+
34
+
35
+ @asynccontextmanager
36
+ async def connect(
37
+ home: str | Path | None = None,
38
+ env: dict[str, str] | None = None,
39
+ on_consent: Callable[[str], bool] | None = None,
40
+ server: StdioServerParameters | None = None,
41
+ ) -> AsyncIterator[Client]:
42
+ """Start `autotool serve` as a subprocess and yield a connected MCP client.
43
+
44
+ home: AutoTool's data directory (default ~/.autotool). env: extra environment for the
45
+ server, e.g. {"AUTOTOOL_KEY_TAVILY_API_KEY": "..."}. on_consent: shown AutoTool's approval
46
+ question ("allow tool X to use KEY?"), returns the user's answer; without it, credentialed
47
+ tools need `autotool tools approve`. server: launch parameters to use instead."""
48
+ if server is None:
49
+ server_env = {**os.environ, **(env or {})}
50
+ if home is not None:
51
+ server_env["AUTOTOOL_HOME"] = str(home)
52
+ server = StdioServerParameters(command=sys.executable, args=["-m", "autotool", "serve"], env=server_env)
53
+
54
+ async def elicit(context: Any, params: types.ElicitRequestParams) -> types.ElicitResult:
55
+ return types.ElicitResult(action="accept", content={"allow": bool(on_consent and on_consent(params.message))})
56
+
57
+ async with Client(server, elicitation_callback=elicit if on_consent else None) as client:
58
+ yield client
59
+
60
+
61
+ def openai_tools(listed: types.ListToolsResult) -> list[dict[str, Any]]:
62
+ """Tool schemas for the OpenAI Responses API (`tools=`)."""
63
+ return [
64
+ {"type": "function", "name": t.name, "description": t.description or "", "parameters": t.input_schema}
65
+ for t in listed.tools
66
+ ]
67
+
68
+
69
+ def anthropic_tools(listed: types.ListToolsResult) -> list[dict[str, Any]]:
70
+ """Tool schemas for the Anthropic Messages API (`tools=`)."""
71
+ return [{"name": t.name, "description": t.description or "", "input_schema": t.input_schema} for t in listed.tools]
72
+
73
+
74
+ def _text(result: types.CallToolResult) -> str:
75
+ return "\n".join(c.text for c in result.content if isinstance(c, types.TextContent))
76
+
77
+
78
+ async def run_agent(
79
+ prompt: str,
80
+ provider: LLMProvider,
81
+ *,
82
+ home: str | Path | None = None,
83
+ env: dict[str, str] | None = None,
84
+ on_consent: Callable[[str], bool] | None = None,
85
+ on_tool: Callable[[str, dict[str, Any], types.CallToolResult], None] | None = None,
86
+ server: StdioServerParameters | None = None,
87
+ max_steps: int = 15,
88
+ ) -> str:
89
+ """A complete agent: the model sees AutoTool's tools, writes new ones when it needs them, and
90
+ answers. Tools are listed every turn, so a tool created in one step is usable in the next."""
91
+ async with connect(home, env, on_consent, server) as session:
92
+ messages: list[Any] = [{"role": "user", "content": prompt}]
93
+ for _ in range(max_steps):
94
+ tools = anthropic_tools(await session.list_tools()) # the providers' neutral shape
95
+ turn = await provider.complete(system=SYSTEM, messages=messages, tools=tools)
96
+ messages.append(provider.assistant_message(turn))
97
+ if not turn.tool_calls:
98
+ return turn.text
99
+ results = []
100
+ for call in turn.tool_calls:
101
+ result = await session.call_tool(call.name, call.arguments)
102
+ if on_tool:
103
+ on_tool(call.name, call.arguments, result)
104
+ results.append(ToolResult(call_id=call.id, content=_text(result), is_error=bool(result.is_error)))
105
+ messages.extend(provider.tool_results_messages(results))
106
+ raise RuntimeError(f"no answer within {max_steps} steps")