bridgemcp-py 0.2.0__py3-none-any.whl
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.
- bridgemcp/__init__.py +10 -0
- bridgemcp/adapters/__init__.py +3 -0
- bridgemcp/adapters/mcp.py +303 -0
- bridgemcp/application.py +711 -0
- bridgemcp/config/__init__.py +10 -0
- bridgemcp/config/models.py +32 -0
- bridgemcp/exceptions.py +211 -0
- bridgemcp/execution.py +108 -0
- bridgemcp/middleware.py +88 -0
- bridgemcp/plugin.py +128 -0
- bridgemcp/prompts/__init__.py +22 -0
- bridgemcp/prompts/normalize.py +61 -0
- bridgemcp/prompts/registry.py +206 -0
- bridgemcp/py.typed +0 -0
- bridgemcp/resources/__init__.py +14 -0
- bridgemcp/resources/normalize.py +69 -0
- bridgemcp/resources/registry.py +144 -0
- bridgemcp/tools/__init__.py +13 -0
- bridgemcp/tools/registry.py +125 -0
- bridgemcp_py-0.2.0.dist-info/METADATA +284 -0
- bridgemcp_py-0.2.0.dist-info/RECORD +23 -0
- bridgemcp_py-0.2.0.dist-info/WHEEL +4 -0
- bridgemcp_py-0.2.0.dist-info/licenses/LICENSE +21 -0
bridgemcp/__init__.py
ADDED
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
"""MCP adapter: bridges a BridgeMCP application to a FastMCP server.
|
|
2
|
+
|
|
3
|
+
Public entry points
|
|
4
|
+
-------------------
|
|
5
|
+
run_stdio(app)
|
|
6
|
+
Build and run the server on stdio transport. Called by BridgeMCP.run().
|
|
7
|
+
|
|
8
|
+
run_http(app, *, host, port)
|
|
9
|
+
Build and run the server on HTTP/SSE transport. Called by
|
|
10
|
+
BridgeMCP.run_http().
|
|
11
|
+
|
|
12
|
+
build_mcp_server(app, *, host, port)
|
|
13
|
+
Build and return the configured FastMCP instance without starting it.
|
|
14
|
+
Useful for tests, introspection, and hosting the server inside a larger
|
|
15
|
+
ASGI application.
|
|
16
|
+
|
|
17
|
+
All transport knowledge (which SDK to import, which transport string to pass,
|
|
18
|
+
which SDK-specific run method to call) lives exclusively in this module.
|
|
19
|
+
BridgeMCP core (application.py) delegates here and never touches the server
|
|
20
|
+
object itself.
|
|
21
|
+
|
|
22
|
+
Lifecycle
|
|
23
|
+
---------
|
|
24
|
+
Plugin startup and shutdown hooks are managed entirely within this module.
|
|
25
|
+
BridgeMCP.application stores ``_startup_hooks`` and ``_shutdown_hooks`` lists;
|
|
26
|
+
the adapter is responsible for invoking them at the correct points.
|
|
27
|
+
|
|
28
|
+
Startup hooks run in registration order before the server accepts requests.
|
|
29
|
+
A startup exception propagates immediately — the server does not start and
|
|
30
|
+
no shutdown hooks run.
|
|
31
|
+
|
|
32
|
+
Shutdown hooks run in reverse registration order after the server stops.
|
|
33
|
+
Each hook is individually guarded: an exception is logged but does not
|
|
34
|
+
prevent the remaining hooks from running.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
from __future__ import annotations
|
|
38
|
+
|
|
39
|
+
import asyncio
|
|
40
|
+
import functools
|
|
41
|
+
import logging
|
|
42
|
+
from collections.abc import Callable
|
|
43
|
+
from typing import TYPE_CHECKING, Any
|
|
44
|
+
|
|
45
|
+
if TYPE_CHECKING:
|
|
46
|
+
from mcp.server.fastmcp import FastMCP
|
|
47
|
+
|
|
48
|
+
from bridgemcp.application import BridgeMCP
|
|
49
|
+
from bridgemcp.prompts.registry import Prompt
|
|
50
|
+
from bridgemcp.resources.registry import Resource
|
|
51
|
+
from bridgemcp.tools.registry import Tool
|
|
52
|
+
|
|
53
|
+
_logger = logging.getLogger(__name__)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
async def _invoke_startup_hooks(app: BridgeMCP) -> None:
|
|
57
|
+
"""Invoke startup hooks in registration order.
|
|
58
|
+
|
|
59
|
+
Raises the first exception encountered, aborting subsequent hooks.
|
|
60
|
+
The server will not start if this coroutine raises.
|
|
61
|
+
"""
|
|
62
|
+
for hook in app._startup_hooks:
|
|
63
|
+
await hook(app)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
async def _invoke_shutdown_hooks(app: BridgeMCP) -> None:
|
|
67
|
+
"""Invoke shutdown hooks in reverse registration order.
|
|
68
|
+
|
|
69
|
+
Each hook is individually guarded. An exception is logged at ERROR
|
|
70
|
+
level but does not prevent the remaining hooks from running.
|
|
71
|
+
"""
|
|
72
|
+
for hook in reversed(app._shutdown_hooks):
|
|
73
|
+
try:
|
|
74
|
+
await hook(app)
|
|
75
|
+
except Exception:
|
|
76
|
+
_logger.exception("Shutdown hook %r raised an exception", hook)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _with_lifecycle(app: BridgeMCP, run_fn: Callable[[], None]) -> None:
|
|
80
|
+
"""Wrap *run_fn* with plugin startup and shutdown lifecycle hooks.
|
|
81
|
+
|
|
82
|
+
Startup hooks execute synchronously (via ``asyncio.run``) before
|
|
83
|
+
*run_fn* is called. If any startup hook raises, *run_fn* is not
|
|
84
|
+
called and no shutdown hooks run — the caller receives the exception.
|
|
85
|
+
|
|
86
|
+
Shutdown hooks execute synchronously (via ``asyncio.run``) after
|
|
87
|
+
*run_fn* returns, whether it returned normally or raised. Shutdown
|
|
88
|
+
hook exceptions are logged; all hooks run regardless of individual
|
|
89
|
+
failures.
|
|
90
|
+
|
|
91
|
+
Args:
|
|
92
|
+
app: BridgeMCP application whose hook lists to invoke.
|
|
93
|
+
run_fn: Zero-argument callable that starts the server transport.
|
|
94
|
+
Typically ``lambda: server.run(transport=...)``.
|
|
95
|
+
"""
|
|
96
|
+
if app._startup_hooks:
|
|
97
|
+
asyncio.run(_invoke_startup_hooks(app))
|
|
98
|
+
try:
|
|
99
|
+
run_fn()
|
|
100
|
+
finally:
|
|
101
|
+
if app._shutdown_hooks:
|
|
102
|
+
asyncio.run(_invoke_shutdown_hooks(app))
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def run_stdio(app: BridgeMCP) -> None:
|
|
106
|
+
"""Build the MCP server and run it on stdio transport.
|
|
107
|
+
|
|
108
|
+
Plugin startup hooks execute before the server begins accepting requests.
|
|
109
|
+
Plugin shutdown hooks execute in reverse registration order after the
|
|
110
|
+
server stops.
|
|
111
|
+
|
|
112
|
+
This is the standard transport for AI clients such as Claude Desktop
|
|
113
|
+
and Cursor that launch the server as a subprocess.
|
|
114
|
+
|
|
115
|
+
Requires: ``pip install 'bridgemcp[mcp]'``
|
|
116
|
+
"""
|
|
117
|
+
server = build_mcp_server(app)
|
|
118
|
+
_with_lifecycle(app, lambda: server.run(transport="stdio"))
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def run_http(app: BridgeMCP, *, host: str, port: int) -> None:
|
|
122
|
+
"""Build the MCP server and run it on HTTP/SSE transport.
|
|
123
|
+
|
|
124
|
+
Plugin startup hooks execute before the server begins accepting requests.
|
|
125
|
+
Plugin shutdown hooks execute in reverse registration order after the
|
|
126
|
+
server stops.
|
|
127
|
+
|
|
128
|
+
Use this when AI clients should connect over the network rather than
|
|
129
|
+
via a subprocess.
|
|
130
|
+
|
|
131
|
+
Requires: ``pip install 'bridgemcp[mcp]'``
|
|
132
|
+
"""
|
|
133
|
+
server = build_mcp_server(app, host=host, port=port)
|
|
134
|
+
_with_lifecycle(app, lambda: server.run(transport="sse"))
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def build_mcp_server(
|
|
138
|
+
app: BridgeMCP,
|
|
139
|
+
*,
|
|
140
|
+
host: str = "127.0.0.1",
|
|
141
|
+
port: int = 8000,
|
|
142
|
+
) -> FastMCP:
|
|
143
|
+
"""Build a FastMCP server from a BridgeMCP application.
|
|
144
|
+
|
|
145
|
+
All registered tools, resources, and prompts are wrapped so that execution
|
|
146
|
+
goes through ``app.call()``, ``app.read_resource()``, and
|
|
147
|
+
``app.render_prompt()`` respectively, keeping BridgeMCP's error handling
|
|
148
|
+
and exception hierarchy intact.
|
|
149
|
+
|
|
150
|
+
Args:
|
|
151
|
+
app: The BridgeMCP application to expose as an MCP server.
|
|
152
|
+
host: Host to bind to for HTTP/SSE transports. Defaults to "127.0.0.1".
|
|
153
|
+
port: Port to listen on for HTTP/SSE transports. Defaults to 8000.
|
|
154
|
+
|
|
155
|
+
Returns:
|
|
156
|
+
A configured FastMCP server instance ready to run.
|
|
157
|
+
|
|
158
|
+
Raises:
|
|
159
|
+
ImportError: If ``mcp`` is not installed.
|
|
160
|
+
"""
|
|
161
|
+
try:
|
|
162
|
+
from mcp.server.fastmcp import FastMCP
|
|
163
|
+
except ImportError:
|
|
164
|
+
raise ImportError(
|
|
165
|
+
"The MCP SDK is required to run a BridgeMCP server. "
|
|
166
|
+
"Install it with: pip install 'bridgemcp[mcp]'"
|
|
167
|
+
) from None
|
|
168
|
+
|
|
169
|
+
server = FastMCP(
|
|
170
|
+
name=app.name,
|
|
171
|
+
instructions=app.description,
|
|
172
|
+
host=host,
|
|
173
|
+
port=port,
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
# FastMCP does not expose a public `version` constructor parameter.
|
|
177
|
+
# The low-level Server it wraps does, and falls back to the MCP SDK's own
|
|
178
|
+
# package version when left unset. Until FastMCP adds first-class version
|
|
179
|
+
# support, set it via the internal attribute so clients see the user's
|
|
180
|
+
# declared version string. When FastMCP adds a public API for this (e.g.
|
|
181
|
+
# a `version=` parameter on FastMCP()), pass app.version there and remove
|
|
182
|
+
# this line.
|
|
183
|
+
server._mcp_server.version = app.version
|
|
184
|
+
|
|
185
|
+
_register_tools(server, app)
|
|
186
|
+
_register_resources(server, app)
|
|
187
|
+
_register_prompts(server, app)
|
|
188
|
+
|
|
189
|
+
return server
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _register_tools(server: Any, app: Any) -> None:
|
|
193
|
+
"""Register all tools from the app with the FastMCP server."""
|
|
194
|
+
for tool in app._tool_registry.list():
|
|
195
|
+
_register_tool(server, app, tool)
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def _register_resources(server: Any, app: Any) -> None:
|
|
199
|
+
"""Register all resources from the app with the FastMCP server."""
|
|
200
|
+
for resource in app._resource_registry.list():
|
|
201
|
+
_register_resource(server, app, resource)
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def _register_prompts(server: Any, app: Any) -> None:
|
|
205
|
+
"""Register all prompts from the app with the FastMCP server."""
|
|
206
|
+
for prompt in app._prompt_registry.list():
|
|
207
|
+
_register_prompt(server, app, prompt)
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _register_tool(server: Any, app: Any, tool: Tool) -> None:
|
|
211
|
+
"""Wrap one BridgeMCP tool and register it with the FastMCP server.
|
|
212
|
+
|
|
213
|
+
``functools.wraps`` copies ``__wrapped__``, ``__annotations__``, and
|
|
214
|
+
``__doc__`` from the original function. FastMCP's ``func_metadata``
|
|
215
|
+
calls ``inspect.signature(fn, eval_str=True)`` which follows
|
|
216
|
+
``__wrapped__``, so the wrapper inherits the original's full parameter
|
|
217
|
+
list and type annotations for schema generation.
|
|
218
|
+
|
|
219
|
+
Execution is always delegated to ``app.acall()`` so that BridgeMCP's
|
|
220
|
+
``ToolExecutionError`` wrapping and registry lookup are used. The wrapper
|
|
221
|
+
is async so FastMCP awaits it for both sync and async tool handlers.
|
|
222
|
+
"""
|
|
223
|
+
tool_name = tool.name
|
|
224
|
+
original_fn = tool.fn
|
|
225
|
+
|
|
226
|
+
@functools.wraps(original_fn)
|
|
227
|
+
async def wrapper(**kwargs: Any) -> Any:
|
|
228
|
+
return await app.acall(tool_name, **kwargs)
|
|
229
|
+
|
|
230
|
+
# functools.wraps copies __name__ from original_fn, but the registered
|
|
231
|
+
# name may differ (e.g. @app.tool(name="list_orders")). Override to match.
|
|
232
|
+
wrapper.__name__ = tool_name
|
|
233
|
+
|
|
234
|
+
server.add_tool(
|
|
235
|
+
wrapper,
|
|
236
|
+
name=tool_name,
|
|
237
|
+
description=tool.description,
|
|
238
|
+
)
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def _register_resource(server: Any, app: Any, resource: Resource) -> None:
|
|
242
|
+
"""Wrap one BridgeMCP resource and register it with the FastMCP server.
|
|
243
|
+
|
|
244
|
+
The handler is a zero-argument closure — intentionally not wrapped with
|
|
245
|
+
``functools.wraps``. FastMCP classifies a resource as a URI template when
|
|
246
|
+
``inspect.signature(fn)`` reveals parameters; wrapping would cause
|
|
247
|
+
``__wrapped__`` to propagate the original function's signature, incorrectly
|
|
248
|
+
marking a static resource as a template.
|
|
249
|
+
|
|
250
|
+
Execution is always delegated to ``app.aread_resource()`` so that BridgeMCP's
|
|
251
|
+
content normalization and error handling remain active. The handler is async
|
|
252
|
+
so FastMCP awaits it for both sync and async resource handlers.
|
|
253
|
+
|
|
254
|
+
Note: when ``mime_type`` is ``None``, FastMCP's ``FunctionResource``
|
|
255
|
+
defaults to ``"text/plain"`` in the ``resources/list`` response. This is
|
|
256
|
+
FastMCP's own default and is not controlled by BridgeMCP.
|
|
257
|
+
"""
|
|
258
|
+
uri = resource.uri
|
|
259
|
+
|
|
260
|
+
async def handler() -> str | bytes:
|
|
261
|
+
return (await app.aread_resource(uri)).content
|
|
262
|
+
|
|
263
|
+
handler.__name__ = resource.name
|
|
264
|
+
|
|
265
|
+
server.resource(
|
|
266
|
+
uri=uri,
|
|
267
|
+
name=resource.name,
|
|
268
|
+
description=resource.description,
|
|
269
|
+
mime_type=resource.mime_type,
|
|
270
|
+
)(handler)
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def _register_prompt(server: Any, app: Any, prompt: Prompt) -> None:
|
|
274
|
+
"""Wrap one BridgeMCP prompt and register it with the FastMCP server.
|
|
275
|
+
|
|
276
|
+
``functools.wraps`` copies ``__wrapped__``, ``__annotations__``, and
|
|
277
|
+
``__doc__`` from the original function. FastMCP's ``func_metadata``
|
|
278
|
+
calls ``inspect.signature(fn, eval_str=True)`` which follows
|
|
279
|
+
``__wrapped__``, so the wrapper inherits the original's full parameter
|
|
280
|
+
list and type annotations for argument schema generation.
|
|
281
|
+
|
|
282
|
+
Each BridgeMCP ``PromptMessage(role, content: str)`` is converted to a
|
|
283
|
+
dict that FastMCP's message validator accepts. All MCP SDK type knowledge
|
|
284
|
+
stays in this module; BridgeMCP core is unaware of the wire format.
|
|
285
|
+
|
|
286
|
+
Execution is always delegated to ``app.arender_prompt()`` so that
|
|
287
|
+
BridgeMCP's normalization and error handling remain active. The wrapper
|
|
288
|
+
is async so FastMCP awaits it for both sync and async prompt handlers.
|
|
289
|
+
"""
|
|
290
|
+
prompt_name = prompt.name
|
|
291
|
+
original_fn = prompt.fn
|
|
292
|
+
|
|
293
|
+
@functools.wraps(original_fn)
|
|
294
|
+
async def handler(**kwargs: Any) -> list[dict]:
|
|
295
|
+
messages = await app.arender_prompt(prompt_name, **kwargs)
|
|
296
|
+
return [
|
|
297
|
+
{"role": msg.role, "content": {"type": "text", "text": msg.content}}
|
|
298
|
+
for msg in messages
|
|
299
|
+
]
|
|
300
|
+
|
|
301
|
+
handler.__name__ = prompt_name
|
|
302
|
+
|
|
303
|
+
server.prompt(name=prompt_name, description=prompt.description)(handler)
|