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.
@@ -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,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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,9 @@
1
+ fastapi
2
+ fastmcp
3
+ pydantic
4
+ typer
5
+ uvicorn
6
+
7
+ [dev]
8
+ pytest
9
+ pytest-asyncio
@@ -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