mcp-switchboard-client 0.3.0.dev5__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.
Files changed (24) hide show
  1. {mcp_switchboard_client-0.3.0.dev5 → mcp_switchboard_client-0.4.0.dev6}/.gitignore +1 -0
  2. {mcp_switchboard_client-0.3.0.dev5 → mcp_switchboard_client-0.4.0.dev6}/PKG-INFO +1 -1
  3. {mcp_switchboard_client-0.3.0.dev5 → mcp_switchboard_client-0.4.0.dev6}/pyproject.toml +1 -1
  4. {mcp_switchboard_client-0.3.0.dev5 → mcp_switchboard_client-0.4.0.dev6}/src/mcp_switchboard_client/__init__.py +1 -1
  5. {mcp_switchboard_client-0.3.0.dev5 → mcp_switchboard_client-0.4.0.dev6}/src/mcp_switchboard_client/cli.py +11 -0
  6. {mcp_switchboard_client-0.3.0.dev5 → mcp_switchboard_client-0.4.0.dev6}/src/mcp_switchboard_client/environment.py +116 -1
  7. {mcp_switchboard_client-0.3.0.dev5 → mcp_switchboard_client-0.4.0.dev6}/src/mcp_switchboard_client/protocol.py +14 -0
  8. {mcp_switchboard_client-0.3.0.dev5 → mcp_switchboard_client-0.4.0.dev6}/src/mcp_switchboard_client/tunnel.py +80 -14
  9. {mcp_switchboard_client-0.3.0.dev5 → mcp_switchboard_client-0.4.0.dev6}/tests/test_instructions.py +3 -2
  10. {mcp_switchboard_client-0.3.0.dev5 → mcp_switchboard_client-0.4.0.dev6}/tests/test_protocol_conformance.py +12 -4
  11. mcp_switchboard_client-0.4.0.dev6/tests/test_skill_scan.py +171 -0
  12. {mcp_switchboard_client-0.3.0.dev5 → mcp_switchboard_client-0.4.0.dev6}/tests/test_tunnel.py +86 -0
  13. {mcp_switchboard_client-0.3.0.dev5 → mcp_switchboard_client-0.4.0.dev6}/README.md +0 -0
  14. {mcp_switchboard_client-0.3.0.dev5 → mcp_switchboard_client-0.4.0.dev6}/src/mcp_switchboard_client/__main__.py +0 -0
  15. {mcp_switchboard_client-0.3.0.dev5 → mcp_switchboard_client-0.4.0.dev6}/src/mcp_switchboard_client/config.py +0 -0
  16. {mcp_switchboard_client-0.3.0.dev5 → mcp_switchboard_client-0.4.0.dev6}/src/mcp_switchboard_client/envconf.py +0 -0
  17. {mcp_switchboard_client-0.3.0.dev5 → mcp_switchboard_client-0.4.0.dev6}/src/mcp_switchboard_client/supervisor.py +0 -0
  18. {mcp_switchboard_client-0.3.0.dev5 → mcp_switchboard_client-0.4.0.dev6}/tests/conftest.py +0 -0
  19. {mcp_switchboard_client-0.3.0.dev5 → mcp_switchboard_client-0.4.0.dev6}/tests/test_config.py +0 -0
  20. {mcp_switchboard_client-0.3.0.dev5 → mcp_switchboard_client-0.4.0.dev6}/tests/test_env_brief.py +0 -0
  21. {mcp_switchboard_client-0.3.0.dev5 → mcp_switchboard_client-0.4.0.dev6}/tests/test_envconf_cases.py +0 -0
  22. {mcp_switchboard_client-0.3.0.dev5 → mcp_switchboard_client-0.4.0.dev6}/tests/test_environment.py +0 -0
  23. {mcp_switchboard_client-0.3.0.dev5 → mcp_switchboard_client-0.4.0.dev6}/tests/test_settings.py +0 -0
  24. {mcp_switchboard_client-0.3.0.dev5 → mcp_switchboard_client-0.4.0.dev6}/tests/test_supervisor.py +0 -0
@@ -1,3 +1,4 @@
1
+ node_modules/
1
2
  __pycache__/
2
3
  *.pyc
3
4
  *.egg-info/
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: mcp-switchboard-client
3
- Version: 0.3.0.dev5
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.3.0.dev5"
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"
@@ -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.3.0"
9
+ __version__ = "0.4.0"
@@ -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
@@ -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
- "run_command/run_python are UNCONFINED and can write anything this user can"
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
- if self._session_ok:
256
- # A session that got as far as hello_ack starts backoff over.
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 and the host brief; push a context_update on change.
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
- if not (changed_files or changed_brief):
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
- try:
580
- await self._send(protocol.context_update(**kwargs))
581
- LOGGER.info(
582
- "sent context_update: %s",
583
- ", ".join(sorted(kwargs)) or "no carried fields",
584
- )
585
- except Exception as e: # noqa: BLE001 - the read loop owns the connection
586
- LOGGER.debug("context_update send failed (connection dropping?): %s", e)
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
@@ -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, environment_brief="host: x\n"
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()
@@ -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}"