mcp-switchboard-client 0.3.0.dev3__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.3.0.dev4/.gitignore +15 -0
- {mcp_switchboard_client-0.3.0.dev3 → mcp_switchboard_client-0.3.0.dev4}/PKG-INFO +19 -1
- {mcp_switchboard_client-0.3.0.dev3 → mcp_switchboard_client-0.3.0.dev4}/README.md +18 -0
- {mcp_switchboard_client-0.3.0.dev3 → mcp_switchboard_client-0.3.0.dev4}/pyproject.toml +1 -1
- {mcp_switchboard_client-0.3.0.dev3 → mcp_switchboard_client-0.3.0.dev4}/src/mcp_switchboard_client/cli.py +41 -2
- mcp_switchboard_client-0.3.0.dev4/src/mcp_switchboard_client/environment.py +469 -0
- {mcp_switchboard_client-0.3.0.dev3 → mcp_switchboard_client-0.3.0.dev4}/src/mcp_switchboard_client/protocol.py +30 -4
- {mcp_switchboard_client-0.3.0.dev3 → mcp_switchboard_client-0.3.0.dev4}/src/mcp_switchboard_client/supervisor.py +15 -1
- {mcp_switchboard_client-0.3.0.dev3 → mcp_switchboard_client-0.3.0.dev4}/src/mcp_switchboard_client/tunnel.py +190 -5
- mcp_switchboard_client-0.3.0.dev4/tests/test_env_brief.py +104 -0
- mcp_switchboard_client-0.3.0.dev4/tests/test_instructions.py +222 -0
- {mcp_switchboard_client-0.3.0.dev3 → mcp_switchboard_client-0.3.0.dev4}/tests/test_protocol_conformance.py +33 -9
- {mcp_switchboard_client-0.3.0.dev3 → mcp_switchboard_client-0.3.0.dev4}/tests/test_settings.py +53 -0
- {mcp_switchboard_client-0.3.0.dev3 → mcp_switchboard_client-0.3.0.dev4}/tests/test_supervisor.py +22 -0
- {mcp_switchboard_client-0.3.0.dev3 → mcp_switchboard_client-0.3.0.dev4}/tests/test_tunnel.py +103 -0
- mcp_switchboard_client-0.3.0.dev3/.gitignore +0 -18
- mcp_switchboard_client-0.3.0.dev3/src/mcp_switchboard_client/environment.py +0 -202
- {mcp_switchboard_client-0.3.0.dev3 → mcp_switchboard_client-0.3.0.dev4}/src/mcp_switchboard_client/__init__.py +0 -0
- {mcp_switchboard_client-0.3.0.dev3 → mcp_switchboard_client-0.3.0.dev4}/src/mcp_switchboard_client/__main__.py +0 -0
- {mcp_switchboard_client-0.3.0.dev3 → mcp_switchboard_client-0.3.0.dev4}/src/mcp_switchboard_client/config.py +0 -0
- {mcp_switchboard_client-0.3.0.dev3 → mcp_switchboard_client-0.3.0.dev4}/src/mcp_switchboard_client/envconf.py +0 -0
- {mcp_switchboard_client-0.3.0.dev3 → mcp_switchboard_client-0.3.0.dev4}/tests/conftest.py +0 -0
- {mcp_switchboard_client-0.3.0.dev3 → mcp_switchboard_client-0.3.0.dev4}/tests/test_config.py +0 -0
- {mcp_switchboard_client-0.3.0.dev3 → mcp_switchboard_client-0.3.0.dev4}/tests/test_envconf_cases.py +0 -0
- {mcp_switchboard_client-0.3.0.dev3 → mcp_switchboard_client-0.3.0.dev4}/tests/test_environment.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: mcp-switchboard-client
|
|
3
|
-
Version: 0.3.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
|
|
@@ -69,6 +69,24 @@ the client in. Turn it off with
|
|
|
69
69
|
`--no-harness` or `MCP_SWITCHBOARD_HARNESS=false`; an `mcp.json` entry named
|
|
70
70
|
`harness` replaces it.
|
|
71
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
|
+
|
|
72
90
|
Settings can also come from `MCP_SWITCHBOARD_*` environment variables or a
|
|
73
91
|
`.env` file (`HUB_URL`, `TUNNEL_TOKEN`, `LABEL`, `CONFIG`, …). Any value that
|
|
74
92
|
starts with `/` and points at an existing regular file is read from that file, so
|
|
@@ -47,6 +47,24 @@ the client in. Turn it off with
|
|
|
47
47
|
`--no-harness` or `MCP_SWITCHBOARD_HARNESS=false`; an `mcp.json` entry named
|
|
48
48
|
`harness` replaces it.
|
|
49
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
|
+
|
|
50
68
|
Settings can also come from `MCP_SWITCHBOARD_*` environment variables or a
|
|
51
69
|
`.env` file (`HUB_URL`, `TUNNEL_TOKEN`, `LABEL`, `CONFIG`, …). Any value that
|
|
52
70
|
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.3.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"
|
|
@@ -54,6 +54,8 @@ class Settings:
|
|
|
54
54
|
harness: bool = True
|
|
55
55
|
project_name: Optional[str] = None
|
|
56
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
|
|
57
59
|
|
|
58
60
|
def tunnel_settings(self) -> TunnelSettings:
|
|
59
61
|
return TunnelSettings(
|
|
@@ -63,6 +65,8 @@ class Settings:
|
|
|
63
65
|
reconnect_delay=self.reconnect_delay,
|
|
64
66
|
max_retries=self.max_retries,
|
|
65
67
|
environment=self.environment,
|
|
68
|
+
instruction_root=self.instruction_root,
|
|
69
|
+
env_brief=self.env_brief,
|
|
66
70
|
)
|
|
67
71
|
|
|
68
72
|
|
|
@@ -150,6 +154,22 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
150
154
|
default=None,
|
|
151
155
|
help=f"Log level (default {DEFAULT_LOG_LEVEL}, or {envconf.PREFIX}LOG_LEVEL)",
|
|
152
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
|
+
)
|
|
153
173
|
return parser
|
|
154
174
|
|
|
155
175
|
|
|
@@ -231,6 +251,12 @@ def load_settings(args: argparse.Namespace) -> Settings:
|
|
|
231
251
|
harness=not args.no_harness and envconf.get_bool("HARNESS", True),
|
|
232
252
|
project_name=project_name,
|
|
233
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),
|
|
234
260
|
)
|
|
235
261
|
|
|
236
262
|
|
|
@@ -271,7 +297,8 @@ def setup_logging(level: str) -> None:
|
|
|
271
297
|
)
|
|
272
298
|
|
|
273
299
|
|
|
274
|
-
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."""
|
|
275
302
|
connection = HubConnection(specs, settings.tunnel_settings(), version=__version__)
|
|
276
303
|
|
|
277
304
|
def request_stop() -> None:
|
|
@@ -297,8 +324,17 @@ async def _run(settings: Settings, specs: List[ServerSpec]) -> None:
|
|
|
297
324
|
)
|
|
298
325
|
if settings.environment is not None:
|
|
299
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)")
|
|
300
335
|
await connection.run()
|
|
301
336
|
LOGGER.info("shutdown complete")
|
|
337
|
+
return connection.failure
|
|
302
338
|
|
|
303
339
|
|
|
304
340
|
def main(argv: Optional[Sequence[str]] = None) -> None:
|
|
@@ -324,7 +360,10 @@ def main(argv: Optional[Sequence[str]] = None) -> None:
|
|
|
324
360
|
sys.exit(1)
|
|
325
361
|
|
|
326
362
|
try:
|
|
327
|
-
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)
|
|
328
367
|
except KeyboardInterrupt:
|
|
329
368
|
pass
|
|
330
369
|
except TunnelError as e:
|
|
@@ -0,0 +1,469 @@
|
|
|
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, Sequence
|
|
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
|
+
)
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
# ---------- Instruction files (hello instructions / context_update) ----------
|
|
206
|
+
#
|
|
207
|
+
# The repo's own agent instructions, read from the client host and shipped to
|
|
208
|
+
# the hub (docs/PROTOCOL.md: hello.client.instructions, refreshed by the
|
|
209
|
+
# context_update frame). These budgets are deliberately SEPARATE from the
|
|
210
|
+
# environment caps above (which are 256-rune strings for display); instruction
|
|
211
|
+
# bodies are content the model must see in full, so they get a larger,
|
|
212
|
+
# explicitly-documented budget. Nothing here may raise: the tunnel treats a
|
|
213
|
+
# failed collection as "no instruction files", never as a fault.
|
|
214
|
+
|
|
215
|
+
INSTRUCTION_FILENAMES = ("AGENTS.md", "CLAUDE.md", ".cursorrules", ".github/copilot-instructions.md")
|
|
216
|
+
|
|
217
|
+
INSTRUCTION_MAX_FILE_CHARS = 32 * 1024 # per file
|
|
218
|
+
INSTRUCTION_MAX_FILES = 8 # per host
|
|
219
|
+
INSTRUCTION_MAX_TOTAL_CHARS = 64 * 1024 # per hello/context_update
|
|
220
|
+
_INSTRUCTION_MAX_DIR_SCANS = 5000 # walk bound so a huge tree cannot stall a connect
|
|
221
|
+
# Cache, dependency and build trees: instruction files under them are never the
|
|
222
|
+
# project's own guidance. Dotted directories are not descended into except
|
|
223
|
+
# .github, which is on the standard-name list.
|
|
224
|
+
_INSTRUCTION_SKIP_DIRS = {
|
|
225
|
+
".git", "node_modules", "__pycache__", ".venv", "venv", ".harness",
|
|
226
|
+
".mypy_cache", ".pytest_cache", ".ruff_cache", ".tox", ".cache",
|
|
227
|
+
"dist", "build", "target", ".next", ".terraform",
|
|
228
|
+
}
|
|
229
|
+
_INSTRUCTION_DESCEND_DOTS = {".github"}
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def find_instruction_files(cwd: Path) -> List[Path]:
|
|
233
|
+
"""All standard instruction files at or under ``cwd``, root-first.
|
|
234
|
+
|
|
235
|
+
Every found file is returned, not just the nearest: the client cannot
|
|
236
|
+
predict which paths the agent will work under, and the hub labels each with
|
|
237
|
+
its path and states the refine/nested rule. Deterministic order: directory
|
|
238
|
+
depth, then path, then the fixed filename order within a directory.
|
|
239
|
+
"""
|
|
240
|
+
names = {n.split("/")[-1] for n in INSTRUCTION_FILENAMES}
|
|
241
|
+
order = {n.split("/")[-1]: i for i, n in enumerate(INSTRUCTION_FILENAMES)}
|
|
242
|
+
located: List[tuple] = [] # (depth, posix, Path)
|
|
243
|
+
scans = 0
|
|
244
|
+
try:
|
|
245
|
+
for dirpath, dirnames, filenames in os.walk(cwd, followlinks=False):
|
|
246
|
+
scans += 1
|
|
247
|
+
if scans > _INSTRUCTION_MAX_DIR_SCANS:
|
|
248
|
+
break
|
|
249
|
+
base = Path(dirpath)
|
|
250
|
+
dirnames[:] = sorted(
|
|
251
|
+
d for d in dirnames
|
|
252
|
+
if d not in _INSTRUCTION_SKIP_DIRS and (not d.startswith(".") or d in _INSTRUCTION_DESCEND_DOTS)
|
|
253
|
+
)
|
|
254
|
+
depth = len(base.relative_to(cwd).parts)
|
|
255
|
+
for name in sorted(filenames):
|
|
256
|
+
if name not in names:
|
|
257
|
+
continue
|
|
258
|
+
# copilot-instructions.md only counts inside a .github
|
|
259
|
+
# directory; the bare names count anywhere kept.
|
|
260
|
+
if name == "copilot-instructions.md" and base.name != ".github":
|
|
261
|
+
continue
|
|
262
|
+
located.append((depth, base / name, order[name]))
|
|
263
|
+
except OSError:
|
|
264
|
+
return []
|
|
265
|
+
located.sort(key=lambda item: (item[0], item[1].as_posix(), item[2]))
|
|
266
|
+
return [item[1] for item in located]
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def collect_instructions(cwd: Path) -> List[Dict[str, str]]:
|
|
270
|
+
"""Instruction files as ``[{path, content}, ...]`` within the wire budget.
|
|
271
|
+
|
|
272
|
+
``path`` is relative to ``cwd`` (forward slashes). A file over the per-file
|
|
273
|
+
budget is truncated with a visible marker; files that do not fit the
|
|
274
|
+
whole-hello budget are replaced by one ``(omitted)`` entry listing them, so
|
|
275
|
+
the agent knows the set is incomplete. Never raises.
|
|
276
|
+
"""
|
|
277
|
+
files = find_instruction_files(cwd)
|
|
278
|
+
out: List[Dict[str, str]] = []
|
|
279
|
+
skipped: List[str] = []
|
|
280
|
+
used = 0
|
|
281
|
+
try:
|
|
282
|
+
for path in files:
|
|
283
|
+
rel = path.relative_to(cwd).as_posix()
|
|
284
|
+
if len(out) >= INSTRUCTION_MAX_FILES:
|
|
285
|
+
skipped.append(rel)
|
|
286
|
+
continue
|
|
287
|
+
try:
|
|
288
|
+
text = path.read_text(encoding="utf-8", errors="replace")
|
|
289
|
+
except OSError:
|
|
290
|
+
continue
|
|
291
|
+
if len(text) > INSTRUCTION_MAX_FILE_CHARS:
|
|
292
|
+
marker = f"\n[... truncated by the client budget: the file is {len(text)} characters]"
|
|
293
|
+
# Keep the marker INSIDE the cap so the hub's identical re-cap
|
|
294
|
+
# cannot shave it off and make the truncation invisible again.
|
|
295
|
+
text = text[: max(INSTRUCTION_MAX_FILE_CHARS - len(marker), 0)] + marker
|
|
296
|
+
remaining = INSTRUCTION_MAX_TOTAL_CHARS - used
|
|
297
|
+
if len(text) > remaining:
|
|
298
|
+
skipped.append(rel)
|
|
299
|
+
continue
|
|
300
|
+
used += len(text)
|
|
301
|
+
out.append({"path": rel, "content": text})
|
|
302
|
+
except Exception: # noqa: BLE001 - collecting must never break the tunnel
|
|
303
|
+
LOGGER.debug("instruction collection failed", exc_info=True)
|
|
304
|
+
if skipped:
|
|
305
|
+
out.append(
|
|
306
|
+
{
|
|
307
|
+
"path": "(omitted by client budget)",
|
|
308
|
+
"content": "More instruction files exist under this root but were not sent "
|
|
309
|
+
"(per-host budgets: %d files, %d total characters). Known names not included:\n%s"
|
|
310
|
+
% (INSTRUCTION_MAX_FILES, INSTRUCTION_MAX_TOTAL_CHARS, "\n".join(f"- {s}" for s in skipped)),
|
|
311
|
+
}
|
|
312
|
+
)
|
|
313
|
+
return out
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
# ---------- Environment brief (hello environment_brief / context_update) -----
|
|
317
|
+
#
|
|
318
|
+
# A short, cheaply-computed description of the client host so the agent knows
|
|
319
|
+
# where its tools actually run without spending a turn on probing (docs/
|
|
320
|
+
# PROTOCOL.md). Rules: everything is read from local sources with a small
|
|
321
|
+
# timeout, nothing may hang a connect (a hung brief is a skipped line), network
|
|
322
|
+
# reachability is NEVER probed (interfaces/resolvers only), and anything that
|
|
323
|
+
# cannot be verified within the budget is reported "unknown", not guessed.
|
|
324
|
+
# Collected again on every refresh tick, so the hub's own staleness flag is
|
|
325
|
+
# what covers a client that vanishes mid-session.
|
|
326
|
+
|
|
327
|
+
BRIEF_MAX_CHARS = 6 * 1024 # wire comfort; the hub caps the field on its side
|
|
328
|
+
BRIEF_MAX_LINES = 30
|
|
329
|
+
|
|
330
|
+
BRIEF_TOOLS = ("git", "rg", "ctags", "python3", "node", "go", "docker", "kubectl", "sbatch", "ruff", "pytest")
|
|
331
|
+
|
|
332
|
+
_CRED_IN_URL = re.compile(r"//[^/@\s]*:[^/@\s]*@")
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
def _brief_proc(cmd: List[str], timeout: float = 2.0) -> Optional[str]:
|
|
336
|
+
"""Run a helper, return stdout stripped; None on any failure or timeout."""
|
|
337
|
+
import subprocess
|
|
338
|
+
|
|
339
|
+
try:
|
|
340
|
+
done = subprocess.run(
|
|
341
|
+
cmd, capture_output=True, text=True, timeout=timeout,
|
|
342
|
+
stdin=subprocess.DEVNULL, encoding="utf-8", errors="replace",
|
|
343
|
+
)
|
|
344
|
+
except (OSError, subprocess.TimeoutExpired, ValueError):
|
|
345
|
+
return None
|
|
346
|
+
if done.returncode != 0:
|
|
347
|
+
return None
|
|
348
|
+
return done.stdout.strip()
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def _strip_git_credentials(url: str) -> str:
|
|
352
|
+
return _CRED_IN_URL.sub("//", url)
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
def _sudo_state(timeout: float = 1.5) -> str:
|
|
356
|
+
"""yes/no/unknown, decided only by a NON-INTERACTIVE check that cannot prompt."""
|
|
357
|
+
import subprocess
|
|
358
|
+
|
|
359
|
+
try:
|
|
360
|
+
done = subprocess.run(
|
|
361
|
+
["sudo", "-n", "true"], capture_output=True, text=True, timeout=timeout,
|
|
362
|
+
stdin=subprocess.DEVNULL, env={**os.environ, "SUDO_ASKPASS": "/bin/false"},
|
|
363
|
+
)
|
|
364
|
+
except (OSError, subprocess.TimeoutExpired, ValueError):
|
|
365
|
+
return "unknown"
|
|
366
|
+
if done.returncode == 0:
|
|
367
|
+
return "yes"
|
|
368
|
+
# -n makes "password required" an immediate exit, so a real code came back.
|
|
369
|
+
return "no"
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
def collect_environment_brief(
|
|
373
|
+
root: Optional[Path] = None, instruction_paths: Optional[Sequence[str]] = None
|
|
374
|
+
) -> str:
|
|
375
|
+
"""A short text brief of the client host for the environment-brief frame.
|
|
376
|
+
|
|
377
|
+
Identity, host, git state, what the file tools can write, DNS (unprobed
|
|
378
|
+
reachability), and presence/absence of a curated tool list plus sudo.
|
|
379
|
+
Never raises; a section whose helper fails is simply left out.
|
|
380
|
+
"""
|
|
381
|
+
import getpass
|
|
382
|
+
import grp
|
|
383
|
+
import platform
|
|
384
|
+
import shutil
|
|
385
|
+
import socket
|
|
386
|
+
import tempfile
|
|
387
|
+
|
|
388
|
+
lines: List[str] = []
|
|
389
|
+
try:
|
|
390
|
+
root = Path(root) if root is not None else Path(os.getcwd())
|
|
391
|
+
|
|
392
|
+
user = f"uid={os.getuid()} user={getpass.getuser()}"
|
|
393
|
+
try:
|
|
394
|
+
gids = {os.getgid(), *os.getgroups()}
|
|
395
|
+
groups = ",".join(sorted({grp.getgrgid(g).gr_name for g in gids})[:8])
|
|
396
|
+
if groups:
|
|
397
|
+
user += f" groups={groups}"
|
|
398
|
+
except (KeyError, OSError):
|
|
399
|
+
pass
|
|
400
|
+
lines.append(f"user: {user}")
|
|
401
|
+
lines.append(
|
|
402
|
+
"host: "
|
|
403
|
+
f"hostname={socket.gethostname()} os={platform.system().lower()}-{platform.machine()} "
|
|
404
|
+
f"python={platform.python_version()}"
|
|
405
|
+
)
|
|
406
|
+
try:
|
|
407
|
+
sudo = _sudo_state()
|
|
408
|
+
except Exception: # noqa: BLE001
|
|
409
|
+
sudo = "unknown"
|
|
410
|
+
lines.append(f"sudo (non-interactive check): {sudo} (unknown = could not verify)")
|
|
411
|
+
|
|
412
|
+
lines.append(f"cwd: {root}")
|
|
413
|
+
git_root = find_git_root(root)
|
|
414
|
+
if git_root is not None:
|
|
415
|
+
branch = _brief_proc(["git", "-C", str(git_root), "branch", "--show-current"]) or "(detached)"
|
|
416
|
+
porcelain = _brief_proc(["git", "-C", str(git_root), "status", "--porcelain"]) or ""
|
|
417
|
+
dirty = sum(1 for l in porcelain.splitlines() if l and not l.startswith("??"))
|
|
418
|
+
untracked = sum(1 for l in porcelain.splitlines() if l.startswith("??"))
|
|
419
|
+
lines.append(
|
|
420
|
+
f"git: root={git_root} branch={branch} modified={dirty} untracked={untracked}"
|
|
421
|
+
)
|
|
422
|
+
remote = _brief_proc(["git", "-C", str(git_root), "remote", "get-url", "origin"])
|
|
423
|
+
if remote:
|
|
424
|
+
lines.append(f"git remote origin: {_strip_git_credentials(remote)} (credentials redacted)")
|
|
425
|
+
|
|
426
|
+
scratch_root = os.environ.get("MCP_SWITCHBOARD_HARNESS_SCRATCH") or ""
|
|
427
|
+
scratch = scratch_root or (root / ".harness" / "scratch")
|
|
428
|
+
lines.append(
|
|
429
|
+
"file tools can write: the root, scratch "
|
|
430
|
+
f"{scratch} (unless overridden), and temp {tempfile.gettempdir()}; "
|
|
431
|
+
"run_command/run_python are UNCONFINED and can write anything this user can"
|
|
432
|
+
)
|
|
433
|
+
lines.append(
|
|
434
|
+
"harness output cap: "
|
|
435
|
+
f"{os.environ.get('MCP_SWITCHBOARD_HARNESS_MAX_OUTPUT', '100000')} characters per stream"
|
|
436
|
+
)
|
|
437
|
+
|
|
438
|
+
resolvers: List[str] = []
|
|
439
|
+
text = _read(Path("/etc/resolv.conf")) or ""
|
|
440
|
+
for l in text.splitlines():
|
|
441
|
+
if l.strip().startswith("nameserver"):
|
|
442
|
+
parts = l.split()
|
|
443
|
+
if len(parts) > 1:
|
|
444
|
+
resolvers.append(parts[1])
|
|
445
|
+
if len(resolvers) >= 3:
|
|
446
|
+
break
|
|
447
|
+
addrs = _brief_proc(["hostname", "-I"], timeout=1.0) or "?"
|
|
448
|
+
lines.append(
|
|
449
|
+
f"network: addresses={addrs} resolvers={','.join(resolvers) or '?'}"
|
|
450
|
+
" (reachability NOT probed)"
|
|
451
|
+
)
|
|
452
|
+
|
|
453
|
+
present = [t for t in BRIEF_TOOLS if shutil.which(t)]
|
|
454
|
+
absent = [t for t in BRIEF_TOOLS if t not in present]
|
|
455
|
+
if absent:
|
|
456
|
+
lines.append(f"tools present: {', '.join(present) or '-'}; absent: {', '.join(absent)}")
|
|
457
|
+
else:
|
|
458
|
+
lines.append(f"tools present: {', '.join(present)}")
|
|
459
|
+
|
|
460
|
+
if instruction_paths is not None:
|
|
461
|
+
joined = ", ".join(instruction_paths)
|
|
462
|
+
lines.append(f"instruction files loaded: {joined or '(none found under the root)'}")
|
|
463
|
+
|
|
464
|
+
if len(lines) > BRIEF_MAX_LINES:
|
|
465
|
+
lines = lines[: BRIEF_MAX_LINES - 1] + ["... (brief truncated)"]
|
|
466
|
+
except Exception: # noqa: BLE001 - a broken helper must not break the tunnel
|
|
467
|
+
LOGGER.debug("environment brief collection failed", exc_info=True)
|
|
468
|
+
brief = "\n".join(lines)
|
|
469
|
+
return brief[:BRIEF_MAX_CHARS] or ""
|