mcp-switchboard-client 0.2.0__tar.gz

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,13 @@
1
+ __pycache__/
2
+ *.pyc
3
+ *.egg-info/
4
+ .venv/
5
+ venv/
6
+ build/
7
+ dist/
8
+ .pytest_cache/
9
+ .mypy_cache/
10
+ .ruff_cache/
11
+ .env
12
+ result
13
+ result-*
@@ -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,45 @@
1
+ # mcp-switchboard-client
2
+
3
+ The client half of [mcp-switchboard](https://github.com/AkosPapp/mcp-switchboard).
4
+
5
+ It reads an `mcp.json`, spawns the local stdio MCP servers it describes, and
6
+ opens a single **outbound** WebSocket to an `mcp-switchboard-hub`, multiplexing
7
+ every server over that one connection. No inbound port is ever opened, so it
8
+ works from behind NAT with nothing forwarded.
9
+
10
+ The client speaks no MCP itself — it is a pipe, shuttling each server's
11
+ stdin/stdout across the tunnel. All MCP logic lives in the hub. That is why its
12
+ only dependency is `websockets`.
13
+
14
+ ## Running
15
+
16
+ ```sh
17
+ uvx mcp-switchboard-client --hub-url wss://switchboard.example.com --token "$TOKEN"
18
+ ```
19
+
20
+ or bootstrap `uvx`/`npx` first with the installer:
21
+
22
+ ```sh
23
+ curl -fsSL https://akospapp.github.io/mcp-switchboard/install.sh | sh -s -- \
24
+ --hub-url wss://switchboard.example.com --token "$TOKEN"
25
+ ```
26
+
27
+ `mcp.json` uses the familiar shape, resolved from the current directory:
28
+
29
+ ```json
30
+ {
31
+ "mcpServers": {
32
+ "git": { "command": "uvx", "args": ["mcp-server-git", "--repository", "."] }
33
+ }
34
+ }
35
+ ```
36
+
37
+ Settings can also come from `MCP_SWITCHBOARD_*` environment variables or a
38
+ `.env` file (`HUB_URL`, `TUNNEL_TOKEN`, `LABEL`, `CONFIG`, …). Any value that
39
+ starts with `/` and points at an existing regular file is read from that file, so
40
+ a token can be passed as a path.
41
+
42
+ `LABEL` defaults to the machine's hostname and is how the hub tags this
43
+ machine's tools, so consumers can tell which host a tool lives on.
44
+
45
+ See the [main README](../README.md) for the full picture.
@@ -0,0 +1,41 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "mcp-switchboard-client"
7
+ version = "0.2.0"
8
+ description = "Tunnels local stdio MCP servers to an mcp-switchboard hub over one outbound WebSocket"
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ license = { text = "MIT" }
12
+ authors = [{ name = "Akos Papp" }]
13
+ keywords = ["mcp", "model-context-protocol", "tunnel", "reverse-proxy", "switchboard"]
14
+ classifiers = [
15
+ "Programming Language :: Python :: 3",
16
+ "License :: OSI Approved :: MIT License",
17
+ "Operating System :: OS Independent",
18
+ ]
19
+ # Deliberately no `mcp` dependency: the client is a dumb pipe and speaks no MCP.
20
+ dependencies = [
21
+ "websockets>=14",
22
+ ]
23
+
24
+ [project.optional-dependencies]
25
+ test = ["pytest", "pytest-asyncio>=0.23"]
26
+
27
+ [project.urls]
28
+ Homepage = "https://github.com/AkosPapp/mcp-switchboard"
29
+ Repository = "https://github.com/AkosPapp/mcp-switchboard"
30
+
31
+ [project.scripts]
32
+ mcp-switchboard-client = "mcp_switchboard_client.cli:main"
33
+
34
+ [tool.hatch.build.targets.wheel]
35
+ packages = ["src/mcp_switchboard_client"]
36
+
37
+ [tool.pytest.ini_options]
38
+ testpaths = ["tests"]
39
+ pythonpath = ["src"]
40
+ asyncio_mode = "auto"
41
+ asyncio_default_fixture_loop_scope = "function"
@@ -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
+ )