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 ADDED
@@ -0,0 +1,10 @@
1
+ """
2
+ BridgeMCP — A production-ready Python framework for building MCP servers.
3
+ """
4
+
5
+ from .application import BridgeMCP
6
+ from .exceptions import BridgeMCPError
7
+
8
+ __version__ = "0.2.0"
9
+
10
+ __all__ = ["BridgeMCP", "BridgeMCPError"]
@@ -0,0 +1,3 @@
1
+ from .mcp import build_mcp_server, run_http, run_stdio
2
+
3
+ __all__ = ["build_mcp_server", "run_stdio", "run_http"]
@@ -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)