ama-cli 0.1.0__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.
ama_cli/cli.py ADDED
@@ -0,0 +1,433 @@
1
+ """Typer app for the public Ama CLI.
2
+
3
+ Thin by design: this module wires commands to the terminal and nothing else.
4
+ Detection lives in `ama_cli.agents`, registration in `ama_cli.agents.adapters`.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import difflib
10
+ import os
11
+ import webbrowser
12
+ from datetime import datetime
13
+
14
+ import typer
15
+ from rich.console import Console
16
+ from rich.table import Table
17
+
18
+ from ama_cli import auth, credentials
19
+ from ama_cli import onboard as onboard_flow
20
+ from ama_cli.agents import Confidence, Detection, Env, detect_all
21
+ from ama_cli.credentials import DEFAULT_HOST, HOST_ENV_VAR
22
+ from ama_cli.agents.adapters import ADAPTERS_BY_ID, get_adapter
23
+ from ama_cli.agents.mcp import (
24
+ DEFAULT_SERVER_NAME,
25
+ Plan,
26
+ RemoteServer,
27
+ RunCommand,
28
+ Scope,
29
+ apply_plan,
30
+ )
31
+
32
+ app = typer.Typer(
33
+ name="ama",
34
+ help="Connect your coding agent to Ama.",
35
+ no_args_is_help=True,
36
+ add_completion=False,
37
+ )
38
+ mcp_app = typer.Typer(help="Manage MCP registration with your coding agents.", no_args_is_help=True)
39
+ app.add_typer(mcp_app, name="mcp")
40
+ console = Console()
41
+
42
+ _CONFIDENCE_LABEL = {
43
+ Confidence.MACHINE: "machine",
44
+ Confidence.BINARY: "binary",
45
+ Confidence.REPO: "repo",
46
+ }
47
+
48
+
49
+ def _version() -> str:
50
+ """This package's installed version.
51
+
52
+ Read from installed metadata rather than duplicated as a constant, so it
53
+ cannot drift from what pyproject.toml declares and PyPI publishes.
54
+ """
55
+ from importlib.metadata import PackageNotFoundError, version
56
+
57
+ try:
58
+ return version("ama-cli")
59
+ except PackageNotFoundError: # running from a source tree, not installed
60
+ return "unknown"
61
+
62
+
63
+ def _version_callback(value: bool) -> None:
64
+ if value:
65
+ console.print(f"ama {_version()}")
66
+ raise typer.Exit()
67
+
68
+
69
+ @app.callback()
70
+ def main(
71
+ version: bool = typer.Option(
72
+ None,
73
+ "--version",
74
+ "-V",
75
+ callback=_version_callback,
76
+ is_eager=True,
77
+ help="Show the version and exit.",
78
+ ),
79
+ ) -> None:
80
+ """Ama — connect your coding agent to Ama.
81
+
82
+ Present so Typer keeps subcommand dispatch. With a single registered command
83
+ and no callback, Typer collapses the app into that command.
84
+ """
85
+
86
+
87
+ def _detected(env: Env) -> list[Detection]:
88
+ return [d for d in detect_all(env) if d.detected]
89
+
90
+
91
+ def _resolve_agents(env: Env, agent: str | None) -> list[Detection]:
92
+ """Pick the agents to act on: an explicit --agent list, or whatever was detected."""
93
+ if agent:
94
+ wanted = [a.strip() for a in agent.split(",") if a.strip()]
95
+ unknown = [a for a in wanted if a not in ADAPTERS_BY_ID]
96
+ if unknown:
97
+ console.print(f"[red]Unknown agent(s):[/red] {', '.join(unknown)}")
98
+ console.print(f"Known: {', '.join(sorted(ADAPTERS_BY_ID))}")
99
+ raise typer.Exit(2)
100
+ by_id = {d.agent_id: d for d in detect_all(env)}
101
+ return [by_id[a] for a in wanted]
102
+ return [d for d in _detected(env) if get_adapter(d.agent_id)]
103
+
104
+
105
+ def _render_plan(plan: Plan) -> None:
106
+ """Print what a plan would do, secrets masked."""
107
+ for step in plan.steps:
108
+ if isinstance(step, RunCommand):
109
+ console.print(f" [cyan]$[/cyan] {step.display}")
110
+ continue
111
+ if step.is_noop:
112
+ console.print(f" [dim]{step.path} — already configured[/dim]")
113
+ continue
114
+ verb = "create" if step.creates else "update"
115
+ console.print(f" [cyan]{verb}[/cyan] {step.path}")
116
+ diff = difflib.unified_diff(
117
+ (step.display_before or "").splitlines(),
118
+ step.display_after.splitlines(),
119
+ fromfile="before",
120
+ tofile="after",
121
+ lineterm="",
122
+ )
123
+ for line in diff:
124
+ if line.startswith("+") and not line.startswith("+++"):
125
+ console.print(f" [green]{line}[/green]")
126
+ elif line.startswith("-") and not line.startswith("---"):
127
+ console.print(f" [red]{line}[/red]")
128
+ elif line.startswith("@@"):
129
+ console.print(f" [dim]{line}[/dim]")
130
+
131
+
132
+ @app.command()
133
+ def onboard(
134
+ host: str = typer.Option(
135
+ None, "--host", help=f"Ama host. Default: ${HOST_ENV_VAR} or {DEFAULT_HOST}."
136
+ ),
137
+ scope: Scope = typer.Option(Scope.GLOBAL, "--scope", help="Where to register."),
138
+ agent: str = typer.Option(
139
+ None, "--agent", help="Comma-separated agent ids. Default: detected."
140
+ ),
141
+ yes: bool = typer.Option(False, "--yes", "-y", help="Skip the confirmation. For CI."),
142
+ no_browser: bool = typer.Option(
143
+ False, "--no-browser", help="Print the sign-in URL instead of opening it."
144
+ ),
145
+ dry_run: bool = typer.Option(
146
+ False, "--dry-run", help="Show what would change, change nothing."
147
+ ),
148
+ no_start: bool = typer.Option(False, "--no-start", help="Skip the offer to launch your agent."),
149
+ name: str = typer.Option(DEFAULT_SERVER_NAME, "--name", help="Server name to register as."),
150
+ ) -> None:
151
+ """Sign in, connect your coding agent, and start.
152
+
153
+ The one command this package exists for: `curl -fsSL https://ama.dev/install | sh`
154
+ ends here.
155
+ """
156
+ code = onboard_flow.run(
157
+ env=Env.current(),
158
+ console=console,
159
+ host_flag=host,
160
+ scope=scope,
161
+ agent=agent,
162
+ yes=yes,
163
+ no_browser=no_browser,
164
+ dry_run=dry_run,
165
+ no_start=no_start,
166
+ server_name=name,
167
+ render=_render_plan,
168
+ deps=onboard_flow.Deps(
169
+ confirm=typer.confirm,
170
+ # Notebook mode's token is a password, not a key we minted: it must
171
+ # not land in scrollback or in the user's shell history.
172
+ ask_token=lambda: typer.prompt("Local token", hide_input=True),
173
+ ),
174
+ )
175
+ raise typer.Exit(code)
176
+
177
+
178
+ @app.command()
179
+ def login(
180
+ host: str = typer.Option(
181
+ None, "--host", help=f"Ama host. Default: ${HOST_ENV_VAR} or {DEFAULT_HOST}."
182
+ ),
183
+ no_browser: bool = typer.Option(
184
+ False, "--no-browser", help="Print the URL instead of opening it."
185
+ ),
186
+ ) -> None:
187
+ """Sign in and store an API key for this machine."""
188
+ env = Env.current()
189
+ target = host or os.environ.get(HOST_ENV_VAR) or DEFAULT_HOST
190
+
191
+ def show_url(url: str) -> None:
192
+ if no_browser:
193
+ console.print("Open this URL to sign in:\n")
194
+ console.print(f" [cyan]{url}[/cyan]\n")
195
+ else:
196
+ console.print(f"Opening [cyan]{target}[/cyan] to sign in…")
197
+
198
+ try:
199
+ creds = auth.login(
200
+ target,
201
+ # --no-browser still runs the loopback listener: the user may be on
202
+ # a machine with a browser but no default handler. Only the auto-open
203
+ # is suppressed.
204
+ open_browser=(lambda _url: False) if no_browser else webbrowser.open,
205
+ on_url=show_url,
206
+ )
207
+ except auth.LoginError as exc:
208
+ console.print(f"[red]Login failed:[/red] {exc}")
209
+ raise typer.Exit(1) from exc
210
+
211
+ path = credentials.save(creds, env.home)
212
+ console.print(f"\n[green]✓[/green] Signed in as [bold]{creds.email or 'unknown'}[/bold]")
213
+ console.print(f" Key {creds.masked_key} stored in {path}")
214
+ console.print("\nNext: [bold]ama mcp add[/bold] to connect your coding agent.")
215
+
216
+
217
+ @app.command()
218
+ def logout() -> None:
219
+ """Delete the stored API key from this machine."""
220
+ env = Env.current()
221
+ if credentials.delete(env.home):
222
+ console.print("[green]✓[/green] Signed out.")
223
+ console.print(
224
+ "[dim]The key still exists server-side — revoke it in settings if it was shared.[/dim]"
225
+ )
226
+ else:
227
+ console.print("[yellow]Not signed in.[/yellow]")
228
+
229
+
230
+ @app.command()
231
+ def whoami() -> None:
232
+ """Show who this machine is signed in as."""
233
+ env = Env.current()
234
+ creds = credentials.load(env.home)
235
+ if creds is None:
236
+ console.print("[yellow]Not signed in.[/yellow] Run [bold]ama login[/bold].")
237
+ raise typer.Exit(1)
238
+
239
+ table = Table(show_header=False, box=None)
240
+ table.add_row("Email", creds.email or "[dim]unknown[/dim]")
241
+ table.add_row("Host", creds.host)
242
+ table.add_row("Key", creds.masked_key)
243
+ table.add_row("MCP", creds.mcp_url)
244
+ console.print(table)
245
+
246
+
247
+ @app.command()
248
+ def detect(
249
+ all_agents: bool = typer.Option(False, "--all", help="Include agents that were not detected."),
250
+ ) -> None:
251
+ """Show which coding agents were detected on this machine."""
252
+ env = Env.current()
253
+ detections = detect_all(env)
254
+ shown = detections if all_agents else [d for d in detections if d.detected]
255
+
256
+ if not shown:
257
+ console.print("[yellow]No coding agents detected.[/yellow]")
258
+ console.print("Run with [bold]--all[/bold] to see everything that was checked.")
259
+ raise typer.Exit(0)
260
+
261
+ table = Table(title="Detected coding agents", title_justify="left")
262
+ table.add_column("", width=1)
263
+ table.add_column("Agent")
264
+ table.add_column("Confidence")
265
+ table.add_column("Evidence", overflow="fold")
266
+
267
+ for d in shown:
268
+ marker = "[green]●[/green]" if d.detected else "[dim]○[/dim]"
269
+ confidence = _CONFIDENCE_LABEL.get(d.confidence, "") if d.confidence else ""
270
+ reason = d.reason or "[dim]not detected[/dim]"
271
+ if d.detected and not get_adapter(d.agent_id):
272
+ confidence += " [dim](no adapter yet)[/dim]"
273
+ table.add_row(marker, d.display_name, confidence, reason)
274
+
275
+ console.print(table)
276
+
277
+
278
+ @mcp_app.command("add")
279
+ def mcp_add(
280
+ agent: str = typer.Option(
281
+ None, "--agent", help="Comma-separated agent ids. Default: detected."
282
+ ),
283
+ scope: Scope = typer.Option(Scope.GLOBAL, "--scope", help="Where to register."),
284
+ token: str = typer.Option(None, "--token", help="API key. Default: stored or $AMA_API_KEY."),
285
+ url: str = typer.Option(None, "--url", help="MCP URL. Default: stored or $AMA_MCP_URL."),
286
+ name: str = typer.Option(DEFAULT_SERVER_NAME, "--name", help="Server name to register as."),
287
+ dry_run: bool = typer.Option(
288
+ False, "--dry-run", help="Show what would change, change nothing."
289
+ ),
290
+ ) -> None:
291
+ """Register the Ama MCP server with your coding agents."""
292
+ env = Env.current()
293
+ creds = credentials.resolve(env.home, token=token, url=url)
294
+ if creds is None:
295
+ console.print(
296
+ "[red]No credentials.[/red] Pass --token, set $AMA_API_KEY, or run `ama login`."
297
+ )
298
+ raise typer.Exit(2)
299
+
300
+ targets = _resolve_agents(env, agent)
301
+ if not targets:
302
+ console.print("[yellow]No supported coding agents detected.[/yellow]")
303
+ console.print(f"Register manually: {creds.mcp_url}")
304
+ raise typer.Exit(0)
305
+
306
+ server = RemoteServer(url=creds.mcp_url, token=creds.api_key, name=name)
307
+ failures = 0
308
+
309
+ for detection in targets:
310
+ adapter = get_adapter(detection.agent_id)
311
+ if adapter is None:
312
+ console.print(f"[dim]{detection.display_name}: no adapter yet, skipping[/dim]")
313
+ continue
314
+
315
+ try:
316
+ plan = adapter.plan_add(server, scope, env)
317
+ except ValueError as exc: # unreadable config -- refuse rather than clobber
318
+ console.print(f"[red]{detection.display_name}:[/red] {exc}")
319
+ failures += 1
320
+ continue
321
+
322
+ console.print(f"\n[bold]{detection.display_name}[/bold]")
323
+ if plan.unsupported:
324
+ console.print(f" [yellow]skipped:[/yellow] {plan.unsupported}")
325
+ continue
326
+
327
+ _render_plan(plan)
328
+ if dry_run:
329
+ continue
330
+
331
+ result = apply_plan(plan, now=datetime.now())
332
+ if result.ok:
333
+ console.print(" [green]✓ registered[/green]")
334
+ for bak in result.backups:
335
+ console.print(f" [dim]backup: {bak}[/dim]")
336
+ else:
337
+ console.print(f" [red]✗ {result.detail}[/red]")
338
+ failures += 1
339
+
340
+ if dry_run:
341
+ console.print("\n[dim]--dry-run: nothing was changed.[/dim]")
342
+ elif not failures:
343
+ console.print("\n[dim]Undo anytime: ama mcp remove[/dim]")
344
+
345
+ if failures:
346
+ raise typer.Exit(1)
347
+
348
+
349
+ @mcp_app.command("list")
350
+ def mcp_list(
351
+ scope: Scope = typer.Option(Scope.GLOBAL, "--scope", help="Which scope to inspect."),
352
+ ) -> None:
353
+ """Show which agents have MCP servers registered, and which."""
354
+ env = Env.current()
355
+ rows: list[tuple[str, str]] = []
356
+
357
+ for detection in _detected(env):
358
+ adapter = get_adapter(detection.agent_id)
359
+ if adapter is None:
360
+ rows.append((detection.display_name, "[dim]no adapter yet[/dim]"))
361
+ continue
362
+ servers = adapter.list_servers(scope, env)
363
+ rows.append((detection.display_name, ", ".join(servers) if servers else "[dim]none[/dim]"))
364
+
365
+ if not rows:
366
+ console.print("[yellow]No coding agents detected.[/yellow]")
367
+ raise typer.Exit(0)
368
+
369
+ table = Table(title=f"MCP servers ({scope.value} scope)", title_justify="left")
370
+ table.add_column("Agent")
371
+ table.add_column("Servers", overflow="fold")
372
+ for agent_name, servers in rows:
373
+ table.add_row(agent_name, servers)
374
+ console.print(table)
375
+
376
+
377
+ @mcp_app.command("remove")
378
+ def mcp_remove(
379
+ agent: str = typer.Option(
380
+ None, "--agent", help="Comma-separated agent ids. Default: detected."
381
+ ),
382
+ scope: Scope = typer.Option(Scope.GLOBAL, "--scope", help="Where to remove from."),
383
+ name: str = typer.Option(DEFAULT_SERVER_NAME, "--name", help="Server name to remove."),
384
+ dry_run: bool = typer.Option(
385
+ False, "--dry-run", help="Show what would change, change nothing."
386
+ ),
387
+ ) -> None:
388
+ """Remove the Ama MCP server from your coding agents."""
389
+ env = Env.current()
390
+ targets = _resolve_agents(env, agent)
391
+ if not targets:
392
+ console.print("[yellow]No supported coding agents detected.[/yellow]")
393
+ raise typer.Exit(0)
394
+
395
+ failures = 0
396
+ for detection in targets:
397
+ adapter = get_adapter(detection.agent_id)
398
+ if adapter is None:
399
+ continue
400
+
401
+ try:
402
+ plan = adapter.plan_remove(name, scope, env)
403
+ except ValueError as exc:
404
+ console.print(f"[red]{detection.display_name}:[/red] {exc}")
405
+ failures += 1
406
+ continue
407
+
408
+ console.print(f"\n[bold]{detection.display_name}[/bold]")
409
+ if plan.unsupported:
410
+ console.print(f" [yellow]skipped:[/yellow] {plan.unsupported}")
411
+ continue
412
+ if not plan.steps:
413
+ console.print(" [dim]not registered[/dim]")
414
+ continue
415
+
416
+ _render_plan(plan)
417
+ if dry_run:
418
+ continue
419
+
420
+ result = apply_plan(plan, now=datetime.now())
421
+ console.print(
422
+ " [green]✓ removed[/green]" if result.ok else f" [red]✗ {result.detail}[/red]"
423
+ )
424
+ failures += 0 if result.ok else 1
425
+
426
+ if dry_run:
427
+ console.print("\n[dim]--dry-run: nothing was changed.[/dim]")
428
+ if failures:
429
+ raise typer.Exit(1)
430
+
431
+
432
+ if __name__ == "__main__":
433
+ app()
ama_cli/credentials.py ADDED
@@ -0,0 +1,126 @@
1
+ """Local credential storage for the public CLI.
2
+
3
+ `~/.ama/credentials.json`, mode 0600. Deliberately separate from the internal
4
+ CLI's `~/.ama/session.json` (a WorkOS sealed session, with no API-key field):
5
+ the two CLIs must not share state, which is the coupling the package split
6
+ exists to prevent.
7
+
8
+ Phase 2 (`ama login`) writes this. Until then `ama mcp add` takes --token/--url
9
+ or reads AMA_API_KEY / AMA_MCP_URL from the environment.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import json
15
+ import os
16
+ import stat
17
+ from collections.abc import Mapping
18
+ from dataclasses import asdict, dataclass
19
+ from pathlib import Path
20
+
21
+ CREDENTIALS_ENV_VAR = "AMA_API_KEY"
22
+ MCP_URL_ENV_VAR = "AMA_MCP_URL"
23
+ HOST_ENV_VAR = "AMA_HOST"
24
+ DEFAULT_HOST = "https://ama.dev"
25
+
26
+
27
+ def credentials_path(home: Path) -> Path:
28
+ """Where credentials live."""
29
+ return home / ".ama" / "credentials.json"
30
+
31
+
32
+ @dataclass(frozen=True)
33
+ class Credentials:
34
+ """A stored API key and the endpoint it belongs to."""
35
+
36
+ host: str
37
+ api_key: str
38
+ mcp_url: str
39
+ email: str | None = None
40
+ tenant_id: str | None = None
41
+ created_at: str | None = None
42
+
43
+ @property
44
+ def masked_key(self) -> str:
45
+ """The key, safe to display: prefix only.
46
+
47
+ Note the server's own key_prefix is the first 8 chars of the whole
48
+ string -- i.e. `ama_` plus four -- so there is little to show and
49
+ showing more would defeat the point.
50
+ """
51
+ return f"{self.api_key[:8]}…" if len(self.api_key) > 8 else "…"
52
+
53
+
54
+ def load(home: Path) -> Credentials | None:
55
+ """Read stored credentials, or None when absent or unreadable."""
56
+ path = credentials_path(home)
57
+ if not path.exists():
58
+ return None
59
+ try:
60
+ data = json.loads(path.read_text())
61
+ except (json.JSONDecodeError, OSError):
62
+ return None
63
+ try:
64
+ return Credentials(
65
+ host=data["host"],
66
+ api_key=data["api_key"],
67
+ mcp_url=data["mcp_url"],
68
+ email=data.get("email"),
69
+ tenant_id=data.get("tenant_id"),
70
+ created_at=data.get("created_at"),
71
+ )
72
+ except KeyError:
73
+ return None
74
+
75
+
76
+ def save(creds: Credentials, home: Path) -> Path:
77
+ """Write credentials with 0600 permissions.
78
+
79
+ The file is chmod'd before the secret is written, so it is never briefly
80
+ world-readable on a fresh create.
81
+ """
82
+ path = credentials_path(home)
83
+ path.parent.mkdir(parents=True, exist_ok=True)
84
+ path.touch(mode=0o600, exist_ok=True)
85
+ path.chmod(stat.S_IRUSR | stat.S_IWUSR)
86
+ path.write_text(json.dumps(asdict(creds), indent=2) + "\n")
87
+ return path
88
+
89
+
90
+ def delete(home: Path) -> bool:
91
+ """Remove stored credentials. True when something was deleted."""
92
+ path = credentials_path(home)
93
+ if path.exists():
94
+ path.unlink()
95
+ return True
96
+ return False
97
+
98
+
99
+ def resolve(
100
+ home: Path,
101
+ *,
102
+ token: str | None = None,
103
+ url: str | None = None,
104
+ environ: Mapping[str, str] | None = None,
105
+ ) -> Credentials | None:
106
+ """Resolve credentials from flags, then env, then the stored file.
107
+
108
+ Explicit flags win so a user can always override; the environment comes next
109
+ for CI; the stored file is the everyday path.
110
+ """
111
+ env_map: Mapping[str, str] = os.environ if environ is None else environ
112
+ token = token or env_map.get(CREDENTIALS_ENV_VAR)
113
+ url = url or env_map.get(MCP_URL_ENV_VAR)
114
+
115
+ stored = load(home)
116
+ if token:
117
+ return Credentials(
118
+ host=stored.host if stored else DEFAULT_HOST,
119
+ api_key=token,
120
+ mcp_url=url or (stored.mcp_url if stored else f"{DEFAULT_HOST}/mcp/sse"),
121
+ )
122
+ if stored and url:
123
+ return Credentials(
124
+ host=stored.host, api_key=stored.api_key, mcp_url=url, email=stored.email
125
+ )
126
+ return stored
ama_cli/discovery.py ADDED
@@ -0,0 +1,114 @@
1
+ """Host discovery via `GET /.well-known/mcp.json`.
2
+
3
+ Asking the host what it is beats hardcoding `ama.dev`: self-hosted deployments
4
+ work for free, and the MCP path stays the server's business rather than a
5
+ constant duplicated in the CLI.
6
+
7
+ The manifest also tells us *which kind* of host this is, which changes the flow:
8
+
9
+ full mode auth "oauth_or_bearer" + a signup key -> browser login works
10
+ notebook mode auth "bearer", no signup -> no tenant, no OAuth;
11
+ the operator sets a
12
+ preshared token
13
+
14
+ So `ama onboard` against a local notebook cannot log in via the browser, and
15
+ must ask for the token instead. See `src/ama/api/app.py`'s mcp_discovery_manifest
16
+ for the two shapes.
17
+
18
+ urllib rather than httpx: this package sits on the install path of a one-line
19
+ onboarding command, so it stays at typer + rich.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import json
25
+ import urllib.error
26
+ import urllib.request
27
+ from dataclasses import dataclass
28
+
29
+ DISCOVERY_PATH = "/.well-known/mcp.json"
30
+ _TIMEOUT_SECONDS = 15
31
+
32
+
33
+ @dataclass(frozen=True)
34
+ class Manifest:
35
+ """What the host says about itself."""
36
+
37
+ mcp_url: str
38
+ auth_type: str
39
+ token_prefix: str | None = None
40
+ signup_url: str | None = None
41
+
42
+ @property
43
+ def is_notebook_mode(self) -> bool:
44
+ """True when this is a single-user local deployment.
45
+
46
+ Notebook mode has no tenant to create and no OAuth flow -- it
47
+ string-compares a preshared token -- so the browser login the public CLI
48
+ is built around does not apply. The manifest is deliberately explicit
49
+ about this rather than making us probe for a 404.
50
+ """
51
+ return self.auth_type == "bearer"
52
+
53
+
54
+ def default_mcp_url(host: str) -> str:
55
+ """The MCP endpoint to assume when the manifest cannot be read."""
56
+ return f"{host.rstrip('/')}/mcp/sse"
57
+
58
+
59
+ def parse_manifest(data: dict, host: str) -> Manifest:
60
+ """Build a Manifest from the raw JSON, tolerating missing keys.
61
+
62
+ Every field is optional here on purpose: this parses a document served by a
63
+ host we may not control, possibly an older one. A missing field falls back to
64
+ convention rather than raising -- a manifest we half-understand is still
65
+ better than none.
66
+ """
67
+ endpoint = data.get("endpoint")
68
+ url = endpoint.get("url") if isinstance(endpoint, dict) else None
69
+
70
+ auth = data.get("authentication")
71
+ auth = auth if isinstance(auth, dict) else {}
72
+ bearer = auth.get("bearer")
73
+ bearer = bearer if isinstance(bearer, dict) else {}
74
+
75
+ signup = data.get("signup")
76
+ signup_url = signup.get("url") if isinstance(signup, dict) else None
77
+
78
+ return Manifest(
79
+ mcp_url=url or default_mcp_url(host),
80
+ auth_type=auth.get("type") or "oauth_or_bearer",
81
+ token_prefix=bearer.get("token_prefix"),
82
+ signup_url=signup_url,
83
+ )
84
+
85
+
86
+ def discover(host: str, *, fetch=None) -> Manifest | None:
87
+ """Read the host's manifest, or None when it cannot be read.
88
+
89
+ Returns None rather than raising: discovery is an optimisation, not a
90
+ requirement. A host that is genuinely unreachable will fail again at login
91
+ with a clearer message than anything this could produce, and a self-hosted
92
+ box serving no manifest should still be onboardable by convention.
93
+
94
+ `fetch` is injected so tests need no network.
95
+ """
96
+ fetcher = _fetch if fetch is None else fetch
97
+ url = f"{host.rstrip('/')}{DISCOVERY_PATH}"
98
+ try:
99
+ data = fetcher(url)
100
+ except (OSError, urllib.error.URLError, json.JSONDecodeError, ValueError):
101
+ return None
102
+ if not isinstance(data, dict):
103
+ return None
104
+ return parse_manifest(data, host)
105
+
106
+
107
+ def _fetch(url: str) -> dict:
108
+ """GET a JSON document."""
109
+ req = urllib.request.Request(url, headers={"Accept": "application/json"})
110
+ with urllib.request.urlopen(req, timeout=_TIMEOUT_SECONDS) as resp:
111
+ return json.loads(resp.read())
112
+
113
+
114
+ __all__ = ["DISCOVERY_PATH", "Manifest", "default_mcp_url", "discover", "parse_manifest"]