mcp-switchboard-client 0.2.0__tar.gz → 0.3.0.dev4__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.2.0 → mcp_switchboard_client-0.3.0.dev4}/.gitignore +2 -0
- {mcp_switchboard_client-0.2.0 → mcp_switchboard_client-0.3.0.dev4}/PKG-INFO +37 -3
- mcp_switchboard_client-0.3.0.dev4/README.md +76 -0
- {mcp_switchboard_client-0.2.0 → mcp_switchboard_client-0.3.0.dev4}/pyproject.toml +21 -3
- {mcp_switchboard_client-0.2.0 → mcp_switchboard_client-0.3.0.dev4}/src/mcp_switchboard_client/__init__.py +1 -1
- {mcp_switchboard_client-0.2.0 → mcp_switchboard_client-0.3.0.dev4}/src/mcp_switchboard_client/cli.py +98 -10
- {mcp_switchboard_client-0.2.0 → mcp_switchboard_client-0.3.0.dev4}/src/mcp_switchboard_client/config.py +49 -7
- mcp_switchboard_client-0.3.0.dev4/src/mcp_switchboard_client/environment.py +469 -0
- {mcp_switchboard_client-0.2.0 → mcp_switchboard_client-0.3.0.dev4}/src/mcp_switchboard_client/protocol.py +35 -5
- {mcp_switchboard_client-0.2.0 → mcp_switchboard_client-0.3.0.dev4}/src/mcp_switchboard_client/supervisor.py +19 -2
- {mcp_switchboard_client-0.2.0 → mcp_switchboard_client-0.3.0.dev4}/src/mcp_switchboard_client/tunnel.py +216 -5
- {mcp_switchboard_client-0.2.0 → mcp_switchboard_client-0.3.0.dev4}/tests/test_config.py +49 -0
- mcp_switchboard_client-0.3.0.dev4/tests/test_env_brief.py +104 -0
- mcp_switchboard_client-0.3.0.dev4/tests/test_envconf_cases.py +73 -0
- mcp_switchboard_client-0.3.0.dev4/tests/test_environment.py +128 -0
- mcp_switchboard_client-0.3.0.dev4/tests/test_instructions.py +222 -0
- mcp_switchboard_client-0.3.0.dev4/tests/test_protocol_conformance.py +121 -0
- {mcp_switchboard_client-0.2.0 → mcp_switchboard_client-0.3.0.dev4}/tests/test_settings.py +67 -0
- {mcp_switchboard_client-0.2.0 → mcp_switchboard_client-0.3.0.dev4}/tests/test_supervisor.py +22 -0
- {mcp_switchboard_client-0.2.0 → mcp_switchboard_client-0.3.0.dev4}/tests/test_tunnel.py +130 -0
- mcp_switchboard_client-0.2.0/README.md +0 -45
- {mcp_switchboard_client-0.2.0 → mcp_switchboard_client-0.3.0.dev4}/src/mcp_switchboard_client/__main__.py +0 -0
- {mcp_switchboard_client-0.2.0 → mcp_switchboard_client-0.3.0.dev4}/src/mcp_switchboard_client/envconf.py +0 -0
- {mcp_switchboard_client-0.2.0 → mcp_switchboard_client-0.3.0.dev4}/tests/conftest.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: mcp-switchboard-client
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0.dev4
|
|
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,34 @@ 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
|
+
|
|
72
|
+
The client also collects the instruction files your repository carries for coding
|
|
73
|
+
agents (`AGENTS.md`, `CLAUDE.md`, `.cursorrules`, `.github/copilot-instructions.md`,
|
|
74
|
+
also at any depth, capped at 8 files / 64 KiB) from the start directory and ships them
|
|
75
|
+
to the hub, which puts them into the agent's system prompt; it re-sends them whenever
|
|
76
|
+
they change, so an edit to `AGENTS.md` reaches a running session. Turn it off with
|
|
77
|
+
`--no-instructions` or `MCP_SWITCHBOARD_INSTRUCTIONS=false`.
|
|
78
|
+
|
|
79
|
+
A push is treated as what it is: the harness marks `git_push` irreversible in its tool `_meta`, so
|
|
80
|
+
the hub asks for approval before any agent can run it — even one set to `approval=never`. If that
|
|
81
|
+
friction is wrong for your deployment, that is a decision to make deliberately, not by silence.
|
|
82
|
+
|
|
83
|
+
Alongside them the client ships a short environment brief of the host your tools run
|
|
84
|
+
on — user, hostname, git branch/dirtiness (remote with credentials redacted), what the
|
|
85
|
+
file tools may write, tool presence; sudo is reported only if provable without a
|
|
86
|
+
prompt, and network reachability is never probed — refreshed like the instruction
|
|
87
|
+
files and marked stale by the hub if a client vanishes. Turn it off with
|
|
88
|
+
`--no-env-brief` or `MCP_SWITCHBOARD_ENV_BRIEF=false`.
|
|
89
|
+
|
|
56
90
|
Settings can also come from `MCP_SWITCHBOARD_*` environment variables or a
|
|
57
91
|
`.env` file (`HUB_URL`, `TUNNEL_TOKEN`, `LABEL`, `CONFIG`, …). Any value that
|
|
58
92
|
starts with `/` and points at an existing regular file is read from that file, so
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# mcp-switchboard-client
|
|
2
|
+
|
|
3
|
+
The client half of [mcp-switchboard](https://github.com/AkosPapp/mcp-switchboard).
|
|
4
|
+
|
|
5
|
+
It reads an `mcp.json`, spawns the local stdio MCP servers it describes, and
|
|
6
|
+
opens a single **outbound** WebSocket to an `mcp-switchboard-hub`, multiplexing
|
|
7
|
+
every server over that one connection. No inbound port is ever opened, so it
|
|
8
|
+
works from behind NAT with nothing forwarded.
|
|
9
|
+
|
|
10
|
+
The client speaks no MCP itself — it is a pipe, shuttling each server's
|
|
11
|
+
stdin/stdout across the tunnel. All MCP logic lives in the hub. That is why its
|
|
12
|
+
only 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).
|
|
15
|
+
|
|
16
|
+
## Running
|
|
17
|
+
|
|
18
|
+
```sh
|
|
19
|
+
uvx mcp-switchboard-client --hub-url wss://switchboard.example.com --token "$TOKEN"
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
or bootstrap `uvx`/`npx` first with the installer:
|
|
23
|
+
|
|
24
|
+
```sh
|
|
25
|
+
curl -fsSL https://akospapp.github.io/mcp-switchboard/install.sh | sh -s -- \
|
|
26
|
+
--hub-url wss://switchboard.example.com --token "$TOKEN"
|
|
27
|
+
```
|
|
28
|
+
|
|
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):
|
|
31
|
+
|
|
32
|
+
```json
|
|
33
|
+
{
|
|
34
|
+
"mcpServers": {
|
|
35
|
+
"git": { "command": "uvx", "args": ["mcp-server-git", "--repository", "."] }
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
```
|
|
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
|
+
|
|
50
|
+
The client also collects the instruction files your repository carries for coding
|
|
51
|
+
agents (`AGENTS.md`, `CLAUDE.md`, `.cursorrules`, `.github/copilot-instructions.md`,
|
|
52
|
+
also at any depth, capped at 8 files / 64 KiB) from the start directory and ships them
|
|
53
|
+
to the hub, which puts them into the agent's system prompt; it re-sends them whenever
|
|
54
|
+
they change, so an edit to `AGENTS.md` reaches a running session. Turn it off with
|
|
55
|
+
`--no-instructions` or `MCP_SWITCHBOARD_INSTRUCTIONS=false`.
|
|
56
|
+
|
|
57
|
+
A push is treated as what it is: the harness marks `git_push` irreversible in its tool `_meta`, so
|
|
58
|
+
the hub asks for approval before any agent can run it — even one set to `approval=never`. If that
|
|
59
|
+
friction is wrong for your deployment, that is a decision to make deliberately, not by silence.
|
|
60
|
+
|
|
61
|
+
Alongside them the client ships a short environment brief of the host your tools run
|
|
62
|
+
on — user, hostname, git branch/dirtiness (remote with credentials redacted), what the
|
|
63
|
+
file tools may write, tool presence; sudo is reported only if provable without a
|
|
64
|
+
prompt, and network reachability is never probed — refreshed like the instruction
|
|
65
|
+
files and marked stale by the hub if a client vanishes. Turn it off with
|
|
66
|
+
`--no-env-brief` or `MCP_SWITCHBOARD_ENV_BRIEF=false`.
|
|
67
|
+
|
|
68
|
+
Settings can also come from `MCP_SWITCHBOARD_*` environment variables or a
|
|
69
|
+
`.env` file (`HUB_URL`, `TUNNEL_TOKEN`, `LABEL`, `CONFIG`, …). Any value that
|
|
70
|
+
starts with `/` and points at an existing regular file is read from that file, so
|
|
71
|
+
a token can be passed as a path.
|
|
72
|
+
|
|
73
|
+
`LABEL` defaults to the machine's hostname and is how the hub tags this
|
|
74
|
+
machine's tools, so consumers can tell which host a tool lives on.
|
|
75
|
+
|
|
76
|
+
See the [main README](../README.md) for the full picture.
|
|
@@ -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.dev4"
|
|
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.dev4}/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,15 @@ 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
|
|
57
|
+
instruction_root: Optional[str] = None # cwd by default; None disables shipping
|
|
58
|
+
env_brief: bool = True # host brief in hello + context_update
|
|
53
59
|
|
|
54
60
|
def tunnel_settings(self) -> TunnelSettings:
|
|
55
61
|
return TunnelSettings(
|
|
@@ -58,6 +64,9 @@ class Settings:
|
|
|
58
64
|
label=self.label,
|
|
59
65
|
reconnect_delay=self.reconnect_delay,
|
|
60
66
|
max_retries=self.max_retries,
|
|
67
|
+
environment=self.environment,
|
|
68
|
+
instruction_root=self.instruction_root,
|
|
69
|
+
env_brief=self.env_brief,
|
|
61
70
|
)
|
|
62
71
|
|
|
63
72
|
|
|
@@ -92,6 +101,14 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
92
101
|
f"(or {envconf.PREFIX}LABEL)"
|
|
93
102
|
),
|
|
94
103
|
)
|
|
104
|
+
parser.add_argument(
|
|
105
|
+
"--project-name",
|
|
106
|
+
default=None,
|
|
107
|
+
help=(
|
|
108
|
+
"Project name shown next to this client in the hub; default is detected "
|
|
109
|
+
f"(devcontainer name, git root, or directory name) (or {envconf.PREFIX}PROJECT_NAME)"
|
|
110
|
+
),
|
|
111
|
+
)
|
|
95
112
|
parser.add_argument(
|
|
96
113
|
"--config",
|
|
97
114
|
default=None,
|
|
@@ -123,12 +140,36 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
123
140
|
f"(default {DEFAULT_MAX_RETRIES}, or {envconf.PREFIX}MAX_RETRIES)"
|
|
124
141
|
),
|
|
125
142
|
)
|
|
143
|
+
parser.add_argument(
|
|
144
|
+
"--no-harness",
|
|
145
|
+
action="store_true",
|
|
146
|
+
help=(
|
|
147
|
+
"Do not add the built-in coding harness server (file, search, git and shell "
|
|
148
|
+
f"tools), which is on by default (or set {envconf.PREFIX}HARNESS=false)"
|
|
149
|
+
),
|
|
150
|
+
)
|
|
126
151
|
parser.add_argument(
|
|
127
152
|
"--log-level",
|
|
128
153
|
choices=LOG_LEVELS,
|
|
129
154
|
default=None,
|
|
130
155
|
help=f"Log level (default {DEFAULT_LOG_LEVEL}, or {envconf.PREFIX}LOG_LEVEL)",
|
|
131
156
|
)
|
|
157
|
+
parser.add_argument(
|
|
158
|
+
"--no-instructions",
|
|
159
|
+
action="store_true",
|
|
160
|
+
help=(
|
|
161
|
+
"Do not collect instruction files (AGENTS.md, CLAUDE.md, ...) from the working "
|
|
162
|
+
f"directory and send them to the hub (or set {envconf.PREFIX}INSTRUCTIONS=false)"
|
|
163
|
+
),
|
|
164
|
+
)
|
|
165
|
+
parser.add_argument(
|
|
166
|
+
"--no-env-brief",
|
|
167
|
+
action="store_true",
|
|
168
|
+
help=(
|
|
169
|
+
"Do not send the host environment brief (identity, host, git state, writable set, "
|
|
170
|
+
f"tool presence) to the hub (or set {envconf.PREFIX}ENV_BRIEF=false)"
|
|
171
|
+
),
|
|
172
|
+
)
|
|
132
173
|
return parser
|
|
133
174
|
|
|
134
175
|
|
|
@@ -168,9 +209,12 @@ def load_settings(args: argparse.Namespace) -> Settings:
|
|
|
168
209
|
except protocol.ProtocolError as e:
|
|
169
210
|
raise ConfigError(str(e)) from e
|
|
170
211
|
|
|
212
|
+
project_name = (args.project_name or envconf.get("PROJECT_NAME") or "").strip() or None
|
|
213
|
+
|
|
171
214
|
# The config path is resolved strictly against the current directory;
|
|
172
215
|
# there is no upward search for an mcp.json.
|
|
173
|
-
|
|
216
|
+
explicit_config = args.config or envconf.get("CONFIG")
|
|
217
|
+
raw_config = explicit_config or DEFAULT_CONFIG_NAME
|
|
174
218
|
config_path = Path(raw_config)
|
|
175
219
|
if not config_path.is_absolute():
|
|
176
220
|
config_path = Path.cwd() / config_path
|
|
@@ -200,9 +244,19 @@ def load_settings(args: argparse.Namespace) -> Settings:
|
|
|
200
244
|
token=token,
|
|
201
245
|
label=label,
|
|
202
246
|
config_path=config_path,
|
|
247
|
+
config_explicit=bool(explicit_config),
|
|
203
248
|
reconnect_delay=reconnect_delay,
|
|
204
249
|
max_retries=max_retries,
|
|
205
250
|
log_level=log_level,
|
|
251
|
+
harness=not args.no_harness and envconf.get_bool("HARNESS", True),
|
|
252
|
+
project_name=project_name,
|
|
253
|
+
environment=environment.detect(project_override=project_name),
|
|
254
|
+
instruction_root=(
|
|
255
|
+
None
|
|
256
|
+
if args.no_instructions or not envconf.get_bool("INSTRUCTIONS", True)
|
|
257
|
+
else str(Path.cwd())
|
|
258
|
+
),
|
|
259
|
+
env_brief=not args.no_env_brief and envconf.get_bool("ENV_BRIEF", True),
|
|
206
260
|
)
|
|
207
261
|
|
|
208
262
|
|
|
@@ -210,12 +264,25 @@ def build_settings(argv: Optional[Sequence[str]] = None) -> Settings:
|
|
|
210
264
|
return load_settings(parse_args(argv))
|
|
211
265
|
|
|
212
266
|
|
|
213
|
-
def load_servers(path: Path) -> List[ServerSpec]:
|
|
214
|
-
"""Load the MCP config, rejecting names the hub could never accept.
|
|
215
|
-
|
|
267
|
+
def load_servers(path: Path, harness: bool = False, config_required: bool = True) -> List[ServerSpec]:
|
|
268
|
+
"""Load the MCP config, rejecting names the hub could never accept.
|
|
269
|
+
|
|
270
|
+
With ``harness``, the built-in coding harness is appended, unless the config
|
|
271
|
+
already defines a server of that name (then the user's entry wins). If the
|
|
272
|
+
config file is absent and not ``config_required``, the harness alone is used.
|
|
273
|
+
"""
|
|
274
|
+
if harness and not config_required and not path.is_file():
|
|
275
|
+
LOGGER.info("no config at %s: tunnelling only the built-in harness", path)
|
|
276
|
+
specs: List[ServerSpec] = []
|
|
277
|
+
else:
|
|
278
|
+
specs = load_config(path)
|
|
279
|
+
if harness and all(spec.name != HARNESS_NAME for spec in specs):
|
|
280
|
+
specs.append(harness_spec())
|
|
216
281
|
for spec in specs:
|
|
217
282
|
try:
|
|
218
283
|
protocol.validate_name(spec.name, "server name")
|
|
284
|
+
if spec.project is not None:
|
|
285
|
+
protocol.validate_name(spec.project, "project name")
|
|
219
286
|
except protocol.ProtocolError as e:
|
|
220
287
|
raise McpConfigError(f"{e} (in {path})") from e
|
|
221
288
|
return specs
|
|
@@ -230,7 +297,8 @@ def setup_logging(level: str) -> None:
|
|
|
230
297
|
)
|
|
231
298
|
|
|
232
299
|
|
|
233
|
-
async def _run(settings: Settings, specs: List[ServerSpec]) ->
|
|
300
|
+
async def _run(settings: Settings, specs: List[ServerSpec]) -> Optional[str]:
|
|
301
|
+
"""Run the tunnel; returns a failure message if it gave up, else None."""
|
|
234
302
|
connection = HubConnection(specs, settings.tunnel_settings(), version=__version__)
|
|
235
303
|
|
|
236
304
|
def request_stop() -> None:
|
|
@@ -254,8 +322,19 @@ async def _run(settings: Settings, specs: List[ServerSpec]) -> None:
|
|
|
254
322
|
settings.label,
|
|
255
323
|
", ".join(spec.name for spec in specs),
|
|
256
324
|
)
|
|
325
|
+
if settings.environment is not None:
|
|
326
|
+
LOGGER.info("%s", environment.summarize(settings.environment))
|
|
327
|
+
if settings.instruction_root:
|
|
328
|
+
found = environment.collect_instructions(Path(settings.instruction_root))
|
|
329
|
+
LOGGER.info(
|
|
330
|
+
"instruction files: %s",
|
|
331
|
+
", ".join(f["path"] for f in found) if found else "none found",
|
|
332
|
+
)
|
|
333
|
+
if settings.env_brief:
|
|
334
|
+
LOGGER.info("environment brief: enabled (turn off with --no-env-brief)")
|
|
257
335
|
await connection.run()
|
|
258
336
|
LOGGER.info("shutdown complete")
|
|
337
|
+
return connection.failure
|
|
259
338
|
|
|
260
339
|
|
|
261
340
|
def main(argv: Optional[Sequence[str]] = None) -> None:
|
|
@@ -269,13 +348,22 @@ def main(argv: Optional[Sequence[str]] = None) -> None:
|
|
|
269
348
|
setup_logging(settings.log_level)
|
|
270
349
|
|
|
271
350
|
try:
|
|
272
|
-
specs = load_servers(
|
|
351
|
+
specs = load_servers(
|
|
352
|
+
settings.config_path,
|
|
353
|
+
settings.harness,
|
|
354
|
+
# A file the user named must exist; the default ./mcp.json is optional
|
|
355
|
+
# as long as the harness gives the client something to tunnel.
|
|
356
|
+
config_required=settings.config_explicit,
|
|
357
|
+
)
|
|
273
358
|
except McpConfigError as e:
|
|
274
359
|
print(f"{PROG}: error: {e}", file=sys.stderr)
|
|
275
360
|
sys.exit(1)
|
|
276
361
|
|
|
277
362
|
try:
|
|
278
|
-
asyncio.run(_run(settings, specs))
|
|
363
|
+
failure = asyncio.run(_run(settings, specs))
|
|
364
|
+
if failure:
|
|
365
|
+
print(f"{PROG}: error: {failure}", file=sys.stderr)
|
|
366
|
+
sys.exit(1)
|
|
279
367
|
except KeyboardInterrupt:
|
|
280
368
|
pass
|
|
281
369
|
except TunnelError as e:
|
|
@@ -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"])
|