mcp-switchboard-client 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.
- mcp_switchboard_client/__init__.py +9 -0
- mcp_switchboard_client/__main__.py +6 -0
- mcp_switchboard_client/cli.py +288 -0
- mcp_switchboard_client/config.py +145 -0
- mcp_switchboard_client/envconf.py +117 -0
- mcp_switchboard_client/protocol.py +93 -0
- mcp_switchboard_client/supervisor.py +219 -0
- mcp_switchboard_client/tunnel.py +390 -0
- mcp_switchboard_client-0.2.0.dist-info/METADATA +64 -0
- mcp_switchboard_client-0.2.0.dist-info/RECORD +12 -0
- mcp_switchboard_client-0.2.0.dist-info/WHEEL +4 -0
- mcp_switchboard_client-0.2.0.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
"""mcp-switchboard-client: tunnels local stdio MCP servers to a switchboard hub.
|
|
2
|
+
|
|
3
|
+
A dumb pipe by design - it speaks no MCP and has no `mcp` dependency. It spawns
|
|
4
|
+
the local servers listed in mcp.json and shuttles their stdin/stdout over one
|
|
5
|
+
outbound WebSocket, tagged with the server name. Every MCP concern lives in the
|
|
6
|
+
hub. See docs/PROTOCOL.md.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
__version__ = "0.2.0"
|
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
"""Console-script entry point: ``mcp-switchboard-client``.
|
|
2
|
+
|
|
3
|
+
Settings come from the environment (prefix ``MCP_SWITCHBOARD_``), optionally
|
|
4
|
+
seeded from a .env file, and every one of them can be overridden by a command
|
|
5
|
+
line flag. Real environment variables win over the .env file; flags win over
|
|
6
|
+
both.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import argparse
|
|
12
|
+
import asyncio
|
|
13
|
+
import logging
|
|
14
|
+
import signal
|
|
15
|
+
import socket
|
|
16
|
+
import sys
|
|
17
|
+
from dataclasses import dataclass
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from typing import List, Optional, Sequence
|
|
20
|
+
|
|
21
|
+
from . import envconf, protocol
|
|
22
|
+
from .config import ConfigError as McpConfigError
|
|
23
|
+
from .config import ServerSpec, load_config
|
|
24
|
+
from .envconf import ConfigError
|
|
25
|
+
from .tunnel import (
|
|
26
|
+
DEFAULT_MAX_RETRIES,
|
|
27
|
+
DEFAULT_RECONNECT_DELAY,
|
|
28
|
+
HubConnection,
|
|
29
|
+
TunnelError,
|
|
30
|
+
TunnelSettings,
|
|
31
|
+
normalize_hub_url,
|
|
32
|
+
)
|
|
33
|
+
from . import __version__
|
|
34
|
+
|
|
35
|
+
PROG = "mcp-switchboard-client"
|
|
36
|
+
DEFAULT_CONFIG_NAME = "mcp.json"
|
|
37
|
+
DEFAULT_ENV_FILE_NAME = ".env"
|
|
38
|
+
DEFAULT_LOG_LEVEL = "INFO"
|
|
39
|
+
LOG_LEVELS = ["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]
|
|
40
|
+
|
|
41
|
+
LOGGER = logging.getLogger("mcp_switchboard_client")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass
|
|
45
|
+
class Settings:
|
|
46
|
+
hub_url: str
|
|
47
|
+
token: str
|
|
48
|
+
label: str
|
|
49
|
+
config_path: Path
|
|
50
|
+
reconnect_delay: float = DEFAULT_RECONNECT_DELAY
|
|
51
|
+
max_retries: int = DEFAULT_MAX_RETRIES
|
|
52
|
+
log_level: str = DEFAULT_LOG_LEVEL
|
|
53
|
+
|
|
54
|
+
def tunnel_settings(self) -> TunnelSettings:
|
|
55
|
+
return TunnelSettings(
|
|
56
|
+
hub_url=self.hub_url,
|
|
57
|
+
token=self.token,
|
|
58
|
+
label=self.label,
|
|
59
|
+
reconnect_delay=self.reconnect_delay,
|
|
60
|
+
max_retries=self.max_retries,
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
65
|
+
parser = argparse.ArgumentParser(
|
|
66
|
+
prog=PROG,
|
|
67
|
+
description=(
|
|
68
|
+
"Tunnel local stdio MCP servers to an mcp-switchboard hub over a single "
|
|
69
|
+
"outbound, authenticated WebSocket."
|
|
70
|
+
),
|
|
71
|
+
allow_abbrev=False,
|
|
72
|
+
)
|
|
73
|
+
parser.add_argument("--version", action="version", version=f"{PROG} {__version__}")
|
|
74
|
+
parser.add_argument(
|
|
75
|
+
"--hub-url",
|
|
76
|
+
default=None,
|
|
77
|
+
help=f"Hub URL, e.g. wss://hub.example.com (or {envconf.PREFIX}HUB_URL)",
|
|
78
|
+
)
|
|
79
|
+
parser.add_argument(
|
|
80
|
+
"--token",
|
|
81
|
+
default=None,
|
|
82
|
+
help=(
|
|
83
|
+
f"Tunnel token, or the path to a file holding it "
|
|
84
|
+
f"(or {envconf.PREFIX}TUNNEL_TOKEN)"
|
|
85
|
+
),
|
|
86
|
+
)
|
|
87
|
+
parser.add_argument(
|
|
88
|
+
"--label",
|
|
89
|
+
default=None,
|
|
90
|
+
help=(
|
|
91
|
+
f"Name this machine is tagged with in the hub, default the hostname "
|
|
92
|
+
f"(or {envconf.PREFIX}LABEL)"
|
|
93
|
+
),
|
|
94
|
+
)
|
|
95
|
+
parser.add_argument(
|
|
96
|
+
"--config",
|
|
97
|
+
default=None,
|
|
98
|
+
help=(
|
|
99
|
+
f"MCP server config file, resolved against the current directory "
|
|
100
|
+
f"(default ./{DEFAULT_CONFIG_NAME}, or {envconf.PREFIX}CONFIG)"
|
|
101
|
+
),
|
|
102
|
+
)
|
|
103
|
+
parser.add_argument(
|
|
104
|
+
"--env-file",
|
|
105
|
+
default=None,
|
|
106
|
+
help=f"Env file to load settings from (default ./{DEFAULT_ENV_FILE_NAME} if present)",
|
|
107
|
+
)
|
|
108
|
+
parser.add_argument(
|
|
109
|
+
"--reconnect-delay",
|
|
110
|
+
type=float,
|
|
111
|
+
default=None,
|
|
112
|
+
help=(
|
|
113
|
+
"Initial reconnect delay in seconds, doubling up to 60s "
|
|
114
|
+
f"(default {DEFAULT_RECONNECT_DELAY}, or {envconf.PREFIX}RECONNECT_DELAY)"
|
|
115
|
+
),
|
|
116
|
+
)
|
|
117
|
+
parser.add_argument(
|
|
118
|
+
"--max-retries",
|
|
119
|
+
type=int,
|
|
120
|
+
default=None,
|
|
121
|
+
help=(
|
|
122
|
+
"Maximum reconnect attempts, 0 = infinite "
|
|
123
|
+
f"(default {DEFAULT_MAX_RETRIES}, or {envconf.PREFIX}MAX_RETRIES)"
|
|
124
|
+
),
|
|
125
|
+
)
|
|
126
|
+
parser.add_argument(
|
|
127
|
+
"--log-level",
|
|
128
|
+
choices=LOG_LEVELS,
|
|
129
|
+
default=None,
|
|
130
|
+
help=f"Log level (default {DEFAULT_LOG_LEVEL}, or {envconf.PREFIX}LOG_LEVEL)",
|
|
131
|
+
)
|
|
132
|
+
return parser
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def parse_args(argv: Optional[Sequence[str]] = None) -> argparse.Namespace:
|
|
136
|
+
# argparse rejects unknown arguments with a usage message and exit code 2,
|
|
137
|
+
# which is what we want: the shell installer forwards its args blindly.
|
|
138
|
+
return build_parser().parse_args(argv)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def load_settings(args: argparse.Namespace) -> Settings:
|
|
142
|
+
"""Resolve flags + env + .env into Settings, or raise ConfigError."""
|
|
143
|
+
env_file = Path(args.env_file) if args.env_file else Path.cwd() / DEFAULT_ENV_FILE_NAME
|
|
144
|
+
if args.env_file and not env_file.is_file():
|
|
145
|
+
raise ConfigError(f"env file not found: {env_file}")
|
|
146
|
+
envconf.load_env_file(env_file)
|
|
147
|
+
|
|
148
|
+
hub_url = args.hub_url or envconf.get("HUB_URL")
|
|
149
|
+
if not hub_url:
|
|
150
|
+
raise ConfigError(
|
|
151
|
+
f"no hub URL: pass --hub-url or set {envconf.PREFIX}HUB_URL"
|
|
152
|
+
)
|
|
153
|
+
try:
|
|
154
|
+
hub_url = normalize_hub_url(hub_url)
|
|
155
|
+
except TunnelError as e:
|
|
156
|
+
raise ConfigError(str(e)) from e
|
|
157
|
+
|
|
158
|
+
token = args.token or envconf.get("TUNNEL_TOKEN", secret=True)
|
|
159
|
+
if not token:
|
|
160
|
+
raise ConfigError(
|
|
161
|
+
f"no tunnel token: pass --token or set {envconf.PREFIX}TUNNEL_TOKEN "
|
|
162
|
+
"(either the token itself or the path to a file holding it)"
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
label = args.label or envconf.get("LABEL") or socket.gethostname()
|
|
166
|
+
try:
|
|
167
|
+
protocol.validate_name(label, "label")
|
|
168
|
+
except protocol.ProtocolError as e:
|
|
169
|
+
raise ConfigError(str(e)) from e
|
|
170
|
+
|
|
171
|
+
# The config path is resolved strictly against the current directory;
|
|
172
|
+
# there is no upward search for an mcp.json.
|
|
173
|
+
raw_config = args.config or envconf.get("CONFIG") or DEFAULT_CONFIG_NAME
|
|
174
|
+
config_path = Path(raw_config)
|
|
175
|
+
if not config_path.is_absolute():
|
|
176
|
+
config_path = Path.cwd() / config_path
|
|
177
|
+
|
|
178
|
+
reconnect_delay = (
|
|
179
|
+
args.reconnect_delay
|
|
180
|
+
if args.reconnect_delay is not None
|
|
181
|
+
else envconf.get_float("RECONNECT_DELAY", DEFAULT_RECONNECT_DELAY)
|
|
182
|
+
)
|
|
183
|
+
if reconnect_delay < 0:
|
|
184
|
+
raise ConfigError("reconnect delay must not be negative")
|
|
185
|
+
|
|
186
|
+
max_retries = (
|
|
187
|
+
args.max_retries
|
|
188
|
+
if args.max_retries is not None
|
|
189
|
+
else envconf.get_int("MAX_RETRIES", DEFAULT_MAX_RETRIES)
|
|
190
|
+
)
|
|
191
|
+
if max_retries < 0:
|
|
192
|
+
raise ConfigError("max retries must not be negative (0 means retry forever)")
|
|
193
|
+
|
|
194
|
+
log_level = (args.log_level or envconf.get("LOG_LEVEL") or DEFAULT_LOG_LEVEL).upper()
|
|
195
|
+
if log_level not in LOG_LEVELS:
|
|
196
|
+
raise ConfigError(f"unknown log level {log_level!r} (one of {', '.join(LOG_LEVELS)})")
|
|
197
|
+
|
|
198
|
+
return Settings(
|
|
199
|
+
hub_url=hub_url,
|
|
200
|
+
token=token,
|
|
201
|
+
label=label,
|
|
202
|
+
config_path=config_path,
|
|
203
|
+
reconnect_delay=reconnect_delay,
|
|
204
|
+
max_retries=max_retries,
|
|
205
|
+
log_level=log_level,
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def build_settings(argv: Optional[Sequence[str]] = None) -> Settings:
|
|
210
|
+
return load_settings(parse_args(argv))
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def load_servers(path: Path) -> List[ServerSpec]:
|
|
214
|
+
"""Load the MCP config, rejecting names the hub could never accept."""
|
|
215
|
+
specs = load_config(path)
|
|
216
|
+
for spec in specs:
|
|
217
|
+
try:
|
|
218
|
+
protocol.validate_name(spec.name, "server name")
|
|
219
|
+
except protocol.ProtocolError as e:
|
|
220
|
+
raise McpConfigError(f"{e} (in {path})") from e
|
|
221
|
+
return specs
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def setup_logging(level: str) -> None:
|
|
225
|
+
logging.basicConfig(
|
|
226
|
+
level=getattr(logging, level, logging.INFO),
|
|
227
|
+
format="%(asctime)s %(name)s %(levelname)s: %(message)s",
|
|
228
|
+
datefmt="%Y-%m-%dT%H:%M:%S",
|
|
229
|
+
stream=sys.stderr,
|
|
230
|
+
)
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
async def _run(settings: Settings, specs: List[ServerSpec]) -> None:
|
|
234
|
+
connection = HubConnection(specs, settings.tunnel_settings(), version=__version__)
|
|
235
|
+
|
|
236
|
+
def request_stop() -> None:
|
|
237
|
+
LOGGER.info("shutdown signal received")
|
|
238
|
+
connection.request_stop()
|
|
239
|
+
|
|
240
|
+
loop = asyncio.get_running_loop()
|
|
241
|
+
for sig in (signal.SIGINT, signal.SIGTERM):
|
|
242
|
+
try:
|
|
243
|
+
loop.add_signal_handler(sig, request_stop)
|
|
244
|
+
except (NotImplementedError, RuntimeError, ValueError):
|
|
245
|
+
# Windows event loops, and loops not running on the main thread.
|
|
246
|
+
try:
|
|
247
|
+
signal.signal(sig, lambda *_a: request_stop())
|
|
248
|
+
except (ValueError, OSError):
|
|
249
|
+
LOGGER.debug("cannot install a handler for %s", sig)
|
|
250
|
+
|
|
251
|
+
LOGGER.info(
|
|
252
|
+
"tunneling %d server(s) as %s: %s",
|
|
253
|
+
len(specs),
|
|
254
|
+
settings.label,
|
|
255
|
+
", ".join(spec.name for spec in specs),
|
|
256
|
+
)
|
|
257
|
+
await connection.run()
|
|
258
|
+
LOGGER.info("shutdown complete")
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def main(argv: Optional[Sequence[str]] = None) -> None:
|
|
262
|
+
args = parse_args(argv)
|
|
263
|
+
try:
|
|
264
|
+
settings = load_settings(args)
|
|
265
|
+
except ConfigError as e:
|
|
266
|
+
print(f"{PROG}: error: {e}", file=sys.stderr)
|
|
267
|
+
sys.exit(1)
|
|
268
|
+
|
|
269
|
+
setup_logging(settings.log_level)
|
|
270
|
+
|
|
271
|
+
try:
|
|
272
|
+
specs = load_servers(settings.config_path)
|
|
273
|
+
except McpConfigError as e:
|
|
274
|
+
print(f"{PROG}: error: {e}", file=sys.stderr)
|
|
275
|
+
sys.exit(1)
|
|
276
|
+
|
|
277
|
+
try:
|
|
278
|
+
asyncio.run(_run(settings, specs))
|
|
279
|
+
except KeyboardInterrupt:
|
|
280
|
+
pass
|
|
281
|
+
except TunnelError as e:
|
|
282
|
+
print(f"{PROG}: error: {e}", file=sys.stderr)
|
|
283
|
+
sys.exit(1)
|
|
284
|
+
sys.exit(0)
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
if __name__ == "__main__":
|
|
288
|
+
main()
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
"""Load and normalize MCP server configs into a uniform list of ServerSpec.
|
|
2
|
+
|
|
3
|
+
Two config shapes are supported (see README.md for the full discriminator rule):
|
|
4
|
+
|
|
5
|
+
1. Plain ``mcpServers`` entries: ``{"command": ..., "args": [...], "env": {...}}``.
|
|
6
|
+
Spawned directly as the local stdio subprocess.
|
|
7
|
+
2. FastMCP-style entries: any entry (or the whole config file, if it has no
|
|
8
|
+
``mcpServers`` wrapper) that contains a top-level ``source`` key is treated
|
|
9
|
+
as a FastMCP config (https://gofastmcp.com/public/schemas/fastmcp.json/v1.json)
|
|
10
|
+
and launched via ``fastmcp run <generated-config>`` instead of being spawned
|
|
11
|
+
directly. The discriminator is exactly: presence of a ``source`` key.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import json
|
|
17
|
+
import logging
|
|
18
|
+
import tempfile
|
|
19
|
+
from dataclasses import dataclass, field
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
from typing import Any, Dict, List, Optional
|
|
22
|
+
|
|
23
|
+
LOGGER = logging.getLogger("mcp_switchboard_client.config")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class ConfigError(Exception):
|
|
27
|
+
"""Raised for any problem loading or interpreting the config file."""
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass
|
|
31
|
+
class ServerSpec:
|
|
32
|
+
"""A single local MCP server to launch and tunnel."""
|
|
33
|
+
|
|
34
|
+
name: str
|
|
35
|
+
argv: List[str]
|
|
36
|
+
env: Dict[str, str] = field(default_factory=dict)
|
|
37
|
+
cwd: Optional[str] = None
|
|
38
|
+
# Set when this spec was materialized from a FastMCP-style entry, so the
|
|
39
|
+
# generated temp config file can be cleaned up on shutdown.
|
|
40
|
+
fastmcp_tempfile: Optional[Path] = None
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def load_config(path: Path) -> List[ServerSpec]:
|
|
44
|
+
"""Load ``path`` and return the list of servers to tunnel.
|
|
45
|
+
|
|
46
|
+
Raises:
|
|
47
|
+
ConfigError: if the file is missing, not valid JSON, or doesn't match
|
|
48
|
+
either supported shape.
|
|
49
|
+
"""
|
|
50
|
+
if not path.is_file():
|
|
51
|
+
raise ConfigError(
|
|
52
|
+
f"config file not found: {path} "
|
|
53
|
+
"(pass --config, or create ./mcp.json in the current directory)"
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
try:
|
|
57
|
+
raw = json.loads(path.read_text(encoding="utf-8"))
|
|
58
|
+
except json.JSONDecodeError as e:
|
|
59
|
+
raise ConfigError(f"config file {path} is not valid JSON: {e}") from e
|
|
60
|
+
|
|
61
|
+
if not isinstance(raw, dict):
|
|
62
|
+
raise ConfigError(f"config file {path} must contain a JSON object at the top level")
|
|
63
|
+
|
|
64
|
+
if "mcpServers" in raw:
|
|
65
|
+
servers = raw["mcpServers"]
|
|
66
|
+
if not isinstance(servers, dict) or not servers:
|
|
67
|
+
raise ConfigError(f"'mcpServers' in {path} must be a non-empty object")
|
|
68
|
+
return [_build_spec(name, entry, path) for name, entry in servers.items()]
|
|
69
|
+
|
|
70
|
+
if "source" in raw:
|
|
71
|
+
# Whole file is a single bare FastMCP config.
|
|
72
|
+
name = raw.get("name") or path.stem or "fastmcp-server"
|
|
73
|
+
return [_build_fastmcp_spec(name, raw)]
|
|
74
|
+
|
|
75
|
+
raise ConfigError(
|
|
76
|
+
f"config file {path} matches neither the 'mcpServers' shape nor the "
|
|
77
|
+
"FastMCP single-server shape (missing both 'mcpServers' and 'source' keys)"
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _build_spec(name: str, entry: Any, config_path: Path) -> ServerSpec:
|
|
82
|
+
if not isinstance(entry, dict):
|
|
83
|
+
raise ConfigError(f"mcpServers.{name} in {config_path} must be an object")
|
|
84
|
+
|
|
85
|
+
if "source" in entry:
|
|
86
|
+
return _build_fastmcp_spec(name, entry)
|
|
87
|
+
|
|
88
|
+
command = entry.get("command")
|
|
89
|
+
if not command or not isinstance(command, str):
|
|
90
|
+
raise ConfigError(
|
|
91
|
+
f"mcpServers.{name} in {config_path} must have a string 'command' "
|
|
92
|
+
"(or a 'source' key to be treated as a FastMCP-style server)"
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
args = entry.get("args", [])
|
|
96
|
+
if not isinstance(args, list) or not all(isinstance(a, str) for a in args):
|
|
97
|
+
raise ConfigError(f"mcpServers.{name}.args in {config_path} must be a list of strings")
|
|
98
|
+
|
|
99
|
+
env = entry.get("env", {})
|
|
100
|
+
if not isinstance(env, dict):
|
|
101
|
+
raise ConfigError(f"mcpServers.{name}.env in {config_path} must be an object")
|
|
102
|
+
|
|
103
|
+
cwd = entry.get("cwd")
|
|
104
|
+
if cwd is not None and not isinstance(cwd, str):
|
|
105
|
+
raise ConfigError(f"mcpServers.{name}.cwd in {config_path} must be a string")
|
|
106
|
+
|
|
107
|
+
return ServerSpec(name=name, argv=[command, *args], env={k: str(v) for k, v in env.items()}, cwd=cwd)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _build_fastmcp_spec(name: str, entry: Dict[str, Any]) -> ServerSpec:
|
|
111
|
+
"""Materialize a FastMCP-style entry into a runnable ServerSpec.
|
|
112
|
+
|
|
113
|
+
We write the entry out verbatim (minus a forced transport override) as its
|
|
114
|
+
own fastmcp.json-shaped file and launch it with ``fastmcp run <file>``,
|
|
115
|
+
letting FastMCP itself handle the uv-based environment/source setup.
|
|
116
|
+
|
|
117
|
+
Only stdio transport can be tunneled by this tool (the wire protocol only
|
|
118
|
+
bridges stdin/stdout), so ``deployment.transport`` is forced to "stdio"
|
|
119
|
+
regardless of what the entry declares, with a warning if it was set to
|
|
120
|
+
something else.
|
|
121
|
+
"""
|
|
122
|
+
fastmcp_config = dict(entry)
|
|
123
|
+
deployment = dict(fastmcp_config.get("deployment") or {})
|
|
124
|
+
original_transport = deployment.get("transport")
|
|
125
|
+
if original_transport and original_transport != "stdio":
|
|
126
|
+
LOGGER.warning(
|
|
127
|
+
"server %r declares deployment.transport=%r, but this tool only "
|
|
128
|
+
"tunnels stdio; overriding to stdio",
|
|
129
|
+
name,
|
|
130
|
+
original_transport,
|
|
131
|
+
)
|
|
132
|
+
deployment["transport"] = "stdio"
|
|
133
|
+
fastmcp_config["deployment"] = deployment
|
|
134
|
+
|
|
135
|
+
fd, tmp_name = tempfile.mkstemp(prefix=f"fastmcp-{name}-", suffix=".json")
|
|
136
|
+
tmp_path = Path(tmp_name)
|
|
137
|
+
with open(fd, "w", encoding="utf-8") as f:
|
|
138
|
+
json.dump(fastmcp_config, f)
|
|
139
|
+
|
|
140
|
+
return ServerSpec(
|
|
141
|
+
name=name,
|
|
142
|
+
argv=["uvx", "fastmcp", "run", str(tmp_path)],
|
|
143
|
+
env={},
|
|
144
|
+
fastmcp_tempfile=tmp_path,
|
|
145
|
+
)
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"""Settings from the process environment and an optional .env file.
|
|
2
|
+
|
|
3
|
+
This module is duplicated byte-for-byte into mcp_switchboard_client and
|
|
4
|
+
mcp_switchboard_hub so neither package depends on the other.
|
|
5
|
+
hub/tests/test_protocol_sync.py fails if the two copies drift.
|
|
6
|
+
|
|
7
|
+
Path indirection: any value that starts with "/" and resolves to an existing
|
|
8
|
+
regular file is replaced by that file's contents. That lets a secret be given
|
|
9
|
+
either literally or as a path to a sops/systemd-provisioned file, with no
|
|
10
|
+
separate *_FILE variants:
|
|
11
|
+
|
|
12
|
+
MCP_SWITCHBOARD_TUNNEL_TOKEN=literal-value
|
|
13
|
+
MCP_SWITCHBOARD_TUNNEL_TOKEN=/run/secrets/mcp-switchboard/tunnel-token
|
|
14
|
+
|
|
15
|
+
Only regular files are substituted, never directories, so a genuine path value
|
|
16
|
+
like MCP_SWITCHBOARD_DATA_DIR=/var/lib/mcp-switchboard survives untouched.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import os
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
from typing import Optional
|
|
24
|
+
|
|
25
|
+
PREFIX = "MCP_SWITCHBOARD_"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class ConfigError(Exception):
|
|
29
|
+
"""Raised for a malformed or unusable setting."""
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def load_env_file(path: Path) -> None:
|
|
33
|
+
"""Populate os.environ from a KEY=VALUE file. Real env vars always win.
|
|
34
|
+
|
|
35
|
+
Lines that are blank, start with '#', or contain no '=' are ignored. There
|
|
36
|
+
is no interpolation and no 'export' keyword support - this is intentionally
|
|
37
|
+
minimal, matching the .env files people hand-write for this tool.
|
|
38
|
+
"""
|
|
39
|
+
try:
|
|
40
|
+
lines = path.read_text(encoding="utf-8").splitlines()
|
|
41
|
+
except FileNotFoundError:
|
|
42
|
+
return
|
|
43
|
+
except OSError as e:
|
|
44
|
+
raise ConfigError(f"cannot read env file {path}: {e}") from e
|
|
45
|
+
|
|
46
|
+
for line in lines:
|
|
47
|
+
line = line.strip()
|
|
48
|
+
if not line or line.startswith("#") or "=" not in line:
|
|
49
|
+
continue
|
|
50
|
+
key, _, value = line.partition("=")
|
|
51
|
+
key = key.strip()
|
|
52
|
+
value = value.strip().strip("'\"")
|
|
53
|
+
if key and key not in os.environ:
|
|
54
|
+
os.environ[key] = value
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def resolve(value: str, *, secret: bool = False, name: str = "") -> str:
|
|
58
|
+
"""Apply path indirection to a single value."""
|
|
59
|
+
if not value.startswith("/"):
|
|
60
|
+
return value
|
|
61
|
+
|
|
62
|
+
path = Path(value)
|
|
63
|
+
if path.is_file():
|
|
64
|
+
try:
|
|
65
|
+
return path.read_text(encoding="utf-8").strip()
|
|
66
|
+
except OSError as e:
|
|
67
|
+
raise ConfigError(f"cannot read {name or 'value'} from {value}: {e}") from e
|
|
68
|
+
|
|
69
|
+
if secret:
|
|
70
|
+
# Far more likely to be a secret that failed to materialize than a
|
|
71
|
+
# token that happens to look like an absolute path. Fail loudly rather
|
|
72
|
+
# than authenticating with the literal string "/run/secrets/...".
|
|
73
|
+
raise ConfigError(
|
|
74
|
+
f"{name or 'setting'} looks like a file path ({value}) but no readable file is there"
|
|
75
|
+
)
|
|
76
|
+
return value
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def get(name: str, default: Optional[str] = None, *, secret: bool = False) -> Optional[str]:
|
|
80
|
+
"""Read PREFIX+name from the environment, with path indirection applied."""
|
|
81
|
+
key = PREFIX + name
|
|
82
|
+
raw = os.environ.get(key)
|
|
83
|
+
if raw is None or raw == "":
|
|
84
|
+
return default
|
|
85
|
+
return resolve(raw, secret=secret, name=key)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def get_int(name: str, default: int) -> int:
|
|
89
|
+
raw = get(name)
|
|
90
|
+
if raw is None:
|
|
91
|
+
return default
|
|
92
|
+
try:
|
|
93
|
+
return int(raw)
|
|
94
|
+
except ValueError as e:
|
|
95
|
+
raise ConfigError(f"{PREFIX}{name} must be an integer, got {raw!r}") from e
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def get_float(name: str, default: float) -> float:
|
|
99
|
+
raw = get(name)
|
|
100
|
+
if raw is None:
|
|
101
|
+
return default
|
|
102
|
+
try:
|
|
103
|
+
return float(raw)
|
|
104
|
+
except ValueError as e:
|
|
105
|
+
raise ConfigError(f"{PREFIX}{name} must be a number, got {raw!r}") from e
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def get_bool(name: str, default: bool) -> bool:
|
|
109
|
+
raw = get(name)
|
|
110
|
+
if raw is None:
|
|
111
|
+
return default
|
|
112
|
+
lowered = raw.strip().lower()
|
|
113
|
+
if lowered in ("1", "true", "yes", "on"):
|
|
114
|
+
return True
|
|
115
|
+
if lowered in ("0", "false", "no", "off"):
|
|
116
|
+
return False
|
|
117
|
+
raise ConfigError(f"{PREFIX}{name} must be a boolean, got {raw!r}")
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"""Tunnel protocol v1 frames. Spec: docs/PROTOCOL.md.
|
|
2
|
+
|
|
3
|
+
This module is duplicated byte-for-byte into mcp_switchboard_client and
|
|
4
|
+
mcp_switchboard_hub so that neither package has to depend on the other - the
|
|
5
|
+
client deliberately carries no MCP dependency. hub/tests/test_protocol_sync.py
|
|
6
|
+
fails if the two copies drift.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from typing import Any, Dict, List, Optional
|
|
12
|
+
|
|
13
|
+
PROTOCOL_VERSION = 1
|
|
14
|
+
|
|
15
|
+
TUNNEL_PATH = "/tunnel/v1"
|
|
16
|
+
|
|
17
|
+
HELLO = "hello"
|
|
18
|
+
HELLO_ACK = "hello_ack"
|
|
19
|
+
MCP = "mcp"
|
|
20
|
+
SERVER_STATE = "server_state"
|
|
21
|
+
RESTART = "restart"
|
|
22
|
+
ERROR = "error"
|
|
23
|
+
|
|
24
|
+
STATE_STARTING = "starting"
|
|
25
|
+
STATE_RUNNING = "running"
|
|
26
|
+
STATE_EXITED = "exited"
|
|
27
|
+
STATE_FAILED = "failed"
|
|
28
|
+
|
|
29
|
+
# Tools are exposed to consumers as {label}__{server}__{tool}, so neither a
|
|
30
|
+
# machine label nor a server name may contain the separator.
|
|
31
|
+
NAME_SEPARATOR = "__"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class ProtocolError(Exception):
|
|
35
|
+
"""Raised for a frame that cannot be acted on."""
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def validate_name(name: str, kind: str) -> str:
|
|
39
|
+
if not name:
|
|
40
|
+
raise ProtocolError(f"{kind} must not be empty")
|
|
41
|
+
if NAME_SEPARATOR in name:
|
|
42
|
+
raise ProtocolError(f"{kind} {name!r} must not contain {NAME_SEPARATOR!r}")
|
|
43
|
+
return name
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def hello(
|
|
47
|
+
client_name: str,
|
|
48
|
+
version: str,
|
|
49
|
+
instance: str,
|
|
50
|
+
label: str,
|
|
51
|
+
servers: List[Dict[str, Any]],
|
|
52
|
+
) -> Dict[str, Any]:
|
|
53
|
+
return {
|
|
54
|
+
"type": HELLO,
|
|
55
|
+
"protocol": PROTOCOL_VERSION,
|
|
56
|
+
"client": {"name": client_name, "version": version, "instance": instance, "label": label},
|
|
57
|
+
"servers": servers,
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def hello_ack(connection_id: str, hub_name: str, hub_version: str) -> Dict[str, Any]:
|
|
62
|
+
return {
|
|
63
|
+
"type": HELLO_ACK,
|
|
64
|
+
"connectionId": connection_id,
|
|
65
|
+
"hub": {"name": hub_name, "version": hub_version},
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def mcp(server: str, payload: Any) -> Dict[str, Any]:
|
|
70
|
+
return {"type": MCP, "server": server, "payload": payload}
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def server_state(
|
|
74
|
+
server: str,
|
|
75
|
+
state: str,
|
|
76
|
+
exit_code: Optional[int] = None,
|
|
77
|
+
error: Optional[str] = None,
|
|
78
|
+
) -> Dict[str, Any]:
|
|
79
|
+
return {
|
|
80
|
+
"type": SERVER_STATE,
|
|
81
|
+
"server": server,
|
|
82
|
+
"state": state,
|
|
83
|
+
"exitCode": exit_code,
|
|
84
|
+
"error": error,
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def restart(server: str) -> Dict[str, Any]:
|
|
89
|
+
return {"type": RESTART, "server": server}
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def error(message: str, server: Optional[str] = None) -> Dict[str, Any]:
|
|
93
|
+
return {"type": ERROR, "message": message, "server": server}
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
"""One local stdio MCP server subprocess: spawn, pipe, supervise.
|
|
2
|
+
|
|
3
|
+
This module knows nothing about MCP or about the tunnel. It owns a subprocess,
|
|
4
|
+
hands every stdout line to a callback, writes lines to stdin on request, and
|
|
5
|
+
reports lifecycle transitions (``starting`` / ``running`` / ``exited`` /
|
|
6
|
+
``failed``) through a second callback so the tunnel can turn them into
|
|
7
|
+
``server_state`` frames.
|
|
8
|
+
|
|
9
|
+
A server that exits on its own is reported and left down: there is deliberately
|
|
10
|
+
no automatic respawn, because a server that fails on startup would otherwise be
|
|
11
|
+
restarted in a hot loop. It comes back on an explicit ``restart`` frame or on
|
|
12
|
+
the next reconnect.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import asyncio
|
|
18
|
+
import logging
|
|
19
|
+
import os
|
|
20
|
+
from contextlib import suppress
|
|
21
|
+
from typing import Awaitable, Callable, Optional
|
|
22
|
+
|
|
23
|
+
from . import protocol
|
|
24
|
+
from .config import ServerSpec
|
|
25
|
+
|
|
26
|
+
LOGGER = logging.getLogger("mcp_switchboard_client.supervisor")
|
|
27
|
+
|
|
28
|
+
# How long a server gets to exit after SIGTERM before it is killed.
|
|
29
|
+
TERMINATE_TIMEOUT = 5.0
|
|
30
|
+
|
|
31
|
+
# asyncio's default StreamReader limit is 64 KiB, and readline() raises once a
|
|
32
|
+
# line exceeds it. MCP tool results routinely do, so give stdout real headroom.
|
|
33
|
+
STDOUT_LIMIT = 4 * 1024 * 1024
|
|
34
|
+
|
|
35
|
+
# (server name, line)
|
|
36
|
+
StdoutCallback = Callable[[str, str], Awaitable[None]]
|
|
37
|
+
# (server name, state, exit code, error)
|
|
38
|
+
StateCallback = Callable[[str, str, Optional[int], Optional[str]], Awaitable[None]]
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class LocalServer:
|
|
42
|
+
"""A single local MCP server process."""
|
|
43
|
+
|
|
44
|
+
def __init__(
|
|
45
|
+
self,
|
|
46
|
+
spec: ServerSpec,
|
|
47
|
+
on_stdout: Optional[StdoutCallback] = None,
|
|
48
|
+
on_state: Optional[StateCallback] = None,
|
|
49
|
+
) -> None:
|
|
50
|
+
self.spec = spec
|
|
51
|
+
self._on_stdout = on_stdout
|
|
52
|
+
self._on_state = on_state
|
|
53
|
+
self.log = logging.getLogger(f"mcp_switchboard_client.server.{spec.name}")
|
|
54
|
+
|
|
55
|
+
self._process: Optional[asyncio.subprocess.Process] = None
|
|
56
|
+
self._stdout_task: Optional[asyncio.Task] = None
|
|
57
|
+
self._state: Optional[str] = None
|
|
58
|
+
|
|
59
|
+
@property
|
|
60
|
+
def name(self) -> str:
|
|
61
|
+
return self.spec.name
|
|
62
|
+
|
|
63
|
+
@property
|
|
64
|
+
def state(self) -> Optional[str]:
|
|
65
|
+
return self._state
|
|
66
|
+
|
|
67
|
+
@property
|
|
68
|
+
def running(self) -> bool:
|
|
69
|
+
return self._process is not None and self._process.returncode is None
|
|
70
|
+
|
|
71
|
+
@property
|
|
72
|
+
def descriptor(self) -> dict:
|
|
73
|
+
"""The entry this server contributes to the ``hello`` frame."""
|
|
74
|
+
return {"name": self.name, "command": " ".join(self.spec.argv)}
|
|
75
|
+
|
|
76
|
+
async def start(self) -> None:
|
|
77
|
+
"""Spawn the process. A no-op if one is already claimed."""
|
|
78
|
+
if self._process is not None:
|
|
79
|
+
return
|
|
80
|
+
|
|
81
|
+
await self._emit_state(protocol.STATE_STARTING)
|
|
82
|
+
|
|
83
|
+
env = os.environ.copy()
|
|
84
|
+
env.update(self.spec.env)
|
|
85
|
+
|
|
86
|
+
self.log.info("starting: %s", " ".join(self.spec.argv))
|
|
87
|
+
try:
|
|
88
|
+
process = await asyncio.create_subprocess_exec(
|
|
89
|
+
*self.spec.argv,
|
|
90
|
+
stdin=asyncio.subprocess.PIPE,
|
|
91
|
+
stdout=asyncio.subprocess.PIPE,
|
|
92
|
+
stderr=None, # inherit, so the server's own logs reach our stderr
|
|
93
|
+
env=env,
|
|
94
|
+
cwd=self.spec.cwd,
|
|
95
|
+
limit=STDOUT_LIMIT,
|
|
96
|
+
)
|
|
97
|
+
except (OSError, ValueError) as e:
|
|
98
|
+
self.log.error("failed to start: %s", e)
|
|
99
|
+
await self._emit_state(protocol.STATE_FAILED, error=str(e))
|
|
100
|
+
return
|
|
101
|
+
|
|
102
|
+
self._process = process
|
|
103
|
+
self._stdout_task = asyncio.create_task(
|
|
104
|
+
self._read_stdout(process), name=f"stdout-{self.name}"
|
|
105
|
+
)
|
|
106
|
+
self.log.info("started (pid %d)", process.pid)
|
|
107
|
+
await self._emit_state(protocol.STATE_RUNNING)
|
|
108
|
+
|
|
109
|
+
async def stop(self, *, remove_tempfile: bool = True) -> None:
|
|
110
|
+
"""Terminate the process, escalating to SIGKILL after TERMINATE_TIMEOUT.
|
|
111
|
+
|
|
112
|
+
``remove_tempfile`` is false for a restart: a FastMCP-derived spec is
|
|
113
|
+
launched from a generated config file that the respawn still needs.
|
|
114
|
+
"""
|
|
115
|
+
# Claim self._process/_stdout_task synchronously - no await before the
|
|
116
|
+
# null-out - so two concurrent stop() calls can't both tear the same
|
|
117
|
+
# process down.
|
|
118
|
+
process = self._process
|
|
119
|
+
self._process = None
|
|
120
|
+
stdout_task = self._stdout_task
|
|
121
|
+
self._stdout_task = None
|
|
122
|
+
|
|
123
|
+
if process is None:
|
|
124
|
+
if remove_tempfile:
|
|
125
|
+
self._remove_tempfile()
|
|
126
|
+
return
|
|
127
|
+
|
|
128
|
+
if stdout_task is not None:
|
|
129
|
+
stdout_task.cancel()
|
|
130
|
+
with suppress(asyncio.CancelledError):
|
|
131
|
+
await stdout_task
|
|
132
|
+
|
|
133
|
+
if process.returncode is None:
|
|
134
|
+
self.log.info("stopping (pid %d)", process.pid)
|
|
135
|
+
with suppress(ProcessLookupError):
|
|
136
|
+
process.terminate()
|
|
137
|
+
try:
|
|
138
|
+
await asyncio.wait_for(process.wait(), timeout=TERMINATE_TIMEOUT)
|
|
139
|
+
except asyncio.TimeoutError:
|
|
140
|
+
self.log.warning("did not exit after %.0fs, killing", TERMINATE_TIMEOUT)
|
|
141
|
+
with suppress(ProcessLookupError):
|
|
142
|
+
process.kill()
|
|
143
|
+
await process.wait()
|
|
144
|
+
|
|
145
|
+
if remove_tempfile:
|
|
146
|
+
self._remove_tempfile()
|
|
147
|
+
|
|
148
|
+
async def restart(self) -> None:
|
|
149
|
+
await self.stop(remove_tempfile=False)
|
|
150
|
+
await self.start()
|
|
151
|
+
|
|
152
|
+
async def send(self, line: str) -> None:
|
|
153
|
+
"""Write one line to the server's stdin."""
|
|
154
|
+
process = self._process
|
|
155
|
+
if process is None or process.stdin is None or process.returncode is not None:
|
|
156
|
+
self.log.warning("not running, dropping message")
|
|
157
|
+
return
|
|
158
|
+
try:
|
|
159
|
+
process.stdin.write(line.rstrip("\n").encode("utf-8") + b"\n")
|
|
160
|
+
await process.stdin.drain()
|
|
161
|
+
except (BrokenPipeError, ConnectionResetError) as e:
|
|
162
|
+
self.log.warning("stdin closed, dropping message: %s", e)
|
|
163
|
+
|
|
164
|
+
# -- internals -----------------------------------------------------
|
|
165
|
+
|
|
166
|
+
async def _read_stdout(self, process: asyncio.subprocess.Process) -> None:
|
|
167
|
+
assert process.stdout is not None
|
|
168
|
+
try:
|
|
169
|
+
while True:
|
|
170
|
+
line = await process.stdout.readline()
|
|
171
|
+
if not line:
|
|
172
|
+
break
|
|
173
|
+
text = line.decode("utf-8", errors="replace").strip()
|
|
174
|
+
if not text:
|
|
175
|
+
continue
|
|
176
|
+
if self._on_stdout is not None:
|
|
177
|
+
try:
|
|
178
|
+
await self._on_stdout(self.name, text)
|
|
179
|
+
except asyncio.CancelledError:
|
|
180
|
+
raise
|
|
181
|
+
except Exception as e: # noqa: BLE001 - one bad line must not kill the reader
|
|
182
|
+
self.log.error("failed to forward stdout line: %s", e)
|
|
183
|
+
except asyncio.CancelledError:
|
|
184
|
+
raise
|
|
185
|
+
except Exception as e: # noqa: BLE001
|
|
186
|
+
self.log.error("error reading stdout: %s", e)
|
|
187
|
+
return
|
|
188
|
+
|
|
189
|
+
# EOF: the server closed stdout, so it is on its way out. Reap it and
|
|
190
|
+
# report; do not respawn here (see module docstring).
|
|
191
|
+
code = await process.wait()
|
|
192
|
+
self.log.warning("exited with code %s", code)
|
|
193
|
+
await self._emit_state(protocol.STATE_EXITED, exit_code=code)
|
|
194
|
+
|
|
195
|
+
async def _emit_state(
|
|
196
|
+
self,
|
|
197
|
+
state: str,
|
|
198
|
+
exit_code: Optional[int] = None,
|
|
199
|
+
error: Optional[str] = None,
|
|
200
|
+
) -> None:
|
|
201
|
+
self._state = state
|
|
202
|
+
if self._on_state is None:
|
|
203
|
+
return
|
|
204
|
+
try:
|
|
205
|
+
await self._on_state(self.name, state, exit_code, error)
|
|
206
|
+
except asyncio.CancelledError:
|
|
207
|
+
raise
|
|
208
|
+
except Exception as e: # noqa: BLE001 - reporting must never break supervision
|
|
209
|
+
self.log.warning("could not report state %r: %s", state, e)
|
|
210
|
+
|
|
211
|
+
def _remove_tempfile(self) -> None:
|
|
212
|
+
if self.spec.fastmcp_tempfile is None:
|
|
213
|
+
return
|
|
214
|
+
try:
|
|
215
|
+
self.spec.fastmcp_tempfile.unlink()
|
|
216
|
+
except FileNotFoundError:
|
|
217
|
+
pass
|
|
218
|
+
except OSError as e:
|
|
219
|
+
self.log.warning("could not remove %s: %s", self.spec.fastmcp_tempfile, e)
|
|
@@ -0,0 +1,390 @@
|
|
|
1
|
+
"""The outbound tunnel: one WebSocket to the hub, every local server on it.
|
|
2
|
+
|
|
3
|
+
One process opens exactly one connection (``wss://<hub>/tunnel/v1``) and
|
|
4
|
+
multiplexes every configured MCP server over it, tagging each frame with the
|
|
5
|
+
server name. See docs/PROTOCOL.md - this is the client half of it.
|
|
6
|
+
|
|
7
|
+
The client is a dumb pipe: it never interprets an MCP payload, it only moves
|
|
8
|
+
lines between a subprocess and the socket.
|
|
9
|
+
|
|
10
|
+
Liveness is WebSocket ping/pong only (20s interval, 10s timeout). There is
|
|
11
|
+
deliberately no application-level heartbeat frame; the protocol this replaces
|
|
12
|
+
had both sides answer ``heartbeat`` with ``heartbeat``, which is an infinite
|
|
13
|
+
loop. Do not add one.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import asyncio
|
|
19
|
+
import json
|
|
20
|
+
import logging
|
|
21
|
+
import uuid
|
|
22
|
+
from contextlib import suppress
|
|
23
|
+
from dataclasses import dataclass
|
|
24
|
+
from typing import Any, Dict, Iterable, List, Optional
|
|
25
|
+
from urllib.parse import urlsplit, urlunsplit
|
|
26
|
+
|
|
27
|
+
import websockets
|
|
28
|
+
from websockets.exceptions import ConnectionClosed
|
|
29
|
+
|
|
30
|
+
from . import protocol
|
|
31
|
+
from .config import ServerSpec
|
|
32
|
+
from .supervisor import LocalServer
|
|
33
|
+
|
|
34
|
+
LOGGER = logging.getLogger("mcp_switchboard_client.tunnel")
|
|
35
|
+
|
|
36
|
+
CLIENT_NAME = "mcp-switchboard-client"
|
|
37
|
+
|
|
38
|
+
DEFAULT_RECONNECT_DELAY = 1.0
|
|
39
|
+
DEFAULT_MAX_RETRIES = 0 # 0 = infinite
|
|
40
|
+
MAX_BACKOFF_DELAY = 60.0
|
|
41
|
+
|
|
42
|
+
PING_INTERVAL = 20
|
|
43
|
+
PING_TIMEOUT = 10
|
|
44
|
+
|
|
45
|
+
_WS_SCHEMES = {"ws": "ws", "wss": "wss", "http": "ws", "https": "wss"}
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class TunnelError(Exception):
|
|
49
|
+
"""Raised for an unusable hub URL or tunnel setting."""
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class FatalTunnelError(TunnelError):
|
|
53
|
+
"""The hub refused this client; retrying cannot help."""
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _header_kwarg() -> str:
|
|
57
|
+
"""Name of the extra-headers keyword for the installed websockets version.
|
|
58
|
+
|
|
59
|
+
websockets >= 14 makes the asyncio client the top-level ``connect`` and
|
|
60
|
+
calls it ``additional_headers``; 12 and 13 still export the legacy client,
|
|
61
|
+
which calls it ``extra_headers``.
|
|
62
|
+
"""
|
|
63
|
+
try:
|
|
64
|
+
major = int(str(websockets.__version__).split(".")[0])
|
|
65
|
+
except (AttributeError, ValueError):
|
|
66
|
+
return "additional_headers"
|
|
67
|
+
return "additional_headers" if major >= 14 else "extra_headers"
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def normalize_hub_url(url: str) -> str:
|
|
71
|
+
"""Turn a user-supplied hub URL into the WebSocket URL to dial.
|
|
72
|
+
|
|
73
|
+
``hub.example.com`` and ``https://hub.example.com`` both become
|
|
74
|
+
``wss://hub.example.com/tunnel/v1``; an explicit path is left alone, so a
|
|
75
|
+
hub mounted under a prefix can be reached as ``wss://host/prefix/tunnel/v1``.
|
|
76
|
+
"""
|
|
77
|
+
raw = (url or "").strip()
|
|
78
|
+
if not raw:
|
|
79
|
+
raise TunnelError("hub URL must not be empty")
|
|
80
|
+
|
|
81
|
+
if "://" not in raw:
|
|
82
|
+
raw = "wss://" + raw
|
|
83
|
+
|
|
84
|
+
parts = urlsplit(raw)
|
|
85
|
+
scheme = _WS_SCHEMES.get(parts.scheme.lower())
|
|
86
|
+
if scheme is None:
|
|
87
|
+
raise TunnelError(
|
|
88
|
+
f"unsupported hub URL scheme {parts.scheme!r} (use wss://, ws://, https:// or http://)"
|
|
89
|
+
)
|
|
90
|
+
if not parts.netloc:
|
|
91
|
+
raise TunnelError(f"hub URL {url!r} has no host")
|
|
92
|
+
|
|
93
|
+
path = parts.path
|
|
94
|
+
if path in ("", "/"):
|
|
95
|
+
path = protocol.TUNNEL_PATH
|
|
96
|
+
|
|
97
|
+
return urlunsplit((scheme, parts.netloc, path, parts.query, ""))
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
@dataclass
|
|
101
|
+
class TunnelSettings:
|
|
102
|
+
hub_url: str
|
|
103
|
+
token: str
|
|
104
|
+
label: str
|
|
105
|
+
reconnect_delay: float = DEFAULT_RECONNECT_DELAY
|
|
106
|
+
max_retries: int = DEFAULT_MAX_RETRIES
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
class HubConnection:
|
|
110
|
+
"""One WebSocket to the hub, multiplexing every local MCP server."""
|
|
111
|
+
|
|
112
|
+
def __init__(
|
|
113
|
+
self,
|
|
114
|
+
specs: Iterable[ServerSpec],
|
|
115
|
+
settings: TunnelSettings,
|
|
116
|
+
*,
|
|
117
|
+
version: str = "0.0.0",
|
|
118
|
+
connect: Any = None,
|
|
119
|
+
server_factory: Any = None,
|
|
120
|
+
) -> None:
|
|
121
|
+
self.settings = settings
|
|
122
|
+
self.url = normalize_hub_url(settings.hub_url)
|
|
123
|
+
self.instance = uuid.uuid4().hex
|
|
124
|
+
self.version = version
|
|
125
|
+
|
|
126
|
+
self._connect = connect if connect is not None else websockets.connect
|
|
127
|
+
factory = server_factory if server_factory is not None else LocalServer
|
|
128
|
+
self._servers: Dict[str, Any] = {}
|
|
129
|
+
for spec in specs:
|
|
130
|
+
self._servers[spec.name] = factory(
|
|
131
|
+
spec,
|
|
132
|
+
on_stdout=self._on_server_stdout,
|
|
133
|
+
on_state=self._on_server_state,
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
self._ws: Any = None
|
|
137
|
+
self._acked = False
|
|
138
|
+
self._session_ok = False
|
|
139
|
+
self._stopping = False
|
|
140
|
+
self._stop_event: Optional[asyncio.Event] = None
|
|
141
|
+
|
|
142
|
+
@property
|
|
143
|
+
def servers(self) -> Dict[str, Any]:
|
|
144
|
+
"""The local servers, keyed by name, in config order."""
|
|
145
|
+
return self._servers
|
|
146
|
+
|
|
147
|
+
# -- lifecycle -----------------------------------------------------
|
|
148
|
+
|
|
149
|
+
async def run(self) -> None:
|
|
150
|
+
"""Connect, serve, and reconnect until stopped or out of retries."""
|
|
151
|
+
self._stop_event = asyncio.Event()
|
|
152
|
+
if self._stopping:
|
|
153
|
+
self._stop_event.set()
|
|
154
|
+
|
|
155
|
+
attempt = 0
|
|
156
|
+
try:
|
|
157
|
+
while not self._stopping:
|
|
158
|
+
try:
|
|
159
|
+
await self._connect_and_serve()
|
|
160
|
+
except asyncio.CancelledError:
|
|
161
|
+
raise
|
|
162
|
+
except FatalTunnelError as e:
|
|
163
|
+
LOGGER.error("%s", e)
|
|
164
|
+
break
|
|
165
|
+
except Exception as e: # noqa: BLE001 - every connection error is retryable
|
|
166
|
+
LOGGER.error("connection error: %s: %s", type(e).__name__, e)
|
|
167
|
+
|
|
168
|
+
if self._stopping:
|
|
169
|
+
break
|
|
170
|
+
|
|
171
|
+
if self._session_ok:
|
|
172
|
+
# A session that got as far as hello_ack starts backoff over.
|
|
173
|
+
attempt = 0
|
|
174
|
+
|
|
175
|
+
attempt += 1
|
|
176
|
+
if 0 < self.settings.max_retries <= attempt:
|
|
177
|
+
LOGGER.error(
|
|
178
|
+
"giving up after %d attempt(s)", self.settings.max_retries
|
|
179
|
+
)
|
|
180
|
+
break
|
|
181
|
+
|
|
182
|
+
delay = min(
|
|
183
|
+
self.settings.reconnect_delay * (2 ** (attempt - 1)),
|
|
184
|
+
MAX_BACKOFF_DELAY,
|
|
185
|
+
)
|
|
186
|
+
LOGGER.info("reconnecting in %.1fs (attempt %d)", delay, attempt)
|
|
187
|
+
if await self._sleep_or_stop(delay):
|
|
188
|
+
break
|
|
189
|
+
finally:
|
|
190
|
+
await self._stop_servers()
|
|
191
|
+
|
|
192
|
+
def request_stop(self) -> None:
|
|
193
|
+
"""Ask the tunnel to shut down. Safe to call from a signal handler."""
|
|
194
|
+
self._stopping = True
|
|
195
|
+
if self._stop_event is not None:
|
|
196
|
+
self._stop_event.set()
|
|
197
|
+
|
|
198
|
+
async def _sleep_or_stop(self, delay: float) -> bool:
|
|
199
|
+
"""Sleep, returning True if a stop was requested instead."""
|
|
200
|
+
assert self._stop_event is not None
|
|
201
|
+
try:
|
|
202
|
+
await asyncio.wait_for(self._stop_event.wait(), timeout=max(delay, 0.0))
|
|
203
|
+
except asyncio.TimeoutError:
|
|
204
|
+
return False
|
|
205
|
+
return True
|
|
206
|
+
|
|
207
|
+
async def _stop_servers(self) -> None:
|
|
208
|
+
if not self._servers:
|
|
209
|
+
return
|
|
210
|
+
LOGGER.info("stopping %d local server(s)", len(self._servers))
|
|
211
|
+
results = await asyncio.gather(
|
|
212
|
+
*(server.stop() for server in self._servers.values()),
|
|
213
|
+
return_exceptions=True,
|
|
214
|
+
)
|
|
215
|
+
for server, result in zip(self._servers.values(), results):
|
|
216
|
+
if isinstance(result, BaseException):
|
|
217
|
+
LOGGER.warning("error stopping %s: %s", server.name, result)
|
|
218
|
+
|
|
219
|
+
# -- one connection ------------------------------------------------
|
|
220
|
+
|
|
221
|
+
async def _connect_and_serve(self) -> None:
|
|
222
|
+
self._acked = False
|
|
223
|
+
self._session_ok = False
|
|
224
|
+
kwargs = {
|
|
225
|
+
_header_kwarg(): {"Authorization": f"Bearer {self.settings.token}"},
|
|
226
|
+
"ping_interval": PING_INTERVAL,
|
|
227
|
+
"ping_timeout": PING_TIMEOUT,
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
LOGGER.info("connecting to %s", self.url)
|
|
231
|
+
async with self._connect(self.url, **kwargs) as ws:
|
|
232
|
+
self._ws = ws
|
|
233
|
+
closer = asyncio.create_task(self._close_when_stopped(ws))
|
|
234
|
+
try:
|
|
235
|
+
await self._send(
|
|
236
|
+
protocol.hello(
|
|
237
|
+
CLIENT_NAME,
|
|
238
|
+
self.version,
|
|
239
|
+
self.instance,
|
|
240
|
+
self.settings.label,
|
|
241
|
+
self._descriptors(),
|
|
242
|
+
)
|
|
243
|
+
)
|
|
244
|
+
async for raw in ws:
|
|
245
|
+
await self._handle_frame(raw)
|
|
246
|
+
except ConnectionClosed as e:
|
|
247
|
+
LOGGER.warning("hub connection closed: %s", e)
|
|
248
|
+
finally:
|
|
249
|
+
closer.cancel()
|
|
250
|
+
with suppress(asyncio.CancelledError):
|
|
251
|
+
await closer
|
|
252
|
+
self._ws = None
|
|
253
|
+
self._acked = False
|
|
254
|
+
LOGGER.info("tunnel down")
|
|
255
|
+
|
|
256
|
+
async def _close_when_stopped(self, ws: Any) -> None:
|
|
257
|
+
assert self._stop_event is not None
|
|
258
|
+
await self._stop_event.wait()
|
|
259
|
+
with suppress(Exception):
|
|
260
|
+
await ws.close()
|
|
261
|
+
|
|
262
|
+
def _descriptors(self) -> List[Dict[str, str]]:
|
|
263
|
+
return [server.descriptor for server in self._servers.values()]
|
|
264
|
+
|
|
265
|
+
# -- inbound -------------------------------------------------------
|
|
266
|
+
|
|
267
|
+
async def _handle_frame(self, raw: Any) -> None:
|
|
268
|
+
if isinstance(raw, (bytes, bytearray)):
|
|
269
|
+
raw = bytes(raw).decode("utf-8", errors="replace")
|
|
270
|
+
try:
|
|
271
|
+
data = json.loads(raw)
|
|
272
|
+
except (json.JSONDecodeError, TypeError):
|
|
273
|
+
LOGGER.error("non-JSON frame from hub, dropping")
|
|
274
|
+
return
|
|
275
|
+
if not isinstance(data, dict):
|
|
276
|
+
LOGGER.error("frame from hub is not an object, dropping")
|
|
277
|
+
return
|
|
278
|
+
|
|
279
|
+
frame_type = data.get("type")
|
|
280
|
+
if frame_type == protocol.MCP:
|
|
281
|
+
await self._handle_mcp(data)
|
|
282
|
+
elif frame_type == protocol.RESTART:
|
|
283
|
+
await self._handle_restart(data)
|
|
284
|
+
elif frame_type == protocol.HELLO_ACK:
|
|
285
|
+
await self._handle_hello_ack(data)
|
|
286
|
+
elif frame_type == protocol.ERROR:
|
|
287
|
+
self._handle_error(data)
|
|
288
|
+
else:
|
|
289
|
+
LOGGER.warning("ignoring unknown frame type %r from hub", frame_type)
|
|
290
|
+
|
|
291
|
+
async def _handle_hello_ack(self, data: Dict[str, Any]) -> None:
|
|
292
|
+
hub = data.get("hub") or {}
|
|
293
|
+
LOGGER.info(
|
|
294
|
+
"tunnel up: connection %s to %s %s",
|
|
295
|
+
data.get("connectionId"),
|
|
296
|
+
hub.get("name", "hub"),
|
|
297
|
+
hub.get("version", "?"),
|
|
298
|
+
)
|
|
299
|
+
self._acked = True
|
|
300
|
+
self._session_ok = True
|
|
301
|
+
# The hub opens a fresh MCP session per connection, and a server that
|
|
302
|
+
# already completed `initialize` would reject a second one, so every
|
|
303
|
+
# (re)connect restarts every local server. See docs/PROTOCOL.md.
|
|
304
|
+
await self._restart_all()
|
|
305
|
+
|
|
306
|
+
def _lookup(self, name: Any) -> Any:
|
|
307
|
+
if not isinstance(name, str):
|
|
308
|
+
return None
|
|
309
|
+
return self._servers.get(name)
|
|
310
|
+
|
|
311
|
+
async def _handle_mcp(self, data: Dict[str, Any]) -> None:
|
|
312
|
+
name = data.get("server")
|
|
313
|
+
server = self._lookup(name)
|
|
314
|
+
if server is None:
|
|
315
|
+
LOGGER.warning("mcp frame for unknown server %r, dropping", name)
|
|
316
|
+
return
|
|
317
|
+
payload = data.get("payload")
|
|
318
|
+
if payload is None:
|
|
319
|
+
LOGGER.warning("mcp frame for %r has no payload, dropping", name)
|
|
320
|
+
return
|
|
321
|
+
await server.send(json.dumps(payload))
|
|
322
|
+
|
|
323
|
+
async def _handle_restart(self, data: Dict[str, Any]) -> None:
|
|
324
|
+
name = data.get("server")
|
|
325
|
+
server = self._lookup(name)
|
|
326
|
+
if server is None:
|
|
327
|
+
LOGGER.warning("restart requested for unknown server %r", name)
|
|
328
|
+
return
|
|
329
|
+
LOGGER.info("restart requested for %s", name)
|
|
330
|
+
await server.restart()
|
|
331
|
+
|
|
332
|
+
def _handle_error(self, data: Dict[str, Any]) -> None:
|
|
333
|
+
message = data.get("message") or "unspecified error"
|
|
334
|
+
server = data.get("server")
|
|
335
|
+
if not self._acked:
|
|
336
|
+
# Before hello_ack an error means the hub refused this client -
|
|
337
|
+
# unsupported protocol version, illegal server name. Retrying with
|
|
338
|
+
# exactly the same hello will never succeed.
|
|
339
|
+
raise FatalTunnelError(f"hub rejected the connection: {message}")
|
|
340
|
+
if server:
|
|
341
|
+
LOGGER.error("hub error for server %s: %s", server, message)
|
|
342
|
+
else:
|
|
343
|
+
LOGGER.error("hub error: %s", message)
|
|
344
|
+
|
|
345
|
+
async def _restart_all(self) -> None:
|
|
346
|
+
if not self._servers:
|
|
347
|
+
LOGGER.warning("no local servers configured")
|
|
348
|
+
return
|
|
349
|
+
LOGGER.info("(re)starting %d local server(s)", len(self._servers))
|
|
350
|
+
results = await asyncio.gather(
|
|
351
|
+
*(server.restart() for server in self._servers.values()),
|
|
352
|
+
return_exceptions=True,
|
|
353
|
+
)
|
|
354
|
+
for server, result in zip(self._servers.values(), results):
|
|
355
|
+
if isinstance(result, BaseException):
|
|
356
|
+
LOGGER.error("failed to restart %s: %s", server.name, result)
|
|
357
|
+
|
|
358
|
+
# -- outbound ------------------------------------------------------
|
|
359
|
+
|
|
360
|
+
async def _on_server_stdout(self, name: str, line: str) -> None:
|
|
361
|
+
try:
|
|
362
|
+
payload = json.loads(line)
|
|
363
|
+
except json.JSONDecodeError:
|
|
364
|
+
LOGGER.warning("%s wrote a non-JSON stdout line, dropping: %s", name, line[:200])
|
|
365
|
+
return
|
|
366
|
+
await self._try_send(protocol.mcp(name, payload))
|
|
367
|
+
|
|
368
|
+
async def _on_server_state(
|
|
369
|
+
self,
|
|
370
|
+
name: str,
|
|
371
|
+
state: str,
|
|
372
|
+
exit_code: Optional[int],
|
|
373
|
+
error: Optional[str],
|
|
374
|
+
) -> None:
|
|
375
|
+
await self._try_send(protocol.server_state(name, state, exit_code, error))
|
|
376
|
+
|
|
377
|
+
async def _send(self, frame: Dict[str, Any]) -> None:
|
|
378
|
+
ws = self._ws
|
|
379
|
+
if ws is None:
|
|
380
|
+
raise TunnelError("not connected to the hub")
|
|
381
|
+
await ws.send(json.dumps(frame))
|
|
382
|
+
|
|
383
|
+
async def _try_send(self, frame: Dict[str, Any]) -> None:
|
|
384
|
+
"""Send a frame, dropping it if the tunnel is down."""
|
|
385
|
+
try:
|
|
386
|
+
await self._send(frame)
|
|
387
|
+
except asyncio.CancelledError:
|
|
388
|
+
raise
|
|
389
|
+
except Exception as e: # noqa: BLE001 - a dropped frame must not kill a server
|
|
390
|
+
LOGGER.debug("dropping %s frame, tunnel unavailable: %s", frame.get("type"), e)
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: mcp-switchboard-client
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: Tunnels local stdio MCP servers to an mcp-switchboard hub over one outbound WebSocket
|
|
5
|
+
Project-URL: Homepage, https://github.com/AkosPapp/mcp-switchboard
|
|
6
|
+
Project-URL: Repository, https://github.com/AkosPapp/mcp-switchboard
|
|
7
|
+
Author: Akos Papp
|
|
8
|
+
License: MIT
|
|
9
|
+
Keywords: mcp,model-context-protocol,reverse-proxy,switchboard,tunnel
|
|
10
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
11
|
+
Classifier: Operating System :: OS Independent
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Requires-Python: >=3.10
|
|
14
|
+
Requires-Dist: websockets>=14
|
|
15
|
+
Provides-Extra: test
|
|
16
|
+
Requires-Dist: pytest; extra == 'test'
|
|
17
|
+
Requires-Dist: pytest-asyncio>=0.23; extra == 'test'
|
|
18
|
+
Description-Content-Type: text/markdown
|
|
19
|
+
|
|
20
|
+
# mcp-switchboard-client
|
|
21
|
+
|
|
22
|
+
The client half of [mcp-switchboard](https://github.com/AkosPapp/mcp-switchboard).
|
|
23
|
+
|
|
24
|
+
It reads an `mcp.json`, spawns the local stdio MCP servers it describes, and
|
|
25
|
+
opens a single **outbound** WebSocket to an `mcp-switchboard-hub`, multiplexing
|
|
26
|
+
every server over that one connection. No inbound port is ever opened, so it
|
|
27
|
+
works from behind NAT with nothing forwarded.
|
|
28
|
+
|
|
29
|
+
The client speaks no MCP itself — it is a pipe, shuttling each server's
|
|
30
|
+
stdin/stdout across the tunnel. All MCP logic lives in the hub. That is why its
|
|
31
|
+
only dependency is `websockets`.
|
|
32
|
+
|
|
33
|
+
## Running
|
|
34
|
+
|
|
35
|
+
```sh
|
|
36
|
+
uvx mcp-switchboard-client --hub-url wss://switchboard.example.com --token "$TOKEN"
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
or bootstrap `uvx`/`npx` first with the installer:
|
|
40
|
+
|
|
41
|
+
```sh
|
|
42
|
+
curl -fsSL https://akospapp.github.io/mcp-switchboard/install.sh | sh -s -- \
|
|
43
|
+
--hub-url wss://switchboard.example.com --token "$TOKEN"
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
`mcp.json` uses the familiar shape, resolved from the current directory:
|
|
47
|
+
|
|
48
|
+
```json
|
|
49
|
+
{
|
|
50
|
+
"mcpServers": {
|
|
51
|
+
"git": { "command": "uvx", "args": ["mcp-server-git", "--repository", "."] }
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
Settings can also come from `MCP_SWITCHBOARD_*` environment variables or a
|
|
57
|
+
`.env` file (`HUB_URL`, `TUNNEL_TOKEN`, `LABEL`, `CONFIG`, …). Any value that
|
|
58
|
+
starts with `/` and points at an existing regular file is read from that file, so
|
|
59
|
+
a token can be passed as a path.
|
|
60
|
+
|
|
61
|
+
`LABEL` defaults to the machine's hostname and is how the hub tags this
|
|
62
|
+
machine's tools, so consumers can tell which host a tool lives on.
|
|
63
|
+
|
|
64
|
+
See the [main README](../README.md) for the full picture.
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
mcp_switchboard_client/__init__.py,sha256=nFlVoZtrB54fwBnDY7Qjnp-CmE2vG7lyomALct8q5aM,374
|
|
2
|
+
mcp_switchboard_client/__main__.py,sha256=RUFXS_35UXObXLHJD-pEu6c5DApldc_9HKckqDMiAPk,106
|
|
3
|
+
mcp_switchboard_client/cli.py,sha256=zLM4fEvkvshYHe3jhMzlCEH5wFRuPAmawMs76CG-osw,8963
|
|
4
|
+
mcp_switchboard_client/config.py,sha256=yce5oPd7-AHld7dl7odQfCF1LyX3Vsc9VCNYSGHYDHY,5532
|
|
5
|
+
mcp_switchboard_client/envconf.py,sha256=sGDp5nu_wC0Y3uPNfQtCGmTi_EdpUYC-T-II3lBV9Y4,3931
|
|
6
|
+
mcp_switchboard_client/protocol.py,sha256=nIoXnOmAQvlZ-uei5IbIfCx9jCxlOotB_AIw8sPKKAo,2408
|
|
7
|
+
mcp_switchboard_client/supervisor.py,sha256=0Sla4wuBSifeHhuZP19E-OeJ6ga4DlTZswaPnz0G-k8,8069
|
|
8
|
+
mcp_switchboard_client/tunnel.py,sha256=TE1-AUIXwbSdsW8td_tsBEJMeFoxjVOsbP6kGDomeBw,13997
|
|
9
|
+
mcp_switchboard_client-0.2.0.dist-info/METADATA,sha256=OaF6pXApTvaPbb7HHbFwYEeYwGkounC2-g15CgWCcn0,2301
|
|
10
|
+
mcp_switchboard_client-0.2.0.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
|
|
11
|
+
mcp_switchboard_client-0.2.0.dist-info/entry_points.txt,sha256=GDtmUrMrsmKu0eHHB-YsdLH4atD7FcC6c2-4_tXYJ7s,75
|
|
12
|
+
mcp_switchboard_client-0.2.0.dist-info/RECORD,,
|