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.
- autotool_mcp-0.1.0/LICENSE +21 -0
- autotool_mcp-0.1.0/PKG-INFO +285 -0
- autotool_mcp-0.1.0/README.md +246 -0
- autotool_mcp-0.1.0/autotool/__init__.py +13 -0
- autotool_mcp-0.1.0/autotool/__main__.py +3 -0
- autotool_mcp-0.1.0/autotool/agent.py +106 -0
- autotool_mcp-0.1.0/autotool/cli.py +214 -0
- autotool_mcp-0.1.0/autotool/clients/__init__.py +0 -0
- autotool_mcp-0.1.0/autotool/clients/dynamic_client.py +198 -0
- autotool_mcp-0.1.0/autotool/core/__init__.py +0 -0
- autotool_mcp-0.1.0/autotool/core/credentials.py +117 -0
- autotool_mcp-0.1.0/autotool/core/llm.py +434 -0
- autotool_mcp-0.1.0/autotool/core/orchestrator.py +201 -0
- autotool_mcp-0.1.0/autotool/core/policy.py +151 -0
- autotool_mcp-0.1.0/autotool/core/registry.py +161 -0
- autotool_mcp-0.1.0/autotool/core/schema.py +142 -0
- autotool_mcp-0.1.0/autotool/core/toolenv.py +190 -0
- autotool_mcp-0.1.0/autotool/guard.py +213 -0
- autotool_mcp-0.1.0/autotool/main.py +111 -0
- autotool_mcp-0.1.0/autotool/server.py +336 -0
- autotool_mcp-0.1.0/autotool/setup.py +147 -0
- autotool_mcp-0.1.0/autotool/synthesis/__init__.py +5 -0
- autotool_mcp-0.1.0/autotool/synthesis/generator.py +135 -0
- autotool_mcp-0.1.0/autotool/synthesis/repair.py +84 -0
- autotool_mcp-0.1.0/autotool/synthesis/verifier.py +252 -0
- autotool_mcp-0.1.0/autotool_mcp.egg-info/PKG-INFO +285 -0
- autotool_mcp-0.1.0/autotool_mcp.egg-info/SOURCES.txt +40 -0
- autotool_mcp-0.1.0/autotool_mcp.egg-info/dependency_links.txt +1 -0
- autotool_mcp-0.1.0/autotool_mcp.egg-info/entry_points.txt +3 -0
- autotool_mcp-0.1.0/autotool_mcp.egg-info/requires.txt +16 -0
- autotool_mcp-0.1.0/autotool_mcp.egg-info/top_level.txt +1 -0
- autotool_mcp-0.1.0/pyproject.toml +71 -0
- autotool_mcp-0.1.0/setup.cfg +4 -0
- autotool_mcp-0.1.0/tests/test_credentials.py +130 -0
- autotool_mcp-0.1.0/tests/test_guard.py +365 -0
- autotool_mcp-0.1.0/tests/test_library.py +49 -0
- autotool_mcp-0.1.0/tests/test_openai_compatible.py +141 -0
- autotool_mcp-0.1.0/tests/test_openai_provider.py +120 -0
- autotool_mcp-0.1.0/tests/test_server.py +397 -0
- autotool_mcp-0.1.0/tests/test_setup.py +124 -0
- autotool_mcp-0.1.0/tests/test_synthesis.py +292 -0
- 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,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")
|