bub-mcp 0.0.1__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.
- bub_mcp/__init__.py +2 -0
- bub_mcp/cli.py +188 -0
- bub_mcp/config.py +37 -0
- bub_mcp/plugin.py +351 -0
- bub_mcp/py.typed +1 -0
- bub_mcp-0.0.1.dist-info/METADATA +80 -0
- bub_mcp-0.0.1.dist-info/RECORD +9 -0
- bub_mcp-0.0.1.dist-info/WHEEL +4 -0
- bub_mcp-0.0.1.dist-info/entry_points.txt +3 -0
bub_mcp/__init__.py
ADDED
bub_mcp/cli.py
ADDED
|
@@ -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
|
bub_mcp/config.py
ADDED
|
@@ -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")
|
bub_mcp/plugin.py
ADDED
|
@@ -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)
|
bub_mcp/py.typed
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -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,9 @@
|
|
|
1
|
+
bub_mcp/__init__.py,sha256=cYLz8ZNXnaQvpf33YumT-yuRbFq5P_3I-JMf_woLZoo,31
|
|
2
|
+
bub_mcp/cli.py,sha256=GXBWYkdt9WuwWRRH9b1vbGPuKeKbjebne58cU1m5gSQ,6157
|
|
3
|
+
bub_mcp/config.py,sha256=KTsBQ6ObTjOsUp9oDJAh8p1unT_gvS0qj0R19yWgrYg,1128
|
|
4
|
+
bub_mcp/plugin.py,sha256=oSq4NeuIGnXbXHws1rJ7CZBeIbxb1jj_dDm-gsU30uM,12354
|
|
5
|
+
bub_mcp/py.typed,sha256=AbpHGcgLb-kRsJGnwFEktk7uzpZOCcBY74-YBdrKVGs,1
|
|
6
|
+
bub_mcp-0.0.1.dist-info/WHEEL,sha256=wXwAVsgVaOZ_pwDFqQm5Rd6PID-Fc74nkLc8X8gHiDo,81
|
|
7
|
+
bub_mcp-0.0.1.dist-info/entry_points.txt,sha256=rqSUZzidIByzXcMrb7QNvpr-f5D_mzMjryyjVdWWwKc,38
|
|
8
|
+
bub_mcp-0.0.1.dist-info/METADATA,sha256=Rcn7jqkrYA4wP1C3ue77CAiJrQUVdlCK6U5AYe6Fn2M,1512
|
|
9
|
+
bub_mcp-0.0.1.dist-info/RECORD,,
|