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.
Files changed (24) hide show
  1. {mcp_switchboard_client-0.2.0 → mcp_switchboard_client-0.3.0.dev4}/.gitignore +2 -0
  2. {mcp_switchboard_client-0.2.0 → mcp_switchboard_client-0.3.0.dev4}/PKG-INFO +37 -3
  3. mcp_switchboard_client-0.3.0.dev4/README.md +76 -0
  4. {mcp_switchboard_client-0.2.0 → mcp_switchboard_client-0.3.0.dev4}/pyproject.toml +21 -3
  5. {mcp_switchboard_client-0.2.0 → mcp_switchboard_client-0.3.0.dev4}/src/mcp_switchboard_client/__init__.py +1 -1
  6. {mcp_switchboard_client-0.2.0 → mcp_switchboard_client-0.3.0.dev4}/src/mcp_switchboard_client/cli.py +98 -10
  7. {mcp_switchboard_client-0.2.0 → mcp_switchboard_client-0.3.0.dev4}/src/mcp_switchboard_client/config.py +49 -7
  8. mcp_switchboard_client-0.3.0.dev4/src/mcp_switchboard_client/environment.py +469 -0
  9. {mcp_switchboard_client-0.2.0 → mcp_switchboard_client-0.3.0.dev4}/src/mcp_switchboard_client/protocol.py +35 -5
  10. {mcp_switchboard_client-0.2.0 → mcp_switchboard_client-0.3.0.dev4}/src/mcp_switchboard_client/supervisor.py +19 -2
  11. {mcp_switchboard_client-0.2.0 → mcp_switchboard_client-0.3.0.dev4}/src/mcp_switchboard_client/tunnel.py +216 -5
  12. {mcp_switchboard_client-0.2.0 → mcp_switchboard_client-0.3.0.dev4}/tests/test_config.py +49 -0
  13. mcp_switchboard_client-0.3.0.dev4/tests/test_env_brief.py +104 -0
  14. mcp_switchboard_client-0.3.0.dev4/tests/test_envconf_cases.py +73 -0
  15. mcp_switchboard_client-0.3.0.dev4/tests/test_environment.py +128 -0
  16. mcp_switchboard_client-0.3.0.dev4/tests/test_instructions.py +222 -0
  17. mcp_switchboard_client-0.3.0.dev4/tests/test_protocol_conformance.py +121 -0
  18. {mcp_switchboard_client-0.2.0 → mcp_switchboard_client-0.3.0.dev4}/tests/test_settings.py +67 -0
  19. {mcp_switchboard_client-0.2.0 → mcp_switchboard_client-0.3.0.dev4}/tests/test_supervisor.py +22 -0
  20. {mcp_switchboard_client-0.2.0 → mcp_switchboard_client-0.3.0.dev4}/tests/test_tunnel.py +130 -0
  21. mcp_switchboard_client-0.2.0/README.md +0 -45
  22. {mcp_switchboard_client-0.2.0 → mcp_switchboard_client-0.3.0.dev4}/src/mcp_switchboard_client/__main__.py +0 -0
  23. {mcp_switchboard_client-0.2.0 → mcp_switchboard_client-0.3.0.dev4}/src/mcp_switchboard_client/envconf.py +0 -0
  24. {mcp_switchboard_client-0.2.0 → mcp_switchboard_client-0.3.0.dev4}/tests/conftest.py +0 -0
@@ -11,3 +11,5 @@ dist/
11
11
  .env
12
12
  result
13
13
  result-*
14
+ .direnv/
15
+ hub/mcp-switchboard-hub
@@ -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.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 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,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.2.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
- # 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,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
- raw_config = args.config or envconf.get("CONFIG") or DEFAULT_CONFIG_NAME
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
- specs = load_config(path)
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]) -> None:
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(settings.config_path)
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, 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"])