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 +10 -0
- mcp_plugin/__main__.py +8 -0
- mcp_plugin/cli.py +366 -0
- mcp_plugin/client.py +451 -0
- mcp_plugin/config.py +464 -0
- mcp_plugin/connection.py +1189 -0
- mcp_plugin/i18n.py +16 -0
- mcp_plugin/logging.py +406 -0
- mcp_plugin/oauth.py +419 -0
- mcp_plugin/os_detect.py +106 -0
- mcp_plugin/platform.py +203 -0
- mcp_plugin/process.py +366 -0
- mcp_plugin/server.py +482 -0
- mcp_plugin/server_runtime.py +254 -0
- mcp_plugin/threads.py +73 -0
- mcp_plugin-0.1.1.dist-info/METADATA +122 -0
- mcp_plugin-0.1.1.dist-info/RECORD +19 -0
- mcp_plugin-0.1.1.dist-info/WHEEL +4 -0
- mcp_plugin-0.1.1.dist-info/entry_points.txt +2 -0
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
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())
|