mcp-switchboard-client 0.3.0.dev4__tar.gz → 0.4.0.dev6__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 → mcp_switchboard_client-0.4.0.dev6}/.gitignore +3 -0
- {mcp_switchboard_client-0.3.0.dev4 → mcp_switchboard_client-0.4.0.dev6}/PKG-INFO +1 -1
- {mcp_switchboard_client-0.3.0.dev4 → mcp_switchboard_client-0.4.0.dev6}/pyproject.toml +1 -1
- {mcp_switchboard_client-0.3.0.dev4 → mcp_switchboard_client-0.4.0.dev6}/src/mcp_switchboard_client/__init__.py +1 -1
- {mcp_switchboard_client-0.3.0.dev4 → mcp_switchboard_client-0.4.0.dev6}/src/mcp_switchboard_client/cli.py +11 -0
- {mcp_switchboard_client-0.3.0.dev4 → mcp_switchboard_client-0.4.0.dev6}/src/mcp_switchboard_client/environment.py +117 -2
- {mcp_switchboard_client-0.3.0.dev4 → mcp_switchboard_client-0.4.0.dev6}/src/mcp_switchboard_client/protocol.py +14 -0
- {mcp_switchboard_client-0.3.0.dev4 → mcp_switchboard_client-0.4.0.dev6}/src/mcp_switchboard_client/tunnel.py +80 -14
- {mcp_switchboard_client-0.3.0.dev4 → mcp_switchboard_client-0.4.0.dev6}/tests/test_instructions.py +3 -2
- {mcp_switchboard_client-0.3.0.dev4 → mcp_switchboard_client-0.4.0.dev6}/tests/test_protocol_conformance.py +12 -4
- mcp_switchboard_client-0.4.0.dev6/tests/test_skill_scan.py +171 -0
- {mcp_switchboard_client-0.3.0.dev4 → mcp_switchboard_client-0.4.0.dev6}/tests/test_tunnel.py +86 -0
- {mcp_switchboard_client-0.3.0.dev4 → mcp_switchboard_client-0.4.0.dev6}/README.md +0 -0
- {mcp_switchboard_client-0.3.0.dev4 → mcp_switchboard_client-0.4.0.dev6}/src/mcp_switchboard_client/__main__.py +0 -0
- {mcp_switchboard_client-0.3.0.dev4 → mcp_switchboard_client-0.4.0.dev6}/src/mcp_switchboard_client/config.py +0 -0
- {mcp_switchboard_client-0.3.0.dev4 → mcp_switchboard_client-0.4.0.dev6}/src/mcp_switchboard_client/envconf.py +0 -0
- {mcp_switchboard_client-0.3.0.dev4 → mcp_switchboard_client-0.4.0.dev6}/src/mcp_switchboard_client/supervisor.py +0 -0
- {mcp_switchboard_client-0.3.0.dev4 → mcp_switchboard_client-0.4.0.dev6}/tests/conftest.py +0 -0
- {mcp_switchboard_client-0.3.0.dev4 → mcp_switchboard_client-0.4.0.dev6}/tests/test_config.py +0 -0
- {mcp_switchboard_client-0.3.0.dev4 → mcp_switchboard_client-0.4.0.dev6}/tests/test_env_brief.py +0 -0
- {mcp_switchboard_client-0.3.0.dev4 → mcp_switchboard_client-0.4.0.dev6}/tests/test_envconf_cases.py +0 -0
- {mcp_switchboard_client-0.3.0.dev4 → mcp_switchboard_client-0.4.0.dev6}/tests/test_environment.py +0 -0
- {mcp_switchboard_client-0.3.0.dev4 → mcp_switchboard_client-0.4.0.dev6}/tests/test_settings.py +0 -0
- {mcp_switchboard_client-0.3.0.dev4 → mcp_switchboard_client-0.4.0.dev6}/tests/test_supervisor.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.4.0.dev6
|
|
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
|
|
@@ -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.4.0.dev6"
|
|
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"
|
|
@@ -56,6 +56,7 @@ class Settings:
|
|
|
56
56
|
environment: Optional[Dict[str, Any]] = None # detected at load_settings
|
|
57
57
|
instruction_root: Optional[str] = None # cwd by default; None disables shipping
|
|
58
58
|
env_brief: bool = True # host brief in hello + context_update
|
|
59
|
+
skills: bool = True # SKILL.md skills in hello + skills_update
|
|
59
60
|
|
|
60
61
|
def tunnel_settings(self) -> TunnelSettings:
|
|
61
62
|
return TunnelSettings(
|
|
@@ -67,6 +68,7 @@ class Settings:
|
|
|
67
68
|
environment=self.environment,
|
|
68
69
|
instruction_root=self.instruction_root,
|
|
69
70
|
env_brief=self.env_brief,
|
|
71
|
+
skills=self.skills,
|
|
70
72
|
)
|
|
71
73
|
|
|
72
74
|
|
|
@@ -170,6 +172,14 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
170
172
|
f"tool presence) to the hub (or set {envconf.PREFIX}ENV_BRIEF=false)"
|
|
171
173
|
),
|
|
172
174
|
)
|
|
175
|
+
parser.add_argument(
|
|
176
|
+
"--no-skills",
|
|
177
|
+
action="store_true",
|
|
178
|
+
help=(
|
|
179
|
+
"Do not scan SKILL.md skills (~/.claude/skills, .opencode/skills, ...) on this "
|
|
180
|
+
f"host or report them to the hub (or set {envconf.PREFIX}SKILLS=false)"
|
|
181
|
+
),
|
|
182
|
+
)
|
|
173
183
|
return parser
|
|
174
184
|
|
|
175
185
|
|
|
@@ -257,6 +267,7 @@ def load_settings(args: argparse.Namespace) -> Settings:
|
|
|
257
267
|
else str(Path.cwd())
|
|
258
268
|
),
|
|
259
269
|
env_brief=not args.no_env_brief and envconf.get_bool("ENV_BRIEF", True),
|
|
270
|
+
skills=not args.no_skills and envconf.get_bool("SKILLS", True),
|
|
260
271
|
)
|
|
261
272
|
|
|
262
273
|
|
|
@@ -313,6 +313,121 @@ def collect_instructions(cwd: Path) -> List[Dict[str, str]]:
|
|
|
313
313
|
return out
|
|
314
314
|
|
|
315
315
|
|
|
316
|
+
# ---------- Skills (hello skills / skills_update) -----------------------------
|
|
317
|
+
#
|
|
318
|
+
# SKILL.md directories on this host, scanned the same way instruction files are
|
|
319
|
+
# (hello + a refresh frame; docs/PROTOCOL.md). opencode/Claude discovery
|
|
320
|
+
# locations; the hub stores them read-only under skills/hosts/<label>/ and can
|
|
321
|
+
# promote copies into its managed library. Budgets mirror the instruction ones.
|
|
322
|
+
|
|
323
|
+
SKILL_MAX_SKILLS = 200
|
|
324
|
+
SKILL_MAX_FILE_CHARS = 64 * 1024 # per SKILL.md
|
|
325
|
+
SKILL_MAX_TOTAL_CHARS = 256 * 1024 # per hello/skills_update
|
|
326
|
+
|
|
327
|
+
_SKILL_DIR_SETS = (".claude", ".opencode", ".agents")
|
|
328
|
+
_CONFIG_SKILL_DIRS = (
|
|
329
|
+
(".claude", "skills"),
|
|
330
|
+
(".config/opencode", "skills"),
|
|
331
|
+
(".agents", "skills"),
|
|
332
|
+
)
|
|
333
|
+
SKILL_NAME_RE = re.compile(r"^[a-z0-9]+(-[a-z0-9]+)*$")
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def _frontmatter_value(text: str, key: str) -> Optional[str]:
|
|
337
|
+
"""First top-level ``key: value`` scalar from a ``---`` frontmatter block."""
|
|
338
|
+
if not text.startswith("---"):
|
|
339
|
+
return None
|
|
340
|
+
lines = text.splitlines()
|
|
341
|
+
for line in lines[1:]:
|
|
342
|
+
if line.strip() in ("---", "..."):
|
|
343
|
+
break
|
|
344
|
+
if not line or line[0] in " \t#-":
|
|
345
|
+
continue
|
|
346
|
+
m = re.match(rf"^{key}:\s*(.*)$", line)
|
|
347
|
+
if not m:
|
|
348
|
+
continue
|
|
349
|
+
value = m.group(1).strip()
|
|
350
|
+
if len(value) >= 2 and value[0] == value[-1] and value[0] in "\"'":
|
|
351
|
+
value = value[1:-1]
|
|
352
|
+
return value
|
|
353
|
+
return None
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
def _scanned_skill_dir(d: Path, base: Path, source: str) -> List[Dict[str, Any]]:
|
|
357
|
+
"""Valid skills from one ``.../skills/`` directory, sorted by name."""
|
|
358
|
+
found: List[Dict[str, Any]] = []
|
|
359
|
+
try:
|
|
360
|
+
if not d.is_dir():
|
|
361
|
+
return found
|
|
362
|
+
entries = sorted(p for p in d.iterdir() if p.is_dir())
|
|
363
|
+
except OSError:
|
|
364
|
+
return found
|
|
365
|
+
for skill_dir in entries:
|
|
366
|
+
name = skill_dir.name
|
|
367
|
+
if not SKILL_NAME_RE.match(name) or len(name) > 64:
|
|
368
|
+
continue
|
|
369
|
+
try:
|
|
370
|
+
text = (skill_dir / "SKILL.md").read_text(encoding="utf-8", errors="replace")
|
|
371
|
+
except OSError:
|
|
372
|
+
continue
|
|
373
|
+
fm_name = _frontmatter_value(text, "name") or name
|
|
374
|
+
if fm_name != name: # name must match the directory (opencode rule)
|
|
375
|
+
continue
|
|
376
|
+
description = _frontmatter_value(text, "description") or ""
|
|
377
|
+
if len(text) > SKILL_MAX_FILE_CHARS:
|
|
378
|
+
text = text[:SKILL_MAX_FILE_CHARS]
|
|
379
|
+
try:
|
|
380
|
+
path = skill_dir.relative_to(base).as_posix() + "/SKILL.md"
|
|
381
|
+
except ValueError:
|
|
382
|
+
path = str(skill_dir)
|
|
383
|
+
found.append({"name": name, "path": path, "source": source,
|
|
384
|
+
"description": description[:1024], "content": text})
|
|
385
|
+
return found
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
def scan_skills(cwd: Optional[Path] = None, home: Optional[Path] = None) -> List[Dict[str, Any]]:
|
|
389
|
+
"""Every SKILL.md skill visible to an agent on this host, within budgets.
|
|
390
|
+
|
|
391
|
+
Global locations come first (``~/.claude/skills`` etc., ``source`` names the
|
|
392
|
+
location), then project locations at ``cwd`` and every ancestor up to the
|
|
393
|
+
git root; an earlier source wins a name clash, and only name/description/
|
|
394
|
+
content travel. Never raises.
|
|
395
|
+
"""
|
|
396
|
+
out: List[Dict[str, str]] = []
|
|
397
|
+
try:
|
|
398
|
+
cwd = Path(cwd) if cwd is not None else Path(os.getcwd())
|
|
399
|
+
home = Path(home) if home is not None else Path.home()
|
|
400
|
+
seen: set = set()
|
|
401
|
+
used = 0
|
|
402
|
+
|
|
403
|
+
def add(found: List[Dict[str, Any]]) -> None:
|
|
404
|
+
nonlocal used
|
|
405
|
+
for item in found:
|
|
406
|
+
if len(out) >= SKILL_MAX_SKILLS or item["name"] in seen:
|
|
407
|
+
continue
|
|
408
|
+
cost = len(item["content"]) + len(item["description"]) + len(item["name"])
|
|
409
|
+
if used + cost > SKILL_MAX_TOTAL_CHARS:
|
|
410
|
+
continue
|
|
411
|
+
used += cost
|
|
412
|
+
seen.add(item["name"])
|
|
413
|
+
out.append(item)
|
|
414
|
+
|
|
415
|
+
for first, second in _CONFIG_SKILL_DIRS:
|
|
416
|
+
add(_scanned_skill_dir(home / first / second, home, f"global:{first}/{second}"))
|
|
417
|
+
git_root = find_git_root(cwd)
|
|
418
|
+
roots: List[Path] = []
|
|
419
|
+
for directory in _parents(cwd):
|
|
420
|
+
roots.append(directory)
|
|
421
|
+
if git_root is not None and directory == git_root:
|
|
422
|
+
break
|
|
423
|
+
for directory in roots:
|
|
424
|
+
for d in _SKILL_DIR_SETS:
|
|
425
|
+
add(_scanned_skill_dir(directory / d / "skills", directory, f"project:{d}/skills"))
|
|
426
|
+
except Exception: # noqa: BLE001 - scanning must never break the tunnel
|
|
427
|
+
LOGGER.debug("skill scan failed", exc_info=True)
|
|
428
|
+
return out
|
|
429
|
+
|
|
430
|
+
|
|
316
431
|
# ---------- Environment brief (hello environment_brief / context_update) -----
|
|
317
432
|
#
|
|
318
433
|
# A short, cheaply-computed description of the client host so the agent knows
|
|
@@ -327,7 +442,7 @@ def collect_instructions(cwd: Path) -> List[Dict[str, str]]:
|
|
|
327
442
|
BRIEF_MAX_CHARS = 6 * 1024 # wire comfort; the hub caps the field on its side
|
|
328
443
|
BRIEF_MAX_LINES = 30
|
|
329
444
|
|
|
330
|
-
BRIEF_TOOLS = ("git", "rg", "ctags", "python3", "node", "go", "docker", "kubectl", "sbatch", "ruff", "pytest")
|
|
445
|
+
BRIEF_TOOLS = ("git", "rg", "ctags", "python3", "node", "go", "docker", "kubectl", "sbatch", "ruff", "pytest", "opencode")
|
|
331
446
|
|
|
332
447
|
_CRED_IN_URL = re.compile(r"//[^/@\s]*:[^/@\s]*@")
|
|
333
448
|
|
|
@@ -428,7 +543,7 @@ def collect_environment_brief(
|
|
|
428
543
|
lines.append(
|
|
429
544
|
"file tools can write: the root, scratch "
|
|
430
545
|
f"{scratch} (unless overridden), and temp {tempfile.gettempdir()}; "
|
|
431
|
-
"
|
|
546
|
+
"bash and the process_* tools are UNCONFINED and can write anything this user can"
|
|
432
547
|
)
|
|
433
548
|
lines.append(
|
|
434
549
|
"harness output cap: "
|
|
@@ -21,6 +21,7 @@ SERVER_STATE = "server_state"
|
|
|
21
21
|
RESTART = "restart"
|
|
22
22
|
ERROR = "error"
|
|
23
23
|
CONTEXT_UPDATE = "context_update"
|
|
24
|
+
SKILLS_UPDATE = "skills_update"
|
|
24
25
|
|
|
25
26
|
STATE_STARTING = "starting"
|
|
26
27
|
STATE_RUNNING = "running"
|
|
@@ -53,6 +54,7 @@ def hello(
|
|
|
53
54
|
environment: Optional[Dict[str, Any]] = None,
|
|
54
55
|
instructions: Optional[List[Dict[str, str]]] = None,
|
|
55
56
|
environment_brief: Optional[str] = None,
|
|
57
|
+
skills: Optional[List[Dict[str, str]]] = None,
|
|
56
58
|
) -> Dict[str, Any]:
|
|
57
59
|
client: Dict[str, Any] = {"name": client_name, "version": version, "instance": instance, "label": label}
|
|
58
60
|
if environment is not None:
|
|
@@ -61,6 +63,8 @@ def hello(
|
|
|
61
63
|
client["instructions"] = instructions
|
|
62
64
|
if environment_brief is not None:
|
|
63
65
|
client["environment_brief"] = environment_brief
|
|
66
|
+
if skills is not None:
|
|
67
|
+
client["skills"] = skills
|
|
64
68
|
return {
|
|
65
69
|
"type": HELLO,
|
|
66
70
|
"protocol": PROTOCOL_VERSION,
|
|
@@ -88,6 +92,16 @@ def context_update(
|
|
|
88
92
|
return frame
|
|
89
93
|
|
|
90
94
|
|
|
95
|
+
def skills_update(skills: List[Dict[str, str]]) -> Dict[str, Any]:
|
|
96
|
+
"""Replace the hub's copy of the skills scanned on this host.
|
|
97
|
+
|
|
98
|
+
The list is complete (an empty list means the host has none), mirrors the
|
|
99
|
+
context_update wholesale-replacement contract, is additive (protocol
|
|
100
|
+
version stays 1), and hubs that do not know the frame ignore it.
|
|
101
|
+
"""
|
|
102
|
+
return {"type": SKILLS_UPDATE, "skills": skills}
|
|
103
|
+
|
|
104
|
+
|
|
91
105
|
def hello_ack(connection_id: str, hub_name: str, hub_version: str) -> Dict[str, Any]:
|
|
92
106
|
return {
|
|
93
107
|
"type": HELLO_ACK,
|
|
@@ -22,6 +22,7 @@ import logging
|
|
|
22
22
|
import os
|
|
23
23
|
import random
|
|
24
24
|
import ssl
|
|
25
|
+
import time
|
|
25
26
|
import uuid
|
|
26
27
|
from contextlib import suppress
|
|
27
28
|
from dataclasses import dataclass
|
|
@@ -49,6 +50,12 @@ MAX_BACKOFF_DELAY = 60.0
|
|
|
49
50
|
PING_INTERVAL = 20
|
|
50
51
|
PING_TIMEOUT = 10
|
|
51
52
|
|
|
53
|
+
# A session must hold this long after hello_ack before a drop re-bases the
|
|
54
|
+
# reconnect backoff. Without it, two clients sharing a label evict each other
|
|
55
|
+
# at the reconnect rate forever (every eviction looks like a fresh successful
|
|
56
|
+
# session). See the hub evict path: it sends the explanatory error frame.
|
|
57
|
+
STABLE_CONNECTION = 15.0
|
|
58
|
+
|
|
52
59
|
_WS_SCHEMES = {"ws": "ws", "wss": "wss", "http": "ws", "https": "wss"}
|
|
53
60
|
|
|
54
61
|
|
|
@@ -169,6 +176,9 @@ class TunnelSettings:
|
|
|
169
176
|
# The host brief (identity, git, tools, scratch) ships with every hello
|
|
170
177
|
# too and refreshes via context_update; --no-env-brief turns it off.
|
|
171
178
|
env_brief: bool = True
|
|
179
|
+
# SKILL.md skills scanned on this host ship with every hello and refresh
|
|
180
|
+
# via skills_update; --no-skills turns it off.
|
|
181
|
+
skills: bool = True
|
|
172
182
|
|
|
173
183
|
|
|
174
184
|
class HubConnection:
|
|
@@ -201,10 +211,18 @@ class HubConnection:
|
|
|
201
211
|
self._ws: Any = None
|
|
202
212
|
self._acked = False
|
|
203
213
|
self._session_ok = False
|
|
214
|
+
# hello_ack's monotonic clock, for the stable-session backoff rule, and
|
|
215
|
+
# the hub's "a newer client took this label" flag (see STABLE_CONNECTION
|
|
216
|
+
# and _handle_error); both refreshed per connection in _connect_and_serve.
|
|
217
|
+
self._ack_at = 0.0
|
|
218
|
+
self._replaced = False
|
|
219
|
+
# last sleep the run loop asked for, exposed for tests and triage
|
|
220
|
+
self.last_reconnect_delay: Optional[float] = None
|
|
204
221
|
# Last instruction set and host brief sent on the live connection
|
|
205
222
|
# (None = never), for the context_update freshness loop.
|
|
206
223
|
self._instructions_last: Optional[List[Dict[str, str]]] = None
|
|
207
224
|
self._brief_last: Optional[str] = None
|
|
225
|
+
self._skills_last: Optional[List[Dict[str, str]]] = None
|
|
208
226
|
self._stopping = False
|
|
209
227
|
self._stop_event: Optional[asyncio.Event] = None
|
|
210
228
|
# Set when run() ended because the tunnel gave up (fatal rejection or
|
|
@@ -252,8 +270,16 @@ class HubConnection:
|
|
|
252
270
|
if self._stopping:
|
|
253
271
|
break
|
|
254
272
|
|
|
255
|
-
|
|
256
|
-
|
|
273
|
+
stable = (
|
|
274
|
+
self._session_ok
|
|
275
|
+
and not self._replaced
|
|
276
|
+
and time.monotonic() - self._ack_at >= STABLE_CONNECTION
|
|
277
|
+
)
|
|
278
|
+
if stable:
|
|
279
|
+
# ONLY a session that reached hello_ack AND held it for
|
|
280
|
+
# STABLE_CONNECTION starts the backoff over: a connection
|
|
281
|
+
# the hub dropped (or replaced) within that window is churn
|
|
282
|
+
# to damp, not success to forget.
|
|
257
283
|
attempt = 0
|
|
258
284
|
|
|
259
285
|
attempt += 1
|
|
@@ -266,11 +292,16 @@ class HubConnection:
|
|
|
266
292
|
)
|
|
267
293
|
break
|
|
268
294
|
|
|
295
|
+
# The exponent is bounded before the power: `2 ** attempt` as
|
|
296
|
+
# an int never overflows on its own, but multiplying it into a
|
|
297
|
+
# float does — an infinite-retry client that never connects
|
|
298
|
+
# would crash outright once attempt passed ~1024.
|
|
269
299
|
delay = min(
|
|
270
|
-
self.settings.reconnect_delay * (2 ** (attempt - 1)),
|
|
300
|
+
self.settings.reconnect_delay * (2 ** min(attempt - 1, 40)),
|
|
271
301
|
MAX_BACKOFF_DELAY,
|
|
272
302
|
)
|
|
273
303
|
delay *= 1 + random.uniform(-BACKOFF_JITTER, BACKOFF_JITTER)
|
|
304
|
+
self.last_reconnect_delay = delay
|
|
274
305
|
LOGGER.info("reconnecting in %.1fs (attempt %d)", delay, attempt)
|
|
275
306
|
if await self._sleep_or_stop(delay):
|
|
276
307
|
break
|
|
@@ -309,6 +340,8 @@ class HubConnection:
|
|
|
309
340
|
async def _connect_and_serve(self) -> None:
|
|
310
341
|
self._acked = False
|
|
311
342
|
self._session_ok = False
|
|
343
|
+
self._replaced = False
|
|
344
|
+
self._ack_at = 0.0
|
|
312
345
|
kwargs = {
|
|
313
346
|
_header_kwarg(): {"Authorization": f"Bearer {self.settings.token}"},
|
|
314
347
|
"ping_interval": PING_INTERVAL,
|
|
@@ -323,6 +356,7 @@ class HubConnection:
|
|
|
323
356
|
closer = asyncio.create_task(self._close_when_stopped(ws))
|
|
324
357
|
self._instructions_last = self._collect_instructions()
|
|
325
358
|
self._brief_last = self._collect_brief()
|
|
359
|
+
self._skills_last = self._collect_skills()
|
|
326
360
|
try:
|
|
327
361
|
await self._send(
|
|
328
362
|
protocol.hello(
|
|
@@ -334,6 +368,7 @@ class HubConnection:
|
|
|
334
368
|
self.settings.environment,
|
|
335
369
|
self._instructions_last,
|
|
336
370
|
environment_brief=self._brief_last,
|
|
371
|
+
skills=self._skills_last,
|
|
337
372
|
)
|
|
338
373
|
)
|
|
339
374
|
refresh = asyncio.create_task(self._context_refresh_loop())
|
|
@@ -427,6 +462,7 @@ class HubConnection:
|
|
|
427
462
|
)
|
|
428
463
|
self._acked = True
|
|
429
464
|
self._session_ok = True
|
|
465
|
+
self._ack_at = time.monotonic()
|
|
430
466
|
# The hub opens a fresh MCP session per connection, and a server that
|
|
431
467
|
# already completed `initialize` would reject a second one, so every
|
|
432
468
|
# (re)connect restarts every local server. See docs/PROTOCOL.md.
|
|
@@ -469,6 +505,14 @@ class HubConnection:
|
|
|
469
505
|
def _handle_error(self, data: Dict[str, Any]) -> None:
|
|
470
506
|
message = data.get("message") or "unspecified error"
|
|
471
507
|
server = data.get("server")
|
|
508
|
+
if self._acked and _is_label_in_use(message):
|
|
509
|
+
# The hub replaced this connection with a newer one carrying the
|
|
510
|
+
# same label (a second live client). Keep reconnecting, but this
|
|
511
|
+
# session must not re-base the backoff, or two such clients evict
|
|
512
|
+
# each other at the reconnect rate forever.
|
|
513
|
+
self._replaced = True
|
|
514
|
+
LOGGER.warning("superseded by a newer connection for label %r: %s", self.settings.label, message)
|
|
515
|
+
return
|
|
472
516
|
if not self._acked:
|
|
473
517
|
# Before hello_ack an error means the hub refused this client -
|
|
474
518
|
# unsupported protocol version, illegal server name. Retrying with
|
|
@@ -549,15 +593,27 @@ class HubConnection:
|
|
|
549
593
|
# None (not "") so an old hub omits the field when collection is off.
|
|
550
594
|
return environment.collect_environment_brief(root, instruction_paths=paths) or None
|
|
551
595
|
|
|
596
|
+
def _collect_skills(self) -> Optional[List[Dict[str, str]]]:
|
|
597
|
+
"""Scanned SKILL.md skills for hello, or None when the feature is off.
|
|
598
|
+
|
|
599
|
+
None (not []) also covers a host with no skills on an old hub: the
|
|
600
|
+
field simply disappears; a hub that knows the feature reads the empty
|
|
601
|
+
list back as skills_update when they change.
|
|
602
|
+
"""
|
|
603
|
+
if not self.settings.skills:
|
|
604
|
+
return None
|
|
605
|
+
root = Path(self.settings.instruction_root or os.getcwd())
|
|
606
|
+
return environment.scan_skills(root) or None
|
|
607
|
+
|
|
552
608
|
async def _context_refresh_loop(self) -> None:
|
|
553
|
-
"""Re-read instruction files
|
|
609
|
+
"""Re-read instruction files, the host brief and the skills; push updates on change.
|
|
554
610
|
|
|
555
611
|
The hub re-injects the fresh text from the next turn on, so editing
|
|
556
612
|
AGENTS.md mid-session actually reaches the agent (P1-A acceptance) and
|
|
557
613
|
a changing git state keeps the brief honest (P2-B). Each refresh resets
|
|
558
614
|
the hub's staleness clock for whatever it carries.
|
|
559
615
|
"""
|
|
560
|
-
if not (self.settings.instruction_root or self.settings.env_brief):
|
|
616
|
+
if not (self.settings.instruction_root or self.settings.env_brief or self.settings.skills):
|
|
561
617
|
return
|
|
562
618
|
interval = max(self.settings.instructions_interval, 0.2)
|
|
563
619
|
while True:
|
|
@@ -566,9 +622,11 @@ class HubConnection:
|
|
|
566
622
|
await asyncio.sleep(interval)
|
|
567
623
|
current = self._collect_instructions()
|
|
568
624
|
brief = self._collect_brief(current)
|
|
625
|
+
skills = self._collect_skills()
|
|
569
626
|
changed_files = current != self._instructions_last
|
|
570
627
|
changed_brief = brief != self._brief_last
|
|
571
|
-
|
|
628
|
+
changed_skills = skills != self._skills_last
|
|
629
|
+
if not (changed_files or changed_brief or changed_skills):
|
|
572
630
|
continue
|
|
573
631
|
kwargs: Dict[str, Any] = {}
|
|
574
632
|
if changed_files and self.settings.instruction_root:
|
|
@@ -576,14 +634,22 @@ class HubConnection:
|
|
|
576
634
|
if changed_brief and self.settings.env_brief:
|
|
577
635
|
kwargs["environment_brief"] = brief or ""
|
|
578
636
|
self._instructions_last, self._brief_last = current, brief
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
637
|
+
if kwargs:
|
|
638
|
+
try:
|
|
639
|
+
await self._send(protocol.context_update(**kwargs))
|
|
640
|
+
LOGGER.info(
|
|
641
|
+
"sent context_update: %s",
|
|
642
|
+
", ".join(sorted(kwargs)) or "no carried fields",
|
|
643
|
+
)
|
|
644
|
+
except Exception as e: # noqa: BLE001 - the read loop owns the connection
|
|
645
|
+
LOGGER.debug("context_update send failed (connection dropping?): %s", e)
|
|
646
|
+
if changed_skills and self.settings.skills:
|
|
647
|
+
self._skills_last = skills
|
|
648
|
+
try:
|
|
649
|
+
await self._send(protocol.skills_update(skills or []))
|
|
650
|
+
LOGGER.info("sent skills_update: %d skills", len(skills or []))
|
|
651
|
+
except Exception as e: # noqa: BLE001 - same reason
|
|
652
|
+
LOGGER.debug("skills_update send failed (connection dropping?): %s", e)
|
|
587
653
|
|
|
588
654
|
async def _send(self, frame: Dict[str, Any]) -> None:
|
|
589
655
|
ws = self._ws
|
{mcp_switchboard_client-0.3.0.dev4 → mcp_switchboard_client-0.4.0.dev6}/tests/test_instructions.py
RENAMED
|
@@ -142,6 +142,7 @@ def make_connection(tmp_path, ws, **overrides):
|
|
|
142
142
|
max_retries=1,
|
|
143
143
|
instruction_root=str(root) if root is not None else None,
|
|
144
144
|
instructions_interval=overrides.pop("instructions_interval", 0.2),
|
|
145
|
+
skills=overrides.pop("skills", False), # the shared factory keeps scanning off unless asked
|
|
145
146
|
**overrides,
|
|
146
147
|
)
|
|
147
148
|
return HubConnection(SPECS, settings, version="0", connect=FakeConnect([ws]), server_factory=FakeServer)
|
|
@@ -177,7 +178,7 @@ async def test_hello_omits_instructions_when_disabled(tmp_path):
|
|
|
177
178
|
async def test_refresh_loop_pushes_context_update_on_change(tmp_path):
|
|
178
179
|
(tmp_path / "AGENTS.md").write_text("before\n")
|
|
179
180
|
ws = FakeWebSocket([])
|
|
180
|
-
connection = make_connection(tmp_path, ws, instructions_interval=0.2, env_brief=False)
|
|
181
|
+
connection = make_connection(tmp_path, ws, instructions_interval=0.2, env_brief=False, skills=False)
|
|
181
182
|
sent = []
|
|
182
183
|
|
|
183
184
|
async def fake_send(frame):
|
|
@@ -213,7 +214,7 @@ async def test_refresh_loop_pushes_context_update_on_change(tmp_path):
|
|
|
213
214
|
|
|
214
215
|
async def test_refresh_loop_stays_quiet_when_disabled(tmp_path):
|
|
215
216
|
ws = FakeWebSocket([])
|
|
216
|
-
connection = make_connection(tmp_path, ws, instruction_root=None, env_brief=False)
|
|
217
|
+
connection = make_connection(tmp_path, ws, instruction_root=None, env_brief=False, skills=False)
|
|
217
218
|
|
|
218
219
|
async def fake_send(frame):
|
|
219
220
|
raise AssertionError("must not send")
|
|
@@ -53,6 +53,7 @@ def test_frame_type_constants_match_the_manifest() -> None:
|
|
|
53
53
|
protocol.RESTART,
|
|
54
54
|
protocol.ERROR,
|
|
55
55
|
protocol.CONTEXT_UPDATE,
|
|
56
|
+
protocol.SKILLS_UPDATE,
|
|
56
57
|
}
|
|
57
58
|
assert declared == set(MANIFEST["frames"])
|
|
58
59
|
|
|
@@ -60,12 +61,16 @@ def test_frame_type_constants_match_the_manifest() -> None:
|
|
|
60
61
|
# One builder call per frame, with arguments chosen so that every optional field is
|
|
61
62
|
# populated - a builder that silently dropped a field would otherwise pass.
|
|
62
63
|
INSTRUCTION_FILES = [{"path": "AGENTS.md", "content": "# rules\n"}]
|
|
64
|
+
SKILLS = [
|
|
65
|
+
{"name": "pdf", "path": ".claude/skills/pdf/SKILL.md", "source": "project:.claude/skills",
|
|
66
|
+
"description": "PDF work", "content": "---\nname: pdf\ndescription: PDF work\n---\nbody\n"},
|
|
67
|
+
]
|
|
63
68
|
|
|
64
69
|
BUILDERS = {
|
|
65
70
|
"hello": lambda: protocol.hello(
|
|
66
71
|
"c", "0.0.0", "i", "lab", [{"name": "git"}],
|
|
67
72
|
{"kinds": ["direnv"], "workspace": "/w"}, INSTRUCTION_FILES,
|
|
68
|
-
environment_brief="user: uid=1 me\n",
|
|
73
|
+
environment_brief="user: uid=1 me\n", skills=SKILLS,
|
|
69
74
|
),
|
|
70
75
|
"hello_ack": lambda: protocol.hello_ack("cid", "hub", "0.0.0"),
|
|
71
76
|
"mcp": lambda: protocol.mcp("git", {"jsonrpc": "2.0"}),
|
|
@@ -73,6 +78,7 @@ BUILDERS = {
|
|
|
73
78
|
"restart": lambda: protocol.restart("git"),
|
|
74
79
|
"error": lambda: protocol.error("bad", "git"),
|
|
75
80
|
"context_update": lambda: protocol.context_update(INSTRUCTION_FILES),
|
|
81
|
+
"skills_update": lambda: protocol.skills_update(SKILLS),
|
|
76
82
|
}
|
|
77
83
|
|
|
78
84
|
|
|
@@ -96,16 +102,18 @@ def test_every_builder_is_covered() -> None:
|
|
|
96
102
|
|
|
97
103
|
def test_hello_client_optional_fields_are_declared_and_emitted() -> None:
|
|
98
104
|
declared = MANIFEST["frames"]["hello"]["clientOptional"]
|
|
99
|
-
assert set(declared) >= {"environment", "instructions", "environment_brief"}
|
|
105
|
+
assert set(declared) >= {"environment", "instructions", "environment_brief", "skills"}
|
|
100
106
|
client = protocol.hello(
|
|
101
|
-
"c", "0.0.0", "i", "lab", [], {"kinds": []}, INSTRUCTION_FILES,
|
|
107
|
+
"c", "0.0.0", "i", "lab", [], {"kinds": []}, INSTRUCTION_FILES,
|
|
108
|
+
environment_brief="host: x\n", skills=SKILLS,
|
|
102
109
|
)["client"]
|
|
103
110
|
assert set(client) <= {"name", "version", "instance", "label"} | set(declared)
|
|
104
111
|
assert client["environment"] == {"kinds": []}
|
|
105
112
|
assert client["instructions"] == INSTRUCTION_FILES
|
|
106
113
|
assert client["environment_brief"] == "host: x\n"
|
|
114
|
+
assert client["skills"] == SKILLS
|
|
107
115
|
omitted = protocol.hello("c", "0.0.0", "i", "lab", [])["client"]
|
|
108
|
-
assert not {"environment", "instructions", "environment_brief"} & set(omitted)
|
|
116
|
+
assert not {"environment", "instructions", "environment_brief", "skills"} & set(omitted)
|
|
109
117
|
|
|
110
118
|
|
|
111
119
|
def test_context_update_fields_are_independently_optional() -> None:
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
"""Client-side skill scanning: discovery locations, frontmatter rules,
|
|
2
|
+
budgets, hello shipping and skills_update refresh — mirroring the instruction
|
|
3
|
+
file pipeline (docs/PROTOCOL.md)."""
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import json
|
|
7
|
+
|
|
8
|
+
import pytest
|
|
9
|
+
|
|
10
|
+
from mcp_switchboard_client import environment, protocol
|
|
11
|
+
from mcp_switchboard_client.config import ServerSpec
|
|
12
|
+
from mcp_switchboard_client.tunnel import HubConnection, TunnelSettings
|
|
13
|
+
|
|
14
|
+
from test_tunnel import FakeConnect, FakeServer, FakeWebSocket
|
|
15
|
+
|
|
16
|
+
pytestmark = pytest.mark.asyncio
|
|
17
|
+
|
|
18
|
+
SKILL_MD = "---\nname: {name}\ndescription: does {name} things\n---\nDo {name} well.\n"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def write_skill(root, name, text=None):
|
|
22
|
+
d = root / name
|
|
23
|
+
d.mkdir(parents=True)
|
|
24
|
+
(d / "SKILL.md").write_text(text if text is not None else SKILL_MD.format(name=name))
|
|
25
|
+
return d
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
# ---------- collection (pure) ------------------------------------------
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def test_global_locations_are_scanned(tmp_path):
|
|
32
|
+
home = tmp_path / "home"
|
|
33
|
+
write_skill(home / ".claude" / "skills", "pdf")
|
|
34
|
+
write_skill(home / ".config" / "opencode" / "skills", "docx")
|
|
35
|
+
write_skill(home / ".agents" / "skills", "pptx")
|
|
36
|
+
found = {s["name"]: s for s in environment.scan_skills(tmp_path / "proj", home)}
|
|
37
|
+
assert set(found) == {"pdf", "docx", "pptx"}
|
|
38
|
+
assert found["pdf"]["source"] == "global:.claude/skills"
|
|
39
|
+
assert found["pdf"]["description"] == "does pdf things"
|
|
40
|
+
assert "Do pdf well." in found["pdf"]["content"]
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def test_project_locations_up_to_git_root(tmp_path):
|
|
44
|
+
home = tmp_path / "home"
|
|
45
|
+
home.mkdir()
|
|
46
|
+
repo = tmp_path / "repo"
|
|
47
|
+
(repo / ".git").mkdir(parents=True)
|
|
48
|
+
deep = repo / "sub" / "deeper"
|
|
49
|
+
deep.mkdir(parents=True)
|
|
50
|
+
write_skill(repo / ".claude" / "skills", "repo-skill")
|
|
51
|
+
write_skill(deep / ".opencode" / "skills", "deep-skill")
|
|
52
|
+
outside = tmp_path / "other"
|
|
53
|
+
write_skill(outside / ".claude" / "skills", "outside")
|
|
54
|
+
found = {s["name"] for s in environment.scan_skills(deep, home)}
|
|
55
|
+
assert found == {"repo-skill", "deep-skill"}
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def test_invalid_names_and_missing_frontmatter_are_skipped(tmp_path):
|
|
59
|
+
home = tmp_path / "home"
|
|
60
|
+
skills = home / ".claude" / "skills"
|
|
61
|
+
write_skill(skills, "Upper-Case")
|
|
62
|
+
write_skill(skills, "bad--name")
|
|
63
|
+
write_skill(skills, "empty-dir")
|
|
64
|
+
(skills / "empty-dir" / "SKILL.md").unlink()
|
|
65
|
+
(skills / "loose.txt").write_text("x")
|
|
66
|
+
# name in frontmatter must match the directory
|
|
67
|
+
write_skill(skills, "mismatch", "---\nname: other\ndescription: d\n---\nbody\n")
|
|
68
|
+
write_skill(skills, "no-frontmatter", "just text, no frontmatter\n")
|
|
69
|
+
found = {s["name"] for s in environment.scan_skills(tmp_path, home)}
|
|
70
|
+
# no-frontmatter files default the name to the (valid, matching) directory
|
|
71
|
+
assert found == {"no-frontmatter"}
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def test_frontmatter_values_are_unquoted(tmp_path):
|
|
75
|
+
home = tmp_path / "home"
|
|
76
|
+
write_skill(
|
|
77
|
+
home / ".claude" / "skills", "quoted",
|
|
78
|
+
'---\nname: quoted\ndescription: "a quoted: description"\nmetadata:\n audience: maintainers\n---\nbody\nmore body\n',
|
|
79
|
+
)
|
|
80
|
+
(skill,) = environment.scan_skills(tmp_path, home)
|
|
81
|
+
assert skill["description"] == "a quoted: description"
|
|
82
|
+
assert skill["content"].endswith("more body\n")
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def test_global_wins_a_name_clash(tmp_path):
|
|
86
|
+
home = tmp_path / "home"
|
|
87
|
+
write_skill(home / ".claude" / "skills", "dup")
|
|
88
|
+
write_skill(tmp_path / "proj" / ".claude" / "skills", "dup")
|
|
89
|
+
found = environment.scan_skills(tmp_path / "proj", home)
|
|
90
|
+
assert [s["source"] for s in found if s["name"] == "dup"] == ["global:.claude/skills"]
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def test_oversized_files_are_cut_to_their_budget(tmp_path):
|
|
94
|
+
home = tmp_path / "home"
|
|
95
|
+
skills = home / ".claude" / "skills"
|
|
96
|
+
write_skill(skills, "big", SKILL_MD.format(name="big") + "x" * (environment.SKILL_MAX_FILE_CHARS + 10))
|
|
97
|
+
(found,) = environment.scan_skills(tmp_path, home)
|
|
98
|
+
assert len(found["content"]) <= environment.SKILL_MAX_FILE_CHARS
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def test_scan_never_raises(tmp_path):
|
|
102
|
+
assert environment.scan_skills(tmp_path / "does-not-exist", tmp_path / "no-home") == []
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
# ---------- hello + skills_update wiring --------------------------------
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _skill(name="pdf"):
|
|
109
|
+
return {"name": name, "path": f"{name}/SKILL.md", "source": "global:.claude/skills",
|
|
110
|
+
"description": "d", "content": "body"}
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _make(tmp_path, sessions, **overrides):
|
|
114
|
+
settings = TunnelSettings(
|
|
115
|
+
hub_url="https://hub.example.com", token="s3cret", label="legion5",
|
|
116
|
+
reconnect_delay=0.0, max_retries=1,
|
|
117
|
+
instruction_root=None, env_brief=False,
|
|
118
|
+
**overrides,
|
|
119
|
+
)
|
|
120
|
+
specs = [ServerSpec(name="noop", argv=["true"])]
|
|
121
|
+
connect = FakeConnect(sessions)
|
|
122
|
+
return HubConnection(specs, settings, version="0.4.0", connect=connect, server_factory=FakeServer)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
async def test_hello_carries_scanned_skills(tmp_path, monkeypatch):
|
|
126
|
+
monkeypatch.setattr(environment, "scan_skills", lambda cwd=None, home=None: [_skill()])
|
|
127
|
+
ws = FakeWebSocket([protocol.hello_ack("c1", "hub", "1.0")])
|
|
128
|
+
connection = _make(tmp_path, [ws], skills=True)
|
|
129
|
+
await connection.run()
|
|
130
|
+
hello = ws.frames("hello")[0]
|
|
131
|
+
assert hello["client"]["skills"][0]["name"] == "pdf"
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
async def test_skills_off_omits_the_field(tmp_path, monkeypatch):
|
|
135
|
+
monkeypatch.setattr(environment, "scan_skills", lambda cwd=None, home=None: [])
|
|
136
|
+
ws = FakeWebSocket([protocol.hello_ack("c1", "hub", "1.0")])
|
|
137
|
+
connection = _make(tmp_path, [ws], skills=False)
|
|
138
|
+
await connection.run()
|
|
139
|
+
hello = ws.frames("hello")[0]
|
|
140
|
+
assert "skills" not in hello["client"]
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
async def test_refresh_loop_sends_skills_update_on_change(tmp_path, monkeypatch):
|
|
144
|
+
scans = [[_skill("a")], [_skill("a"), _skill("b")]]
|
|
145
|
+
monkeypatch.setattr(
|
|
146
|
+
environment, "scan_skills",
|
|
147
|
+
lambda cwd=None, home=None: scans.pop(0) if scans else [_skill("a"), _skill("b")],
|
|
148
|
+
)
|
|
149
|
+
ws = FakeWebSocket([])
|
|
150
|
+
connection = _make(tmp_path, [ws], skills=True, instructions_interval=0.05)
|
|
151
|
+
sent = []
|
|
152
|
+
|
|
153
|
+
async def fake_send(frame):
|
|
154
|
+
sent.append(frame)
|
|
155
|
+
|
|
156
|
+
connection._send = fake_send
|
|
157
|
+
connection._skills_last = connection._collect_skills()
|
|
158
|
+
|
|
159
|
+
task = asyncio.create_task(connection._context_refresh_loop())
|
|
160
|
+
try:
|
|
161
|
+
await asyncio.sleep(0.1)
|
|
162
|
+
assert sent == [], "an unchanged set must not churn the wire"
|
|
163
|
+
for _ in range(100):
|
|
164
|
+
await asyncio.sleep(0.02)
|
|
165
|
+
if sent:
|
|
166
|
+
break
|
|
167
|
+
assert len(sent) == 1
|
|
168
|
+
assert sent[0]["type"] == protocol.SKILLS_UPDATE
|
|
169
|
+
assert [s["name"] for s in sent[0]["skills"]] == ["a", "b"]
|
|
170
|
+
finally:
|
|
171
|
+
task.cancel()
|
{mcp_switchboard_client-0.3.0.dev4 → mcp_switchboard_client-0.4.0.dev6}/tests/test_tunnel.py
RENAMED
|
@@ -420,3 +420,89 @@ def test_tls_context_keeps_the_system_store_too(monkeypatch, tmp_path):
|
|
|
420
420
|
monkeypatch.setenv("SSL_CERT_FILE", str(pem))
|
|
421
421
|
assert len(tunnel._tls_context().get_ca_certs()) >= 1
|
|
422
422
|
assert baseline >= 1
|
|
423
|
+
|
|
424
|
+
|
|
425
|
+
# -- label-replacement damping (eviction-tennis guard) --------------------
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
def _replacement_ws():
|
|
429
|
+
"""A session that acks, learns it was replaced, and drops — like a second
|
|
430
|
+
live client on the same label sees, over and over."""
|
|
431
|
+
return FakeWebSocket([
|
|
432
|
+
protocol.hello_ack("c1", "hub", "0.1"),
|
|
433
|
+
protocol.error('label "legion5" is already connected from a newer connection; this one is replaced', ""),
|
|
434
|
+
])
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
async def test_replaced_after_ack_is_flagged_not_fatal():
|
|
438
|
+
ws = FakeWebSocket([
|
|
439
|
+
protocol.hello_ack("c1", "hub", "0.1"),
|
|
440
|
+
protocol.error('label "legion5" is already connected from a newer connection', ""),
|
|
441
|
+
])
|
|
442
|
+
connection, _ = make_connection([ws], max_retries=1)
|
|
443
|
+
await connection.run()
|
|
444
|
+
# The point: post-ack "already connected" never raises (the run loop ran
|
|
445
|
+
# to its normal give-up), and it flags the session as churn instead.
|
|
446
|
+
assert connection._replaced is True
|
|
447
|
+
|
|
448
|
+
|
|
449
|
+
async def test_backoff_keeps_growing_across_fast_replacements():
|
|
450
|
+
"""Three ack-then-replaced sessions in a row must grow the delay (attempt
|
|
451
|
+
1,2,3), not restart at the base every time like plain success would."""
|
|
452
|
+
delays = []
|
|
453
|
+
connection, _ = make_connection([], max_retries=4)
|
|
454
|
+
connection.settings.reconnect_delay = 0.5
|
|
455
|
+
|
|
456
|
+
async def fake_sleep(delay):
|
|
457
|
+
delays.append(delay)
|
|
458
|
+
return False
|
|
459
|
+
|
|
460
|
+
connection._sleep_or_stop = fake_sleep
|
|
461
|
+
await connection.run()
|
|
462
|
+
assert connection.failure is not None and "giving up" in connection.failure
|
|
463
|
+
assert len(delays) == 3
|
|
464
|
+
assert delays[1] > delays[0] and delays[2] > delays[1], f"backoff did not grow: {delays}"
|
|
465
|
+
|
|
466
|
+
|
|
467
|
+
class _HeldThenClose(FakeWebSocket):
|
|
468
|
+
"""Acks, holds the session open, then the stream ends (a clean drop)."""
|
|
469
|
+
|
|
470
|
+
def __init__(self, hold):
|
|
471
|
+
super().__init__([protocol.hello_ack("c1", "hub", "0.1")])
|
|
472
|
+
self._hold = hold
|
|
473
|
+
|
|
474
|
+
async def __anext__(self):
|
|
475
|
+
if self.inbound:
|
|
476
|
+
return self.inbound.pop(0)
|
|
477
|
+
await asyncio.sleep(self._hold)
|
|
478
|
+
raise StopAsyncIteration
|
|
479
|
+
|
|
480
|
+
|
|
481
|
+
def connection_stop(connection):
|
|
482
|
+
connection.request_stop()
|
|
483
|
+
|
|
484
|
+
|
|
485
|
+
async def test_stable_session_re_bases_the_backoff(monkeypatch):
|
|
486
|
+
monkeypatch.setattr(tunnel_mod, "STABLE_CONNECTION", 0.05)
|
|
487
|
+
delays = []
|
|
488
|
+
sessions = [
|
|
489
|
+
_replacement_ws(), # churn 1 -> attempt 1
|
|
490
|
+
_HeldThenClose(0.3), # holds past the stability window
|
|
491
|
+
_replacement_ws(), # churn 2 -> attempt was reset -> smallest delay
|
|
492
|
+
_replacement_ws(), # exhausts the retry budget
|
|
493
|
+
]
|
|
494
|
+
connection, _ = make_connection(sessions, max_retries=3)
|
|
495
|
+
connection.settings.reconnect_delay = 0.5
|
|
496
|
+
|
|
497
|
+
async def fake_sleep(delay):
|
|
498
|
+
delays.append(delay)
|
|
499
|
+
return len(delays) >= 3
|
|
500
|
+
|
|
501
|
+
connection._sleep_or_stop = fake_sleep
|
|
502
|
+
await connection.run()
|
|
503
|
+
assert len(delays) == 3
|
|
504
|
+
# Without the stable-session rule these would be ~1x, 2x, 4x of the base.
|
|
505
|
+
# With it: the churn before the stable session is 1x, the drop after the
|
|
506
|
+
# stable session is re-based to ~1x again, and plain churn grows from there.
|
|
507
|
+
assert delays[1] < delays[0] * 1.5, f"no re-base after the stable session: {delays}"
|
|
508
|
+
assert delays[2] > delays[1], f"growth must simply start over: {delays}"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{mcp_switchboard_client-0.3.0.dev4 → mcp_switchboard_client-0.4.0.dev6}/tests/test_config.py
RENAMED
|
File without changes
|
{mcp_switchboard_client-0.3.0.dev4 → mcp_switchboard_client-0.4.0.dev6}/tests/test_env_brief.py
RENAMED
|
File without changes
|
{mcp_switchboard_client-0.3.0.dev4 → mcp_switchboard_client-0.4.0.dev6}/tests/test_envconf_cases.py
RENAMED
|
File without changes
|
{mcp_switchboard_client-0.3.0.dev4 → mcp_switchboard_client-0.4.0.dev6}/tests/test_environment.py
RENAMED
|
File without changes
|
{mcp_switchboard_client-0.3.0.dev4 → mcp_switchboard_client-0.4.0.dev6}/tests/test_settings.py
RENAMED
|
File without changes
|
{mcp_switchboard_client-0.3.0.dev4 → mcp_switchboard_client-0.4.0.dev6}/tests/test_supervisor.py
RENAMED
|
File without changes
|