mcp-plugin 0.1.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.
mcp_plugin/__init__.py ADDED
@@ -0,0 +1,10 @@
1
+ """mcp_plugin — standalone MCP gateway.
2
+
3
+ Persistent connections to external MCP servers (stdio, SSE, streamable HTTP),
4
+ OAuth 2.0 device-code flow, and a management CLI. Ships with Slife as its MCP
5
+ plugin but has no dependency on it.
6
+ """
7
+
8
+ __version__ = "0.1.0"
9
+
10
+ __all__ = ["__version__"]
mcp_plugin/__main__.py ADDED
@@ -0,0 +1,8 @@
1
+ """Entry point for `python -m mcp_plugin` and the `mcp-plugin` console script."""
2
+
3
+ import sys
4
+
5
+ from mcp_plugin.cli import main
6
+
7
+ if __name__ == "__main__":
8
+ sys.exit(main())
mcp_plugin/cli.py ADDED
@@ -0,0 +1,366 @@
1
+ """mcp-plugin CLI — configure and test external MCP servers.
2
+
3
+ Commands:
4
+ ``mcp-plugin`` overview of configured servers
5
+ ``mcp-plugin set <s>`` interactively add/configure a server
6
+ ``mcp-plugin remove <s>`` remove a server (takes effect next server start)
7
+ ``mcp-plugin test [--port N]`` start the plugin server and verify it serves MCP
8
+ ``mcp-plugin test mcp <s>`` bare-connect to one server (no framework) and list its tools
9
+
10
+ The CLI is a thin front-end over the same library the server uses: reads and
11
+ writes mcp-plugin.json5 through :mod:`mcp_plugin.config` and connects through
12
+ :class:`mcp_plugin.connection.ConnectionPool`.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import argparse
18
+ import asyncio
19
+ import getpass
20
+ import os
21
+ import sys
22
+
23
+ from mcp_plugin import config as plugin_config
24
+ from mcp_plugin.client import MCPClient
25
+ from mcp_plugin.config import _is_env_ref, _resolve_embedded_refs, _resolve_secret
26
+ from mcp_plugin.connection import ServerConfig
27
+ from mcp_plugin.platform import kill_process_tree, resolve_command
28
+
29
+ # Snakeable for tests.
30
+ _input_fn = input
31
+ _getpass_fn = getpass.getpass
32
+
33
+
34
+ def build_parser() -> argparse.ArgumentParser:
35
+ parser = argparse.ArgumentParser(
36
+ prog="mcp-plugin",
37
+ description="Standalone MCP gateway — manage external MCP servers.",
38
+ epilog="Run 'mcp-plugin' with no subcommand to show an overview "
39
+ "of the configured MCP servers (name + description).",
40
+ )
41
+ sub = parser.add_subparsers(dest="command")
42
+
43
+ p_set = sub.add_parser("set", help="Interactively add/configure a server.")
44
+ p_set.add_argument("server", help="Server name.")
45
+
46
+ p_remove = sub.add_parser("remove", help="Remove a server from config.")
47
+ p_remove.add_argument("server", help="Server name.")
48
+
49
+ p_test = sub.add_parser(
50
+ "test", help="Start the plugin server and verify it, or bare-check one MCP server."
51
+ )
52
+ p_test.add_argument(
53
+ "--port", type=int, default=0,
54
+ help="Port for the plugin server (default: auto-assign a free port).",
55
+ )
56
+ test_sub = p_test.add_subparsers(dest="test_command")
57
+ p_test_mcp = test_sub.add_parser(
58
+ "mcp", help="Bare-connect to a server (no framework) and list its tools."
59
+ )
60
+ p_test_mcp.add_argument("server", help="MCP server name.")
61
+
62
+ return parser
63
+
64
+
65
+ def main(argv: list[str] | None = None) -> int:
66
+ args = build_parser().parse_args(argv)
67
+
68
+ command = args.command
69
+ if command is None:
70
+ return _overview()
71
+ if command == "set":
72
+ return _set_cmd(args.server)
73
+ if command == "remove":
74
+ return _remove_cmd(args.server)
75
+ if command == "test":
76
+ return asyncio.run(_test_cmd(args))
77
+ print(f"Unknown command: {command}")
78
+ return 2
79
+
80
+
81
+ # ── Overview ────────────────────────────────────────────────────────────
82
+
83
+
84
+ def _overview() -> int:
85
+ path = plugin_config.current_path()
86
+ raw = plugin_config.load_config(path)
87
+ servers = plugin_config._servers_dict(raw)
88
+ print(f"mcp-plugin config: {path}")
89
+ items = [(n, e) for n, e in servers.items() if isinstance(e, dict)]
90
+ if not items:
91
+ print("No servers configured. Use 'mcp-plugin set <server>' to add one.")
92
+ return 0
93
+ width = len(str(len(items)))
94
+ for i, (name, entry) in enumerate(items, 1):
95
+ transport = "http" if entry.get("url") else "stdio"
96
+ target = entry.get("url") or entry.get("command") or ""
97
+ flag = "" if entry.get("enabled") is not False else " (disabled)"
98
+ print(f" {i:>{width}}. {name:<18} {transport:<5} {target}{flag}")
99
+ if entry.get("description"):
100
+ print(f"{' ' * (width + 8)}{entry['description']}")
101
+ return 0
102
+
103
+
104
+ # ── test ────────────────────────────────────────────────────────────────
105
+
106
+
107
+ async def _test_cmd(args: argparse.Namespace) -> int:
108
+ if args.test_command == "mcp":
109
+ return await _test_mcp_cmd(args.server)
110
+ return await _test_plugin_cmd(port=args.port)
111
+
112
+
113
+ async def _test_plugin_cmd(*, port: int = 0) -> int:
114
+ """Start the REAL plugin server and verify it serves MCP end-to-end.
115
+
116
+ Spawns ``python -m mcp_plugin.server`` (the same entry Slife launches),
117
+ reads its ``{"port": N}`` ready signal, connects over Streamable HTTP,
118
+ and reports the external servers the plugin auto-connected. This drives
119
+ the plugin's actual startup path — not a re-implementation of it.
120
+ """
121
+ import json
122
+
123
+ cmd = [sys.executable, "-m", "mcp_plugin.server"]
124
+ if port:
125
+ cmd += ["--port", str(port)]
126
+ print(f"plugin startup: spawning {' '.join(cmd)} ...")
127
+ proc = await asyncio.create_subprocess_exec(
128
+ *cmd,
129
+ stdin=asyncio.subprocess.DEVNULL,
130
+ stdout=asyncio.subprocess.PIPE,
131
+ stderr=asyncio.subprocess.DEVNULL,
132
+ )
133
+ client: MCPClient | None = None
134
+ try:
135
+ stdout = proc.stdout
136
+ if stdout is None:
137
+ print("[FAIL] plugin server produced no stdout stream")
138
+ return 1
139
+ try:
140
+ line = await asyncio.wait_for(stdout.readline(), timeout=30.0)
141
+ serving_port = int(json.loads(line.decode("utf-8").strip())["port"])
142
+ except Exception as e: # noqa: BLE001 - any failure to read the port
143
+ print(f"[FAIL] plugin server did not signal a port: {e}")
144
+ return 1
145
+
146
+ url = f"http://127.0.0.1:{serving_port}/mcp"
147
+ print(f"plugin server: ready on port {serving_port}")
148
+ client = MCPClient()
149
+ try:
150
+ await client.connect(url)
151
+ except Exception as e: # noqa: BLE001 - report connect failure
152
+ print(f"[FAIL] plugin MCP connect failed: {type(e).__name__}: {e}")
153
+ return 1
154
+
155
+ tools = await client.list_tools()
156
+ mgmt = sorted({t.get("name", "") for t in tools} & _MANAGEMENT_TOOLS)
157
+ print(f"[OK] plugin connected: {len(tools)} tools, "
158
+ f"{len(mgmt)} management tools ({', '.join(mgmt)})")
159
+
160
+ servers = await _plugin_servers_report(client)
161
+ if not servers:
162
+ print(" (no external servers reported)")
163
+ for name, info in servers.items():
164
+ if info.get("state") == "running":
165
+ print(f" [OK] {name}: running, {info.get('tool_count', 0)} tools")
166
+ else:
167
+ err = f" — {info.get('error')}" if info.get("error") else ""
168
+ print(f" [--] {name}: {info.get('status', 'stopped')}{err}")
169
+ return 0
170
+ finally:
171
+ if client is not None:
172
+ try:
173
+ await client.disconnect()
174
+ except Exception: # noqa: BLE001, S110 - best-effort cleanup
175
+ pass
176
+ # Kill the plugin AND its spawned external servers (orphan-free).
177
+ await kill_process_tree(proc)
178
+ try:
179
+ await asyncio.wait_for(proc.wait(), timeout=5.0)
180
+ except TimeoutError:
181
+ proc.kill()
182
+
183
+
184
+ _MANAGEMENT_TOOLS = frozenset({
185
+ "mcp_list", "mcp_set", "mcp_remove", "mcp_set_enabled",
186
+ "mcp_list_tools", "__mcp_call_tool", "__mcp_connection_status",
187
+ })
188
+
189
+
190
+ async def _plugin_servers_report(client: MCPClient, timeout: float = 25.0) -> dict:
191
+ """Poll the plugin's live connection status until servers settle (≤*timeout*).
192
+
193
+ The plugin's lifespan auto-connects enabled servers concurrently and
194
+ fire-and-forget (so the ready signal is never blocked), so the test waits
195
+ up to *timeout* for them to reach a terminal state, mirroring how the
196
+ agent discovers the plugin's connections.
197
+ """
198
+ import json
199
+
200
+ deadline = asyncio.get_running_loop().time() + timeout
201
+ report: dict = {}
202
+ while True:
203
+ raw = await client.call_tool("__mcp_connection_status", {})
204
+ try:
205
+ report = {s["name"]: s for s in json.loads(raw)}
206
+ except json.JSONDecodeError: # status not ready yet — tool returned "Error: …"
207
+ pass
208
+ pending = [
209
+ s for s in report.values()
210
+ if s.get("status") in ("connecting", "disconnected")
211
+ ]
212
+ if not pending or asyncio.get_running_loop().time() >= deadline:
213
+ break
214
+ await asyncio.sleep(0.5)
215
+ return report
216
+
217
+
218
+ async def _test_mcp_cmd(server: str) -> int:
219
+ """Bare-connect to one MCP server (no pool/framework) to confirm it works."""
220
+ raw = plugin_config.load_config()
221
+ servers = plugin_config._servers_dict(raw)
222
+ entry = servers.get(server)
223
+ if not isinstance(entry, dict):
224
+ print(f"Server '{server}' is not configured.")
225
+ return 1
226
+ cfg = plugin_config.resolve_server_config(server, entry)
227
+ print(f"[connect] {server} ({cfg.transport}) ...")
228
+ try:
229
+ if cfg.transport == "http":
230
+ tools, info = await _raw_connect_http(cfg)
231
+ else:
232
+ tools, info = await _raw_connect_stdio(cfg)
233
+ except Exception as e: # noqa: BLE001 - report any bare-connect failure
234
+ print(f"[FAIL] {server}: {type(e).__name__}: {e}")
235
+ return 1
236
+ print(f"[OK] {server}: connected ({info}), {len(tools)} tools")
237
+ for tool in tools:
238
+ print(f" {tool}")
239
+ return 0
240
+
241
+
242
+ async def _raw_connect_stdio(cfg: ServerConfig) -> tuple[list[str], str]:
243
+ """Spawn *cfg* as a subprocess and bare-connect over stdio (no framework)."""
244
+ from mcp import ClientSession
245
+ from mcp.client.stdio import StdioServerParameters, stdio_client
246
+
247
+ env = dict(os.environ)
248
+ if cfg.env:
249
+ env.update({k: _resolve_secret(v) for k, v in cfg.env.items()})
250
+ resolved_args = [
251
+ _resolve_secret(arg) if _is_env_ref(arg) else _resolve_embedded_refs(arg)
252
+ for arg in cfg.args
253
+ ]
254
+ if cfg.os_paths:
255
+ from mcp_plugin.os_detect import get_os_accessible_paths
256
+ for p in get_os_accessible_paths():
257
+ resolved_args += ["--allow-path", p]
258
+ params = StdioServerParameters(
259
+ command=resolve_command(cfg.command), args=resolved_args, env=env or None,
260
+ )
261
+ async with stdio_client(params) as (read, write), ClientSession(read, write) as session:
262
+ init = await session.initialize()
263
+ result = await session.list_tools()
264
+ info = f"{init.serverInfo.name} {init.serverInfo.version}".strip()
265
+ return [t.name for t in result.tools], info
266
+
267
+
268
+ async def _raw_connect_http(cfg: ServerConfig) -> tuple[list[str], str]:
269
+ """Bare-connect to an HTTP MCP endpoint (no framework)."""
270
+ import httpx
271
+ from mcp import ClientSession
272
+ from mcp.client.streamable_http import streamable_http_client
273
+
274
+ url = _resolve_embedded_refs(cfg.url)
275
+ headers = {}
276
+ if cfg.headers:
277
+ headers = {k: _resolve_embedded_refs(v) for k, v in cfg.headers.items()}
278
+ # The SDK does not own a caller-provided http_client, so it is created and
279
+ # closed here (headers carry e.g. bearer-token auth).
280
+ async with httpx.AsyncClient(
281
+ headers=headers,
282
+ timeout=httpx.Timeout(connect=10.0, read=None, write=None, pool=10.0),
283
+ ) as http_client, streamable_http_client(
284
+ url, http_client=http_client,
285
+ ) as (read, write, _), ClientSession(read, write) as session:
286
+ init = await session.initialize()
287
+ result = await session.list_tools()
288
+ info = f"{init.serverInfo.name} {init.serverInfo.version}".strip()
289
+ return [t.name for t in result.tools], info
290
+
291
+
292
+ # ── remove ──────────────────────────────────────────────────────────────
293
+
294
+
295
+ def _remove_cmd(server: str) -> int:
296
+ if plugin_config.remove_server_entry(server):
297
+ print(f"[OK] Removed server '{server}' "
298
+ "(takes effect at the next server start).")
299
+ return 0
300
+ print(f"Server '{server}' is not configured.")
301
+ return 1
302
+
303
+
304
+ # ── set (interactive) ───────────────────────────────────────────────────
305
+
306
+
307
+ def _prompt(label: str, default: str = "") -> str:
308
+ if default:
309
+ raw = _input_fn(f"{label} [{default}]: ").strip()
310
+ return raw or default
311
+ return _input_fn(f"{label}: ").strip()
312
+
313
+
314
+ def _ask_yes_no(label: str, default: str = "no") -> bool:
315
+ return _prompt(label, default).strip().lower() in ("y", "yes")
316
+
317
+
318
+ def _set_cmd(server: str) -> int:
319
+ print(f"Configuring server '{server}' "
320
+ f"(config: {plugin_config.current_path()}).")
321
+ entry: dict = {}
322
+
323
+ transport = _prompt("Transport", "stdio")
324
+ if transport == "http":
325
+ entry["url"] = _prompt("URL (SSE or /mcp endpoint)")
326
+ else:
327
+ entry["command"] = _prompt("Command (e.g. npx)")
328
+ args_raw = _prompt("Args (space-separated; empty to skip)")
329
+ if args_raw:
330
+ entry["args"] = args_raw.split()
331
+
332
+ env_raw = _prompt("Env overrides (KEY=VALUE, comma-separated; empty to skip)")
333
+ if env_raw:
334
+ entry["env"] = {
335
+ kv.split("=", 1)[0]: kv.split("=", 1)[1]
336
+ for kv in env_raw.split(",")
337
+ if "=" in kv
338
+ }
339
+
340
+ desc = _prompt("Description (empty to skip)")
341
+ if desc:
342
+ entry["description"] = desc
343
+
344
+ if _ask_yes_no("OAuth 2.0 device flow?"):
345
+ auth: dict = {
346
+ "type": "oauth",
347
+ "device_auth_url": _prompt("Device auth URL"),
348
+ "token_url": _prompt("Token URL"),
349
+ "client_id": _prompt("Client ID"),
350
+ }
351
+ if _ask_yes_no("Client secret?"):
352
+ auth["client_secret"] = _getpass_fn("Client secret (hidden): ")
353
+ scopes = _prompt("Scopes (space-separated; empty to skip)")
354
+ if scopes:
355
+ auth["scopes"] = scopes.split()
356
+ entry["auth"] = auth
357
+
358
+ plugin_config.add_server_entry(server, entry)
359
+ print(f"[OK] Saved server '{server}'.")
360
+ if _ask_yes_no("Test connection now?"):
361
+ return asyncio.run(_test_mcp_cmd(server))
362
+ return 0
363
+
364
+
365
+ if __name__ == "__main__":
366
+ sys.exit(main())