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
@@ -0,0 +1,325 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+
5
+ from pydantic import JsonValue, TypeAdapter, ValidationError
6
+
7
+ from ..base import InputModel
8
+ from ..plugin_catalog import installed_plugins
9
+ from ..settings import get_settings
10
+ from .types import ServerConfig, ServerMap
11
+
12
+ _MCP_CONFIG = ".mcp.json"
13
+
14
+ _SERVER_MAP: TypeAdapter[ServerMap] = TypeAdapter(ServerMap)
15
+
16
+
17
+ class _Wrapped(InputModel):
18
+ """The `{"mcpServers": {...}}` shape; the flat shape is that same map at the top level."""
19
+
20
+ mcpServers: ServerMap = {}
21
+
22
+
23
+ _WRAPPED: TypeAdapter[_Wrapped] = TypeAdapter(_Wrapped)
24
+
25
+
26
+ class _ManifestMcp(InputModel):
27
+ """`mcpServers` in a plugin manifest — an inline map, or path(s) to `.mcp.json` files.
28
+
29
+ A raw union, not a discriminated model: this mirrors an external plugin manifest's JSON,
30
+ which carries no discriminator field to tag the three shapes with.
31
+ """
32
+
33
+ mcpServers: ServerMap | str | list[str] = {}
34
+
35
+
36
+ _MANIFEST_MCP: TypeAdapter[_ManifestMcp] = TypeAdapter(_ManifestMcp)
37
+ _PLUGIN_MANIFEST = Path(".claude-plugin") / "plugin.json"
38
+
39
+
40
+ class _PluginOptions(InputModel):
41
+ """One plugin's `pluginConfigs` entry — only the non-sensitive `options` (sensitive values
42
+ are kept in the keychain, not here)."""
43
+
44
+ options: dict[str, JsonValue] = {}
45
+
46
+
47
+ class _Settings(InputModel):
48
+ pluginConfigs: dict[str, _PluginOptions] = {}
49
+
50
+
51
+ _SETTINGS: TypeAdapter[_Settings] = TypeAdapter(_Settings)
52
+
53
+
54
+ class _ProjectScope(InputModel):
55
+ """One `projects[<path>]` entry of `~/.claude.json`, narrowed to its private MCP servers."""
56
+
57
+ mcpServers: ServerMap = {}
58
+
59
+
60
+ class _ClaudeJson(InputModel):
61
+ """The slice of `~/.claude.json` holding MCP servers: the user-global `mcpServers` map and each
62
+ project's private `projects[<path>].mcpServers`. Every other key is ignored."""
63
+
64
+ mcpServers: ServerMap = {}
65
+ projects: dict[str, _ProjectScope] = {}
66
+
67
+
68
+ _CLAUDE_JSON: TypeAdapter[_ClaudeJson] = TypeAdapter(_ClaudeJson)
69
+
70
+
71
+ def server_configs(raw: str) -> ServerMap:
72
+ """Parse a `.mcp.json` body into its `{server_name: config}` map.
73
+
74
+ Accepts both on-disk shapes — a `mcpServers` wrapper or the servers map at the top level.
75
+ The wrapper is tried first, falling back to the flat map.
76
+
77
+ Args:
78
+ raw: Raw `.mcp.json` text, not a path.
79
+
80
+ Returns:
81
+ The server map, or `{}` when neither shape parses.
82
+ """
83
+ try:
84
+ wrapped = _WRAPPED.validate_json(raw)
85
+ except ValidationError:
86
+ return {}
87
+ if wrapped.mcpServers:
88
+ return wrapped.mcpServers
89
+ try:
90
+ return _SERVER_MAP.validate_json(raw)
91
+ except ValidationError:
92
+ return {}
93
+
94
+
95
+ def scalar_str(value: JsonValue) -> str | None:
96
+ """Render a JSON scalar as a `${...}` substitution string.
97
+
98
+ Args:
99
+ value: A user_config value of any JSON type.
100
+
101
+ Returns:
102
+ The string form (`bool` as `"true"`/`"false"`), or None for lists/objects/null,
103
+ which have no scalar substitution.
104
+ """
105
+ if isinstance(value, bool):
106
+ return "true" if value else "false"
107
+ if isinstance(value, (str, int, float)):
108
+ return str(value)
109
+ return None
110
+
111
+
112
+ def user_config_subs(options: dict[str, JsonValue]) -> dict[str, str]:
113
+ """Build the `${user_config.KEY}` substitution map from a plugin's non-sensitive options.
114
+
115
+ Sensitive options live in the keychain, never in settings.json, so they are absent here by
116
+ design — the credential store is never read, and any sensitive placeholder is left unresolved.
117
+
118
+ Args:
119
+ options: A plugin's non-sensitive `options` mapping.
120
+
121
+ Returns:
122
+ `{"user_config.<key>": <scalar>}` for each scalar option; non-scalar options are skipped.
123
+ """
124
+ subs: dict[str, str] = {}
125
+ for key, value in options.items():
126
+ rendered = scalar_str(value)
127
+ if rendered is not None:
128
+ subs[f"user_config.{key}"] = rendered
129
+ return subs
130
+
131
+
132
+ def read_plugin_configs(path: Path) -> dict[str, _PluginOptions]:
133
+ """Read the `pluginConfigs` map (non-sensitive plugin options) from a settings.json.
134
+
135
+ Args:
136
+ path: Path to a settings.json file.
137
+
138
+ Returns:
139
+ The `pluginConfigs` map, or `{}` if the file is missing or unparseable.
140
+ """
141
+ try:
142
+ return _SETTINGS.validate_json(path.read_text(encoding="utf-8")).pluginConfigs
143
+ except (OSError, ValidationError):
144
+ return {}
145
+
146
+
147
+ def resolve_placeholders(value: JsonValue, subs: dict[str, str]) -> JsonValue:
148
+ """Substitute `${VAR}` placeholders through a JSON value, recursing into lists and objects.
149
+
150
+ Args:
151
+ value: Any JSON value; strings have placeholders replaced, containers are recursed.
152
+ subs: `{var_name: replacement}` map of known placeholders.
153
+
154
+ Returns:
155
+ The value with known placeholders replaced. Unknown placeholders are left as-is — the
156
+ server then simply fails to connect and is skipped downstream.
157
+ """
158
+ if isinstance(value, str):
159
+ for var, replacement in subs.items():
160
+ value = value.replace("${" + var + "}", replacement)
161
+ return value
162
+ if isinstance(value, list):
163
+ return [resolve_placeholders(item, subs) for item in value]
164
+ if isinstance(value, dict):
165
+ return {key: resolve_placeholders(item, subs) for key, item in value.items()}
166
+ return value
167
+
168
+
169
+ def manifest_server_configs(install_path: Path) -> ServerMap:
170
+ """Extract a plugin manifest's declared MCP servers.
171
+
172
+ Args:
173
+ install_path: The plugin's install directory (holds `.claude-plugin/plugin.json`).
174
+
175
+ Returns:
176
+ The `{server_name: config}` map from the manifest's `mcpServers` — read inline, or from
177
+ the `.mcp.json` file(s) it points at — or `{}` when absent, unparseable, or a declared
178
+ path resolves outside the plugin's own install directory (rejected, not followed).
179
+ """
180
+ try:
181
+ raw = (install_path / _PLUGIN_MANIFEST).read_text(encoding="utf-8")
182
+ except OSError:
183
+ return {}
184
+ try:
185
+ declared = _MANIFEST_MCP.validate_json(raw).mcpServers
186
+ except ValidationError:
187
+ return {}
188
+ match declared:
189
+ case dict():
190
+ return declared
191
+ case str():
192
+ paths = [declared]
193
+ case list():
194
+ paths = declared
195
+ base = install_path.resolve()
196
+ servers: ServerMap = {}
197
+ for rel in paths:
198
+ target = install_path / rel
199
+ if not target.resolve().is_relative_to(base):
200
+ continue
201
+ try:
202
+ servers.update(server_configs(target.read_text(encoding="utf-8")))
203
+ except OSError:
204
+ continue
205
+ return servers
206
+
207
+
208
+ def _plugin_server_configs(plugins_root: Path, project_dir: Path) -> list[tuple[str, ServerConfig]]:
209
+ """Collect every plugin-provided MCP server, resolved and ready to spawn.
210
+
211
+ Each server's `${CLAUDE_PLUGIN_ROOT}`, `${CLAUDE_PROJECT_DIR}`, and the plugin's non-sensitive
212
+ `${user_config.*}` options are substituted so the client can spawn it.
213
+
214
+ Args:
215
+ plugins_root: Root of installed plugins (holds `installed_plugins.json`).
216
+ project_dir: Project root; resolves `${CLAUDE_PROJECT_DIR}` for the same project the
217
+ scope-configured servers use, not necessarily the process cwd.
218
+
219
+ Returns:
220
+ `(full_ref, resolved_config)` pairs, where `full_ref` is `plugin:<plugin>:<server>` to
221
+ match `claude mcp list` naming.
222
+ """
223
+ plugin_configs = read_plugin_configs(get_settings().user_settings)
224
+ out: list[tuple[str, ServerConfig]] = []
225
+ for key, install_path in installed_plugins(plugins_root).items():
226
+ try:
227
+ raw = (install_path / _MCP_CONFIG).read_text(encoding="utf-8")
228
+ except OSError:
229
+ raw = ""
230
+ # a plugin declares servers in `.mcp.json`, its manifest's `mcpServers`, or both;
231
+ # `.mcp.json` wins on a duplicate server name
232
+ merged = manifest_server_configs(install_path) | server_configs(raw)
233
+ if not merged:
234
+ continue
235
+ subs = {"CLAUDE_PLUGIN_ROOT": str(install_path), "CLAUDE_PROJECT_DIR": str(project_dir)}
236
+ configured = plugin_configs.get(key)
237
+ if configured is not None:
238
+ subs.update(user_config_subs(configured.options))
239
+ plugin = key.split("@", 1)[0]
240
+ # resolve every field of every server through the shared helper, then key each by its
241
+ # `plugin:<plugin>:<server>` ref (matching `claude mcp list` naming)
242
+ resolved = _resolve_configs(merged, subs)
243
+ out.extend((f"plugin:{plugin}:{server}", config) for server, config in resolved.items())
244
+ return out
245
+
246
+
247
+ def _resolve_configs(configs: ServerMap, subs: dict[str, str]) -> ServerMap:
248
+ """Apply `${VAR}` substitution to every field of every server config in a map.
249
+
250
+ Args:
251
+ configs: `{server_name: config}` map to resolve.
252
+ subs: `{var_name: replacement}` map applied to each config field.
253
+
254
+ Returns:
255
+ A new map with the same keys and every field resolved.
256
+ """
257
+ return {
258
+ name: {field: resolve_placeholders(item, subs) for field, item in config.items()}
259
+ for name, config in configs.items()
260
+ }
261
+
262
+
263
+ def claude_json_scopes(claude_json: Path, project_dir: Path) -> tuple[ServerMap, ServerMap]:
264
+ """Read the user-global and project-private MCP server maps from `~/.claude.json`.
265
+
266
+ Args:
267
+ claude_json: Path to the `~/.claude.json` file.
268
+ project_dir: Project whose private servers to find; the private map is
269
+ `projects[<path>].mcpServers` for the entry whose path resolves to this dir.
270
+
271
+ Returns:
272
+ A `(user_global, project_private)` pair. Either is `{}` if absent; both are `{}` if the
273
+ file is missing or unparseable.
274
+ """
275
+ try:
276
+ parsed = _CLAUDE_JSON.validate_json(claude_json.read_text(encoding="utf-8"))
277
+ except (OSError, ValidationError):
278
+ return {}, {}
279
+ target = project_dir.resolve()
280
+ private = next(
281
+ (s.mcpServers for path, s in parsed.projects.items() if Path(path).resolve() == target),
282
+ dict[str, ServerConfig](),
283
+ )
284
+ return parsed.mcpServers, private
285
+
286
+
287
+ def scope_server_configs(project_dir: Path, claude_json: Path) -> ServerMap:
288
+ """Merge the trusted non-plugin MCP servers across scopes into one resolved map.
289
+
290
+ The project's committed `.mcp.json` is deliberately excluded: connecting to a server spawns
291
+ its command, and a repo-committed `.mcp.json` is untrusted input — auto-harvesting it would let
292
+ a cloned repo run arbitrary code. Only the user's own servers in `~/.claude.json` (global and
293
+ project-private) are harvested.
294
+
295
+ Args:
296
+ project_dir: Project root; selects the project-private servers and resolves
297
+ `${CLAUDE_PROJECT_DIR}`.
298
+ claude_json: Path to `~/.claude.json` for user-global and project-private servers.
299
+
300
+ Returns:
301
+ `{name: resolved_config}` merged across scopes. On a name clash, project-private (local)
302
+ wins over user-global.
303
+ """
304
+ user, private = claude_json_scopes(claude_json, project_dir)
305
+ merged = {**user, **private}
306
+ return _resolve_configs(merged, {"CLAUDE_PROJECT_DIR": str(project_dir)})
307
+
308
+
309
+ def all_server_configs(
310
+ plugins_root: Path, project_dir: Path, claude_json: Path
311
+ ) -> list[tuple[str, ServerConfig]]:
312
+ """Collect every MCP server to harvest, scope-configured and plugin-provided.
313
+
314
+ Args:
315
+ plugins_root: Root of installed plugins, for plugin-provided servers.
316
+ project_dir: Project root, for project and local scope servers.
317
+ claude_json: Path to `~/.claude.json`, for user and project-private servers.
318
+
319
+ Returns:
320
+ `(ref, resolved_config)` pairs: scope-configured servers (user, project, local) by plain
321
+ name, plus plugin-provided servers by their `plugin:<p>:<s>` ref.
322
+ """
323
+ configs = list(scope_server_configs(project_dir, claude_json).items())
324
+ configs += _plugin_server_configs(plugins_root, project_dir)
325
+ return configs
@@ -0,0 +1,187 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import logging
5
+ from typing import Any
6
+
7
+ from fastmcp import Client
8
+ from fastmcp.client.auth import OAuth
9
+ from pydantic import TypeAdapter, ValidationError
10
+
11
+ from ..catalog import (
12
+ MCP_TOOL_DESCRIPTION_MAX,
13
+ CatalogMcpServer,
14
+ McpInputSchema,
15
+ McpServerRef,
16
+ McpTool,
17
+ McpToolName,
18
+ catalog_id,
19
+ )
20
+ from .auth import server_auth
21
+ from .config import scalar_str
22
+ from .types import ServerConfig
23
+
24
+ logger = logging.getLogger(__name__)
25
+
26
+ _CONNECT_TIMEOUT = 30.0
27
+ _OAUTH_TIMEOUT = 300.0 # interactive: covers the browser round-trip on first authorization
28
+
29
+ _MAX_CONCURRENT_HARVESTS = 8 # bounds simultaneous connects/spawns; also caps concurrent oauth
30
+
31
+ _SERVER_REF: TypeAdapter[McpServerRef] = TypeAdapter(McpServerRef)
32
+ _TOOL_PARAM_NAME: TypeAdapter[McpToolName] = TypeAdapter(McpToolName)
33
+
34
+
35
+ def _is_valid_param_name(name: str) -> bool:
36
+ """Whether `name` validates as an `McpToolName` — the same constraint a tool's own name
37
+ must satisfy, reused here so the two can never drift apart."""
38
+ try:
39
+ _TOOL_PARAM_NAME.validate_python(name)
40
+ except ValidationError:
41
+ return False
42
+ return True
43
+
44
+
45
+ def to_mcp_tool(
46
+ name: str, description: str | None, input_schema: McpInputSchema | None
47
+ ) -> McpTool | None:
48
+ """Convert one harvested tool into an `McpTool`.
49
+
50
+ Args:
51
+ name: The tool's name; the whole tool is dropped if it will not validate as one.
52
+ description: The tool's description; None becomes empty, then truncated to
53
+ `MCP_TOOL_DESCRIPTION_MAX`.
54
+ input_schema: The tool's JSON input schema; its `properties` keys become param names.
55
+ Params whose names don't validate are dropped individually (one odd param shouldn't
56
+ lose the whole tool).
57
+
58
+ Returns:
59
+ The `McpTool`, or None if the name will not validate.
60
+ """
61
+ props = (input_schema or {}).get("properties", {})
62
+ names = list(props) if isinstance(props, dict) else []
63
+ params = [p for p in names if _is_valid_param_name(p)]
64
+ try:
65
+ return McpTool(
66
+ name=name,
67
+ description=(description or "")[:MCP_TOOL_DESCRIPTION_MAX],
68
+ params=params,
69
+ input_schema=input_schema,
70
+ )
71
+ except ValidationError:
72
+ return None
73
+
74
+
75
+ async def harvest_one(
76
+ name: str, config: ServerConfig, auth: str | OAuth | None = None
77
+ ) -> list[McpTool]:
78
+ """Connect to one server and list its tools.
79
+
80
+ Args:
81
+ name: The server name, used as the single key of the spawned client config.
82
+ config: The server's spawn config (command/args/env or url), passed to the MCP client.
83
+ auth: Bearer token or OAuth handler for an HTTP server; None connects as configured.
84
+
85
+ Returns:
86
+ The server's tools as `McpTool`s, dropping any that fail to validate.
87
+
88
+ Raises:
89
+ Exception: Any connect or list-tools failure propagates; the caller skips the server.
90
+ """
91
+ client: Client[Any] # Any: the two branches infer different transport generics, same API
92
+ url = scalar_str(config.get("url")) if auth is not None else None
93
+ if auth is not None and url is not None:
94
+ client = Client(url, auth=auth)
95
+ else:
96
+ client = Client({"mcpServers": {name: config}})
97
+ async with client:
98
+ raw_tools = await client.list_tools()
99
+ harvested = (to_mcp_tool(t.name, t.description, t.inputSchema) for t in raw_tools)
100
+ return [tool for tool in harvested if tool is not None]
101
+
102
+
103
+ def _dedupe_first_wins(
104
+ configs: list[tuple[str, ServerConfig]],
105
+ ) -> list[tuple[McpServerRef, str, ServerConfig]]:
106
+ """Resolve each entry's ref and drop repeats, keeping the first occurrence of each ref.
107
+
108
+ Done up front, before any concurrent connect, so which config wins a collision depends only
109
+ on input order — never on which connection happens to finish first.
110
+
111
+ Args:
112
+ configs: `(name, config)` pairs; a name that fails `McpServerRef` validation is dropped.
113
+
114
+ Returns:
115
+ `(ref, name, config)` triples, one per distinct ref, first occurrence kept.
116
+ """
117
+ seen: set[McpServerRef] = set()
118
+ out: list[tuple[McpServerRef, str, ServerConfig]] = []
119
+ for name, config in configs:
120
+ try:
121
+ ref = _SERVER_REF.validate_python(name)
122
+ except ValidationError:
123
+ continue
124
+ if ref in seen:
125
+ continue
126
+ seen.add(ref)
127
+ out.append((ref, name, config))
128
+ return out
129
+
130
+
131
+ async def _harvest_card(
132
+ ref: McpServerRef,
133
+ name: str,
134
+ config: ServerConfig,
135
+ oauth: bool,
136
+ limit: asyncio.Semaphore,
137
+ ) -> CatalogMcpServer | None:
138
+ """Connect to one server, bounded by `limit`, and build its card — or None on any failure.
139
+
140
+ Args:
141
+ ref: The server's validated, deduplicated ref.
142
+ name: The server's raw name/ref as configured, used to spawn the client.
143
+ config: The server's spawn config (command/args/env or url).
144
+ oauth: Allow the interactive OAuth flow for HTTP servers with a pre-registered client.
145
+ limit: Bounds how many servers connect at once (process spawns, network connects,
146
+ and any interactive OAuth browser flow all count against it).
147
+
148
+ Returns:
149
+ The harvested card, or None if the server failed to connect or list tools within its
150
+ timeout. The failure reason is logged by type only — never the exception's message,
151
+ which for an authenticated server could otherwise leak a token or credential-bearing URL.
152
+ """
153
+ async with limit:
154
+ try:
155
+ auth = server_auth(name, config, oauth)
156
+ timeout = _OAUTH_TIMEOUT if isinstance(auth, OAuth) else _CONNECT_TIMEOUT
157
+ tools = await asyncio.wait_for(harvest_one(name, config, auth), timeout=timeout)
158
+ except Exception as exc:
159
+ logger.warning("skipped MCP server %s: %s", name, type(exc).__name__)
160
+ return None
161
+ return CatalogMcpServer(
162
+ id=catalog_id("mcp", ref), ref=ref, description=f"MCP server: {name}", tools=tools
163
+ )
164
+
165
+
166
+ async def harvest_servers(
167
+ configs: list[tuple[str, ServerConfig]], oauth: bool = False
168
+ ) -> list[CatalogMcpServer]:
169
+ """Connect to every server concurrently (bounded) and build its harvested card.
170
+
171
+ Args:
172
+ configs: `(ref, config)` pairs to connect to; refs that don't validate or repeat are
173
+ skipped, so the first occurrence of each server wins.
174
+ oauth: Allow the interactive OAuth flow for HTTP servers with a pre-registered client.
175
+
176
+ Returns:
177
+ One `CatalogMcpServer` per reachable, distinct server, in input order. Any server that
178
+ fails to connect or list tools within its timeout is skipped. Servers connect up to
179
+ `_MAX_CONCURRENT_HARVESTS` at a time, so N servers no longer cost N times a single
180
+ server's timeout.
181
+ """
182
+ limit = asyncio.Semaphore(_MAX_CONCURRENT_HARVESTS)
183
+ unique = _dedupe_first_wins(configs)
184
+ cards = await asyncio.gather(
185
+ *(_harvest_card(ref, name, config, oauth, limit) for ref, name, config in unique)
186
+ )
187
+ return [card for card in cards if card is not None]
@@ -0,0 +1,29 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Annotated
4
+
5
+ from pydantic import Field, JsonValue
6
+
7
+ ServerConfig = Annotated[
8
+ dict[str, JsonValue],
9
+ Field(
10
+ title="MCP server config",
11
+ description=(
12
+ "One server's spawn config (command/args/env, or url), passed straight to the MCP "
13
+ "client — an opaque mapping; its shape is the client's concern, not ours."
14
+ ),
15
+ examples=[{"command": "npx", "args": ["-y", "@modelcontextprotocol/server-github"]}],
16
+ ),
17
+ ]
18
+ ServerMap = Annotated[
19
+ dict[str, ServerConfig],
20
+ Field(
21
+ title="MCP server map",
22
+ description=(
23
+ "`{server_name: config}`, as found in a `.mcp.json` or a manifest's `mcpServers`."
24
+ ),
25
+ examples=[
26
+ {"github": {"command": "npx", "args": ["-y", "@modelcontextprotocol/server-github"]}}
27
+ ],
28
+ ),
29
+ ]