claude-code-capabilities 0.1.2__py3-none-any.whl

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 (47) hide show
  1. capdisc/__init__.py +69 -0
  2. capdisc/base.py +56 -0
  3. capdisc/catalog/__init__.py +71 -0
  4. capdisc/catalog/entries.py +104 -0
  5. capdisc/catalog/types.py +227 -0
  6. capdisc/discovery.py +278 -0
  7. capdisc/frontmatter.py +49 -0
  8. capdisc/hooks/__init__.py +61 -0
  9. capdisc/hooks/schema.py +216 -0
  10. capdisc/hooks/types.py +156 -0
  11. capdisc/html.py +75 -0
  12. capdisc/mcp_catalog.py +87 -0
  13. capdisc/mcp_harvest/__init__.py +17 -0
  14. capdisc/mcp_harvest/auth.py +120 -0
  15. capdisc/mcp_harvest/cache.py +144 -0
  16. capdisc/mcp_harvest/config.py +325 -0
  17. capdisc/mcp_harvest/connect.py +187 -0
  18. capdisc/mcp_harvest/types.py +29 -0
  19. capdisc/plugin_catalog.py +308 -0
  20. capdisc/py.typed +0 -0
  21. capdisc/report/__init__.py +36 -0
  22. capdisc/report/__main__.py +6 -0
  23. capdisc/report/assets.py +138 -0
  24. capdisc/report/cards.py +109 -0
  25. capdisc/report/cli.py +35 -0
  26. capdisc/report/components.py +144 -0
  27. capdisc/report/harvest.py +150 -0
  28. capdisc/report/html.py +79 -0
  29. capdisc/report/models.py +82 -0
  30. capdisc/report/page.py +129 -0
  31. capdisc/report/sections.py +154 -0
  32. capdisc/report/types.py +43 -0
  33. capdisc/scope/__init__.py +83 -0
  34. capdisc/scope/inventory/__init__.py +20 -0
  35. capdisc/scope/inventory/assets.py +54 -0
  36. capdisc/scope/inventory/capture.py +219 -0
  37. capdisc/scope/inventory/render.py +149 -0
  38. capdisc/scope/inventory/roots.py +175 -0
  39. capdisc/scope/locations.py +330 -0
  40. capdisc/scope/types.py +154 -0
  41. capdisc/settings.py +230 -0
  42. capdisc/tokens.py +48 -0
  43. claude_code_capabilities-0.1.2.dist-info/METADATA +136 -0
  44. claude_code_capabilities-0.1.2.dist-info/RECORD +47 -0
  45. claude_code_capabilities-0.1.2.dist-info/WHEEL +4 -0
  46. claude_code_capabilities-0.1.2.dist-info/entry_points.txt +2 -0
  47. claude_code_capabilities-0.1.2.dist-info/licenses/LICENSE +21 -0
capdisc/hooks/types.py ADDED
@@ -0,0 +1,156 @@
1
+ from __future__ import annotations
2
+
3
+ from enum import StrEnum
4
+ from typing import Annotated
5
+
6
+ from pydantic import Field
7
+
8
+ HookTimeout = Annotated[
9
+ int,
10
+ Field(
11
+ ge=1,
12
+ le=86_400,
13
+ title="Hook timeout",
14
+ description="Seconds the hook may run before it is killed. Defaults vary by type "
15
+ "(command/http/mcp_tool 600, prompt 30, agent 60) and the docs set no maximum, so the "
16
+ "cap is only an absurd-value guard, not a product limit.",
17
+ examples=[30, 120, 600],
18
+ ),
19
+ ]
20
+ ShellCommand = Annotated[
21
+ str,
22
+ Field(
23
+ min_length=1,
24
+ max_length=4000,
25
+ title="Shell command",
26
+ description="Command a command hook runs, or the executable to spawn when `args` is set.",
27
+ examples=["./run-tests.sh"],
28
+ ),
29
+ ]
30
+ ShellArg = Annotated[
31
+ str,
32
+ Field(
33
+ max_length=2000,
34
+ title="Shell argument",
35
+ description="One argument in a command hook's argument vector.",
36
+ ),
37
+ ]
38
+ HookUrl = Annotated[
39
+ str,
40
+ Field(
41
+ pattern=r"^https?://",
42
+ max_length=2000,
43
+ title="Hook URL",
44
+ description="Endpoint an http hook POSTs the event JSON to.",
45
+ examples=["http://localhost:8080/hooks/tool-use"],
46
+ ),
47
+ ]
48
+ HeaderName = Annotated[
49
+ str,
50
+ Field(
51
+ pattern=r"^[A-Za-z0-9-]+$",
52
+ max_length=128,
53
+ title="Header name",
54
+ description="Name of an http hook request header.",
55
+ examples=["Authorization"],
56
+ ),
57
+ ]
58
+ HeaderValue = Annotated[
59
+ str,
60
+ Field(
61
+ max_length=2000,
62
+ title="Header value",
63
+ description="Value of an http hook request header; supports `$VAR` interpolation.",
64
+ examples=["Bearer $MY_TOKEN"],
65
+ ),
66
+ ]
67
+ EnvVarName = Annotated[
68
+ str,
69
+ Field(
70
+ pattern=r"^[A-Za-z_][A-Za-z0-9_]*$",
71
+ title="Env var",
72
+ description="Name of an environment variable.",
73
+ examples=["MY_TOKEN"],
74
+ ),
75
+ ]
76
+ HookModel = Annotated[
77
+ str,
78
+ Field(
79
+ min_length=1,
80
+ max_length=64,
81
+ title="Hook model",
82
+ description="Model a prompt or agent hook evaluates with; omit for a fast default.",
83
+ examples=["haiku"],
84
+ ),
85
+ ]
86
+ HookPrompt = Annotated[
87
+ str,
88
+ Field(
89
+ min_length=1,
90
+ max_length=8000,
91
+ title="Hook prompt",
92
+ description="Prompt a prompt or agent hook evaluates; `$ARGUMENTS` injects the input JSON.",
93
+ examples=["Verify all unit tests pass. $ARGUMENTS"],
94
+ ),
95
+ ]
96
+ StatusMessage = Annotated[
97
+ str,
98
+ Field(
99
+ min_length=1,
100
+ max_length=200,
101
+ title="Status message",
102
+ description="Text shown while the hook runs.",
103
+ ),
104
+ ]
105
+ HookCondition = Annotated[
106
+ str,
107
+ Field(
108
+ pattern=r"^[A-Za-z][A-Za-z0-9_]*(\(.*\))?$",
109
+ max_length=400,
110
+ title="Hook condition",
111
+ description=(
112
+ "A single permission rule narrowing when the handler runs on tool events, e.g. "
113
+ "'Bash(git *)' or 'Edit(*.ts)'; ignored on non-tool events."
114
+ ),
115
+ examples=["Bash(rm *)", "Edit(*.ts)"],
116
+ ),
117
+ ]
118
+ HookMatcherPattern = Annotated[
119
+ str,
120
+ Field(
121
+ max_length=200,
122
+ title="Hook matcher",
123
+ description="Pattern selecting which subjects an event's handlers run for, e.g. "
124
+ "'Write|Edit', 'Bash', or '*' for all; empty also means all.",
125
+ examples=["Write|Edit", "Bash", "*", ""],
126
+ ),
127
+ ]
128
+ RunOnce = Annotated[
129
+ bool,
130
+ Field(
131
+ description="If true, the hook runs once per session, then is removed. Only honored for "
132
+ "hooks declared in skill frontmatter — ignored in settings files and agent frontmatter.",
133
+ ),
134
+ ]
135
+ RunAsync = Annotated[
136
+ bool,
137
+ Field(
138
+ description="If true, a command hook runs in the background without blocking Claude; its "
139
+ "output is delivered on the next conversation turn. Command hooks only.",
140
+ ),
141
+ ]
142
+ AsyncRewake = Annotated[
143
+ bool,
144
+ Field(
145
+ description="If true, a command hook runs in the background and wakes Claude on exit "
146
+ "code 2 (implies async). The hook's stderr — or stdout if stderr is empty — is shown to "
147
+ "Claude as a system reminder, so it can react to a long-running background failure.",
148
+ ),
149
+ ]
150
+
151
+
152
+ class HookShell(StrEnum):
153
+ """Interpreter a command hook runs under."""
154
+
155
+ bash = "bash"
156
+ powershell = "powershell"
capdisc/html.py ADDED
@@ -0,0 +1,75 @@
1
+ """Tiny HTML-escaping and markup primitives shared by the report and scope-inventory renderers.
2
+
3
+ Lives at the package root (not inside `report/` or `scope/`) because both packages need it and
4
+ `report/` already depends on `scope/` — putting it in `scope/` would make the reverse import
5
+ circular.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import re
11
+ from html import escape
12
+ from pathlib import Path
13
+
14
+ _FULL_MATCH_SECRET_PATTERNS: tuple[re.Pattern[str], ...] = (
15
+ re.compile(r"(?i)\b(?:Bearer|Basic)\s+[A-Za-z0-9\-_.+/=]{8,}"),
16
+ re.compile(r"\bsk-(?:ant-)?[A-Za-z0-9_-]{10,}"),
17
+ re.compile(r"\bgh[pousr]_[A-Za-z0-9]{20,}"),
18
+ re.compile(r"\bgithub_pat_[A-Za-z0-9_]{20,}"),
19
+ re.compile(r"\bAKIA[0-9A-Z]{16}"),
20
+ re.compile(r"\bxox[baprs]-[A-Za-z0-9-]{10,}"),
21
+ )
22
+ _KEY_VALUE_SECRET_PATTERN = re.compile(
23
+ r"(?i)\b((?:api[-_]?key|secret|token|password|passwd|auth)\w*)"
24
+ r"([\"'=:\s]{1,3})([A-Za-z0-9\-_./+=]{6,})"
25
+ )
26
+ _REDACTED = "[redacted]"
27
+
28
+
29
+ def e(value: object) -> str:
30
+ """Escape any value for safe HTML text/attribute interpolation."""
31
+ return escape("" if value is None else str(value))
32
+
33
+
34
+ def redact_home(value: object) -> str:
35
+ """Render `value` with the user's home directory prefix collapsed to `~`.
36
+
37
+ Applied to every path rendered into the discovery report so a shared report doesn't
38
+ disclose the OS username via each scanned/captured path."""
39
+ text = "" if value is None else str(value)
40
+ home = str(Path.home())
41
+ if home not in ("", "/") and text.startswith(home):
42
+ return "~" + text[len(home) :]
43
+ return text
44
+
45
+
46
+ def redact_secrets(text: str) -> str:
47
+ """Best-effort scrub of secret-shaped substrings from arbitrary preview text: bearer/basic
48
+ auth headers, common provider API key prefixes, and `token=`/`secret=`-style key-value pairs.
49
+
50
+ Pattern-based, not a guarantee — a secret in an unusual shape can still slip through. Applied
51
+ to raw file-content previews (e.g. a hook's command string) that this tool didn't author,
52
+ before they're rendered into a report meant to be shared."""
53
+ redacted = text
54
+ for pattern in _FULL_MATCH_SECRET_PATTERNS:
55
+ redacted = pattern.sub(_REDACTED, redacted)
56
+ return _KEY_VALUE_SECRET_PATTERN.sub(lambda m: f"{m.group(1)}{m.group(2)}{_REDACTED}", redacted)
57
+
58
+
59
+ def pill(label: str, css: str = "sc") -> str:
60
+ """A small uppercase badge; `css` picks its colour class."""
61
+ return f'<span class="pill {css}">{e(label)}</span>'
62
+
63
+
64
+ def preview(text: str) -> str:
65
+ """A collapsible content preview; the toggle button only flips a CSS class.
66
+
67
+ Runs `redact_secrets` before escaping — a content preview of a file this tool didn't author
68
+ (a hook's command, a settings JSON slice) may carry a literal credential."""
69
+ if not text:
70
+ return ""
71
+ return (
72
+ '<button class="toggle" onclick="this.parentElement.classList.toggle(\'open\')">'
73
+ "&#9656; preview</button>"
74
+ f'<div class="preview">{e(redact_secrets(text))}</div>'
75
+ )
capdisc/mcp_catalog.py ADDED
@@ -0,0 +1,87 @@
1
+ from __future__ import annotations
2
+
3
+ import subprocess
4
+ from typing import Any
5
+
6
+ from pydantic import TypeAdapter, ValidationError, model_validator
7
+
8
+ from .base import InputModel
9
+ from .catalog import CatalogMcpServer, McpServerRef, catalog_id
10
+
11
+ _MCP_LIST = ("claude", "mcp", "list")
12
+ SERVER_REF: TypeAdapter[McpServerRef] = TypeAdapter(
13
+ McpServerRef
14
+ ) # the one place a server name becomes a ref
15
+
16
+
17
+ class _ConnectedServerLine(InputModel):
18
+ """One `claude mcp list` line, kept only when it names a `Connected` server. The
19
+ command/URL after the name (which can carry credentials) is split off and never modeled."""
20
+
21
+ name: str
22
+
23
+ @model_validator(mode="before")
24
+ @classmethod
25
+ def _parse_line(cls, data: Any) -> Any:
26
+ if not isinstance(data, str):
27
+ return data
28
+ if ": " not in data or " - " not in data:
29
+ raise ValueError("not a 'name: command - status' line")
30
+ if "Connected" not in data.rsplit(" - ", 1)[-1]:
31
+ raise ValueError("server is not Connected")
32
+ return {"name": data.split(": ", 1)[0].strip()}
33
+
34
+
35
+ def parse_mcp_servers(list_output: str) -> list[CatalogMcpServer]:
36
+ """Parse `claude mcp list` output into cards for the connected servers.
37
+
38
+ Only the server name is kept; the command/URL after it (which can carry credentials) is
39
+ split off and never indexed. The MCP config file is never read.
40
+
41
+ Args:
42
+ list_output: The stdout of `claude mcp list`.
43
+
44
+ Returns:
45
+ One card per distinct connected server (de-duplicated by id), skipping disconnected
46
+ lines and names that don't validate.
47
+ """
48
+ servers: list[CatalogMcpServer] = []
49
+ seen: set[str] = set()
50
+ for line in list_output.splitlines():
51
+ try:
52
+ name = _ConnectedServerLine.model_validate(line).name
53
+ except ValidationError:
54
+ continue
55
+ try:
56
+ ref = SERVER_REF.validate_python(name)
57
+ except ValidationError:
58
+ continue
59
+ try:
60
+ card = CatalogMcpServer(
61
+ id=catalog_id("mcp", ref), ref=ref, description=f"MCP server: {name}"
62
+ )
63
+ except ValidationError:
64
+ continue
65
+ # dedupe by id, not ref: two distinct refs can still truncate to the same catalog_id
66
+ if card.id in seen:
67
+ continue
68
+ seen.add(card.id)
69
+ servers.append(card)
70
+ return servers
71
+
72
+
73
+ def enumerate_mcp_servers() -> list[CatalogMcpServer]:
74
+ """Enumerate connected MCP servers by running `claude mcp list`.
75
+
76
+ Spawned as an argv array (no shell); the config file is never touched.
77
+
78
+ Returns:
79
+ The connected server cards, or `[]` if the CLI is missing, times out, or errors.
80
+ """
81
+ try:
82
+ result = subprocess.run( # noqa: S603 — fixed argv tuple, no shell, no untrusted input
83
+ _MCP_LIST, capture_output=True, text=True, timeout=30, check=False
84
+ )
85
+ except (OSError, subprocess.SubprocessError):
86
+ return []
87
+ return parse_mcp_servers(result.stdout)
@@ -0,0 +1,17 @@
1
+ from __future__ import annotations
2
+
3
+ from .cache import (
4
+ cache_is_stale,
5
+ read_mcp_cache,
6
+ refresh_in_background,
7
+ refresh_mcp_cache,
8
+ write_mcp_cache,
9
+ )
10
+
11
+ __all__ = [
12
+ "cache_is_stale",
13
+ "read_mcp_cache",
14
+ "refresh_in_background",
15
+ "refresh_mcp_cache",
16
+ "write_mcp_cache",
17
+ ]
@@ -0,0 +1,120 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+ import os
5
+ from pathlib import Path
6
+ from urllib.parse import urlsplit
7
+
8
+ from fastmcp.client.auth import OAuth
9
+ from key_value.aio.stores.filetree import FileTreeStore
10
+
11
+ from ..settings import get_settings
12
+ from .config import scalar_str
13
+ from .types import ServerConfig
14
+
15
+ logger = logging.getLogger(__name__)
16
+
17
+ _LOCAL_HOSTS = frozenset({"localhost", "127.0.0.1", "::1"})
18
+
19
+
20
+ def bare_name(ref: str) -> str:
21
+ """The bare server name from a full ref: `plugin:github:github` → `github`."""
22
+ return ref.rsplit(":", 1)[-1]
23
+
24
+
25
+ def _host_of(url: str) -> str | None:
26
+ """The lowercased hostname of a url, or None when it has none."""
27
+ hostname = urlsplit(url).hostname
28
+ return hostname.lower() if hostname else None
29
+
30
+
31
+ def _safe_for_auth(url: str) -> bool:
32
+ """Whether credentials may be attached to `url` at all: https, or plaintext http only to
33
+ a loopback host (local dev servers) — never a bearer token or OAuth client secret over an
34
+ unencrypted connection to a real host."""
35
+ scheme = urlsplit(url).scheme
36
+ if scheme == "https":
37
+ return True
38
+ return scheme == "http" and _host_of(url) in _LOCAL_HOSTS
39
+
40
+
41
+ def _host_matches(url: str, expected_host: str) -> bool:
42
+ """Whether `url` resolves to exactly `expected_host` (case-insensitive)."""
43
+ return _host_of(url) == expected_host.lower()
44
+
45
+
46
+ def ensure_private_dir(path: Path) -> None:
47
+ """Create `path` (with parents) and force it to mode 0700, whether newly created or
48
+ pre-existing — `Path.mkdir(mode=...)` only applies to a directory it creates, so a
49
+ pre-existing world-readable directory would otherwise silently hold plaintext OAuth
50
+ tokens."""
51
+ path.mkdir(mode=0o700, parents=True, exist_ok=True)
52
+ path.chmod(0o700)
53
+
54
+
55
+ def server_auth(ref: str, config: ServerConfig, oauth: bool) -> str | OAuth | None:
56
+ """Resolve auth for one HTTP server, or None to connect anonymously.
57
+
58
+ A bearer token named in the settings' `mcp_bearer_env` always wins (non-interactive).
59
+ Otherwise, when `oauth` is set and the settings pre-register a client for the server,
60
+ the full browser flow runs, with tokens cached under `oauth_token_dir`. Stdio servers
61
+ (no `url`) never get auth — their config already carries what they need.
62
+
63
+ The bare server name is only a lookup key, never a trust boundary: a credential is
64
+ attached only when the server's own `url` also matches the configured `host`, so a
65
+ different (or malicious) server declared under the same bare name — e.g. by an
66
+ installed plugin — cannot receive a credential meant for another server. Credentials
67
+ are also refused entirely over a non-loopback plaintext connection.
68
+
69
+ Args:
70
+ ref: The server's full ref (`plugin:<plugin>:<server>` or a bare name).
71
+ config: The server's spawn config.
72
+ oauth: Whether the interactive OAuth flow is allowed for this run.
73
+
74
+ Returns:
75
+ A bearer token string, an `OAuth` flow handler, or None.
76
+ """
77
+ url = scalar_str(config.get("url"))
78
+ if url is None:
79
+ return None
80
+ if not _safe_for_auth(url):
81
+ logger.warning("refusing to attach credentials to non-HTTPS server %s", ref)
82
+ return None
83
+ name = bare_name(ref)
84
+ settings = get_settings()
85
+ bearer = settings.mcp_bearer_env.get(name)
86
+ if bearer is not None:
87
+ if not _host_matches(url, bearer.host):
88
+ logger.warning(
89
+ "refusing bearer token for %s: url host does not match configured %s",
90
+ ref,
91
+ bearer.host,
92
+ )
93
+ elif token := os.environ.get(bearer.env):
94
+ return token
95
+ else:
96
+ logger.warning(
97
+ "bearer env var %s is configured for %s but unset in this environment",
98
+ bearer.env,
99
+ ref,
100
+ )
101
+ client_conf = settings.mcp_oauth_clients.get(name) if oauth else None
102
+ if client_conf is None:
103
+ return None
104
+ if not _host_matches(url, client_conf.host):
105
+ logger.warning(
106
+ "refusing OAuth client for %s: url host does not match configured %s",
107
+ ref,
108
+ client_conf.host,
109
+ )
110
+ return None
111
+ token_dir = settings.oauth_token_dir
112
+ ensure_private_dir(token_dir)
113
+ return OAuth(
114
+ mcp_url=url,
115
+ scopes=client_conf.scopes,
116
+ client_id=client_conf.client_id,
117
+ client_secret=(os.environ.get(client_conf.secret_env) if client_conf.secret_env else None),
118
+ token_storage=FileTreeStore(data_directory=token_dir),
119
+ callback_port=client_conf.callback_port,
120
+ )
@@ -0,0 +1,144 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import logging
5
+ import os
6
+ import threading
7
+ import time
8
+ from pathlib import Path
9
+
10
+ from pydantic import TypeAdapter, ValidationError
11
+
12
+ from ..catalog import CatalogMcpServer
13
+ from ..settings import get_settings
14
+ from .config import all_server_configs
15
+ from .connect import harvest_servers
16
+
17
+ logger = logging.getLogger(__name__)
18
+
19
+ _CACHE_TTL = 12 * 3600 # seconds; MCP tool inventories shift rarely, so a soft half-day is plenty
20
+ _refresh_lock = threading.Lock() # one background refresh per process at a time
21
+
22
+ _CACHE: TypeAdapter[list[CatalogMcpServer]] = TypeAdapter(list[CatalogMcpServer])
23
+
24
+
25
+ def read_mcp_cache(path: Path | None = None) -> list[CatalogMcpServer]:
26
+ """Read the cached, tool-enriched MCP server cards.
27
+
28
+ Args:
29
+ path: Cache file to read; the settings' `mcp_cache` when None.
30
+
31
+ Returns:
32
+ The cached cards, or `[]` when there is no valid cache yet.
33
+ """
34
+ path = path if path is not None else get_settings().mcp_cache
35
+ try:
36
+ return _CACHE.validate_json(path.read_text(encoding="utf-8"))
37
+ except (OSError, ValidationError):
38
+ return []
39
+
40
+
41
+ def write_mcp_cache(servers: list[CatalogMcpServer], path: Path | None = None) -> None:
42
+ """Persist harvested server cards as the cache that recall reads.
43
+
44
+ Written atomically (write a sibling temp file, then rename it over the destination) so a
45
+ concurrent `read_mcp_cache` — e.g. `build_report` while a background refresh is writing —
46
+ never observes a truncated file; it sees either the old cache or the new one, never neither.
47
+
48
+ Args:
49
+ servers: The cards to write.
50
+ path: Destination cache file; parent dirs are created. The settings' `mcp_cache`
51
+ when None.
52
+ """
53
+ path = path if path is not None else get_settings().mcp_cache
54
+ path.parent.mkdir(parents=True, exist_ok=True)
55
+ tmp = path.with_suffix(f"{path.suffix}.tmp-{os.getpid()}-{threading.get_ident()}")
56
+ tmp.write_text(_CACHE.dump_json(servers, indent=2).decode(), encoding="utf-8")
57
+ tmp.replace(path)
58
+
59
+
60
+ def refresh_mcp_cache(
61
+ plugins_root: Path | None = None,
62
+ path: Path | None = None,
63
+ project_dir: Path | None = None,
64
+ claude_json: Path | None = None,
65
+ oauth: bool = False,
66
+ ) -> list[CatalogMcpServer]:
67
+ """Harvest every MCP server across scopes and write the cache.
68
+
69
+ The impure refresh step: network and process spawning live here, never on the recall path.
70
+ A server's config (including any secrets in `env`) is used only to spawn it; only tool names,
71
+ descriptions, and parameters are cached.
72
+
73
+ Args:
74
+ plugins_root: Root of installed plugins; the settings' `plugins_root` when None.
75
+ path: Cache file to write; the settings' `mcp_cache` when None.
76
+ project_dir: Project root for project/local scope; the cwd when None.
77
+ claude_json: Path to `~/.claude.json`; the settings' `claude_json` when None.
78
+ oauth: Allow the interactive OAuth flow for HTTP servers with a pre-registered client.
79
+ Leave False on any background or hook path — it may open a browser.
80
+
81
+ Returns:
82
+ The freshly harvested cards (also written to `path`).
83
+ """
84
+ plugins_root = plugins_root if plugins_root is not None else get_settings().plugins_root
85
+ claude_json = claude_json if claude_json is not None else get_settings().claude_json
86
+ configs = all_server_configs(plugins_root, project_dir or Path.cwd(), claude_json)
87
+ cards = asyncio.run(harvest_servers(configs, oauth))
88
+ write_mcp_cache(cards, path)
89
+ return cards
90
+
91
+
92
+ def cache_is_stale(path: Path | None = None, ttl_seconds: float = _CACHE_TTL) -> bool:
93
+ """Report whether the cache is due for a refresh.
94
+
95
+ Args:
96
+ path: Cache file to check; the settings' `mcp_cache` when None.
97
+ ttl_seconds: Maximum age before the cache is considered stale. Defaults to `_CACHE_TTL`.
98
+
99
+ Returns:
100
+ True if the cache is missing or older than `ttl_seconds`.
101
+ """
102
+ path = path if path is not None else get_settings().mcp_cache
103
+ try:
104
+ age = time.time() - path.stat().st_mtime
105
+ except OSError:
106
+ return True
107
+ return age > ttl_seconds
108
+
109
+
110
+ def refresh_in_background(
111
+ plugins_root: Path | None = None,
112
+ path: Path | None = None,
113
+ project_dir: Path | None = None,
114
+ claude_json: Path | None = None,
115
+ ) -> threading.Thread | None:
116
+ """Run a one-shot cache refresh in a daemon thread and return immediately.
117
+
118
+ The harvest spawns MCP servers and does network I/O, so it must never run on the recall path;
119
+ this is how callers trigger it off that path. Best-effort: any failure is logged and swallowed,
120
+ never surfaced to the recall caller.
121
+
122
+ Args:
123
+ plugins_root: Root of installed plugins; the settings' `plugins_root` when None.
124
+ path: Cache file to write; the settings' `mcp_cache` when None.
125
+ project_dir: Project root for project/local scope; the cwd when None.
126
+ claude_json: Path to `~/.claude.json`; the settings' `claude_json` when None.
127
+
128
+ Returns:
129
+ The started daemon thread, or None if a refresh is already running in this process.
130
+ """
131
+ if not _refresh_lock.acquire(blocking=False):
132
+ return None
133
+
134
+ def _run() -> None:
135
+ try:
136
+ refresh_mcp_cache(plugins_root, path, project_dir, claude_json)
137
+ except Exception:
138
+ logger.exception("background MCP cache refresh failed")
139
+ finally:
140
+ _refresh_lock.release()
141
+
142
+ thread = threading.Thread(target=_run, name="mcp-cache-refresh", daemon=True)
143
+ thread.start()
144
+ return thread