mcp-switchboard-client 0.2.0__tar.gz → 0.3.0.dev3__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.
Files changed (22) hide show
  1. mcp_switchboard_client-0.3.0.dev3/.gitignore +18 -0
  2. {mcp_switchboard_client-0.2.0 → mcp_switchboard_client-0.3.0.dev3}/PKG-INFO +19 -3
  3. {mcp_switchboard_client-0.2.0 → mcp_switchboard_client-0.3.0.dev3}/README.md +15 -2
  4. {mcp_switchboard_client-0.2.0 → mcp_switchboard_client-0.3.0.dev3}/pyproject.toml +21 -3
  5. {mcp_switchboard_client-0.2.0 → mcp_switchboard_client-0.3.0.dev3}/src/mcp_switchboard_client/__init__.py +1 -1
  6. {mcp_switchboard_client-0.2.0 → mcp_switchboard_client-0.3.0.dev3}/src/mcp_switchboard_client/cli.py +57 -8
  7. {mcp_switchboard_client-0.2.0 → mcp_switchboard_client-0.3.0.dev3}/src/mcp_switchboard_client/config.py +49 -7
  8. mcp_switchboard_client-0.3.0.dev3/src/mcp_switchboard_client/environment.py +202 -0
  9. {mcp_switchboard_client-0.2.0 → mcp_switchboard_client-0.3.0.dev3}/src/mcp_switchboard_client/protocol.py +5 -1
  10. {mcp_switchboard_client-0.2.0 → mcp_switchboard_client-0.3.0.dev3}/src/mcp_switchboard_client/supervisor.py +4 -1
  11. {mcp_switchboard_client-0.2.0 → mcp_switchboard_client-0.3.0.dev3}/src/mcp_switchboard_client/tunnel.py +26 -0
  12. {mcp_switchboard_client-0.2.0 → mcp_switchboard_client-0.3.0.dev3}/tests/test_config.py +49 -0
  13. mcp_switchboard_client-0.3.0.dev3/tests/test_envconf_cases.py +73 -0
  14. mcp_switchboard_client-0.3.0.dev3/tests/test_environment.py +128 -0
  15. mcp_switchboard_client-0.3.0.dev3/tests/test_protocol_conformance.py +97 -0
  16. {mcp_switchboard_client-0.2.0 → mcp_switchboard_client-0.3.0.dev3}/tests/test_settings.py +14 -0
  17. {mcp_switchboard_client-0.2.0 → mcp_switchboard_client-0.3.0.dev3}/tests/test_tunnel.py +27 -0
  18. mcp_switchboard_client-0.2.0/.gitignore +0 -13
  19. {mcp_switchboard_client-0.2.0 → mcp_switchboard_client-0.3.0.dev3}/src/mcp_switchboard_client/__main__.py +0 -0
  20. {mcp_switchboard_client-0.2.0 → mcp_switchboard_client-0.3.0.dev3}/src/mcp_switchboard_client/envconf.py +0 -0
  21. {mcp_switchboard_client-0.2.0 → mcp_switchboard_client-0.3.0.dev3}/tests/conftest.py +0 -0
  22. {mcp_switchboard_client-0.2.0 → mcp_switchboard_client-0.3.0.dev3}/tests/test_supervisor.py +0 -0
@@ -0,0 +1,18 @@
1
+ __pycache__/
2
+ *.pyc
3
+ *.egg-info/
4
+ .venv/
5
+ venv/
6
+ build/
7
+ dist/
8
+ # ...except the console's, which is committed on purpose: the hub embeds it, so
9
+ # `go build` has to work without npm (spec.md U1). CI rebuilds it and fails if
10
+ # this copy is stale.
11
+ !hub/web/dist/
12
+ .pytest_cache/
13
+ .mypy_cache/
14
+ .ruff_cache/
15
+ .env
16
+ result
17
+ result-*
18
+ .direnv/
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: mcp-switchboard-client
3
- Version: 0.2.0
3
+ Version: 0.3.0.dev3
4
4
  Summary: Tunnels local stdio MCP servers to an mcp-switchboard hub over one outbound WebSocket
5
5
  Project-URL: Homepage, https://github.com/AkosPapp/mcp-switchboard
6
6
  Project-URL: Repository, https://github.com/AkosPapp/mcp-switchboard
@@ -11,8 +11,11 @@ Classifier: License :: OSI Approved :: MIT License
11
11
  Classifier: Operating System :: OS Independent
12
12
  Classifier: Programming Language :: Python :: 3
13
13
  Requires-Python: >=3.10
14
+ Requires-Dist: certifi>=2024.2.2
15
+ Requires-Dist: mcp-switchboard-server-harness
14
16
  Requires-Dist: websockets>=14
15
17
  Provides-Extra: test
18
+ Requires-Dist: httpx; extra == 'test'
16
19
  Requires-Dist: pytest; extra == 'test'
17
20
  Requires-Dist: pytest-asyncio>=0.23; extra == 'test'
18
21
  Description-Content-Type: text/markdown
@@ -28,7 +31,9 @@ works from behind NAT with nothing forwarded.
28
31
 
29
32
  The client speaks no MCP itself — it is a pipe, shuttling each server's
30
33
  stdin/stdout across the tunnel. All MCP logic lives in the hub. That is why its
31
- only dependency is `websockets`.
34
+ only own dependencies are `websockets` and `certifi` (plus the harness package,
35
+ which brings the `mcp` SDK) (a bundled CA bundle, because
36
+ `uvx`'s portable Pythons often cannot find a system certificate store).
32
37
 
33
38
  ## Running
34
39
 
@@ -43,7 +48,8 @@ curl -fsSL https://akospapp.github.io/mcp-switchboard/install.sh | sh -s -- \
43
48
  --hub-url wss://switchboard.example.com --token "$TOKEN"
44
49
  ```
45
50
 
46
- `mcp.json` uses the familiar shape, resolved from the current directory:
51
+ `mcp.json` uses the familiar shape, resolved from the current directory. It is optional:
52
+ without one, the client tunnels just the built-in coding harness (see below):
47
53
 
48
54
  ```json
49
55
  {
@@ -53,6 +59,16 @@ curl -fsSL https://akospapp.github.io/mcp-switchboard/install.sh | sh -s -- \
53
59
  }
54
60
  ```
55
61
 
62
+ An entry may set `"project"` to group it under a project name (a top-level
63
+ `"project"` is the default for all entries). The client sends it to the hub, which
64
+ uses it in tool names and project-scoped endpoints. Names must not contain `__`.
65
+
66
+ A first-party [coding harness](../servers/harness) (file, search, git and shell
67
+ tools) is added as a server named `harness` by default, confined to the directory you start
68
+ the client in. Turn it off with
69
+ `--no-harness` or `MCP_SWITCHBOARD_HARNESS=false`; an `mcp.json` entry named
70
+ `harness` replaces it.
71
+
56
72
  Settings can also come from `MCP_SWITCHBOARD_*` environment variables or a
57
73
  `.env` file (`HUB_URL`, `TUNNEL_TOKEN`, `LABEL`, `CONFIG`, …). Any value that
58
74
  starts with `/` and points at an existing regular file is read from that file, so
@@ -9,7 +9,9 @@ works from behind NAT with nothing forwarded.
9
9
 
10
10
  The client speaks no MCP itself — it is a pipe, shuttling each server's
11
11
  stdin/stdout across the tunnel. All MCP logic lives in the hub. That is why its
12
- only dependency is `websockets`.
12
+ only own dependencies are `websockets` and `certifi` (plus the harness package,
13
+ which brings the `mcp` SDK) (a bundled CA bundle, because
14
+ `uvx`'s portable Pythons often cannot find a system certificate store).
13
15
 
14
16
  ## Running
15
17
 
@@ -24,7 +26,8 @@ curl -fsSL https://akospapp.github.io/mcp-switchboard/install.sh | sh -s -- \
24
26
  --hub-url wss://switchboard.example.com --token "$TOKEN"
25
27
  ```
26
28
 
27
- `mcp.json` uses the familiar shape, resolved from the current directory:
29
+ `mcp.json` uses the familiar shape, resolved from the current directory. It is optional:
30
+ without one, the client tunnels just the built-in coding harness (see below):
28
31
 
29
32
  ```json
30
33
  {
@@ -34,6 +37,16 @@ curl -fsSL https://akospapp.github.io/mcp-switchboard/install.sh | sh -s -- \
34
37
  }
35
38
  ```
36
39
 
40
+ An entry may set `"project"` to group it under a project name (a top-level
41
+ `"project"` is the default for all entries). The client sends it to the hub, which
42
+ uses it in tool names and project-scoped endpoints. Names must not contain `__`.
43
+
44
+ A first-party [coding harness](../servers/harness) (file, search, git and shell
45
+ tools) is added as a server named `harness` by default, confined to the directory you start
46
+ the client in. Turn it off with
47
+ `--no-harness` or `MCP_SWITCHBOARD_HARNESS=false`; an `mcp.json` entry named
48
+ `harness` replaces it.
49
+
37
50
  Settings can also come from `MCP_SWITCHBOARD_*` environment variables or a
38
51
  `.env` file (`HUB_URL`, `TUNNEL_TOKEN`, `LABEL`, `CONFIG`, …). Any value that
39
52
  starts with `/` and points at an existing regular file is read from that file, so
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "mcp-switchboard-client"
7
- version = "0.2.0"
7
+ version = "0.3.0.dev3"
8
8
  description = "Tunnels local stdio MCP servers to an mcp-switchboard hub over one outbound WebSocket"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -16,13 +16,31 @@ classifiers = [
16
16
  "License :: OSI Approved :: MIT License",
17
17
  "Operating System :: OS Independent",
18
18
  ]
19
- # Deliberately no `mcp` dependency: the client is a dumb pipe and speaks no MCP.
19
+ # The client itself speaks no MCP (it is a dumb pipe). The `mcp` SDK arrives only
20
+ # via the built-in coding harness server, which the client spawns by default.
20
21
  dependencies = [
22
+ "mcp-switchboard-server-harness",
21
23
  "websockets>=14",
24
+ # A bundled CA bundle for the TLS handshake, not just for convenience: this
25
+ # client is typically run via `uvx` with astral's portable CPython builds,
26
+ # which often can't find a usable system cert store (NixOS's is at a
27
+ # nonstandard path), causing every wss:// connection to fail with
28
+ # CERTIFICATE_VERIFY_FAILED regardless of the host actually being fine.
29
+ "certifi>=2024.2.2",
22
30
  ]
23
31
 
24
32
  [project.optional-dependencies]
25
- test = ["pytest", "pytest-asyncio>=0.23"]
33
+ test = [
34
+ "pytest",
35
+ "pytest-asyncio>=0.23",
36
+ # Not used by the client itself - only by tests/test_end_to_end.py, which
37
+ # calls the hub's console API and MCP endpoint over real HTTP. It lives here
38
+ # rather than in its own workspace member because tests/ isn't packaged.
39
+ "httpx",
40
+ ]
41
+
42
+ [tool.uv.sources]
43
+ mcp-switchboard-server-harness = { workspace = true }
26
44
 
27
45
  [project.urls]
28
46
  Homepage = "https://github.com/AkosPapp/mcp-switchboard"
@@ -6,4 +6,4 @@ outbound WebSocket, tagged with the server name. Every MCP concern lives in the
6
6
  hub. See docs/PROTOCOL.md.
7
7
  """
8
8
 
9
- __version__ = "0.2.0"
9
+ __version__ = "0.3.0"
@@ -16,11 +16,11 @@ import socket
16
16
  import sys
17
17
  from dataclasses import dataclass
18
18
  from pathlib import Path
19
- from typing import List, Optional, Sequence
19
+ from typing import Any, Dict, List, Optional, Sequence
20
20
 
21
- from . import envconf, protocol
21
+ from . import envconf, environment, protocol
22
22
  from .config import ConfigError as McpConfigError
23
- from .config import ServerSpec, load_config
23
+ from .config import HARNESS_NAME, ServerSpec, harness_spec, load_config
24
24
  from .envconf import ConfigError
25
25
  from .tunnel import (
26
26
  DEFAULT_MAX_RETRIES,
@@ -47,9 +47,13 @@ class Settings:
47
47
  token: str
48
48
  label: str
49
49
  config_path: Path
50
+ config_explicit: bool = False # --config / MCP_SWITCHBOARD_CONFIG was given
50
51
  reconnect_delay: float = DEFAULT_RECONNECT_DELAY
51
52
  max_retries: int = DEFAULT_MAX_RETRIES
52
53
  log_level: str = DEFAULT_LOG_LEVEL
54
+ harness: bool = True
55
+ project_name: Optional[str] = None
56
+ environment: Optional[Dict[str, Any]] = None # detected at load_settings
53
57
 
54
58
  def tunnel_settings(self) -> TunnelSettings:
55
59
  return TunnelSettings(
@@ -58,6 +62,7 @@ class Settings:
58
62
  label=self.label,
59
63
  reconnect_delay=self.reconnect_delay,
60
64
  max_retries=self.max_retries,
65
+ environment=self.environment,
61
66
  )
62
67
 
63
68
 
@@ -92,6 +97,14 @@ def build_parser() -> argparse.ArgumentParser:
92
97
  f"(or {envconf.PREFIX}LABEL)"
93
98
  ),
94
99
  )
100
+ parser.add_argument(
101
+ "--project-name",
102
+ default=None,
103
+ help=(
104
+ "Project name shown next to this client in the hub; default is detected "
105
+ f"(devcontainer name, git root, or directory name) (or {envconf.PREFIX}PROJECT_NAME)"
106
+ ),
107
+ )
95
108
  parser.add_argument(
96
109
  "--config",
97
110
  default=None,
@@ -123,6 +136,14 @@ def build_parser() -> argparse.ArgumentParser:
123
136
  f"(default {DEFAULT_MAX_RETRIES}, or {envconf.PREFIX}MAX_RETRIES)"
124
137
  ),
125
138
  )
139
+ parser.add_argument(
140
+ "--no-harness",
141
+ action="store_true",
142
+ help=(
143
+ "Do not add the built-in coding harness server (file, search, git and shell "
144
+ f"tools), which is on by default (or set {envconf.PREFIX}HARNESS=false)"
145
+ ),
146
+ )
126
147
  parser.add_argument(
127
148
  "--log-level",
128
149
  choices=LOG_LEVELS,
@@ -168,9 +189,12 @@ def load_settings(args: argparse.Namespace) -> Settings:
168
189
  except protocol.ProtocolError as e:
169
190
  raise ConfigError(str(e)) from e
170
191
 
192
+ project_name = (args.project_name or envconf.get("PROJECT_NAME") or "").strip() or None
193
+
171
194
  # The config path is resolved strictly against the current directory;
172
195
  # there is no upward search for an mcp.json.
173
- raw_config = args.config or envconf.get("CONFIG") or DEFAULT_CONFIG_NAME
196
+ explicit_config = args.config or envconf.get("CONFIG")
197
+ raw_config = explicit_config or DEFAULT_CONFIG_NAME
174
198
  config_path = Path(raw_config)
175
199
  if not config_path.is_absolute():
176
200
  config_path = Path.cwd() / config_path
@@ -200,9 +224,13 @@ def load_settings(args: argparse.Namespace) -> Settings:
200
224
  token=token,
201
225
  label=label,
202
226
  config_path=config_path,
227
+ config_explicit=bool(explicit_config),
203
228
  reconnect_delay=reconnect_delay,
204
229
  max_retries=max_retries,
205
230
  log_level=log_level,
231
+ harness=not args.no_harness and envconf.get_bool("HARNESS", True),
232
+ project_name=project_name,
233
+ environment=environment.detect(project_override=project_name),
206
234
  )
207
235
 
208
236
 
@@ -210,12 +238,25 @@ def build_settings(argv: Optional[Sequence[str]] = None) -> Settings:
210
238
  return load_settings(parse_args(argv))
211
239
 
212
240
 
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)
241
+ def load_servers(path: Path, harness: bool = False, config_required: bool = True) -> List[ServerSpec]:
242
+ """Load the MCP config, rejecting names the hub could never accept.
243
+
244
+ With ``harness``, the built-in coding harness is appended, unless the config
245
+ already defines a server of that name (then the user's entry wins). If the
246
+ config file is absent and not ``config_required``, the harness alone is used.
247
+ """
248
+ if harness and not config_required and not path.is_file():
249
+ LOGGER.info("no config at %s: tunnelling only the built-in harness", path)
250
+ specs: List[ServerSpec] = []
251
+ else:
252
+ specs = load_config(path)
253
+ if harness and all(spec.name != HARNESS_NAME for spec in specs):
254
+ specs.append(harness_spec())
216
255
  for spec in specs:
217
256
  try:
218
257
  protocol.validate_name(spec.name, "server name")
258
+ if spec.project is not None:
259
+ protocol.validate_name(spec.project, "project name")
219
260
  except protocol.ProtocolError as e:
220
261
  raise McpConfigError(f"{e} (in {path})") from e
221
262
  return specs
@@ -254,6 +295,8 @@ async def _run(settings: Settings, specs: List[ServerSpec]) -> None:
254
295
  settings.label,
255
296
  ", ".join(spec.name for spec in specs),
256
297
  )
298
+ if settings.environment is not None:
299
+ LOGGER.info("%s", environment.summarize(settings.environment))
257
300
  await connection.run()
258
301
  LOGGER.info("shutdown complete")
259
302
 
@@ -269,7 +312,13 @@ def main(argv: Optional[Sequence[str]] = None) -> None:
269
312
  setup_logging(settings.log_level)
270
313
 
271
314
  try:
272
- specs = load_servers(settings.config_path)
315
+ specs = load_servers(
316
+ settings.config_path,
317
+ settings.harness,
318
+ # A file the user named must exist; the default ./mcp.json is optional
319
+ # as long as the harness gives the client something to tunnel.
320
+ config_required=settings.config_explicit,
321
+ )
273
322
  except McpConfigError as e:
274
323
  print(f"{PROG}: error: {e}", file=sys.stderr)
275
324
  sys.exit(1)
@@ -9,11 +9,17 @@ Two config shapes are supported (see README.md for the full discriminator rule):
9
9
  as a FastMCP config (https://gofastmcp.com/public/schemas/fastmcp.json/v1.json)
10
10
  and launched via ``fastmcp run <generated-config>`` instead of being spawned
11
11
  directly. The discriminator is exactly: presence of a ``source`` key.
12
+
13
+ A server entry may also carry a ``"project"``, grouping it under that name in
14
+ the hub's tool naming and scoped endpoints (e.g. ``host/legion5/project/nix/
15
+ server/lsp``). A top-level ``"project"`` in the config file sets the default
16
+ for every entry that does not specify its own.
12
17
  """
13
18
 
14
19
  from __future__ import annotations
15
20
 
16
21
  import json
22
+ import sys
17
23
  import logging
18
24
  import tempfile
19
25
  from dataclasses import dataclass, field
@@ -35,6 +41,7 @@ class ServerSpec:
35
41
  argv: List[str]
36
42
  env: Dict[str, str] = field(default_factory=dict)
37
43
  cwd: Optional[str] = None
44
+ project: Optional[str] = None
38
45
  # Set when this spec was materialized from a FastMCP-style entry, so the
39
46
  # generated temp config file can be cleaned up on shutdown.
40
47
  fastmcp_tempfile: Optional[Path] = None
@@ -50,7 +57,7 @@ def load_config(path: Path) -> List[ServerSpec]:
50
57
  if not path.is_file():
51
58
  raise ConfigError(
52
59
  f"config file not found: {path} "
53
- "(pass --config, or create ./mcp.json in the current directory)"
60
+ "(pass --config, create ./mcp.json in the current directory, or leave the built-in harness enabled)"
54
61
  )
55
62
 
56
63
  try:
@@ -61,16 +68,18 @@ def load_config(path: Path) -> List[ServerSpec]:
61
68
  if not isinstance(raw, dict):
62
69
  raise ConfigError(f"config file {path} must contain a JSON object at the top level")
63
70
 
71
+ default_project = _read_project(raw, path, "top-level 'project'")
72
+
64
73
  if "mcpServers" in raw:
65
74
  servers = raw["mcpServers"]
66
75
  if not isinstance(servers, dict) or not servers:
67
76
  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()]
77
+ return [_build_spec(name, entry, path, default_project) for name, entry in servers.items()]
69
78
 
70
79
  if "source" in raw:
71
80
  # Whole file is a single bare FastMCP config.
72
81
  name = raw.get("name") or path.stem or "fastmcp-server"
73
- return [_build_fastmcp_spec(name, raw)]
82
+ return [_build_fastmcp_spec(name, raw, default_project)]
74
83
 
75
84
  raise ConfigError(
76
85
  f"config file {path} matches neither the 'mcpServers' shape nor the "
@@ -78,12 +87,24 @@ def load_config(path: Path) -> List[ServerSpec]:
78
87
  )
79
88
 
80
89
 
81
- def _build_spec(name: str, entry: Any, config_path: Path) -> ServerSpec:
90
+ def _read_project(entry: Dict[str, Any], config_path: Path, where: str) -> Optional[str]:
91
+ """Read and normalize an optional ``"project"`` key. Blank counts as absent."""
92
+ project = entry.get("project")
93
+ if project is None:
94
+ return None
95
+ if not isinstance(project, str):
96
+ raise ConfigError(f"{where} in {config_path} must be a string")
97
+ return project.strip() or None
98
+
99
+
100
+ def _build_spec(name: str, entry: Any, config_path: Path, default_project: Optional[str] = None) -> ServerSpec:
82
101
  if not isinstance(entry, dict):
83
102
  raise ConfigError(f"mcpServers.{name} in {config_path} must be an object")
84
103
 
85
104
  if "source" in entry:
86
- return _build_fastmcp_spec(name, entry)
105
+ return _build_fastmcp_spec(name, entry, default_project)
106
+
107
+ project = _read_project(entry, config_path, f"mcpServers.{name}.project") or default_project
87
108
 
88
109
  command = entry.get("command")
89
110
  if not command or not isinstance(command, str):
@@ -104,10 +125,12 @@ def _build_spec(name: str, entry: Any, config_path: Path) -> ServerSpec:
104
125
  if cwd is not None and not isinstance(cwd, str):
105
126
  raise ConfigError(f"mcpServers.{name}.cwd in {config_path} must be a string")
106
127
 
107
- return ServerSpec(name=name, argv=[command, *args], env={k: str(v) for k, v in env.items()}, cwd=cwd)
128
+ return ServerSpec(
129
+ name=name, argv=[command, *args], env={k: str(v) for k, v in env.items()}, cwd=cwd, project=project
130
+ )
108
131
 
109
132
 
110
- def _build_fastmcp_spec(name: str, entry: Dict[str, Any]) -> ServerSpec:
133
+ def _build_fastmcp_spec(name: str, entry: Dict[str, Any], default_project: Optional[str] = None) -> ServerSpec:
111
134
  """Materialize a FastMCP-style entry into a runnable ServerSpec.
112
135
 
113
136
  We write the entry out verbatim (minus a forced transport override) as its
@@ -119,7 +142,13 @@ def _build_fastmcp_spec(name: str, entry: Dict[str, Any]) -> ServerSpec:
119
142
  regardless of what the entry declares, with a warning if it was set to
120
143
  something else.
121
144
  """
145
+ raw_project = entry.get("project")
146
+ if raw_project is not None and not isinstance(raw_project, str):
147
+ raise ConfigError(f"mcpServers.{name}.project must be a string")
148
+ project = (raw_project.strip() if isinstance(raw_project, str) else None) or default_project
149
+
122
150
  fastmcp_config = dict(entry)
151
+ fastmcp_config.pop("project", None) # not a FastMCP config key
123
152
  deployment = dict(fastmcp_config.get("deployment") or {})
124
153
  original_transport = deployment.get("transport")
125
154
  if original_transport and original_transport != "stdio":
@@ -141,5 +170,18 @@ def _build_fastmcp_spec(name: str, entry: Dict[str, Any]) -> ServerSpec:
141
170
  name=name,
142
171
  argv=["uvx", "fastmcp", "run", str(tmp_path)],
143
172
  env={},
173
+ project=project,
144
174
  fastmcp_tempfile=tmp_path,
145
175
  )
176
+
177
+
178
+ HARNESS_NAME = "harness"
179
+
180
+
181
+ def harness_spec() -> ServerSpec:
182
+ """The built-in coding harness, run with this interpreter.
183
+
184
+ It is a dependency of this package, so it is always importable here and
185
+ needs no separate install or network fetch at startup.
186
+ """
187
+ return ServerSpec(name=HARNESS_NAME, argv=[sys.executable, "-m", "mcp_switchboard_server_harness"])
@@ -0,0 +1,202 @@
1
+ """Detect where the client runs, for the ``hello`` frame's ``client.environment``.
2
+
3
+ Pure and best-effort: :func:`detect` never raises, reads only a small fixed set of
4
+ signals, and reports paths and names, never environment variable values beyond the
5
+ documented details (docs/AGENT_MODEL_API.md, "Connections: environment detection").
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import logging
12
+ import os
13
+ import re
14
+ import sys
15
+ from pathlib import Path
16
+ from typing import Any, Dict, List, Mapping, Optional
17
+
18
+ LOGGER = logging.getLogger("mcp_switchboard_client.environment")
19
+
20
+ _CGROUP_MARKERS = ("docker", "kubepods", "containerd", "podman")
21
+ _MAX_STR = 256
22
+ _MAX_WALK = 64
23
+
24
+
25
+ def _cap(value: str) -> str:
26
+ return value[:_MAX_STR]
27
+
28
+
29
+ def strip_jsonc(text: str) -> str:
30
+ """Remove ``//`` and ``/* */`` comments and trailing commas, leaving strings intact."""
31
+ out: List[str] = []
32
+ i, n = 0, len(text)
33
+ while i < n:
34
+ c = text[i]
35
+ if c == '"':
36
+ j = i + 1
37
+ while j < n and text[j] != '"':
38
+ j += 2 if text[j] == "\\" else 1
39
+ out.append(text[i : j + 1])
40
+ i = j + 1
41
+ elif text.startswith("//", i):
42
+ while i < n and text[i] != "\n":
43
+ i += 1
44
+ elif text.startswith("/*", i):
45
+ end = text.find("*/", i + 2)
46
+ i = n if end < 0 else end + 2
47
+ else:
48
+ out.append(c)
49
+ i += 1
50
+ cleaned = "".join(out)
51
+ # Trailing commas: a comma followed only by whitespace and a closer. Strings
52
+ # were already tokenised above, but the regex could match inside one, so redo
53
+ # it string-aware.
54
+ result: List[str] = []
55
+ i, n = 0, len(cleaned)
56
+ while i < n:
57
+ c = cleaned[i]
58
+ if c == '"':
59
+ j = i + 1
60
+ while j < n and cleaned[j] != '"':
61
+ j += 2 if cleaned[j] == "\\" else 1
62
+ result.append(cleaned[i : j + 1])
63
+ i = j + 1
64
+ elif c == "," and re.match(r"\s*[}\]]", cleaned[i + 1 :]):
65
+ i += 1
66
+ else:
67
+ result.append(c)
68
+ i += 1
69
+ return "".join(result)
70
+
71
+
72
+ def _read(path: Path, limit: int = 1_000_000) -> Optional[str]:
73
+ try:
74
+ with open(path, "r", encoding="utf-8", errors="replace") as f:
75
+ return f.read(limit)
76
+ except OSError:
77
+ return None
78
+
79
+
80
+ def _parents(cwd: Path) -> List[Path]:
81
+ return [cwd, *cwd.parents][:_MAX_WALK]
82
+
83
+
84
+ def find_devcontainer_name(cwd: Path) -> Optional[str]:
85
+ """``name`` from the nearest devcontainer.json at or above ``cwd``."""
86
+ for directory in _parents(cwd):
87
+ for candidate in (
88
+ directory / ".devcontainer" / "devcontainer.json",
89
+ directory / ".devcontainer.json",
90
+ ):
91
+ text = _read(candidate)
92
+ if text is None:
93
+ continue
94
+ try:
95
+ data = json.loads(strip_jsonc(text))
96
+ except ValueError:
97
+ continue
98
+ name = data.get("name") if isinstance(data, dict) else None
99
+ if isinstance(name, str) and name.strip():
100
+ return _cap(name.strip())
101
+ return None
102
+
103
+
104
+ def find_git_root(cwd: Path) -> Optional[Path]:
105
+ """The nearest directory at or above ``cwd`` containing ``.git`` (dir or file)."""
106
+ for directory in _parents(cwd):
107
+ try:
108
+ if (directory / ".git").exists():
109
+ return directory
110
+ except OSError:
111
+ continue
112
+ return None
113
+
114
+
115
+ def _in_container(root: Path) -> bool:
116
+ for marker in ("/.dockerenv", "/run/.containerenv"):
117
+ try:
118
+ if Path(marker).exists():
119
+ return True
120
+ except OSError:
121
+ pass
122
+ cgroup = _read(Path("/proc/1/cgroup"), 65536) if root == Path("/") else None
123
+ return bool(cgroup) and any(m in cgroup.lower() for m in _CGROUP_MARKERS)
124
+
125
+
126
+ def detect(
127
+ cwd: Optional[Path] = None,
128
+ env: Optional[Mapping[str, str]] = None,
129
+ project_override: Optional[str] = None,
130
+ ) -> Dict[str, Any]:
131
+ """Return the ``environment`` object for ``hello``. Never raises."""
132
+ try:
133
+ return _detect(cwd, env, project_override)
134
+ except Exception: # noqa: BLE001 - detection must never break the client
135
+ LOGGER.debug("environment detection failed", exc_info=True)
136
+ return {"kinds": []}
137
+
138
+
139
+ def _detect(
140
+ cwd: Optional[Path], env: Optional[Mapping[str, str]], project_override: Optional[str]
141
+ ) -> Dict[str, Any]:
142
+ env = os.environ if env is None else env
143
+ cwd = Path(os.getcwd() if cwd is None else cwd).absolute()
144
+ kinds: List[str] = []
145
+ details: Dict[str, str] = {}
146
+
147
+ container = _in_container(Path("/"))
148
+ devcontainer = any(
149
+ env.get(k) for k in ("REMOTE_CONTAINERS", "DEVCONTAINER", "CODESPACES", "VSCODE_REMOTE_CONTAINERS_SESSION")
150
+ ) or (container and str(cwd).startswith("/workspaces/"))
151
+ dc_name = find_devcontainer_name(cwd) if devcontainer else None
152
+
153
+ if devcontainer:
154
+ kinds.append("devcontainer")
155
+ if dc_name:
156
+ details["devcontainerName"] = dc_name
157
+ image = env.get("DEVCONTAINER_IMAGE") or env.get("CONTAINER_IMAGE")
158
+ if image:
159
+ details["image"] = _cap(image)
160
+ if container:
161
+ kinds.append("container")
162
+ if env.get("DIRENV_DIR") or env.get("DIRENV_FILE") or env.get("DIRENV_DIFF"):
163
+ kinds.append("direnv")
164
+ direnv_dir = env.get("DIRENV_DIR", "").lstrip("-")
165
+ if direnv_dir:
166
+ details["direnvDir"] = _cap(direnv_dir)
167
+ in_nix = env.get("IN_NIX_SHELL")
168
+ if in_nix or env.get("NIX_BUILD_TOP") or "/nix/store/" in env.get("PATH", ""):
169
+ kinds.append("nix-shell")
170
+ if in_nix in ("pure", "impure"):
171
+ details["nixShell"] = in_nix
172
+ elif in_nix:
173
+ details["nixShell"] = "impure"
174
+ venv = env.get("VIRTUAL_ENV")
175
+ if venv or sys.prefix != getattr(sys, "base_prefix", sys.prefix):
176
+ kinds.append("venv")
177
+ name = os.path.basename((venv or sys.prefix).rstrip("/\\"))
178
+ if name:
179
+ details["venv"] = _cap(name)
180
+
181
+ project = (project_override or "").strip() or None
182
+ if project is None and dc_name:
183
+ project = dc_name
184
+ if project is None:
185
+ root = find_git_root(cwd)
186
+ project = (root or cwd).name or None
187
+
188
+ result: Dict[str, Any] = {"kinds": kinds}
189
+ if project:
190
+ result["project"] = _cap(project)
191
+ result["workspace"] = _cap(str(cwd))
192
+ if details:
193
+ result["details"] = details
194
+ return result
195
+
196
+
197
+ def summarize(environment: Mapping[str, Any]) -> str:
198
+ kinds = ",".join(environment.get("kinds", [])) or "none"
199
+ return (
200
+ f"environment: kinds={kinds} project={environment.get('project', '-')} "
201
+ f"workspace={environment.get('workspace', '-')}"
202
+ )
@@ -49,11 +49,15 @@ def hello(
49
49
  instance: str,
50
50
  label: str,
51
51
  servers: List[Dict[str, Any]],
52
+ environment: Optional[Dict[str, Any]] = None,
52
53
  ) -> Dict[str, Any]:
54
+ client: Dict[str, Any] = {"name": client_name, "version": version, "instance": instance, "label": label}
55
+ if environment is not None:
56
+ client["environment"] = environment
53
57
  return {
54
58
  "type": HELLO,
55
59
  "protocol": PROTOCOL_VERSION,
56
- "client": {"name": client_name, "version": version, "instance": instance, "label": label},
60
+ "client": client,
57
61
  "servers": servers,
58
62
  }
59
63
 
@@ -71,7 +71,10 @@ class LocalServer:
71
71
  @property
72
72
  def descriptor(self) -> dict:
73
73
  """The entry this server contributes to the ``hello`` frame."""
74
- return {"name": self.name, "command": " ".join(self.spec.argv)}
74
+ entry = {"name": self.name, "command": " ".join(self.spec.argv)}
75
+ if self.spec.project:
76
+ entry["project"] = self.spec.project
77
+ return entry
75
78
 
76
79
  async def start(self) -> None:
77
80
  """Spawn the process. A no-op if one is already claimed."""
@@ -18,12 +18,14 @@ from __future__ import annotations
18
18
  import asyncio
19
19
  import json
20
20
  import logging
21
+ import ssl
21
22
  import uuid
22
23
  from contextlib import suppress
23
24
  from dataclasses import dataclass
24
25
  from typing import Any, Dict, Iterable, List, Optional
25
26
  from urllib.parse import urlsplit, urlunsplit
26
27
 
28
+ import certifi
27
29
  import websockets
28
30
  from websockets.exceptions import ConnectionClosed
29
31
 
@@ -53,6 +55,25 @@ class FatalTunnelError(TunnelError):
53
55
  """The hub refused this client; retrying cannot help."""
54
56
 
55
57
 
58
+ def _tls_context() -> ssl.SSLContext:
59
+ """A TLS context trusting the system store *and* certifi's CA bundle.
60
+
61
+ Built once and reused. This client typically runs under `uvx`, whose
62
+ portable CPython builds frequently can't find a system cert store (NixOS
63
+ keeps its trust root at a nonstandard path), so certifi is the floor that
64
+ makes public certificates verify on any OS. The system store (and
65
+ SSL_CERT_FILE / SSL_CERT_DIR) stays in the mix on purpose: a private or
66
+ corporate CA installed there must keep working, which `cafile=` alone
67
+ would silently drop.
68
+ """
69
+ context = ssl.create_default_context()
70
+ context.load_verify_locations(cafile=certifi.where())
71
+ return context
72
+
73
+
74
+ _TLS_CONTEXT = _tls_context()
75
+
76
+
56
77
  def _header_kwarg() -> str:
57
78
  """Name of the extra-headers keyword for the installed websockets version.
58
79
 
@@ -104,6 +125,8 @@ class TunnelSettings:
104
125
  label: str
105
126
  reconnect_delay: float = DEFAULT_RECONNECT_DELAY
106
127
  max_retries: int = DEFAULT_MAX_RETRIES
128
+ # Detected once at startup (see environment.detect); sent in every hello.
129
+ environment: Optional[Dict[str, Any]] = None
107
130
 
108
131
 
109
132
  class HubConnection:
@@ -226,6 +249,8 @@ class HubConnection:
226
249
  "ping_interval": PING_INTERVAL,
227
250
  "ping_timeout": PING_TIMEOUT,
228
251
  }
252
+ if urlsplit(self.url).scheme == "wss":
253
+ kwargs["ssl"] = _TLS_CONTEXT
229
254
 
230
255
  LOGGER.info("connecting to %s", self.url)
231
256
  async with self._connect(self.url, **kwargs) as ws:
@@ -239,6 +264,7 @@ class HubConnection:
239
264
  self.instance,
240
265
  self.settings.label,
241
266
  self._descriptors(),
267
+ self.settings.environment,
242
268
  )
243
269
  )
244
270
  async for raw in ws:
@@ -75,3 +75,52 @@ def test_neither_shape_is_an_error(tmp_path):
75
75
  cfg = write_json(tmp_path / "mcp.json", {"unrelated": True})
76
76
  with pytest.raises(ConfigError):
77
77
  load_config(cfg)
78
+
79
+
80
+ def test_empty_mcp_servers_is_rejected(tmp_path):
81
+ cfg = write_json(tmp_path / "mcp.json", {"mcpServers": {}})
82
+ with pytest.raises(ConfigError, match="non-empty"):
83
+ load_config(cfg)
84
+
85
+
86
+ def test_project_is_read_per_entry_and_from_top_level_default(tmp_path):
87
+ cfg = write_json(
88
+ tmp_path / "mcp.json",
89
+ {
90
+ "project": "nix",
91
+ "mcpServers": {
92
+ "a": {"command": "x"},
93
+ "b": {"command": "y", "project": "other"},
94
+ "c": {"command": "z", "project": " "},
95
+ },
96
+ },
97
+ )
98
+ projects = {s.name: s.project for s in load_config(cfg)}
99
+ assert projects == {"a": "nix", "b": "other", "c": "nix"}
100
+
101
+
102
+ def test_harness_is_added_by_default_and_can_be_disabled(tmp_path):
103
+ from mcp_switchboard_client.cli import load_servers
104
+
105
+ cfg = write_json(tmp_path / "mcp.json", {"mcpServers": {"git": {"command": "x"}}})
106
+ assert [s.name for s in load_servers(cfg, harness=True)] == ["git", "harness"]
107
+ assert [s.name for s in load_servers(cfg, harness=False)] == ["git"]
108
+
109
+
110
+ def test_user_defined_harness_wins(tmp_path):
111
+ from mcp_switchboard_client.cli import load_servers
112
+
113
+ cfg = write_json(tmp_path / "mcp.json", {"mcpServers": {"harness": {"command": "mine"}}})
114
+ specs = load_servers(cfg, harness=True)
115
+ assert [(s.name, s.argv) for s in specs] == [("harness", ["mine"])]
116
+
117
+
118
+ def test_missing_default_config_falls_back_to_the_harness_alone(tmp_path):
119
+ from mcp_switchboard_client.cli import load_servers
120
+
121
+ absent = tmp_path / "mcp.json"
122
+ assert [s.name for s in load_servers(absent, harness=True, config_required=False)] == ["harness"]
123
+ with pytest.raises(ConfigError, match="not found"): # an explicitly named file must exist
124
+ load_servers(absent, harness=True, config_required=True)
125
+ with pytest.raises(ConfigError, match="not found"): # nothing to tunnel without the harness
126
+ load_servers(absent, harness=False, config_required=False)
@@ -0,0 +1,73 @@
1
+ """envconf is checked against docs/envconf-cases.json, the fixture the Go hub also runs.
2
+
3
+ envconf.py stays Python-only; the hub reimplements the same two rules in Go
4
+ (spec.md P3). This shared table is what stops the two implementations from
5
+ disagreeing about, say, whether an empty file substitutes to an empty string
6
+ (spec.md V4).
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ from pathlib import Path
13
+ from typing import Any
14
+
15
+ import pytest
16
+
17
+ from mcp_switchboard_client import envconf
18
+
19
+
20
+ def fixture() -> dict:
21
+ here = Path(__file__).resolve()
22
+ for candidate in here.parents:
23
+ path = candidate / "docs" / "envconf-cases.json"
24
+ if path.is_file():
25
+ return json.loads(path.read_text(encoding="utf-8"))
26
+ pytest.skip("not running from a source checkout")
27
+
28
+
29
+ FIXTURE = fixture()
30
+
31
+
32
+ @pytest.fixture(scope="module")
33
+ def sandbox(tmp_path_factory: pytest.TempPathFactory) -> Path:
34
+ root = tmp_path_factory.mktemp("envconf")
35
+ for name, content in FIXTURE["files"].items():
36
+ (root / name).write_text(content, encoding="utf-8")
37
+ for name in FIXTURE["dirs"]:
38
+ (root / name).mkdir()
39
+ return root
40
+
41
+
42
+ def expand(value: Any, root: Path) -> Any:
43
+ return value.replace("{{dir}}", str(root)) if isinstance(value, str) else value
44
+
45
+
46
+ def test_prefix_matches_the_fixture() -> None:
47
+ assert envconf.PREFIX == FIXTURE["prefix"]
48
+
49
+
50
+ @pytest.mark.parametrize("case", FIXTURE["cases"], ids=lambda c: c["name"])
51
+ def test_case(case: dict, sandbox: Path, monkeypatch: pytest.MonkeyPatch) -> None:
52
+ for key in list(__import__("os").environ):
53
+ if key.startswith(FIXTURE["prefix"]):
54
+ monkeypatch.delenv(key, raising=False)
55
+ for key, value in case["env"].items():
56
+ monkeypatch.setenv(key, expand(value, sandbox))
57
+
58
+ spec = case["get"]
59
+ getter = {
60
+ "str": lambda: envconf.get(
61
+ spec["name"], spec.get("default"), secret=spec.get("secret", False)
62
+ ),
63
+ "int": lambda: envconf.get_int(spec["name"], spec["default"]),
64
+ "float": lambda: envconf.get_float(spec["name"], spec["default"]),
65
+ "bool": lambda: envconf.get_bool(spec["name"], spec["default"]),
66
+ }[spec["kind"]]
67
+
68
+ expected = case["expect"]
69
+ if isinstance(expected, dict) and expected.get("error"):
70
+ with pytest.raises(envconf.ConfigError):
71
+ getter()
72
+ else:
73
+ assert getter() == expand(expected, sandbox)
@@ -0,0 +1,128 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from pathlib import Path
5
+
6
+ import pytest
7
+
8
+ from mcp_switchboard_client import cli, environment, protocol
9
+
10
+
11
+ @pytest.fixture(autouse=True)
12
+ def _no_host_container(monkeypatch):
13
+ monkeypatch.setattr(environment, "_in_container", lambda root: False)
14
+ monkeypatch.setattr(environment.sys, "base_prefix", environment.sys.prefix)
15
+
16
+
17
+ def detect(tmp_path, env=None, **kw):
18
+ return environment.detect(cwd=tmp_path, env=env or {}, **kw)
19
+
20
+
21
+ def test_plain_environment(tmp_path):
22
+ result = detect(tmp_path)
23
+ assert result["kinds"] == []
24
+ assert result["workspace"] == str(tmp_path)
25
+ assert result["project"] == tmp_path.name
26
+ assert "details" not in result
27
+
28
+
29
+ def test_container(tmp_path, monkeypatch):
30
+ monkeypatch.setattr(environment, "_in_container", lambda root: True)
31
+ assert detect(tmp_path)["kinds"] == ["container"]
32
+
33
+
34
+ def test_devcontainer_env_and_jsonc_name(tmp_path):
35
+ (tmp_path / ".git").mkdir()
36
+ dc = tmp_path / ".devcontainer"
37
+ dc.mkdir()
38
+ (dc / "devcontainer.json").write_text(
39
+ '// c\n{\n /* x */ "name": "My // Proj",\n "image": "a",\n "list": [1, 2,],\n}\n'
40
+ )
41
+ sub = tmp_path / "src" / "deep"
42
+ sub.mkdir(parents=True)
43
+ result = detect(sub, {"REMOTE_CONTAINERS": "true"})
44
+ assert result["kinds"] == ["devcontainer"]
45
+ assert result["project"] == "My // Proj"
46
+ assert result["details"]["devcontainerName"] == "My // Proj"
47
+
48
+
49
+ def test_devcontainer_via_workspaces_path(tmp_path, monkeypatch):
50
+ monkeypatch.setattr(environment, "_in_container", lambda root: True)
51
+ ws = tmp_path / "workspaces" / "p"
52
+ ws.mkdir(parents=True)
53
+ # cwd does not start with /workspaces/ here, so only "container"
54
+ assert detect(ws)["kinds"] == ["container"]
55
+ monkeypatch.setattr(environment, "find_devcontainer_name", lambda cwd: None)
56
+ result = environment.detect(cwd=Path("/workspaces/p"), env={})
57
+ assert result["kinds"] == ["devcontainer", "container"]
58
+
59
+
60
+ def test_bad_devcontainer_json_falls_back_to_git_root(tmp_path):
61
+ (tmp_path / ".git").write_text("gitdir: x")
62
+ (tmp_path / ".devcontainer.json").write_text("{not json")
63
+ sub = tmp_path / "a"
64
+ sub.mkdir()
65
+ result = detect(sub, {"CODESPACES": "true"})
66
+ assert result["kinds"] == ["devcontainer"]
67
+ assert result["project"] == tmp_path.name
68
+
69
+
70
+ def test_direnv_and_nix(tmp_path):
71
+ result = detect(tmp_path, {"DIRENV_DIR": "-/home/x/p", "IN_NIX_SHELL": "pure", "PATH": "/bin"})
72
+ assert result["kinds"] == ["direnv", "nix-shell"]
73
+ assert result["details"] == {"direnvDir": "/home/x/p", "nixShell": "pure"}
74
+
75
+
76
+ def test_nix_from_path_marker(tmp_path):
77
+ result = detect(tmp_path, {"PATH": "/nix/store/abc-x/bin:/bin"})
78
+ assert result["kinds"] == ["nix-shell"]
79
+ assert "nixShell" not in result.get("details", {})
80
+
81
+
82
+ def test_venv_basename_only(tmp_path):
83
+ result = detect(tmp_path, {"VIRTUAL_ENV": "/secret/place/.venv"})
84
+ assert result["kinds"] == ["venv"]
85
+ assert result["details"] == {"venv": ".venv"}
86
+
87
+
88
+ def test_git_root_project(tmp_path):
89
+ root = tmp_path / "repo"
90
+ (root / ".git").mkdir(parents=True)
91
+ sub = root / "x" / "y"
92
+ sub.mkdir(parents=True)
93
+ assert detect(sub)["project"] == "repo"
94
+
95
+
96
+ def test_override_wins(tmp_path):
97
+ assert detect(tmp_path, project_override=" mine ")["project"] == "mine"
98
+
99
+
100
+ def test_never_raises(tmp_path, monkeypatch):
101
+ def boom(*a, **k):
102
+ raise RuntimeError("x")
103
+
104
+ monkeypatch.setattr(environment, "find_git_root", boom)
105
+ assert environment.detect(cwd=tmp_path, env={}) == {"kinds": []}
106
+
107
+
108
+ def test_strip_jsonc_keeps_strings():
109
+ text = '{"a": "x,]", "b": [1,], // t\n}'
110
+ assert json.loads(environment.strip_jsonc(text)) == {"a": "x,]", "b": [1]}
111
+
112
+
113
+ def test_hello_carries_environment():
114
+ env = {"kinds": ["direnv"], "workspace": "/w"}
115
+ assert protocol.hello("c", "1", "i", "l", [], env)["client"]["environment"] == env
116
+ assert "environment" not in protocol.hello("c", "1", "i", "l", [])["client"]
117
+
118
+
119
+ def test_project_name_setting(tmp_path, monkeypatch):
120
+ monkeypatch.chdir(tmp_path)
121
+ monkeypatch.setenv("MCP_SWITCHBOARD_HUB_URL", "wss://h.example")
122
+ monkeypatch.setenv("MCP_SWITCHBOARD_TUNNEL_TOKEN", "t")
123
+ s = cli.build_settings([])
124
+ assert s.environment["project"] == tmp_path.name
125
+ monkeypatch.setenv("MCP_SWITCHBOARD_PROJECT_NAME", "fromenv")
126
+ assert cli.build_settings([]).environment["project"] == "fromenv"
127
+ assert cli.build_settings(["--project-name", "flag"]).environment["project"] == "flag"
128
+ assert cli.build_settings([]).tunnel_settings().environment["project"] == "fromenv"
@@ -0,0 +1,97 @@
1
+ """protocol.py is checked against docs/protocol.json, not against another copy.
2
+
3
+ Byte-identity between the hub's and the client's copy (hub/tests/test_protocol_sync.py)
4
+ only works while both are Python. The Go hub cannot participate in that check, so
5
+ docs/protocol.json is the manifest both languages assert against instead: this file
6
+ is the Python half, hub/internal/protocol/protocol_test.go is the Go half. Drift in
7
+ either direction fails CI (spec.md P1/P2).
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import json
13
+ from pathlib import Path
14
+
15
+ import pytest
16
+
17
+ from mcp_switchboard_client import protocol
18
+
19
+
20
+ def manifest() -> dict:
21
+ here = Path(__file__).resolve()
22
+ for candidate in here.parents:
23
+ path = candidate / "docs" / "protocol.json"
24
+ if path.is_file():
25
+ return json.loads(path.read_text(encoding="utf-8"))
26
+ pytest.skip("not running from a source checkout")
27
+
28
+
29
+ MANIFEST = manifest()
30
+
31
+
32
+ def test_constants_match_the_manifest() -> None:
33
+ assert protocol.PROTOCOL_VERSION == MANIFEST["protocolVersion"]
34
+ assert protocol.TUNNEL_PATH == MANIFEST["tunnelPath"]
35
+ assert protocol.NAME_SEPARATOR == MANIFEST["nameSeparator"]
36
+
37
+
38
+ def test_server_states_match_the_manifest() -> None:
39
+ declared = {
40
+ protocol.STATE_STARTING,
41
+ protocol.STATE_RUNNING,
42
+ protocol.STATE_EXITED,
43
+ protocol.STATE_FAILED,
44
+ }
45
+ assert declared == set(MANIFEST["serverStates"])
46
+
47
+
48
+ def test_frame_type_constants_match_the_manifest() -> None:
49
+ declared = {
50
+ protocol.HELLO,
51
+ protocol.HELLO_ACK,
52
+ protocol.MCP,
53
+ protocol.SERVER_STATE,
54
+ protocol.RESTART,
55
+ protocol.ERROR,
56
+ }
57
+ assert declared == set(MANIFEST["frames"])
58
+
59
+
60
+ # One builder call per frame, with arguments chosen so that every optional field is
61
+ # populated - a builder that silently dropped a field would otherwise pass.
62
+ BUILDERS = {
63
+ "hello": lambda: protocol.hello(
64
+ "c", "0.0.0", "i", "lab", [{"name": "git"}], {"kinds": ["direnv"], "workspace": "/w"}
65
+ ),
66
+ "hello_ack": lambda: protocol.hello_ack("cid", "hub", "0.0.0"),
67
+ "mcp": lambda: protocol.mcp("git", {"jsonrpc": "2.0"}),
68
+ "server_state": lambda: protocol.server_state("git", protocol.STATE_EXITED, 1, "boom"),
69
+ "restart": lambda: protocol.restart("git"),
70
+ "error": lambda: protocol.error("bad", "git"),
71
+ }
72
+
73
+
74
+ @pytest.mark.parametrize("frame_type", sorted(MANIFEST["frames"]))
75
+ def test_builder_emits_exactly_the_declared_fields(frame_type: str) -> None:
76
+ declared = MANIFEST["frames"][frame_type]
77
+ frame = BUILDERS[frame_type]()
78
+
79
+ assert frame["type"] == frame_type
80
+ allowed = set(declared["required"]) | set(declared["optional"])
81
+ assert set(frame) <= allowed, f"{frame_type} emits undeclared fields"
82
+ for field in declared["required"]:
83
+ assert field in frame, f"{frame_type} is missing required field {field}"
84
+ assert frame[field] is not None, f"required field {field} must not be null"
85
+
86
+
87
+ def test_every_builder_is_covered() -> None:
88
+ """A frame added to the manifest without a builder here would not be checked."""
89
+ assert set(BUILDERS) == set(MANIFEST["frames"])
90
+
91
+
92
+ def test_hello_client_optional_fields_are_declared_and_emitted() -> None:
93
+ declared = MANIFEST["frames"]["hello"]["clientOptional"]
94
+ assert "environment" in declared
95
+ client = protocol.hello("c", "0.0.0", "i", "lab", [], {"kinds": []})["client"]
96
+ assert set(client) <= {"name", "version", "instance", "label"} | set(declared)
97
+ assert "environment" in client
@@ -177,3 +177,17 @@ def test_settings_normalizes_the_hub_url(tmp_path, monkeypatch):
177
177
  settings = build_settings(["--hub-url", "https://hub.example.com", "--token", "t"])
178
178
  assert settings.hub_url == "wss://hub.example.com/tunnel/v1"
179
179
  assert settings.tunnel_settings().hub_url == settings.hub_url
180
+
181
+
182
+ def test_harness_defaults_on_and_can_be_switched_off(monkeypatch):
183
+ assert build_settings(BASE).harness is True
184
+ assert build_settings(BASE + ["--no-harness"]).harness is False
185
+ monkeypatch.setenv("MCP_SWITCHBOARD_HARNESS", "false")
186
+ assert build_settings(BASE).harness is False
187
+
188
+
189
+ def test_config_explicit_only_when_asked_for(monkeypatch):
190
+ assert build_settings(BASE).config_explicit is False
191
+ assert build_settings(BASE + ["--config", "x.json"]).config_explicit is True
192
+ monkeypatch.setenv("MCP_SWITCHBOARD_CONFIG", "y.json")
193
+ assert build_settings(BASE).config_explicit is True
@@ -290,3 +290,30 @@ async def test_max_retries_gives_up():
290
290
  await connection.run()
291
291
 
292
292
  assert len(connect.calls) == 3
293
+
294
+
295
+ def test_tls_context_trusts_certifi_even_without_a_system_store(monkeypatch, tmp_path):
296
+ """The NixOS case: no usable system CA file, so certifi alone must supply roots."""
297
+ from mcp_switchboard_client import tunnel
298
+
299
+ monkeypatch.setenv("SSL_CERT_FILE", str(tmp_path / "missing.pem"))
300
+ monkeypatch.setenv("SSL_CERT_DIR", str(tmp_path / "missing-dir"))
301
+ context = tunnel._tls_context()
302
+ assert len(context.get_ca_certs()) > 0
303
+ assert context.verify_mode.name == "CERT_REQUIRED" and context.check_hostname
304
+
305
+
306
+ def test_tls_context_keeps_the_system_store_too(monkeypatch, tmp_path):
307
+ """A private CA supplied via SSL_CERT_FILE must still be trusted alongside certifi."""
308
+ import certifi
309
+
310
+ from mcp_switchboard_client import tunnel
311
+
312
+ pem = tmp_path / "extra.pem"
313
+ with open(certifi.where()) as f:
314
+ first = f.read().split("-----END CERTIFICATE-----")[0] + "-----END CERTIFICATE-----\n"
315
+ pem.write_text(first)
316
+ baseline = len(tunnel._tls_context().get_ca_certs())
317
+ monkeypatch.setenv("SSL_CERT_FILE", str(pem))
318
+ assert len(tunnel._tls_context().get_ca_certs()) >= 1
319
+ assert baseline >= 1
@@ -1,13 +0,0 @@
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-*