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.
- capdisc/__init__.py +69 -0
- capdisc/base.py +56 -0
- capdisc/catalog/__init__.py +71 -0
- capdisc/catalog/entries.py +104 -0
- capdisc/catalog/types.py +227 -0
- capdisc/discovery.py +278 -0
- capdisc/frontmatter.py +49 -0
- capdisc/hooks/__init__.py +61 -0
- capdisc/hooks/schema.py +216 -0
- capdisc/hooks/types.py +156 -0
- capdisc/html.py +75 -0
- capdisc/mcp_catalog.py +87 -0
- capdisc/mcp_harvest/__init__.py +17 -0
- capdisc/mcp_harvest/auth.py +120 -0
- capdisc/mcp_harvest/cache.py +144 -0
- capdisc/mcp_harvest/config.py +325 -0
- capdisc/mcp_harvest/connect.py +187 -0
- capdisc/mcp_harvest/types.py +29 -0
- capdisc/plugin_catalog.py +308 -0
- capdisc/py.typed +0 -0
- capdisc/report/__init__.py +36 -0
- capdisc/report/__main__.py +6 -0
- capdisc/report/assets.py +138 -0
- capdisc/report/cards.py +109 -0
- capdisc/report/cli.py +35 -0
- capdisc/report/components.py +144 -0
- capdisc/report/harvest.py +150 -0
- capdisc/report/html.py +79 -0
- capdisc/report/models.py +82 -0
- capdisc/report/page.py +129 -0
- capdisc/report/sections.py +154 -0
- capdisc/report/types.py +43 -0
- capdisc/scope/__init__.py +83 -0
- capdisc/scope/inventory/__init__.py +20 -0
- capdisc/scope/inventory/assets.py +54 -0
- capdisc/scope/inventory/capture.py +219 -0
- capdisc/scope/inventory/render.py +149 -0
- capdisc/scope/inventory/roots.py +175 -0
- capdisc/scope/locations.py +330 -0
- capdisc/scope/types.py +154 -0
- capdisc/settings.py +230 -0
- capdisc/tokens.py +48 -0
- claude_code_capabilities-0.1.2.dist-info/METADATA +136 -0
- claude_code_capabilities-0.1.2.dist-info/RECORD +47 -0
- claude_code_capabilities-0.1.2.dist-info/WHEEL +4 -0
- claude_code_capabilities-0.1.2.dist-info/entry_points.txt +2 -0
- 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
|
+
"▸ 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
|