bub-mcp 0.0.1__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.
bub_mcp-0.0.1/PKG-INFO ADDED
@@ -0,0 +1,80 @@
1
+ Metadata-Version: 2.3
2
+ Name: bub-mcp
3
+ Version: 0.0.1
4
+ Summary: Expose configured MCP servers as Bub tools
5
+ Author: Frost Ming
6
+ Author-email: Frost Ming <me@frostming.com>
7
+ Requires-Dist: fastmcp>=3.2.0,<4
8
+ Requires-Dist: pydantic-settings>=2.10.1
9
+ Requires-Python: >=3.12
10
+ Description-Content-Type: text/markdown
11
+
12
+ # bub-mcp
13
+
14
+ Expose configured MCP servers as Bub tools.
15
+
16
+ ## Configuration
17
+
18
+ The plugin reads MCP server definitions from a dedicated JSON file under Bub home:
19
+
20
+ - `~/.bub/mcp.json`
21
+ - or `$BUB_HOME/mcp.json` when `BUB_HOME` is set
22
+
23
+ The file must contain a top-level `mcpServers` mapping:
24
+
25
+ ```json
26
+ {
27
+ "mcpServers": {
28
+ "weather": {
29
+ "url": "https://weather.example.com/mcp",
30
+ "transport": "http"
31
+ },
32
+ "local": {
33
+ "command": "python",
34
+ "args": ["./server.py"]
35
+ }
36
+ }
37
+ }
38
+ ```
39
+
40
+ ## CLI Usage
41
+
42
+ Use the CLI to inspect and manage `mcp.json`:
43
+
44
+ ```bash
45
+ bub mcp list
46
+ ```
47
+
48
+ Add an HTTP server:
49
+
50
+ ```bash
51
+ bub mcp add --transport http weather https://weather.example.com/mcp
52
+ ```
53
+
54
+ Add an SSE server with headers:
55
+
56
+ ```bash
57
+ bub mcp add \
58
+ --transport sse \
59
+ --header "Authorization: Bearer token" \
60
+ events \
61
+ https://events.example.com/mcp
62
+ ```
63
+
64
+ Add a stdio server with environment variables:
65
+
66
+ ```bash
67
+ bub mcp add \
68
+ --transport stdio \
69
+ --env API_KEY=secret \
70
+ filesystem \
71
+ -- npx -y @modelcontextprotocol/server-filesystem /tmp
72
+ ```
73
+
74
+ Remove a server:
75
+
76
+ ```bash
77
+ bub mcp remove weather
78
+ ```
79
+
80
+ `bub mcp add` writes the server config into `mcp.json` and performs a connection test before exiting.
@@ -0,0 +1,69 @@
1
+ # bub-mcp
2
+
3
+ Expose configured MCP servers as Bub tools.
4
+
5
+ ## Configuration
6
+
7
+ The plugin reads MCP server definitions from a dedicated JSON file under Bub home:
8
+
9
+ - `~/.bub/mcp.json`
10
+ - or `$BUB_HOME/mcp.json` when `BUB_HOME` is set
11
+
12
+ The file must contain a top-level `mcpServers` mapping:
13
+
14
+ ```json
15
+ {
16
+ "mcpServers": {
17
+ "weather": {
18
+ "url": "https://weather.example.com/mcp",
19
+ "transport": "http"
20
+ },
21
+ "local": {
22
+ "command": "python",
23
+ "args": ["./server.py"]
24
+ }
25
+ }
26
+ }
27
+ ```
28
+
29
+ ## CLI Usage
30
+
31
+ Use the CLI to inspect and manage `mcp.json`:
32
+
33
+ ```bash
34
+ bub mcp list
35
+ ```
36
+
37
+ Add an HTTP server:
38
+
39
+ ```bash
40
+ bub mcp add --transport http weather https://weather.example.com/mcp
41
+ ```
42
+
43
+ Add an SSE server with headers:
44
+
45
+ ```bash
46
+ bub mcp add \
47
+ --transport sse \
48
+ --header "Authorization: Bearer token" \
49
+ events \
50
+ https://events.example.com/mcp
51
+ ```
52
+
53
+ Add a stdio server with environment variables:
54
+
55
+ ```bash
56
+ bub mcp add \
57
+ --transport stdio \
58
+ --env API_KEY=secret \
59
+ filesystem \
60
+ -- npx -y @modelcontextprotocol/server-filesystem /tmp
61
+ ```
62
+
63
+ Remove a server:
64
+
65
+ ```bash
66
+ bub mcp remove weather
67
+ ```
68
+
69
+ `bub mcp add` writes the server config into `mcp.json` and performs a connection test before exiting.
@@ -0,0 +1,25 @@
1
+ [project]
2
+ name = "bub-mcp"
3
+ version = "0.0.1"
4
+ description = "Expose configured MCP servers as Bub tools"
5
+ readme = "README.md"
6
+ authors = [
7
+ { name = "Frost Ming", email = "me@frostming.com" }
8
+ ]
9
+ requires-python = ">=3.12"
10
+ dependencies = [
11
+ "fastmcp>=3.2.0,<4",
12
+ "pydantic-settings>=2.10.1",
13
+ ]
14
+
15
+ [project.entry-points.bub]
16
+ mcp = "bub_mcp.plugin:MCPPlugin"
17
+
18
+ [build-system]
19
+ requires = ["uv_build>=0.10.4,<0.11.0"]
20
+ build-backend = "uv_build"
21
+
22
+ [dependency-groups]
23
+ dev = [
24
+ "pytest>=9.0.3",
25
+ ]
@@ -0,0 +1,2 @@
1
+ """Bub MCP plugin package."""
2
+
@@ -0,0 +1,188 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ from enum import StrEnum
5
+ from typing import TYPE_CHECKING
6
+
7
+ import typer
8
+
9
+ if TYPE_CHECKING:
10
+ from bub_mcp.plugin import MCPChannel, MCPServerState
11
+
12
+
13
+ class MCPTransport(StrEnum):
14
+ HTTP = "http"
15
+ SSE = "sse"
16
+ STDIO = "stdio"
17
+
18
+
19
+ def _format_server_list(servers: dict[str, MCPServerState]) -> str:
20
+ lines: list[str] = []
21
+ lines.append(typer.style("🔌 MCP Tools", bold=True))
22
+ for name, server in servers.items():
23
+ lines.append(f"- {name}")
24
+ if server.connected:
25
+ lines.append(" Status: Connected")
26
+ lines.append(
27
+ f" Tools: {', '.join(tool.name for tool in server.tools) if server.tools else 'No tools'}"
28
+ )
29
+ else:
30
+ lines.append(" Status: Not connected")
31
+ if server.error:
32
+ lines.append(f" Error: {server.error}")
33
+ return "\n".join(lines)
34
+
35
+
36
+ def _parse_key_value(option_name: str, item: str, separator: str) -> tuple[str, str]:
37
+ if separator not in item:
38
+ raise typer.BadParameter(f"{option_name} must be in KEY{separator}VALUE format")
39
+ key, value = item.split(separator, 1)
40
+ key = key.strip()
41
+ value = value.strip()
42
+ if not key:
43
+ raise typer.BadParameter(f"{option_name} key must not be blank")
44
+ return key, value
45
+
46
+
47
+ def _build_stdio_server_config(
48
+ command_args: list[str], env_items: list[str], header_items: list[str]
49
+ ) -> dict[str, object]:
50
+ if not command_args:
51
+ raise typer.BadParameter("stdio transport requires a command after '--'")
52
+ if header_items:
53
+ raise typer.BadParameter("--header is only supported for http or sse transport")
54
+
55
+ command, *args = command_args
56
+ config: dict[str, object] = {"command": command}
57
+ if args:
58
+ config["args"] = args
59
+ if env_items:
60
+ config["env"] = {
61
+ key: value
62
+ for key, value in (
63
+ _parse_key_value("--env", item, "=") for item in env_items
64
+ )
65
+ }
66
+ return config
67
+
68
+
69
+ def _build_remote_server_config(
70
+ transport: MCPTransport,
71
+ targets: list[str],
72
+ env_items: list[str],
73
+ header_items: list[str],
74
+ ) -> dict[str, object]:
75
+ if env_items:
76
+ raise typer.BadParameter("--env is only supported for stdio transport")
77
+ if len(targets) != 1:
78
+ raise typer.BadParameter(
79
+ f"{transport.value} transport requires exactly one URL argument"
80
+ )
81
+
82
+ config: dict[str, object] = {"url": targets[0], "transport": transport.value}
83
+ if header_items:
84
+ config["headers"] = {
85
+ key: value
86
+ for key, value in (
87
+ _parse_key_value("--header", item, ":") for item in header_items
88
+ )
89
+ }
90
+ return config
91
+
92
+
93
+ def _build_server_config(
94
+ transport: MCPTransport,
95
+ targets: list[str],
96
+ env_items: list[str],
97
+ header_items: list[str],
98
+ ) -> dict[str, object]:
99
+ if transport is MCPTransport.STDIO:
100
+ return _build_stdio_server_config(targets, env_items, header_items)
101
+ return _build_remote_server_config(transport, targets, env_items, header_items)
102
+
103
+
104
+ def _probe_server(manager: MCPChannel, name: str) -> tuple[bool, str]:
105
+ try:
106
+ manager.bootstrap()
107
+ server = manager.list().get(name)
108
+ if server is None:
109
+ return False, f"MCP server '{name}' is missing after bootstrap."
110
+ rendered = _format_server_list({name: server})
111
+ if not server.connected:
112
+ error_message = server.error or "unknown connection error"
113
+ return False, f"{rendered}\nConnection test failed: {error_message}"
114
+ return True, rendered
115
+ finally:
116
+ asyncio.run(manager.stop())
117
+
118
+
119
+ def make_mcp_command(manager: MCPChannel) -> typer.Typer:
120
+ app = typer.Typer()
121
+
122
+ @app.command("list")
123
+ def list_tools() -> None:
124
+ """List registered MCP tools."""
125
+ manager.bootstrap()
126
+ mcp_tools = manager.list()
127
+ if not mcp_tools:
128
+ typer.echo("No MCP tools registered.")
129
+ return
130
+ typer.echo(_format_server_list(mcp_tools))
131
+
132
+ @app.command("add")
133
+ def add_server(
134
+ transport: MCPTransport = typer.Option(
135
+ ..., "--transport", help="MCP transport: http, sse, or stdio."
136
+ ),
137
+ env: list[str] | None = typer.Option(
138
+ None,
139
+ "--env",
140
+ help="Environment variable in KEY=VALUE format for stdio servers.",
141
+ ),
142
+ header: list[str] | None = typer.Option(
143
+ None,
144
+ "--header",
145
+ help="Header in 'Name: Value' format for http or sse servers.",
146
+ ),
147
+ name: str = typer.Argument(..., help="Server name."),
148
+ target: list[str] = typer.Argument(
149
+ ..., help="URL for remote servers, or '-- <command> [args...]' for stdio."
150
+ ),
151
+ ) -> None:
152
+ """Add an MCP server configuration."""
153
+ server_config = _build_server_config(
154
+ transport,
155
+ target,
156
+ env or [],
157
+ header or [],
158
+ )
159
+ try:
160
+ asyncio.run(manager.add(name, server_config))
161
+ except ValueError as exc:
162
+ raise typer.BadParameter(str(exc)) from exc
163
+
164
+ typer.echo(f"Added MCP server '{name}'.")
165
+ typer.echo(f"Config: {manager.settings.config_path}")
166
+ connected, output = _probe_server(manager, name)
167
+ typer.echo("Connection test: ok" if connected else "Connection test: failed")
168
+ typer.echo(output, err=not connected)
169
+ if not connected:
170
+ raise typer.Exit(code=1)
171
+
172
+ @app.command("remove")
173
+ def remove_server(
174
+ name: str = typer.Argument(..., help="Server name."),
175
+ ) -> None:
176
+ """Remove an MCP server configuration."""
177
+ try:
178
+ asyncio.run(manager.remove(name))
179
+ except KeyError as exc:
180
+ typer.echo(f"MCP server '{name}' does not exist.", err=True)
181
+ raise typer.Exit(code=1) from exc
182
+ except ValueError as exc:
183
+ raise typer.BadParameter(str(exc)) from exc
184
+
185
+ typer.echo(f"Removed MCP server '{name}'.")
186
+ typer.echo(f"Config: {manager.settings.config_path}")
187
+
188
+ return app
@@ -0,0 +1,37 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from pathlib import Path
5
+ from typing import Any
6
+
7
+ import bub
8
+ from pydantic import Field
9
+ from pydantic_settings import SettingsConfigDict
10
+
11
+
12
+ def default_config_path() -> Path:
13
+ return bub.home / "mcp.json"
14
+
15
+
16
+ def read_config(config_file: Path) -> dict[str, Any]:
17
+ if not config_file.exists():
18
+ return {}
19
+ loaded = json.loads(config_file.read_text(encoding="utf-8"))
20
+ if not isinstance(loaded, dict):
21
+ raise RuntimeError("MCP config file must contain a top-level mapping")
22
+ return loaded.get("mcpServers", {})
23
+
24
+
25
+ @bub.config(name="mcp")
26
+ class MCPSettings(bub.Settings):
27
+ model_config = SettingsConfigDict(env_prefix="BUB_MCP_", extra="ignore")
28
+
29
+ config_path: Path = Field(default_factory=default_config_path)
30
+ init_timeout_seconds: float | None = 20.0
31
+
32
+ def read_mcp_servers(self) -> dict[str, Any]:
33
+ return read_config(self.config_path)
34
+
35
+ def write_mcp_servers(self, mcp_servers: dict[str, Any]) -> None:
36
+ config = {"mcpServers": mcp_servers}
37
+ self.config_path.write_text(json.dumps(config, indent=2), encoding="utf-8")
@@ -0,0 +1,351 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import contextlib
5
+ import json
6
+ from dataclasses import dataclass, field
7
+ from typing import Any
8
+
9
+ import fastmcp
10
+ import mcp.types
11
+ import typer
12
+ import bub
13
+ from bub import hookimpl, tool
14
+ from bub.channels import Channel, Lifecycle
15
+ from bub.tools import REGISTRY
16
+ from bub.types import Envelope, MessageHandler, State
17
+ from loguru import logger
18
+ from republic import Tool, ToolContext
19
+
20
+ from bub_mcp.config import MCPSettings
21
+
22
+ TOOL_PREFIX = "mcp."
23
+ LIFECYCLE_CHANNEL_NAME = "mcp.lifecycle"
24
+
25
+
26
+ def _create_fastmcp_client(
27
+ config: dict[str, Any], *, init_timeout_seconds: float | None
28
+ ) -> fastmcp.Client[Any]:
29
+
30
+ kwargs: dict[str, Any] = {}
31
+ if init_timeout_seconds is not None:
32
+ kwargs["init_timeout"] = init_timeout_seconds
33
+ return fastmcp.Client(config, **kwargs)
34
+
35
+
36
+ def _tool_name(server_name: str, remote_name: str) -> str:
37
+ normalized_name = remote_name
38
+ if not remote_name.startswith(f"{server_name}_"):
39
+ normalized_name = f"{server_name}_{remote_name}"
40
+ return f"{TOOL_PREFIX}{normalized_name}"
41
+
42
+
43
+ def _tool_parameters(remote_tool: mcp.types.Tool) -> dict[str, Any]:
44
+ schema = remote_tool.inputSchema
45
+ if isinstance(schema, dict) and schema.get("type") == "object":
46
+ return schema
47
+ return {"type": "object", "properties": {}}
48
+
49
+
50
+ def _render_binary_placeholder(kind: str, item: Any) -> str:
51
+ mime_type = getattr(item, "mimeType", "application/octet-stream")
52
+ return f"[Binary content: {kind} {mime_type}]"
53
+
54
+
55
+ def _format_tool_result(result: Any) -> str:
56
+ content = getattr(result, "content", []) or []
57
+ blocks: list[str] = []
58
+
59
+ for item in content:
60
+ item_type = getattr(item, "type", None)
61
+ if item_type == "text":
62
+ text = getattr(item, "text", "")
63
+ if isinstance(text, str) and text:
64
+ blocks.append(text)
65
+ continue
66
+ if item_type == "resource":
67
+ resource = getattr(item, "resource", None)
68
+ text = getattr(resource, "text", None)
69
+ if isinstance(text, str) and text:
70
+ blocks.append(text)
71
+ continue
72
+ uri = getattr(resource, "uri", "unknown")
73
+ mime_type = getattr(resource, "mimeType", "application/octet-stream")
74
+ blocks.append(f"[Resource content: {uri} ({mime_type})]")
75
+ continue
76
+ if item_type == "image":
77
+ blocks.append(_render_binary_placeholder("image", item))
78
+ continue
79
+ if item_type == "audio":
80
+ blocks.append(_render_binary_placeholder("audio", item))
81
+ continue
82
+
83
+ structured = getattr(result, "structuredContent", None)
84
+ if not blocks and structured is not None:
85
+ return json.dumps(structured, ensure_ascii=False, indent=2, sort_keys=True)
86
+
87
+ rendered = "\n".join(blocks).strip()
88
+ if rendered:
89
+ return rendered
90
+
91
+ is_error = bool(getattr(result, "isError", False))
92
+ return "error: remote MCP tool returned no content" if is_error else "ok"
93
+
94
+
95
+ @dataclass
96
+ class MCPServerState:
97
+ client: Any | None = None
98
+ tools: list[Tool] = field(default_factory=list)
99
+ connected: bool = False
100
+ error: str | None = None
101
+
102
+
103
+ class MCPChannel(Lifecycle):
104
+ name = LIFECYCLE_CHANNEL_NAME
105
+
106
+ def __init__(self) -> None:
107
+ self.settings = bub.ensure_config(MCPSettings)
108
+ self._lock = asyncio.Lock()
109
+ self._bootstrap_task: asyncio.Task[None] | None = None
110
+ self._servers: dict[str, MCPServerState] = {}
111
+ self._stop_event: asyncio.Event | None = None
112
+
113
+ async def start(self, stop_event: asyncio.Event) -> None:
114
+ self._stop_event = stop_event
115
+ if any(server.connected for server in self._servers.values()):
116
+ return
117
+ if self._bootstrap_task is not None and not self._bootstrap_task.done():
118
+ return
119
+ self._bootstrap_task = asyncio.create_task(
120
+ self._bootstrap(stop_event), name="bub-mcp.bootstrap"
121
+ )
122
+
123
+ async def stop(self) -> None:
124
+ task = self._bootstrap_task
125
+ self._bootstrap_task = None
126
+ self._stop_event = None
127
+ if task is not None and not task.done():
128
+ task.cancel()
129
+ with contextlib.suppress(asyncio.CancelledError):
130
+ await task
131
+
132
+ async with self._lock:
133
+ clients = [
134
+ server.client
135
+ for server in self._servers.values()
136
+ if server.client is not None
137
+ ]
138
+ for server in self._servers.values():
139
+ server.client = None
140
+ server.connected = False
141
+
142
+ for client in clients:
143
+ await self._close_client(client)
144
+
145
+ def list(self) -> dict[str, MCPServerState]:
146
+ return self._servers.copy()
147
+
148
+ async def add(self, name: str, server: dict[str, Any]) -> dict[str, dict[str, Any]]:
149
+ server_name = name.strip()
150
+ if not server_name:
151
+ raise ValueError("server name must not be blank")
152
+ if not isinstance(server, dict) or not server:
153
+ raise ValueError("server config must be a non-empty mapping")
154
+
155
+ mcp_servers = self.settings.read_mcp_servers()
156
+
157
+ if server_name in mcp_servers:
158
+ raise ValueError(f"MCP server '{server_name}' already exists")
159
+ mcp_servers[server_name] = server
160
+ self.settings.write_mcp_servers(mcp_servers)
161
+ return mcp_servers
162
+
163
+ async def remove(self, name: str) -> dict[str, dict[str, Any]]:
164
+ server_name = name.strip()
165
+ if not server_name:
166
+ raise ValueError("server name must not be blank")
167
+
168
+ mcp_servers = self.settings.read_mcp_servers()
169
+
170
+ if server_name not in mcp_servers:
171
+ raise KeyError(server_name)
172
+ mcp_servers.pop(server_name)
173
+ self.settings.write_mcp_servers(mcp_servers)
174
+ return mcp_servers
175
+
176
+ async def call_tool(
177
+ self, server_name: str, remote_name: str, arguments: dict[str, Any]
178
+ ) -> str:
179
+ server = self._servers.get(server_name)
180
+ if server is None or server.client is None:
181
+ raise RuntimeError(
182
+ f"MCP client for server '{server_name}' is not connected"
183
+ )
184
+ result = await server.client.call_tool(remote_name, arguments or {})
185
+ return _format_tool_result(result)
186
+
187
+ def bootstrap(self) -> None:
188
+ async def main() -> None:
189
+ stop_event = asyncio.Event()
190
+ await self._bootstrap(stop_event)
191
+
192
+ asyncio.run(main())
193
+
194
+ async def _bootstrap(self, stop_event: asyncio.Event) -> None:
195
+ async with self._lock:
196
+ try:
197
+ config = self.settings.read_mcp_servers()
198
+ if not config:
199
+ self._servers = {}
200
+ return
201
+
202
+ config_items = list(config.items())
203
+ server_states = await asyncio.gather(
204
+ *[
205
+ self._connect_server(server_name, server_config)
206
+ for server_name, server_config in config_items
207
+ ]
208
+ )
209
+
210
+ self._servers = {
211
+ server_name: server_state
212
+ for (server_name, _server_config), server_state in zip(
213
+ config_items, server_states, strict=False
214
+ )
215
+ }
216
+
217
+ for server_name, server in self._servers.items():
218
+ for tool in server.tools:
219
+ REGISTRY[tool.name] = tool
220
+
221
+ if self._servers and not any(
222
+ server.connected for server in self._servers.values()
223
+ ):
224
+ stop_event.set()
225
+ except asyncio.CancelledError:
226
+ for server in self._servers.values():
227
+ if server.client is not None:
228
+ await self._close_client(server.client)
229
+ server.client = None
230
+ server.connected = False
231
+ raise
232
+ except Exception as exc:
233
+ for server in self._servers.values():
234
+ if server.client is not None:
235
+ await self._close_client(server.client)
236
+ server.client = None
237
+ server.connected = False
238
+ logger.warning("bub-mcp bootstrap failed: {}", exc)
239
+ stop_event.set()
240
+
241
+ async def _connect_server(
242
+ self, server_name: str, server_config: dict[str, Any]
243
+ ) -> MCPServerState:
244
+ server = MCPServerState()
245
+ client: Any | None = None
246
+ try:
247
+ client = _create_fastmcp_client(
248
+ {server_name: server_config},
249
+ init_timeout_seconds=self.settings.init_timeout_seconds,
250
+ )
251
+ await client.__aenter__()
252
+ remote_tools = await client.list_tools()
253
+ server.client = client
254
+ server.connected = True
255
+ server.error = None
256
+
257
+ for remote_tool in remote_tools:
258
+ tool = self._build_tool(server_name, remote_tool)
259
+ if tool is not None:
260
+ server.tools.append(tool)
261
+ except asyncio.CancelledError:
262
+ if client is not None:
263
+ await self._close_client(client)
264
+ raise
265
+ except Exception as exc:
266
+ if client is not None:
267
+ await self._close_client(client)
268
+ self._record_failed_server(server_name, server, exc)
269
+ return server
270
+
271
+ def _build_tool(self, server_name: str, remote_tool: mcp.types.Tool) -> Tool | None:
272
+ remote_name = remote_tool.name.strip()
273
+ if not remote_name:
274
+ return None
275
+ bub_name = _tool_name(server_name, remote_name)
276
+ return Tool(
277
+ name=bub_name,
278
+ description=str(remote_tool.description or f"MCP tool {remote_name}"),
279
+ parameters=_tool_parameters(remote_tool),
280
+ handler=self._make_handler(server_name, remote_name),
281
+ )
282
+
283
+ def _record_failed_server(
284
+ self, server_name: str, server: MCPServerState, exc: Exception
285
+ ) -> None:
286
+ error_message = str(exc) or exc.__class__.__name__
287
+ server.client = None
288
+ server.connected = False
289
+ server.error = error_message
290
+ logger.warning(
291
+ "bub-mcp failed to connect MCP server '{}': {}", server_name, error_message
292
+ )
293
+
294
+ def _make_handler(self, server_name: str, remote_name: str):
295
+ async def _handler(**payload: Any) -> str:
296
+ return await self.call_tool(server_name, remote_name, payload)
297
+
298
+ return _handler
299
+
300
+ @staticmethod
301
+ async def _close_client(client: Any) -> None:
302
+ with contextlib.suppress(Exception):
303
+ await client.__aexit__(None, None, None)
304
+
305
+
306
+ class MCPPlugin:
307
+ def __init__(self, framework: Any) -> None:
308
+ del framework
309
+ self._manager = MCPChannel()
310
+
311
+ @hookimpl
312
+ def load_state(self, message: Envelope, session_id: str) -> State:
313
+ return {"mcp": self._manager}
314
+
315
+ @hookimpl
316
+ def provide_channels(self, message_handler: MessageHandler) -> list[Channel]:
317
+ del message_handler
318
+ return [self._manager]
319
+
320
+ @hookimpl
321
+ def register_cli_commands(self, app: typer.Typer) -> None:
322
+ from bub_mcp.cli import make_mcp_command
323
+
324
+ app.add_typer(
325
+ make_mcp_command(self._manager), name="mcp", help="Manage MCP servers"
326
+ )
327
+
328
+
329
+ @tool(name="mcp", context=True)
330
+ def mcp_list(*, context: ToolContext) -> str:
331
+ """List configured MCP servers."""
332
+ manager = context.state.get("mcp")
333
+ if not isinstance(manager, MCPChannel):
334
+ raise RuntimeError("MCP channel is not available in state")
335
+ servers = manager.list()
336
+ if not servers:
337
+ return "No MCP servers configured."
338
+ lines: list[str] = []
339
+ lines.append("🔌 MCP Servers:")
340
+ for name, server in servers.items():
341
+ lines.append(f"- {name}")
342
+ if server.connected:
343
+ lines.append(" Status: Connected")
344
+ lines.append(
345
+ f" Tools: {', '.join(tool.name for tool in server.tools) if server.tools else 'No tools'}"
346
+ )
347
+ else:
348
+ lines.append(" Status: Not connected")
349
+ if server.error:
350
+ lines.append(f" Error: {server.error}")
351
+ return "\n".join(lines)
@@ -0,0 +1 @@
1
+