toolaccess 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.
- toolaccess-0.1.0/LICENSE +21 -0
- toolaccess-0.1.0/PKG-INFO +217 -0
- toolaccess-0.1.0/README.md +198 -0
- toolaccess-0.1.0/pyproject.toml +30 -0
- toolaccess-0.1.0/setup.cfg +4 -0
- toolaccess-0.1.0/src/toolaccess/__init__.py +17 -0
- toolaccess-0.1.0/src/toolaccess/toolaccess.py +417 -0
- toolaccess-0.1.0/src/toolaccess.egg-info/PKG-INFO +217 -0
- toolaccess-0.1.0/src/toolaccess.egg-info/SOURCES.txt +11 -0
- toolaccess-0.1.0/src/toolaccess.egg-info/dependency_links.txt +1 -0
- toolaccess-0.1.0/src/toolaccess.egg-info/requires.txt +9 -0
- toolaccess-0.1.0/src/toolaccess.egg-info/top_level.txt +1 -0
- toolaccess-0.1.0/tests/test_toolaccess.py +232 -0
toolaccess-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 whogben
|
|
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,217 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: toolaccess
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Make your custom tools accessible across multiple protocols, including MCP, REST and CLI.
|
|
5
|
+
Project-URL: Homepage, https://github.com/whogben/toolaccess
|
|
6
|
+
Project-URL: Repository, https://github.com/whogben/toolaccess
|
|
7
|
+
Requires-Python: >=3.10
|
|
8
|
+
Description-Content-Type: text/markdown
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Requires-Dist: fastapi
|
|
11
|
+
Requires-Dist: fastmcp
|
|
12
|
+
Requires-Dist: pydantic
|
|
13
|
+
Requires-Dist: typer
|
|
14
|
+
Requires-Dist: uvicorn
|
|
15
|
+
Provides-Extra: dev
|
|
16
|
+
Requires-Dist: pytest; extra == "dev"
|
|
17
|
+
Requires-Dist: pytest-asyncio; extra == "dev"
|
|
18
|
+
Dynamic: license-file
|
|
19
|
+
|
|
20
|
+
# toolaccess
|
|
21
|
+
|
|
22
|
+
Define your Python functions once, expose them as **REST APIs**, **MCP servers**, and **CLI commands** — simultaneously, with zero boilerplate duplication.
|
|
23
|
+
|
|
24
|
+
## When to use this
|
|
25
|
+
|
|
26
|
+
You have Python functions that need to be callable from more than one interface. Common scenarios:
|
|
27
|
+
|
|
28
|
+
- **AI/LLM tool servers** — you want the same tools available over MCP (for agents) and REST (for web apps) and CLI (for local testing).
|
|
29
|
+
- **Internal tooling** — a set of utility functions your team invokes from scripts, HTTP clients, and AI assistants.
|
|
30
|
+
- **Rapid prototyping** — skip the plumbing and get a working API + MCP server + CLI in minutes.
|
|
31
|
+
|
|
32
|
+
Without `toolaccess` you'd write separate FastAPI routes, a FastMCP server, and Typer commands that all call the same underlying code. This library removes that duplication.
|
|
33
|
+
|
|
34
|
+
## Install
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
pip install toolaccess
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
Or from source:
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
pip install -e .
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
## Quick start
|
|
47
|
+
|
|
48
|
+
```python
|
|
49
|
+
from toolaccess import (
|
|
50
|
+
ServerManager,
|
|
51
|
+
ToolService,
|
|
52
|
+
ToolDefinition,
|
|
53
|
+
OpenAPIServer,
|
|
54
|
+
SSEMCPServer,
|
|
55
|
+
CLIServer,
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
# 1. Write plain functions (sync or async)
|
|
59
|
+
def add(a: int, b: int) -> int:
|
|
60
|
+
"""Add two numbers."""
|
|
61
|
+
return a + b
|
|
62
|
+
|
|
63
|
+
async def greet(name: str) -> str:
|
|
64
|
+
"""Return a greeting."""
|
|
65
|
+
return f"Hello, {name}!"
|
|
66
|
+
|
|
67
|
+
# 2. Group them into a service
|
|
68
|
+
service = ToolService("math", [add, greet])
|
|
69
|
+
|
|
70
|
+
# 3. Create servers and mount the service
|
|
71
|
+
rest = OpenAPIServer(path_prefix="/api", title="Math API")
|
|
72
|
+
rest.mount(service)
|
|
73
|
+
|
|
74
|
+
mcp = SSEMCPServer("math")
|
|
75
|
+
mcp.mount(service)
|
|
76
|
+
|
|
77
|
+
cli = CLIServer("math")
|
|
78
|
+
cli.mount(service)
|
|
79
|
+
|
|
80
|
+
# 4. Wire everything into the manager
|
|
81
|
+
manager = ServerManager(name="my-tools")
|
|
82
|
+
manager.add_server(rest)
|
|
83
|
+
manager.add_server(mcp)
|
|
84
|
+
manager.add_server(cli)
|
|
85
|
+
|
|
86
|
+
# 5. Run
|
|
87
|
+
manager.run()
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
That single file gives you:
|
|
91
|
+
|
|
92
|
+
| Interface | Access |
|
|
93
|
+
|---|---|
|
|
94
|
+
| REST API | `POST /api/add`, `POST /api/greet` |
|
|
95
|
+
| OpenAPI docs | `GET /api/docs` |
|
|
96
|
+
| MCP (SSE) | `http://localhost:8000/mcp/math/sse` |
|
|
97
|
+
| MCP (stdio) | `python app.py mcp-run --name math` |
|
|
98
|
+
| CLI | `python app.py math add 1 2` |
|
|
99
|
+
| Health check | `GET /health` |
|
|
100
|
+
|
|
101
|
+
### Starting the HTTP server
|
|
102
|
+
|
|
103
|
+
```bash
|
|
104
|
+
python app.py start # default 127.0.0.1:8000
|
|
105
|
+
python app.py start --host 0.0.0.0 --port 9000
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
### Running MCP over stdio
|
|
109
|
+
|
|
110
|
+
```bash
|
|
111
|
+
python app.py mcp-run --name math
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
Use this when connecting from Claude Desktop, Cursor, or any MCP client that expects a stdio transport.
|
|
115
|
+
|
|
116
|
+
## Core concepts
|
|
117
|
+
|
|
118
|
+
### ToolDefinition
|
|
119
|
+
|
|
120
|
+
Wraps a callable with metadata. If you pass a bare function to `ToolService`, one is created automatically using the function name and docstring. Use it explicitly when you need control over the HTTP method or name:
|
|
121
|
+
|
|
122
|
+
```python
|
|
123
|
+
ToolDefinition(func=add, name="add_numbers", http_method="POST", description="Sum two ints")
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
### ToolService
|
|
127
|
+
|
|
128
|
+
A named group of tools. Mount the same service onto multiple servers to keep them in sync:
|
|
129
|
+
|
|
130
|
+
```python
|
|
131
|
+
service = ToolService("admin", [check_health, restart_worker])
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
### Servers
|
|
135
|
+
|
|
136
|
+
| Class | Protocol | Notes |
|
|
137
|
+
|---|---|---|
|
|
138
|
+
| `OpenAPIServer` | HTTP / REST | Backed by FastAPI. Set `path_prefix` to namespace routes. |
|
|
139
|
+
| `SSEMCPServer` | MCP (SSE + stdio) | Backed by FastMCP. Mounted at `/mcp/{name}/sse`. |
|
|
140
|
+
| `CLIServer` | CLI | Backed by Typer. Async functions are handled automatically. |
|
|
141
|
+
|
|
142
|
+
### ServerManager
|
|
143
|
+
|
|
144
|
+
The runtime host. It owns a FastAPI app, a Typer CLI, and a dynamic ASGI dispatcher that routes requests to the correct sub-app by path prefix.
|
|
145
|
+
|
|
146
|
+
Servers can be added and removed at runtime:
|
|
147
|
+
|
|
148
|
+
```python
|
|
149
|
+
manager.add_server(new_api) # immediately routable
|
|
150
|
+
manager.remove_server(new_api) # immediately gone
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
## Multiple isolated groups
|
|
154
|
+
|
|
155
|
+
You can create separate servers for different audiences and mount different services onto each:
|
|
156
|
+
|
|
157
|
+
```python
|
|
158
|
+
public_api = OpenAPIServer("/public", "Public API")
|
|
159
|
+
public_api.mount(public_service)
|
|
160
|
+
|
|
161
|
+
admin_api = OpenAPIServer("/admin", "Admin API")
|
|
162
|
+
admin_api.mount(admin_service)
|
|
163
|
+
|
|
164
|
+
manager.add_server(public_api)
|
|
165
|
+
manager.add_server(admin_api)
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
The same pattern works for MCP — create multiple `SSEMCPServer` instances with different names.
|
|
169
|
+
|
|
170
|
+
## Lifespan support
|
|
171
|
+
|
|
172
|
+
Pass an async context manager to `ServerManager` to run setup/teardown logic (database connections, model loading, etc.). The lifespan is entered for both the HTTP server and CLI command execution:
|
|
173
|
+
|
|
174
|
+
```python
|
|
175
|
+
from contextlib import asynccontextmanager
|
|
176
|
+
|
|
177
|
+
@asynccontextmanager
|
|
178
|
+
async def lifespan(app):
|
|
179
|
+
db = await connect_db()
|
|
180
|
+
yield
|
|
181
|
+
await db.close()
|
|
182
|
+
|
|
183
|
+
manager = ServerManager(name="my-service", lifespan=lifespan)
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
## Mounting custom ASGI apps
|
|
187
|
+
|
|
188
|
+
Use `MountableApp` to add an existing FastAPI or ASGI application alongside your tool servers:
|
|
189
|
+
|
|
190
|
+
```python
|
|
191
|
+
from toolaccess.toolaccess import MountableApp
|
|
192
|
+
from fastapi import FastAPI
|
|
193
|
+
|
|
194
|
+
dashboard = FastAPI()
|
|
195
|
+
|
|
196
|
+
@dashboard.get("/")
|
|
197
|
+
async def index():
|
|
198
|
+
return {"page": "dashboard"}
|
|
199
|
+
|
|
200
|
+
manager.add_server(MountableApp(dashboard, path_prefix="/dashboard", name="dashboard"))
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
## Requirements
|
|
204
|
+
|
|
205
|
+
- Python >= 3.10
|
|
206
|
+
- fastapi
|
|
207
|
+
- fastmcp
|
|
208
|
+
- pydantic
|
|
209
|
+
- typer
|
|
210
|
+
- uvicorn
|
|
211
|
+
|
|
212
|
+
## Development
|
|
213
|
+
|
|
214
|
+
```bash
|
|
215
|
+
pip install -e ".[dev]"
|
|
216
|
+
pytest
|
|
217
|
+
```
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
# toolaccess
|
|
2
|
+
|
|
3
|
+
Define your Python functions once, expose them as **REST APIs**, **MCP servers**, and **CLI commands** — simultaneously, with zero boilerplate duplication.
|
|
4
|
+
|
|
5
|
+
## When to use this
|
|
6
|
+
|
|
7
|
+
You have Python functions that need to be callable from more than one interface. Common scenarios:
|
|
8
|
+
|
|
9
|
+
- **AI/LLM tool servers** — you want the same tools available over MCP (for agents) and REST (for web apps) and CLI (for local testing).
|
|
10
|
+
- **Internal tooling** — a set of utility functions your team invokes from scripts, HTTP clients, and AI assistants.
|
|
11
|
+
- **Rapid prototyping** — skip the plumbing and get a working API + MCP server + CLI in minutes.
|
|
12
|
+
|
|
13
|
+
Without `toolaccess` you'd write separate FastAPI routes, a FastMCP server, and Typer commands that all call the same underlying code. This library removes that duplication.
|
|
14
|
+
|
|
15
|
+
## Install
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
pip install toolaccess
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
Or from source:
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
pip install -e .
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## Quick start
|
|
28
|
+
|
|
29
|
+
```python
|
|
30
|
+
from toolaccess import (
|
|
31
|
+
ServerManager,
|
|
32
|
+
ToolService,
|
|
33
|
+
ToolDefinition,
|
|
34
|
+
OpenAPIServer,
|
|
35
|
+
SSEMCPServer,
|
|
36
|
+
CLIServer,
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
# 1. Write plain functions (sync or async)
|
|
40
|
+
def add(a: int, b: int) -> int:
|
|
41
|
+
"""Add two numbers."""
|
|
42
|
+
return a + b
|
|
43
|
+
|
|
44
|
+
async def greet(name: str) -> str:
|
|
45
|
+
"""Return a greeting."""
|
|
46
|
+
return f"Hello, {name}!"
|
|
47
|
+
|
|
48
|
+
# 2. Group them into a service
|
|
49
|
+
service = ToolService("math", [add, greet])
|
|
50
|
+
|
|
51
|
+
# 3. Create servers and mount the service
|
|
52
|
+
rest = OpenAPIServer(path_prefix="/api", title="Math API")
|
|
53
|
+
rest.mount(service)
|
|
54
|
+
|
|
55
|
+
mcp = SSEMCPServer("math")
|
|
56
|
+
mcp.mount(service)
|
|
57
|
+
|
|
58
|
+
cli = CLIServer("math")
|
|
59
|
+
cli.mount(service)
|
|
60
|
+
|
|
61
|
+
# 4. Wire everything into the manager
|
|
62
|
+
manager = ServerManager(name="my-tools")
|
|
63
|
+
manager.add_server(rest)
|
|
64
|
+
manager.add_server(mcp)
|
|
65
|
+
manager.add_server(cli)
|
|
66
|
+
|
|
67
|
+
# 5. Run
|
|
68
|
+
manager.run()
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
That single file gives you:
|
|
72
|
+
|
|
73
|
+
| Interface | Access |
|
|
74
|
+
|---|---|
|
|
75
|
+
| REST API | `POST /api/add`, `POST /api/greet` |
|
|
76
|
+
| OpenAPI docs | `GET /api/docs` |
|
|
77
|
+
| MCP (SSE) | `http://localhost:8000/mcp/math/sse` |
|
|
78
|
+
| MCP (stdio) | `python app.py mcp-run --name math` |
|
|
79
|
+
| CLI | `python app.py math add 1 2` |
|
|
80
|
+
| Health check | `GET /health` |
|
|
81
|
+
|
|
82
|
+
### Starting the HTTP server
|
|
83
|
+
|
|
84
|
+
```bash
|
|
85
|
+
python app.py start # default 127.0.0.1:8000
|
|
86
|
+
python app.py start --host 0.0.0.0 --port 9000
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
### Running MCP over stdio
|
|
90
|
+
|
|
91
|
+
```bash
|
|
92
|
+
python app.py mcp-run --name math
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
Use this when connecting from Claude Desktop, Cursor, or any MCP client that expects a stdio transport.
|
|
96
|
+
|
|
97
|
+
## Core concepts
|
|
98
|
+
|
|
99
|
+
### ToolDefinition
|
|
100
|
+
|
|
101
|
+
Wraps a callable with metadata. If you pass a bare function to `ToolService`, one is created automatically using the function name and docstring. Use it explicitly when you need control over the HTTP method or name:
|
|
102
|
+
|
|
103
|
+
```python
|
|
104
|
+
ToolDefinition(func=add, name="add_numbers", http_method="POST", description="Sum two ints")
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
### ToolService
|
|
108
|
+
|
|
109
|
+
A named group of tools. Mount the same service onto multiple servers to keep them in sync:
|
|
110
|
+
|
|
111
|
+
```python
|
|
112
|
+
service = ToolService("admin", [check_health, restart_worker])
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
### Servers
|
|
116
|
+
|
|
117
|
+
| Class | Protocol | Notes |
|
|
118
|
+
|---|---|---|
|
|
119
|
+
| `OpenAPIServer` | HTTP / REST | Backed by FastAPI. Set `path_prefix` to namespace routes. |
|
|
120
|
+
| `SSEMCPServer` | MCP (SSE + stdio) | Backed by FastMCP. Mounted at `/mcp/{name}/sse`. |
|
|
121
|
+
| `CLIServer` | CLI | Backed by Typer. Async functions are handled automatically. |
|
|
122
|
+
|
|
123
|
+
### ServerManager
|
|
124
|
+
|
|
125
|
+
The runtime host. It owns a FastAPI app, a Typer CLI, and a dynamic ASGI dispatcher that routes requests to the correct sub-app by path prefix.
|
|
126
|
+
|
|
127
|
+
Servers can be added and removed at runtime:
|
|
128
|
+
|
|
129
|
+
```python
|
|
130
|
+
manager.add_server(new_api) # immediately routable
|
|
131
|
+
manager.remove_server(new_api) # immediately gone
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
## Multiple isolated groups
|
|
135
|
+
|
|
136
|
+
You can create separate servers for different audiences and mount different services onto each:
|
|
137
|
+
|
|
138
|
+
```python
|
|
139
|
+
public_api = OpenAPIServer("/public", "Public API")
|
|
140
|
+
public_api.mount(public_service)
|
|
141
|
+
|
|
142
|
+
admin_api = OpenAPIServer("/admin", "Admin API")
|
|
143
|
+
admin_api.mount(admin_service)
|
|
144
|
+
|
|
145
|
+
manager.add_server(public_api)
|
|
146
|
+
manager.add_server(admin_api)
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
The same pattern works for MCP — create multiple `SSEMCPServer` instances with different names.
|
|
150
|
+
|
|
151
|
+
## Lifespan support
|
|
152
|
+
|
|
153
|
+
Pass an async context manager to `ServerManager` to run setup/teardown logic (database connections, model loading, etc.). The lifespan is entered for both the HTTP server and CLI command execution:
|
|
154
|
+
|
|
155
|
+
```python
|
|
156
|
+
from contextlib import asynccontextmanager
|
|
157
|
+
|
|
158
|
+
@asynccontextmanager
|
|
159
|
+
async def lifespan(app):
|
|
160
|
+
db = await connect_db()
|
|
161
|
+
yield
|
|
162
|
+
await db.close()
|
|
163
|
+
|
|
164
|
+
manager = ServerManager(name="my-service", lifespan=lifespan)
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
## Mounting custom ASGI apps
|
|
168
|
+
|
|
169
|
+
Use `MountableApp` to add an existing FastAPI or ASGI application alongside your tool servers:
|
|
170
|
+
|
|
171
|
+
```python
|
|
172
|
+
from toolaccess.toolaccess import MountableApp
|
|
173
|
+
from fastapi import FastAPI
|
|
174
|
+
|
|
175
|
+
dashboard = FastAPI()
|
|
176
|
+
|
|
177
|
+
@dashboard.get("/")
|
|
178
|
+
async def index():
|
|
179
|
+
return {"page": "dashboard"}
|
|
180
|
+
|
|
181
|
+
manager.add_server(MountableApp(dashboard, path_prefix="/dashboard", name="dashboard"))
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
## Requirements
|
|
185
|
+
|
|
186
|
+
- Python >= 3.10
|
|
187
|
+
- fastapi
|
|
188
|
+
- fastmcp
|
|
189
|
+
- pydantic
|
|
190
|
+
- typer
|
|
191
|
+
- uvicorn
|
|
192
|
+
|
|
193
|
+
## Development
|
|
194
|
+
|
|
195
|
+
```bash
|
|
196
|
+
pip install -e ".[dev]"
|
|
197
|
+
pytest
|
|
198
|
+
```
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=61.0"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "toolaccess"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Make your custom tools accessible across multiple protocols, including MCP, REST and CLI."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
dependencies = [
|
|
12
|
+
"fastapi",
|
|
13
|
+
"fastmcp",
|
|
14
|
+
"pydantic",
|
|
15
|
+
"typer",
|
|
16
|
+
"uvicorn",
|
|
17
|
+
]
|
|
18
|
+
|
|
19
|
+
[project.urls]
|
|
20
|
+
Homepage = "https://github.com/whogben/toolaccess"
|
|
21
|
+
Repository = "https://github.com/whogben/toolaccess"
|
|
22
|
+
|
|
23
|
+
[project.optional-dependencies]
|
|
24
|
+
dev = [
|
|
25
|
+
"pytest",
|
|
26
|
+
"pytest-asyncio",
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
[tool.setuptools.packages.find]
|
|
30
|
+
where = ["src"]
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
from .toolaccess import (
|
|
2
|
+
ServerManager,
|
|
3
|
+
ToolService,
|
|
4
|
+
ToolDefinition,
|
|
5
|
+
OpenAPIServer,
|
|
6
|
+
SSEMCPServer,
|
|
7
|
+
CLIServer,
|
|
8
|
+
)
|
|
9
|
+
|
|
10
|
+
__all__ = [
|
|
11
|
+
"ServerManager",
|
|
12
|
+
"ToolService",
|
|
13
|
+
"ToolDefinition",
|
|
14
|
+
"OpenAPIServer",
|
|
15
|
+
"SSEMCPServer",
|
|
16
|
+
"CLIServer",
|
|
17
|
+
]
|
|
@@ -0,0 +1,417 @@
|
|
|
1
|
+
import inspect
|
|
2
|
+
import asyncio
|
|
3
|
+
import json
|
|
4
|
+
import logging
|
|
5
|
+
from functools import wraps
|
|
6
|
+
from typing import Any, Callable, Literal, Union, get_origin, get_args
|
|
7
|
+
|
|
8
|
+
import typer
|
|
9
|
+
import uvicorn
|
|
10
|
+
from fastapi import FastAPI
|
|
11
|
+
from fastmcp import FastMCP
|
|
12
|
+
from dataclasses import dataclass, field
|
|
13
|
+
from abc import ABC, abstractmethod
|
|
14
|
+
from starlette.types import Scope, Receive, Send
|
|
15
|
+
from starlette.responses import Response
|
|
16
|
+
|
|
17
|
+
"""
|
|
18
|
+
Generic Tool Server Utility (Polymorphic Server Model)
|
|
19
|
+
Provides reusable components for exposing Python functions as tools via
|
|
20
|
+
CLI, OpenAPI (REST), and MCP (SSE/Stdio).
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
logger = logging.getLogger(__name__)
|
|
24
|
+
|
|
25
|
+
# --- 1. Tool Definition & Service ---
|
|
26
|
+
|
|
27
|
+
HttpMethod = Literal["GET", "POST", "PUT", "DELETE", "PATCH"]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass
|
|
31
|
+
class ToolDefinition:
|
|
32
|
+
"""Metadata for a single tool function."""
|
|
33
|
+
|
|
34
|
+
func: Callable
|
|
35
|
+
name: str
|
|
36
|
+
http_method: HttpMethod = "POST"
|
|
37
|
+
description: str | None = None
|
|
38
|
+
|
|
39
|
+
def __post_init__(self):
|
|
40
|
+
if self.description is None and self.func.__doc__:
|
|
41
|
+
self.description = inspect.cleandoc(self.func.__doc__)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class ToolService:
|
|
45
|
+
"""A collection of tools to be exposed together."""
|
|
46
|
+
|
|
47
|
+
def __init__(self, name: str, tools: list[Callable | ToolDefinition]):
|
|
48
|
+
self.name = name
|
|
49
|
+
self.tools: list[ToolDefinition] = []
|
|
50
|
+
for t in tools:
|
|
51
|
+
if isinstance(t, ToolDefinition):
|
|
52
|
+
self.tools.append(t)
|
|
53
|
+
else:
|
|
54
|
+
self.tools.append(ToolDefinition(func=t, name=t.__name__))
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
# --- 2. Abstract Base Server ---
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class BaseServer(ABC):
|
|
61
|
+
"""Abstract base for any server interface."""
|
|
62
|
+
|
|
63
|
+
@abstractmethod
|
|
64
|
+
def mount(self, service: ToolService):
|
|
65
|
+
"""Add a service's tools to this server."""
|
|
66
|
+
pass
|
|
67
|
+
|
|
68
|
+
@abstractmethod
|
|
69
|
+
def register_to(self, manager: "ServerManager"):
|
|
70
|
+
"""Hook this server into the runtime manager."""
|
|
71
|
+
pass
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
# --- 3. Concrete Servers ---
|
|
75
|
+
|
|
76
|
+
METHOD_ROUTERS: dict[str, Callable] = {
|
|
77
|
+
"GET": FastAPI.get,
|
|
78
|
+
"POST": FastAPI.post,
|
|
79
|
+
"PUT": FastAPI.put,
|
|
80
|
+
"DELETE": FastAPI.delete,
|
|
81
|
+
"PATCH": FastAPI.patch,
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class OpenAPIServer(BaseServer):
|
|
86
|
+
"""Exposes tools via FastAPI sub-application."""
|
|
87
|
+
|
|
88
|
+
def __init__(self, path_prefix: str = "", title: str = "API"):
|
|
89
|
+
self.path_prefix = path_prefix
|
|
90
|
+
self.app = FastAPI(title=title)
|
|
91
|
+
|
|
92
|
+
def mount(self, service: ToolService):
|
|
93
|
+
for tool in service.tools:
|
|
94
|
+
self._add_route(tool)
|
|
95
|
+
|
|
96
|
+
def _add_route(self, tool: ToolDefinition):
|
|
97
|
+
router = METHOD_ROUTERS.get(tool.http_method, FastAPI.post)
|
|
98
|
+
router(self.app, f"/{tool.name}", name=tool.name, description=tool.description)(
|
|
99
|
+
tool.func
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
def register_to(self, manager: "ServerManager"):
|
|
103
|
+
pass
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
class SSEMCPServer(BaseServer):
|
|
107
|
+
"""Exposes tools via FastMCP (SSE & Stdio capability)."""
|
|
108
|
+
|
|
109
|
+
def __init__(self, name: str = "default"):
|
|
110
|
+
self.name = name
|
|
111
|
+
self.mcp = FastMCP(name)
|
|
112
|
+
|
|
113
|
+
def mount(self, service: ToolService):
|
|
114
|
+
for tool in service.tools:
|
|
115
|
+
# Wrap function to handle JSON string arguments from MCP clients
|
|
116
|
+
wrapped_func = _wrap_for_mcp(tool.func)
|
|
117
|
+
self.mcp.tool(wrapped_func, name=tool.name, description=tool.description)
|
|
118
|
+
|
|
119
|
+
def register_to(self, manager: "ServerManager"):
|
|
120
|
+
manager.mcp_servers[self.name] = self.mcp
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
class CLIServer(BaseServer):
|
|
124
|
+
"""Exposes tools via Typer CLI."""
|
|
125
|
+
|
|
126
|
+
def __init__(self, name: str | None = None):
|
|
127
|
+
self.name = name
|
|
128
|
+
self.typer_app = typer.Typer(name=name) if name else typer.Typer()
|
|
129
|
+
self.manager: "ServerManager" | None = None
|
|
130
|
+
|
|
131
|
+
def mount(self, service: ToolService):
|
|
132
|
+
for tool in service.tools:
|
|
133
|
+
self._add_command(self.typer_app, tool)
|
|
134
|
+
|
|
135
|
+
def _add_command(self, app: typer.Typer, tool: ToolDefinition):
|
|
136
|
+
func = tool.func
|
|
137
|
+
if inspect.iscoroutinefunction(func):
|
|
138
|
+
|
|
139
|
+
@wraps(func)
|
|
140
|
+
def wrapper(*args, **kwargs):
|
|
141
|
+
async def runner():
|
|
142
|
+
if self.manager and self.manager.lifespan_ctx:
|
|
143
|
+
async with self.manager.lifespan_ctx(self.manager.app):
|
|
144
|
+
return await func(*args, **kwargs)
|
|
145
|
+
else:
|
|
146
|
+
return await func(*args, **kwargs)
|
|
147
|
+
|
|
148
|
+
try:
|
|
149
|
+
return asyncio.run(runner())
|
|
150
|
+
except KeyboardInterrupt:
|
|
151
|
+
return None
|
|
152
|
+
|
|
153
|
+
wrapper.__signature__ = inspect.signature(func)
|
|
154
|
+
cli_func = wrapper
|
|
155
|
+
else:
|
|
156
|
+
cli_func = func
|
|
157
|
+
app.command(name=tool.name, help=tool.description)(cli_func)
|
|
158
|
+
|
|
159
|
+
def register_to(self, manager: "ServerManager"):
|
|
160
|
+
self.manager = manager
|
|
161
|
+
manager.cli.add_typer(self.typer_app, name=self.name)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
class MountableApp(BaseServer):
|
|
165
|
+
"""
|
|
166
|
+
Wraps an existing FastAPI/ASGI application to be mounted by the ServerManager.
|
|
167
|
+
Useful for custom web interfaces, separate API sub-apps, or static file servers.
|
|
168
|
+
"""
|
|
169
|
+
|
|
170
|
+
def __init__(self, app: FastAPI, path_prefix: str = "", name: str = "app"):
|
|
171
|
+
self.app = app
|
|
172
|
+
self.path_prefix = path_prefix
|
|
173
|
+
self.name = name
|
|
174
|
+
|
|
175
|
+
def mount(self, service: ToolService):
|
|
176
|
+
"""
|
|
177
|
+
MountableApp generally doesn't accept ToolServices, as its routes
|
|
178
|
+
are defined internally. We can leave this empty or log a warning.
|
|
179
|
+
"""
|
|
180
|
+
pass
|
|
181
|
+
|
|
182
|
+
def register_to(self, manager: "ServerManager"):
|
|
183
|
+
# No special registration needed, the Dispatcher handles it
|
|
184
|
+
pass
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
# --- 4. Server Manager (Runtime) ---
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
class DynamicDispatcher:
|
|
191
|
+
"""ASGI Application that dynamically dispatches requests to sub-apps."""
|
|
192
|
+
|
|
193
|
+
def __init__(self, manager: "ServerManager"):
|
|
194
|
+
self.manager = manager
|
|
195
|
+
|
|
196
|
+
async def __call__(self, scope: Scope, receive: Receive, send: Send):
|
|
197
|
+
if scope["type"] != "http":
|
|
198
|
+
await Response("Not Found", status_code=404)(scope, receive, send)
|
|
199
|
+
return
|
|
200
|
+
|
|
201
|
+
path = scope["path"]
|
|
202
|
+
logger.debug(f"Dispatching path={path}")
|
|
203
|
+
|
|
204
|
+
# Collect matches (server, prefix_length)
|
|
205
|
+
matches = []
|
|
206
|
+
for server in self.manager.active_servers.values():
|
|
207
|
+
if isinstance(server, OpenAPIServer):
|
|
208
|
+
prefix = server.path_prefix.strip("/")
|
|
209
|
+
if not prefix:
|
|
210
|
+
continue
|
|
211
|
+
check_prefix = f"/{prefix}"
|
|
212
|
+
if path.startswith(check_prefix):
|
|
213
|
+
remaining = path[len(check_prefix) :]
|
|
214
|
+
if not remaining or remaining.startswith("/"):
|
|
215
|
+
matches.append((server, len(check_prefix)))
|
|
216
|
+
|
|
217
|
+
elif isinstance(server, SSEMCPServer):
|
|
218
|
+
prefix = f"/mcp/{server.name}"
|
|
219
|
+
if path.startswith(prefix):
|
|
220
|
+
remaining = path[len(prefix) :]
|
|
221
|
+
if not remaining or remaining.startswith("/"):
|
|
222
|
+
matches.append((server, len(prefix)))
|
|
223
|
+
|
|
224
|
+
elif isinstance(server, MountableApp):
|
|
225
|
+
prefix = server.path_prefix.strip("/")
|
|
226
|
+
check_prefix = f"/{prefix}" if prefix else "/"
|
|
227
|
+
if path.startswith(check_prefix):
|
|
228
|
+
# Special handling for root
|
|
229
|
+
if check_prefix == "/":
|
|
230
|
+
matches.append((server, 1))
|
|
231
|
+
else:
|
|
232
|
+
remaining = path[len(check_prefix) :]
|
|
233
|
+
if not remaining or remaining.startswith("/"):
|
|
234
|
+
matches.append((server, len(check_prefix)))
|
|
235
|
+
|
|
236
|
+
if not matches:
|
|
237
|
+
logger.debug("No match found")
|
|
238
|
+
await Response("Not Found", status_code=404)(scope, receive, send)
|
|
239
|
+
return
|
|
240
|
+
|
|
241
|
+
# Sort by specificity (longest prefix wins)
|
|
242
|
+
matches.sort(key=lambda x: x[1], reverse=True)
|
|
243
|
+
server, prefix_len = matches[0]
|
|
244
|
+
|
|
245
|
+
# Dispatch
|
|
246
|
+
if isinstance(server, OpenAPIServer):
|
|
247
|
+
prefix = server.path_prefix.strip("/")
|
|
248
|
+
check_prefix = f"/{prefix}"
|
|
249
|
+
scope["root_path"] = scope.get("root_path", "") + check_prefix
|
|
250
|
+
scope["path"] = path[prefix_len:] or "/"
|
|
251
|
+
await server.app(scope, receive, send)
|
|
252
|
+
|
|
253
|
+
elif isinstance(server, SSEMCPServer):
|
|
254
|
+
prefix = f"/mcp/{server.name}"
|
|
255
|
+
scope["root_path"] = scope.get("root_path", "") + prefix
|
|
256
|
+
scope["path"] = path[prefix_len:] or "/"
|
|
257
|
+
await server.mcp.http_app(transport="sse")(scope, receive, send)
|
|
258
|
+
|
|
259
|
+
elif isinstance(server, MountableApp):
|
|
260
|
+
prefix = server.path_prefix.strip("/")
|
|
261
|
+
check_prefix = f"/{prefix}" if prefix else ""
|
|
262
|
+
if check_prefix == "/":
|
|
263
|
+
check_prefix = "" # Don't add trailing slash to root path
|
|
264
|
+
|
|
265
|
+
scope["root_path"] = scope.get("root_path", "") + check_prefix
|
|
266
|
+
scope["path"] = path[prefix_len:] if prefix_len > 1 else path
|
|
267
|
+
if not scope["path"]:
|
|
268
|
+
scope["path"] = "/"
|
|
269
|
+
await server.app(scope, receive, send)
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
class ServerManager:
|
|
273
|
+
"""The runtime host that manages all servers."""
|
|
274
|
+
|
|
275
|
+
def __init__(self, name: str = "service", lifespan: Callable | None = None):
|
|
276
|
+
self.name = name
|
|
277
|
+
self.lifespan_ctx = lifespan
|
|
278
|
+
self.app = FastAPI(title=name, lifespan=lifespan)
|
|
279
|
+
self.cli = typer.Typer(name=name)
|
|
280
|
+
self.mcp_servers: dict[str, FastMCP] = {}
|
|
281
|
+
self.active_servers: dict[str, BaseServer] = {}
|
|
282
|
+
self._add_infrastructure()
|
|
283
|
+
self.app.mount("/", DynamicDispatcher(self))
|
|
284
|
+
|
|
285
|
+
def add_server(self, server: BaseServer):
|
|
286
|
+
"""Register a polymorphic server instance."""
|
|
287
|
+
self.active_servers[str(id(server))] = server
|
|
288
|
+
server.register_to(self)
|
|
289
|
+
|
|
290
|
+
def remove_server(self, server: BaseServer):
|
|
291
|
+
"""Unregister a server instance."""
|
|
292
|
+
server_id = str(id(server))
|
|
293
|
+
if server_id in self.active_servers:
|
|
294
|
+
del self.active_servers[server_id]
|
|
295
|
+
if isinstance(server, SSEMCPServer) and server.name in self.mcp_servers:
|
|
296
|
+
del self.mcp_servers[server.name]
|
|
297
|
+
|
|
298
|
+
def _add_infrastructure(self):
|
|
299
|
+
@self.app.get("/health")
|
|
300
|
+
async def health():
|
|
301
|
+
"""Health check endpoint listing all MCP servers."""
|
|
302
|
+
return {"mcp_servers": list(self.mcp_servers.keys())}
|
|
303
|
+
|
|
304
|
+
@self.cli.command()
|
|
305
|
+
def start(host: str = "127.0.0.1", port: int = 8000):
|
|
306
|
+
"""Start the server (REST + MCP SSE)."""
|
|
307
|
+
print(f"🚀 {self.name} Server Starting...")
|
|
308
|
+
print(f"---------------------------------------------------")
|
|
309
|
+
print(f"📋 OpenAPI: http://{host}:{port}/docs")
|
|
310
|
+
for mcp_name in self.mcp_servers:
|
|
311
|
+
print(f"🤖 MCP Server: http://{host}:{port}/mcp/{mcp_name}/sse")
|
|
312
|
+
|
|
313
|
+
# Print URLs for MountableApps
|
|
314
|
+
for server in self.active_servers.values():
|
|
315
|
+
if isinstance(server, MountableApp):
|
|
316
|
+
prefix = server.path_prefix if server.path_prefix else "/"
|
|
317
|
+
print(f"🌐 Web App ({server.name}): http://{host}:{port}{prefix}")
|
|
318
|
+
|
|
319
|
+
print(f"---------------------------------------------------")
|
|
320
|
+
uvicorn.run(self.app, host=host, port=port)
|
|
321
|
+
|
|
322
|
+
@self.cli.command()
|
|
323
|
+
def mcp_run(name: str = "default"):
|
|
324
|
+
"""Run an MCP server via Stdio."""
|
|
325
|
+
if name not in self.mcp_servers:
|
|
326
|
+
print(
|
|
327
|
+
f"❌ MCP Server '{name}' not found. Available: {list(self.mcp_servers.keys())}"
|
|
328
|
+
)
|
|
329
|
+
return
|
|
330
|
+
|
|
331
|
+
async def run_stdio():
|
|
332
|
+
if self.lifespan_ctx:
|
|
333
|
+
async with self.lifespan_ctx(self.app):
|
|
334
|
+
await self.mcp_servers[name].run_stdio_async()
|
|
335
|
+
else:
|
|
336
|
+
await self.mcp_servers[name].run_stdio_async()
|
|
337
|
+
|
|
338
|
+
try:
|
|
339
|
+
asyncio.run(run_stdio())
|
|
340
|
+
except KeyboardInterrupt:
|
|
341
|
+
return
|
|
342
|
+
except Exception as e:
|
|
343
|
+
print(f"Error running MCP stdio: {e}")
|
|
344
|
+
self.mcp_servers[name].run(transport="stdio")
|
|
345
|
+
|
|
346
|
+
def run(self):
|
|
347
|
+
"""Entry point for the CLI."""
|
|
348
|
+
self.cli()
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
# --- MISC UTILS ---
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def _wrap_for_mcp(func: Callable) -> Callable:
|
|
355
|
+
"""
|
|
356
|
+
Wrap a function to pre-process arguments for MCP compatibility.
|
|
357
|
+
|
|
358
|
+
This ensures JSON strings are parsed into proper dicts before
|
|
359
|
+
the function is called, handling clients that serialize nested
|
|
360
|
+
objects as strings. It inspects the function signature to avoid
|
|
361
|
+
parsing arguments that are explicitly typed as strings.
|
|
362
|
+
"""
|
|
363
|
+
sig = inspect.signature(func)
|
|
364
|
+
|
|
365
|
+
def process_kwargs(kwargs: dict[str, Any]) -> dict[str, Any]:
|
|
366
|
+
new_kwargs = {}
|
|
367
|
+
for k, v in kwargs.items():
|
|
368
|
+
# If the value is NOT a string, keep it as is
|
|
369
|
+
if not isinstance(v, str):
|
|
370
|
+
new_kwargs[k] = v
|
|
371
|
+
continue
|
|
372
|
+
|
|
373
|
+
# Check parameter type hint
|
|
374
|
+
param = sig.parameters.get(k)
|
|
375
|
+
should_skip = False
|
|
376
|
+
|
|
377
|
+
if param:
|
|
378
|
+
annotation = param.annotation
|
|
379
|
+
if annotation is str:
|
|
380
|
+
should_skip = True
|
|
381
|
+
else:
|
|
382
|
+
# Handle Optional[str] -> Union[str, None]
|
|
383
|
+
origin = get_origin(annotation)
|
|
384
|
+
if origin is Union:
|
|
385
|
+
args = get_args(annotation)
|
|
386
|
+
non_none = [a for a in args if a is not type(None)]
|
|
387
|
+
if len(non_none) == 1 and non_none[0] is str:
|
|
388
|
+
should_skip = True
|
|
389
|
+
|
|
390
|
+
if should_skip:
|
|
391
|
+
new_kwargs[k] = v
|
|
392
|
+
else:
|
|
393
|
+
try:
|
|
394
|
+
# Only parse top-level string arguments
|
|
395
|
+
new_kwargs[k] = json.loads(v)
|
|
396
|
+
except (json.JSONDecodeError, TypeError):
|
|
397
|
+
new_kwargs[k] = v
|
|
398
|
+
|
|
399
|
+
return new_kwargs
|
|
400
|
+
|
|
401
|
+
if inspect.iscoroutinefunction(func):
|
|
402
|
+
|
|
403
|
+
@wraps(func)
|
|
404
|
+
async def async_wrapper(*args, **kwargs):
|
|
405
|
+
return await func(*args, **process_kwargs(kwargs))
|
|
406
|
+
|
|
407
|
+
# Preserve signature for FastMCP schema generation
|
|
408
|
+
async_wrapper.__signature__ = sig
|
|
409
|
+
return async_wrapper
|
|
410
|
+
else:
|
|
411
|
+
|
|
412
|
+
@wraps(func)
|
|
413
|
+
def sync_wrapper(*args, **kwargs):
|
|
414
|
+
return func(*args, **process_kwargs(kwargs))
|
|
415
|
+
|
|
416
|
+
sync_wrapper.__signature__ = sig
|
|
417
|
+
return sync_wrapper
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: toolaccess
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Make your custom tools accessible across multiple protocols, including MCP, REST and CLI.
|
|
5
|
+
Project-URL: Homepage, https://github.com/whogben/toolaccess
|
|
6
|
+
Project-URL: Repository, https://github.com/whogben/toolaccess
|
|
7
|
+
Requires-Python: >=3.10
|
|
8
|
+
Description-Content-Type: text/markdown
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Requires-Dist: fastapi
|
|
11
|
+
Requires-Dist: fastmcp
|
|
12
|
+
Requires-Dist: pydantic
|
|
13
|
+
Requires-Dist: typer
|
|
14
|
+
Requires-Dist: uvicorn
|
|
15
|
+
Provides-Extra: dev
|
|
16
|
+
Requires-Dist: pytest; extra == "dev"
|
|
17
|
+
Requires-Dist: pytest-asyncio; extra == "dev"
|
|
18
|
+
Dynamic: license-file
|
|
19
|
+
|
|
20
|
+
# toolaccess
|
|
21
|
+
|
|
22
|
+
Define your Python functions once, expose them as **REST APIs**, **MCP servers**, and **CLI commands** — simultaneously, with zero boilerplate duplication.
|
|
23
|
+
|
|
24
|
+
## When to use this
|
|
25
|
+
|
|
26
|
+
You have Python functions that need to be callable from more than one interface. Common scenarios:
|
|
27
|
+
|
|
28
|
+
- **AI/LLM tool servers** — you want the same tools available over MCP (for agents) and REST (for web apps) and CLI (for local testing).
|
|
29
|
+
- **Internal tooling** — a set of utility functions your team invokes from scripts, HTTP clients, and AI assistants.
|
|
30
|
+
- **Rapid prototyping** — skip the plumbing and get a working API + MCP server + CLI in minutes.
|
|
31
|
+
|
|
32
|
+
Without `toolaccess` you'd write separate FastAPI routes, a FastMCP server, and Typer commands that all call the same underlying code. This library removes that duplication.
|
|
33
|
+
|
|
34
|
+
## Install
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
pip install toolaccess
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
Or from source:
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
pip install -e .
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
## Quick start
|
|
47
|
+
|
|
48
|
+
```python
|
|
49
|
+
from toolaccess import (
|
|
50
|
+
ServerManager,
|
|
51
|
+
ToolService,
|
|
52
|
+
ToolDefinition,
|
|
53
|
+
OpenAPIServer,
|
|
54
|
+
SSEMCPServer,
|
|
55
|
+
CLIServer,
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
# 1. Write plain functions (sync or async)
|
|
59
|
+
def add(a: int, b: int) -> int:
|
|
60
|
+
"""Add two numbers."""
|
|
61
|
+
return a + b
|
|
62
|
+
|
|
63
|
+
async def greet(name: str) -> str:
|
|
64
|
+
"""Return a greeting."""
|
|
65
|
+
return f"Hello, {name}!"
|
|
66
|
+
|
|
67
|
+
# 2. Group them into a service
|
|
68
|
+
service = ToolService("math", [add, greet])
|
|
69
|
+
|
|
70
|
+
# 3. Create servers and mount the service
|
|
71
|
+
rest = OpenAPIServer(path_prefix="/api", title="Math API")
|
|
72
|
+
rest.mount(service)
|
|
73
|
+
|
|
74
|
+
mcp = SSEMCPServer("math")
|
|
75
|
+
mcp.mount(service)
|
|
76
|
+
|
|
77
|
+
cli = CLIServer("math")
|
|
78
|
+
cli.mount(service)
|
|
79
|
+
|
|
80
|
+
# 4. Wire everything into the manager
|
|
81
|
+
manager = ServerManager(name="my-tools")
|
|
82
|
+
manager.add_server(rest)
|
|
83
|
+
manager.add_server(mcp)
|
|
84
|
+
manager.add_server(cli)
|
|
85
|
+
|
|
86
|
+
# 5. Run
|
|
87
|
+
manager.run()
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
That single file gives you:
|
|
91
|
+
|
|
92
|
+
| Interface | Access |
|
|
93
|
+
|---|---|
|
|
94
|
+
| REST API | `POST /api/add`, `POST /api/greet` |
|
|
95
|
+
| OpenAPI docs | `GET /api/docs` |
|
|
96
|
+
| MCP (SSE) | `http://localhost:8000/mcp/math/sse` |
|
|
97
|
+
| MCP (stdio) | `python app.py mcp-run --name math` |
|
|
98
|
+
| CLI | `python app.py math add 1 2` |
|
|
99
|
+
| Health check | `GET /health` |
|
|
100
|
+
|
|
101
|
+
### Starting the HTTP server
|
|
102
|
+
|
|
103
|
+
```bash
|
|
104
|
+
python app.py start # default 127.0.0.1:8000
|
|
105
|
+
python app.py start --host 0.0.0.0 --port 9000
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
### Running MCP over stdio
|
|
109
|
+
|
|
110
|
+
```bash
|
|
111
|
+
python app.py mcp-run --name math
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
Use this when connecting from Claude Desktop, Cursor, or any MCP client that expects a stdio transport.
|
|
115
|
+
|
|
116
|
+
## Core concepts
|
|
117
|
+
|
|
118
|
+
### ToolDefinition
|
|
119
|
+
|
|
120
|
+
Wraps a callable with metadata. If you pass a bare function to `ToolService`, one is created automatically using the function name and docstring. Use it explicitly when you need control over the HTTP method or name:
|
|
121
|
+
|
|
122
|
+
```python
|
|
123
|
+
ToolDefinition(func=add, name="add_numbers", http_method="POST", description="Sum two ints")
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
### ToolService
|
|
127
|
+
|
|
128
|
+
A named group of tools. Mount the same service onto multiple servers to keep them in sync:
|
|
129
|
+
|
|
130
|
+
```python
|
|
131
|
+
service = ToolService("admin", [check_health, restart_worker])
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
### Servers
|
|
135
|
+
|
|
136
|
+
| Class | Protocol | Notes |
|
|
137
|
+
|---|---|---|
|
|
138
|
+
| `OpenAPIServer` | HTTP / REST | Backed by FastAPI. Set `path_prefix` to namespace routes. |
|
|
139
|
+
| `SSEMCPServer` | MCP (SSE + stdio) | Backed by FastMCP. Mounted at `/mcp/{name}/sse`. |
|
|
140
|
+
| `CLIServer` | CLI | Backed by Typer. Async functions are handled automatically. |
|
|
141
|
+
|
|
142
|
+
### ServerManager
|
|
143
|
+
|
|
144
|
+
The runtime host. It owns a FastAPI app, a Typer CLI, and a dynamic ASGI dispatcher that routes requests to the correct sub-app by path prefix.
|
|
145
|
+
|
|
146
|
+
Servers can be added and removed at runtime:
|
|
147
|
+
|
|
148
|
+
```python
|
|
149
|
+
manager.add_server(new_api) # immediately routable
|
|
150
|
+
manager.remove_server(new_api) # immediately gone
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
## Multiple isolated groups
|
|
154
|
+
|
|
155
|
+
You can create separate servers for different audiences and mount different services onto each:
|
|
156
|
+
|
|
157
|
+
```python
|
|
158
|
+
public_api = OpenAPIServer("/public", "Public API")
|
|
159
|
+
public_api.mount(public_service)
|
|
160
|
+
|
|
161
|
+
admin_api = OpenAPIServer("/admin", "Admin API")
|
|
162
|
+
admin_api.mount(admin_service)
|
|
163
|
+
|
|
164
|
+
manager.add_server(public_api)
|
|
165
|
+
manager.add_server(admin_api)
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
The same pattern works for MCP — create multiple `SSEMCPServer` instances with different names.
|
|
169
|
+
|
|
170
|
+
## Lifespan support
|
|
171
|
+
|
|
172
|
+
Pass an async context manager to `ServerManager` to run setup/teardown logic (database connections, model loading, etc.). The lifespan is entered for both the HTTP server and CLI command execution:
|
|
173
|
+
|
|
174
|
+
```python
|
|
175
|
+
from contextlib import asynccontextmanager
|
|
176
|
+
|
|
177
|
+
@asynccontextmanager
|
|
178
|
+
async def lifespan(app):
|
|
179
|
+
db = await connect_db()
|
|
180
|
+
yield
|
|
181
|
+
await db.close()
|
|
182
|
+
|
|
183
|
+
manager = ServerManager(name="my-service", lifespan=lifespan)
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
## Mounting custom ASGI apps
|
|
187
|
+
|
|
188
|
+
Use `MountableApp` to add an existing FastAPI or ASGI application alongside your tool servers:
|
|
189
|
+
|
|
190
|
+
```python
|
|
191
|
+
from toolaccess.toolaccess import MountableApp
|
|
192
|
+
from fastapi import FastAPI
|
|
193
|
+
|
|
194
|
+
dashboard = FastAPI()
|
|
195
|
+
|
|
196
|
+
@dashboard.get("/")
|
|
197
|
+
async def index():
|
|
198
|
+
return {"page": "dashboard"}
|
|
199
|
+
|
|
200
|
+
manager.add_server(MountableApp(dashboard, path_prefix="/dashboard", name="dashboard"))
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
## Requirements
|
|
204
|
+
|
|
205
|
+
- Python >= 3.10
|
|
206
|
+
- fastapi
|
|
207
|
+
- fastmcp
|
|
208
|
+
- pydantic
|
|
209
|
+
- typer
|
|
210
|
+
- uvicorn
|
|
211
|
+
|
|
212
|
+
## Development
|
|
213
|
+
|
|
214
|
+
```bash
|
|
215
|
+
pip install -e ".[dev]"
|
|
216
|
+
pytest
|
|
217
|
+
```
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
src/toolaccess/__init__.py
|
|
5
|
+
src/toolaccess/toolaccess.py
|
|
6
|
+
src/toolaccess.egg-info/PKG-INFO
|
|
7
|
+
src/toolaccess.egg-info/SOURCES.txt
|
|
8
|
+
src/toolaccess.egg-info/dependency_links.txt
|
|
9
|
+
src/toolaccess.egg-info/requires.txt
|
|
10
|
+
src/toolaccess.egg-info/top_level.txt
|
|
11
|
+
tests/test_toolaccess.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
toolaccess
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
from fastapi.testclient import TestClient
|
|
3
|
+
from typer.testing import CliRunner
|
|
4
|
+
from toolaccess import (
|
|
5
|
+
ServerManager,
|
|
6
|
+
ToolService,
|
|
7
|
+
ToolDefinition,
|
|
8
|
+
OpenAPIServer,
|
|
9
|
+
SSEMCPServer,
|
|
10
|
+
CLIServer,
|
|
11
|
+
)
|
|
12
|
+
from contextlib import asynccontextmanager
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def dummy_tool(a: int, b: int) -> int:
|
|
16
|
+
"""A dummy tool that adds two numbers."""
|
|
17
|
+
return a + b
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def dummy_admin():
|
|
21
|
+
"""A dummy admin function."""
|
|
22
|
+
return {"status": "admin_ok"}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@pytest.fixture
|
|
26
|
+
def manager():
|
|
27
|
+
mgr = ServerManager(name="test_service")
|
|
28
|
+
|
|
29
|
+
# Services
|
|
30
|
+
tool_svc = ToolService("tools", [ToolDefinition(dummy_tool, "add_dummy", "POST")])
|
|
31
|
+
admin_svc = ToolService(
|
|
32
|
+
"admin", [ToolDefinition(dummy_admin, "check_admin", "GET")]
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
# 1. API v1
|
|
36
|
+
api_v1 = OpenAPIServer("/tools", "Tools API")
|
|
37
|
+
api_v1.mount(tool_svc)
|
|
38
|
+
mgr.add_server(api_v1)
|
|
39
|
+
|
|
40
|
+
# 2. Admin API
|
|
41
|
+
api_admin = OpenAPIServer("/admin", "Admin API")
|
|
42
|
+
api_admin.mount(admin_svc)
|
|
43
|
+
mgr.add_server(api_admin)
|
|
44
|
+
|
|
45
|
+
# 3. Default MCP
|
|
46
|
+
mcp = SSEMCPServer("default")
|
|
47
|
+
mcp.mount(tool_svc)
|
|
48
|
+
mgr.add_server(mcp)
|
|
49
|
+
|
|
50
|
+
# 4. Admin MCP
|
|
51
|
+
mcp_admin = SSEMCPServer("admin")
|
|
52
|
+
mcp_admin.mount(admin_svc)
|
|
53
|
+
mgr.add_server(mcp_admin)
|
|
54
|
+
|
|
55
|
+
# 5. CLI
|
|
56
|
+
cli_tools = CLIServer("tools")
|
|
57
|
+
cli_tools.mount(tool_svc)
|
|
58
|
+
mgr.add_server(cli_tools)
|
|
59
|
+
|
|
60
|
+
cli_admin = CLIServer("admin")
|
|
61
|
+
cli_admin.mount(admin_svc)
|
|
62
|
+
mgr.add_server(cli_admin)
|
|
63
|
+
|
|
64
|
+
return mgr
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@pytest.fixture
|
|
68
|
+
def client(manager):
|
|
69
|
+
return TestClient(manager.app)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@pytest.fixture
|
|
73
|
+
def runner():
|
|
74
|
+
return CliRunner()
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def test_server_health(client):
|
|
78
|
+
"""Test that the server health endpoint works."""
|
|
79
|
+
response = client.get("/health")
|
|
80
|
+
assert response.status_code == 200
|
|
81
|
+
data = response.json()
|
|
82
|
+
assert "mcp_servers" in data
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def test_tool_rest(client):
|
|
86
|
+
"""Test accessing a tool via the mounted HTTP endpoint."""
|
|
87
|
+
response = client.post("/tools/add_dummy", params={"a": 1, "b": 2})
|
|
88
|
+
assert response.status_code == 200
|
|
89
|
+
assert response.json() == 3
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def test_admin_rest(client):
|
|
93
|
+
"""Test accessing an admin function via the mounted HTTP endpoint."""
|
|
94
|
+
response = client.get("/admin/check_admin")
|
|
95
|
+
assert response.status_code == 200
|
|
96
|
+
assert response.json() == {"status": "admin_ok"}
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def test_mcp_endpoints(client):
|
|
100
|
+
"""Test that MCP SSE endpoints are mounted."""
|
|
101
|
+
# Check default MCP server via messages endpoint (POST)
|
|
102
|
+
# FastMCP might redirect if slashes mismatch. Allow redirects.
|
|
103
|
+
# Note: If it redirects to a path without the prefix, that's a bug in the Dispatcher/SubApp interaction.
|
|
104
|
+
# We check for 404 specifically.
|
|
105
|
+
|
|
106
|
+
# Try with trailing slash to avoid 307 Redirect stripping the prefix
|
|
107
|
+
response = client.post("/mcp/default/messages/")
|
|
108
|
+
assert (
|
|
109
|
+
response.status_code != 404
|
|
110
|
+
), f"Got {response.status_code}. History: {response.history}"
|
|
111
|
+
|
|
112
|
+
# Check admin MCP server
|
|
113
|
+
response = client.post("/mcp/admin/messages/")
|
|
114
|
+
assert response.status_code != 404
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def test_openapi_specs(client):
|
|
118
|
+
"""Test that OpenAPI specs are generated for mounted sub-apps."""
|
|
119
|
+
# Check public tools spec - FastAPIMounts sub-apps don't always expose
|
|
120
|
+
# sub-openapi.json at the mount point automatically unless configured.
|
|
121
|
+
# However, our OpenAPIServer creates a full FastAPI app.
|
|
122
|
+
# When mounted, FastAPI serves the sub-app documentation at /tools/docs usually.
|
|
123
|
+
# The openapi.json is at /tools/openapi.json
|
|
124
|
+
response = client.get("/tools/openapi.json")
|
|
125
|
+
assert response.status_code == 200
|
|
126
|
+
spec = response.json()
|
|
127
|
+
assert "paths" in spec
|
|
128
|
+
assert "/add_dummy" in spec["paths"]
|
|
129
|
+
|
|
130
|
+
# Check admin spec
|
|
131
|
+
response = client.get("/admin/openapi.json")
|
|
132
|
+
assert response.status_code == 200
|
|
133
|
+
spec = response.json()
|
|
134
|
+
assert "/check_admin" in spec["paths"]
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def test_cli_integration(manager, runner):
|
|
138
|
+
"""Test that registered tools appear in the CLI help."""
|
|
139
|
+
result = runner.invoke(manager.cli, ["tools", "--help"])
|
|
140
|
+
assert result.exit_code == 0
|
|
141
|
+
assert "add_dummy" in result.stdout
|
|
142
|
+
|
|
143
|
+
result = runner.invoke(manager.cli, ["admin", "--help"])
|
|
144
|
+
assert result.exit_code == 0
|
|
145
|
+
assert "check_admin" in result.stdout
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def test_async_cli_execution(runner):
|
|
149
|
+
"""Test that async tools are correctly wrapped and executed in CLI."""
|
|
150
|
+
mgr = ServerManager("async_service")
|
|
151
|
+
|
|
152
|
+
async def async_tool(x: int) -> int:
|
|
153
|
+
return x * 2
|
|
154
|
+
|
|
155
|
+
# Service
|
|
156
|
+
svc = ToolService("svc", [ToolDefinition(async_tool, "double", "POST")])
|
|
157
|
+
|
|
158
|
+
# Server
|
|
159
|
+
cli = CLIServer("math")
|
|
160
|
+
cli.mount(svc)
|
|
161
|
+
mgr.add_server(cli)
|
|
162
|
+
|
|
163
|
+
result = runner.invoke(mgr.cli, ["math", "double", "21"])
|
|
164
|
+
assert result.exit_code == 0
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def test_lifespan_integration(runner):
|
|
168
|
+
"""Test that lifespan context is entered during CLI execution."""
|
|
169
|
+
events = []
|
|
170
|
+
|
|
171
|
+
@asynccontextmanager
|
|
172
|
+
async def mock_lifespan(app):
|
|
173
|
+
events.append("startup")
|
|
174
|
+
yield
|
|
175
|
+
events.append("shutdown")
|
|
176
|
+
|
|
177
|
+
mgr = ServerManager(name="lifespan_service", lifespan=mock_lifespan)
|
|
178
|
+
|
|
179
|
+
async def simple_tool():
|
|
180
|
+
return "ok"
|
|
181
|
+
|
|
182
|
+
svc = ToolService("svc", [ToolDefinition(simple_tool, "simple_tool", "POST")])
|
|
183
|
+
cli = CLIServer("tools")
|
|
184
|
+
cli.mount(svc)
|
|
185
|
+
mgr.add_server(cli)
|
|
186
|
+
|
|
187
|
+
result = runner.invoke(mgr.cli, ["tools", "simple_tool"])
|
|
188
|
+
assert result.exit_code == 0
|
|
189
|
+
assert "startup" in events
|
|
190
|
+
assert "shutdown" in events
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def test_mcp_run_cli(manager, runner):
|
|
194
|
+
"""Minimal test to ensure mcp-run command exists and doesn't crash on invocation."""
|
|
195
|
+
result = runner.invoke(manager.cli, ["mcp-run", "--name", "non_existent"])
|
|
196
|
+
assert result.exit_code == 0
|
|
197
|
+
assert "not found" in result.stdout
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def test_dynamic_server_lifecycle_explicit():
|
|
201
|
+
"""Explicit test of dynamic add/remove."""
|
|
202
|
+
mgr = ServerManager("dynamic_test")
|
|
203
|
+
client = TestClient(mgr.app)
|
|
204
|
+
|
|
205
|
+
# 1. Verify 404 for non-existent path
|
|
206
|
+
response = client.get("/dynamic/openapi.json")
|
|
207
|
+
assert response.status_code == 404
|
|
208
|
+
|
|
209
|
+
# 2. Add Server
|
|
210
|
+
dynamic_api = OpenAPIServer("/dynamic", "Dynamic API")
|
|
211
|
+
dynamic_svc = ToolService(
|
|
212
|
+
"dynamic", [ToolDefinition(dummy_tool, "add_dynamic", "POST")]
|
|
213
|
+
)
|
|
214
|
+
dynamic_api.mount(dynamic_svc)
|
|
215
|
+
|
|
216
|
+
mgr.add_server(dynamic_api)
|
|
217
|
+
|
|
218
|
+
# 3. Verify 200 (It works!)
|
|
219
|
+
response = client.get("/dynamic/openapi.json")
|
|
220
|
+
assert response.status_code == 200
|
|
221
|
+
|
|
222
|
+
# Verify tool execution
|
|
223
|
+
response = client.post("/dynamic/add_dynamic", params={"a": 10, "b": 20})
|
|
224
|
+
assert response.status_code == 200
|
|
225
|
+
assert response.json() == 30
|
|
226
|
+
|
|
227
|
+
# 4. Remove Server
|
|
228
|
+
mgr.remove_server(dynamic_api)
|
|
229
|
+
|
|
230
|
+
# 5. Verify 404 again
|
|
231
|
+
response = client.get("/dynamic/openapi.json")
|
|
232
|
+
assert response.status_code == 404
|