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.
- mcp_switchboard_client-0.3.0.dev3/.gitignore +18 -0
- {mcp_switchboard_client-0.2.0 → mcp_switchboard_client-0.3.0.dev3}/PKG-INFO +19 -3
- {mcp_switchboard_client-0.2.0 → mcp_switchboard_client-0.3.0.dev3}/README.md +15 -2
- {mcp_switchboard_client-0.2.0 → mcp_switchboard_client-0.3.0.dev3}/pyproject.toml +21 -3
- {mcp_switchboard_client-0.2.0 → mcp_switchboard_client-0.3.0.dev3}/src/mcp_switchboard_client/__init__.py +1 -1
- {mcp_switchboard_client-0.2.0 → mcp_switchboard_client-0.3.0.dev3}/src/mcp_switchboard_client/cli.py +57 -8
- {mcp_switchboard_client-0.2.0 → mcp_switchboard_client-0.3.0.dev3}/src/mcp_switchboard_client/config.py +49 -7
- mcp_switchboard_client-0.3.0.dev3/src/mcp_switchboard_client/environment.py +202 -0
- {mcp_switchboard_client-0.2.0 → mcp_switchboard_client-0.3.0.dev3}/src/mcp_switchboard_client/protocol.py +5 -1
- {mcp_switchboard_client-0.2.0 → mcp_switchboard_client-0.3.0.dev3}/src/mcp_switchboard_client/supervisor.py +4 -1
- {mcp_switchboard_client-0.2.0 → mcp_switchboard_client-0.3.0.dev3}/src/mcp_switchboard_client/tunnel.py +26 -0
- {mcp_switchboard_client-0.2.0 → mcp_switchboard_client-0.3.0.dev3}/tests/test_config.py +49 -0
- mcp_switchboard_client-0.3.0.dev3/tests/test_envconf_cases.py +73 -0
- mcp_switchboard_client-0.3.0.dev3/tests/test_environment.py +128 -0
- mcp_switchboard_client-0.3.0.dev3/tests/test_protocol_conformance.py +97 -0
- {mcp_switchboard_client-0.2.0 → mcp_switchboard_client-0.3.0.dev3}/tests/test_settings.py +14 -0
- {mcp_switchboard_client-0.2.0 → mcp_switchboard_client-0.3.0.dev3}/tests/test_tunnel.py +27 -0
- mcp_switchboard_client-0.2.0/.gitignore +0 -13
- {mcp_switchboard_client-0.2.0 → mcp_switchboard_client-0.3.0.dev3}/src/mcp_switchboard_client/__main__.py +0 -0
- {mcp_switchboard_client-0.2.0 → mcp_switchboard_client-0.3.0.dev3}/src/mcp_switchboard_client/envconf.py +0 -0
- {mcp_switchboard_client-0.2.0 → mcp_switchboard_client-0.3.0.dev3}/tests/conftest.py +0 -0
- {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.
|
|
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
|
|
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
|
|
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.
|
|
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
|
-
#
|
|
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 = [
|
|
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"
|
{mcp_switchboard_client-0.2.0 → mcp_switchboard_client-0.3.0.dev3}/src/mcp_switchboard_client/cli.py
RENAMED
|
@@ -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
|
-
|
|
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
|
-
|
|
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(
|
|
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,
|
|
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
|
|
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(
|
|
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":
|
|
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
|
-
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|