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.
@@ -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,6 @@
1
+ """``python -m mcp_switchboard_client``."""
2
+
3
+ from .cli import main
4
+
5
+ if __name__ == "__main__":
6
+ main()
@@ -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,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ mcp-switchboard-client = mcp_switchboard_client.cli:main