observal-cli 0.2.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.
- observal_cli/README.md +150 -0
- observal_cli/__init__.py +0 -0
- observal_cli/analyzer.py +565 -0
- observal_cli/branding.py +19 -0
- observal_cli/client.py +264 -0
- observal_cli/cmd_agent.py +783 -0
- observal_cli/cmd_auth.py +823 -0
- observal_cli/cmd_doctor.py +674 -0
- observal_cli/cmd_hook.py +246 -0
- observal_cli/cmd_mcp.py +1044 -0
- observal_cli/cmd_migrate.py +764 -0
- observal_cli/cmd_ops.py +1250 -0
- observal_cli/cmd_profile.py +308 -0
- observal_cli/cmd_prompt.py +200 -0
- observal_cli/cmd_pull.py +324 -0
- observal_cli/cmd_sandbox.py +178 -0
- observal_cli/cmd_scan.py +1056 -0
- observal_cli/cmd_skill.py +202 -0
- observal_cli/cmd_uninstall.py +340 -0
- observal_cli/config.py +160 -0
- observal_cli/constants.py +151 -0
- observal_cli/hooks/__init__.py +0 -0
- observal_cli/hooks/buffer_event.py +97 -0
- observal_cli/hooks/flush_buffer.py +141 -0
- observal_cli/hooks/kiro_hook.py +210 -0
- observal_cli/hooks/kiro_stop_hook.py +220 -0
- observal_cli/hooks/observal-hook.sh +31 -0
- observal_cli/hooks/observal-stop-hook.sh +134 -0
- observal_cli/hooks/payload_crypto.py +78 -0
- observal_cli/hooks_spec.py +154 -0
- observal_cli/main.py +105 -0
- observal_cli/prompts.py +92 -0
- observal_cli/proxy.py +205 -0
- observal_cli/render.py +139 -0
- observal_cli/requirements.txt +3 -0
- observal_cli/sandbox_runner.py +217 -0
- observal_cli/settings_reconciler.py +188 -0
- observal_cli/shim.py +459 -0
- observal_cli/telemetry_buffer.py +163 -0
- observal_cli-0.2.0.dist-info/METADATA +528 -0
- observal_cli-0.2.0.dist-info/RECORD +44 -0
- observal_cli-0.2.0.dist-info/WHEEL +4 -0
- observal_cli-0.2.0.dist-info/entry_points.txt +5 -0
- observal_cli-0.2.0.dist-info/licenses/LICENSE +108 -0
observal_cli/cmd_mcp.py
ADDED
|
@@ -0,0 +1,1044 @@
|
|
|
1
|
+
"""MCP server CLI commands."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import re
|
|
7
|
+
import sys
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
import typer
|
|
11
|
+
from rich import print as rprint
|
|
12
|
+
from rich.table import Table
|
|
13
|
+
|
|
14
|
+
from observal_cli import client, config
|
|
15
|
+
from observal_cli.analyzer import analyze_local
|
|
16
|
+
from observal_cli.constants import VALID_IDES, VALID_MCP_CATEGORIES
|
|
17
|
+
from observal_cli.prompts import fuzzy_select, select_one
|
|
18
|
+
from observal_cli.render import (
|
|
19
|
+
console,
|
|
20
|
+
ide_tags,
|
|
21
|
+
kv_panel,
|
|
22
|
+
output_json,
|
|
23
|
+
relative_time,
|
|
24
|
+
spinner,
|
|
25
|
+
status_badge,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
mcp_app = typer.Typer(help="MCP server registry commands")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
# ── Env var configuration helpers ────────────────────────────
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _parse_env_file(file_path: str) -> list[dict]:
|
|
35
|
+
"""Parse a .env-style file and return env var dicts."""
|
|
36
|
+
path = Path(file_path).expanduser().resolve()
|
|
37
|
+
if not path.exists():
|
|
38
|
+
rprint(f"[red]File not found:[/red] {path}")
|
|
39
|
+
return []
|
|
40
|
+
|
|
41
|
+
env_vars: list[dict] = []
|
|
42
|
+
for line in path.read_text(errors="ignore").splitlines():
|
|
43
|
+
line = line.strip()
|
|
44
|
+
if not line or line.startswith("#"):
|
|
45
|
+
continue
|
|
46
|
+
key = line.split("=", 1)[0].strip()
|
|
47
|
+
if key and key == key.upper():
|
|
48
|
+
env_vars.append({"name": key, "description": "", "required": True})
|
|
49
|
+
return env_vars
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _configure_env_vars_interactive(detected: list[dict]) -> list[dict]:
|
|
53
|
+
"""Interactive env var configuration at submit time.
|
|
54
|
+
|
|
55
|
+
Offers three paths:
|
|
56
|
+
1. Review and edit auto-detected vars
|
|
57
|
+
2. Load from an env file path
|
|
58
|
+
3. Enter manually
|
|
59
|
+
"""
|
|
60
|
+
is_tty = sys.stdin.isatty()
|
|
61
|
+
|
|
62
|
+
if detected:
|
|
63
|
+
rprint(f"\n[bold]Auto-detected {len(detected)} env var(s):[/bold]")
|
|
64
|
+
for ev in detected:
|
|
65
|
+
rprint(f" [cyan]*[/cyan] {ev['name']}")
|
|
66
|
+
|
|
67
|
+
rprint("\n[bold]How would you like to configure environment variables?[/bold]")
|
|
68
|
+
|
|
69
|
+
if is_tty:
|
|
70
|
+
choices = []
|
|
71
|
+
if detected:
|
|
72
|
+
choices.append("Review auto-detected vars")
|
|
73
|
+
choices.extend(["Load from .env file", "Enter manually", "Skip (no env vars)"])
|
|
74
|
+
choice = select_one("Env var configuration", choices)
|
|
75
|
+
else:
|
|
76
|
+
if detected:
|
|
77
|
+
rprint(" 1. Review auto-detected vars")
|
|
78
|
+
rprint(" 2. Load from .env file")
|
|
79
|
+
rprint(" 3. Enter manually")
|
|
80
|
+
rprint(" 4. Skip (no env vars)")
|
|
81
|
+
raw = typer.prompt("Choose", default="1")
|
|
82
|
+
else:
|
|
83
|
+
rprint(" 1. Load from .env file")
|
|
84
|
+
rprint(" 2. Enter manually")
|
|
85
|
+
rprint(" 3. Skip (no env vars)")
|
|
86
|
+
raw = typer.prompt("Choose", default="3")
|
|
87
|
+
choice_map = {
|
|
88
|
+
"1": "Review auto-detected vars" if detected else "Load from .env file",
|
|
89
|
+
"2": "Load from .env file" if detected else "Enter manually",
|
|
90
|
+
"3": "Enter manually" if detected else "Skip (no env vars)",
|
|
91
|
+
"4": "Skip (no env vars)",
|
|
92
|
+
}
|
|
93
|
+
choice = choice_map.get(raw, "Skip (no env vars)")
|
|
94
|
+
|
|
95
|
+
if choice == "Skip (no env vars)":
|
|
96
|
+
return []
|
|
97
|
+
|
|
98
|
+
if choice == "Load from .env file":
|
|
99
|
+
file_path = typer.prompt("Path to .env file (e.g. .env.example)")
|
|
100
|
+
env_vars = _parse_env_file(file_path)
|
|
101
|
+
if not env_vars:
|
|
102
|
+
rprint("[yellow]No variables found in file.[/yellow]")
|
|
103
|
+
return []
|
|
104
|
+
rprint(f"\n[green]Loaded {len(env_vars)} var(s) from file.[/green]")
|
|
105
|
+
return _review_env_vars(env_vars)
|
|
106
|
+
|
|
107
|
+
if choice == "Enter manually":
|
|
108
|
+
return _enter_env_vars_manually()
|
|
109
|
+
|
|
110
|
+
# Review auto-detected
|
|
111
|
+
return _review_env_vars(detected)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _review_env_vars(env_vars: list[dict]) -> list[dict]:
|
|
115
|
+
"""Let the developer review, remove, and annotate each env var."""
|
|
116
|
+
reviewed: list[dict] = []
|
|
117
|
+
|
|
118
|
+
rprint("\n[bold]Review each variable[/bold]\n")
|
|
119
|
+
|
|
120
|
+
for ev in env_vars:
|
|
121
|
+
action = typer.prompt(
|
|
122
|
+
f" {ev['name']} — keep? [Enter=keep / r=remove / o=optional]",
|
|
123
|
+
default="",
|
|
124
|
+
show_default=False,
|
|
125
|
+
)
|
|
126
|
+
action = action.strip().lower()
|
|
127
|
+
|
|
128
|
+
if action == "r":
|
|
129
|
+
rprint(" [dim]removed[/dim]")
|
|
130
|
+
continue
|
|
131
|
+
|
|
132
|
+
required = action != "o"
|
|
133
|
+
desc = ev.get("description", "")
|
|
134
|
+
if not desc:
|
|
135
|
+
desc = typer.prompt(f" Description for {ev['name']} (optional)", default="")
|
|
136
|
+
|
|
137
|
+
reviewed.append({"name": ev["name"], "description": desc, "required": required})
|
|
138
|
+
status = "[green]required[/green]" if required else "[yellow]optional[/yellow]"
|
|
139
|
+
rprint(f" {status}")
|
|
140
|
+
|
|
141
|
+
# Offer to add more
|
|
142
|
+
while True:
|
|
143
|
+
add_more = typer.prompt("\n Add another env var? (name or Enter to finish)", default="")
|
|
144
|
+
if not add_more:
|
|
145
|
+
break
|
|
146
|
+
desc = typer.prompt(f" Description for {add_more} (optional)", default="")
|
|
147
|
+
req = typer.confirm(" Required?", default=True)
|
|
148
|
+
reviewed.append({"name": add_more.strip().upper(), "description": desc, "required": req})
|
|
149
|
+
|
|
150
|
+
return reviewed
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _enter_env_vars_manually() -> list[dict]:
|
|
154
|
+
"""Prompt the developer to enter env vars one by one."""
|
|
155
|
+
env_vars: list[dict] = []
|
|
156
|
+
rprint("\n[bold]Enter env vars one at a time[/bold] [dim](empty name to finish)[/dim]\n")
|
|
157
|
+
|
|
158
|
+
while True:
|
|
159
|
+
name = typer.prompt(" Variable name (or Enter to finish)", default="")
|
|
160
|
+
if not name:
|
|
161
|
+
break
|
|
162
|
+
name = name.strip().upper()
|
|
163
|
+
desc = typer.prompt(f" Description for {name} (optional)", default="")
|
|
164
|
+
req = typer.confirm(" Required?", default=True)
|
|
165
|
+
env_vars.append({"name": name, "description": desc, "required": req})
|
|
166
|
+
|
|
167
|
+
return env_vars
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
# ── Dollar-sign variable detection ──────────────────────────
|
|
171
|
+
|
|
172
|
+
_DOLLAR_VAR_RE = re.compile(r"\$\{?([A-Z][A-Z0-9_]+)\}?")
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _extract_dollar_vars(args: list[str], env: dict[str, str]) -> list[str]:
|
|
176
|
+
"""Extract unique $VAR / ${VAR} references from args and env values.
|
|
177
|
+
|
|
178
|
+
Returns a sorted list of uppercase variable names found in the args list
|
|
179
|
+
and the *values* (not keys) of the env dict, filtered to exclude
|
|
180
|
+
system/infrastructure vars (PATH, HOME, CI_*, etc.).
|
|
181
|
+
"""
|
|
182
|
+
from observal_cli.analyzer import _is_filtered_env_var
|
|
183
|
+
|
|
184
|
+
found: set[str] = set()
|
|
185
|
+
for arg in args:
|
|
186
|
+
found.update(_DOLLAR_VAR_RE.findall(arg))
|
|
187
|
+
for value in env.values():
|
|
188
|
+
if isinstance(value, str):
|
|
189
|
+
found.update(_DOLLAR_VAR_RE.findall(value))
|
|
190
|
+
return sorted(name for name in found if not _is_filtered_env_var(name))
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
# ── Direct config helpers ────────────────────────────────────
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def _unwrap_mcp_config(cfg: dict) -> tuple[dict, str | None]:
|
|
197
|
+
"""Unwrap nested mcpServers / named-server wrappers.
|
|
198
|
+
|
|
199
|
+
Accepts three shapes:
|
|
200
|
+
1. {"mcpServers": {"name": {config}}}
|
|
201
|
+
2. {"name": {config}} (single key whose value has command/url/type)
|
|
202
|
+
3. {config} (bare config with command/args or url)
|
|
203
|
+
|
|
204
|
+
Returns (inner_config, server_name | None).
|
|
205
|
+
"""
|
|
206
|
+
# Shape 1: wrapped under mcpServers
|
|
207
|
+
if "mcpServers" in cfg and isinstance(cfg["mcpServers"], dict):
|
|
208
|
+
servers = cfg["mcpServers"]
|
|
209
|
+
if len(servers) == 1:
|
|
210
|
+
server_name, inner = next(iter(servers.items()))
|
|
211
|
+
if isinstance(inner, dict):
|
|
212
|
+
return inner, server_name
|
|
213
|
+
return cfg, None
|
|
214
|
+
|
|
215
|
+
# Shape 3: bare config — has a direct config key
|
|
216
|
+
if cfg.get("command") or cfg.get("url") or cfg.get("type"):
|
|
217
|
+
return cfg, None
|
|
218
|
+
|
|
219
|
+
# Shape 2: single named key wrapping a config dict
|
|
220
|
+
if len(cfg) == 1:
|
|
221
|
+
server_name, inner = next(iter(cfg.items()))
|
|
222
|
+
if isinstance(inner, dict) and (inner.get("command") or inner.get("url") or inner.get("type")):
|
|
223
|
+
return inner, server_name
|
|
224
|
+
|
|
225
|
+
return cfg, None
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def _parse_direct_config(cfg: dict) -> dict:
|
|
229
|
+
"""Normalize a JSON config dict (mcp.json style) into submit-ready fields.
|
|
230
|
+
|
|
231
|
+
Accepts wrapped (mcpServers) or bare configs.
|
|
232
|
+
Handles two transport shapes:
|
|
233
|
+
- stdio: {command, args, env}
|
|
234
|
+
- SSE/HTTP: {url, type, headers, autoApprove}
|
|
235
|
+
"""
|
|
236
|
+
inner, server_name = _unwrap_mcp_config(cfg)
|
|
237
|
+
parsed: dict = {}
|
|
238
|
+
if server_name:
|
|
239
|
+
parsed["_server_name"] = server_name
|
|
240
|
+
|
|
241
|
+
if inner.get("url") and not inner.get("command"):
|
|
242
|
+
# SSE / streamable-http transport
|
|
243
|
+
transport = inner.get("type", "sse")
|
|
244
|
+
parsed["transport"] = transport
|
|
245
|
+
parsed["url"] = inner["url"]
|
|
246
|
+
|
|
247
|
+
# Convert headers dict {name: value} → list of {name, description, required}
|
|
248
|
+
raw_headers = inner.get("headers") or {}
|
|
249
|
+
if isinstance(raw_headers, dict):
|
|
250
|
+
parsed["headers"] = [{"name": k, "description": "", "required": True} for k in raw_headers]
|
|
251
|
+
elif isinstance(raw_headers, list):
|
|
252
|
+
parsed["headers"] = raw_headers
|
|
253
|
+
|
|
254
|
+
if inner.get("autoApprove"):
|
|
255
|
+
parsed["auto_approve"] = inner["autoApprove"]
|
|
256
|
+
|
|
257
|
+
# env as environment_variables
|
|
258
|
+
raw_env = inner.get("env") or {}
|
|
259
|
+
if isinstance(raw_env, dict):
|
|
260
|
+
parsed["environment_variables"] = [{"name": k, "description": "", "required": True} for k in raw_env]
|
|
261
|
+
|
|
262
|
+
# Detect $VAR references in env values
|
|
263
|
+
dollar_vars = _extract_dollar_vars([], raw_env)
|
|
264
|
+
existing_names = {ev["name"] for ev in parsed.get("environment_variables", [])}
|
|
265
|
+
for var_name in dollar_vars:
|
|
266
|
+
if var_name not in existing_names:
|
|
267
|
+
parsed.setdefault("environment_variables", []).append(
|
|
268
|
+
{"name": var_name, "description": "", "required": True}
|
|
269
|
+
)
|
|
270
|
+
existing_names.add(var_name)
|
|
271
|
+
if dollar_vars:
|
|
272
|
+
parsed["_dollar_vars_detected"] = dollar_vars
|
|
273
|
+
|
|
274
|
+
elif inner.get("command"):
|
|
275
|
+
# stdio transport
|
|
276
|
+
parsed["transport"] = "stdio"
|
|
277
|
+
parsed["command"] = inner["command"]
|
|
278
|
+
parsed["args"] = inner.get("args") or []
|
|
279
|
+
|
|
280
|
+
# Derive framework from command
|
|
281
|
+
cmd = inner["command"]
|
|
282
|
+
if cmd == "docker":
|
|
283
|
+
parsed["framework"] = "docker"
|
|
284
|
+
# Extract docker_image: last non-flag arg
|
|
285
|
+
args = parsed["args"]
|
|
286
|
+
for arg in reversed(args):
|
|
287
|
+
if not arg.startswith("-"):
|
|
288
|
+
parsed["docker_image"] = arg
|
|
289
|
+
break
|
|
290
|
+
elif cmd in ("python", "python3"):
|
|
291
|
+
parsed["framework"] = "python"
|
|
292
|
+
elif cmd in ("npx", "node"):
|
|
293
|
+
parsed["framework"] = "typescript"
|
|
294
|
+
else:
|
|
295
|
+
parsed["framework"] = None
|
|
296
|
+
|
|
297
|
+
# env as environment_variables
|
|
298
|
+
raw_env = inner.get("env") or {}
|
|
299
|
+
if isinstance(raw_env, dict):
|
|
300
|
+
parsed["environment_variables"] = [{"name": k, "description": "", "required": True} for k in raw_env]
|
|
301
|
+
|
|
302
|
+
# Detect $VAR references in args and env values
|
|
303
|
+
dollar_vars = _extract_dollar_vars(parsed["args"], raw_env)
|
|
304
|
+
existing_names = {ev["name"] for ev in parsed.get("environment_variables", [])}
|
|
305
|
+
for var_name in dollar_vars:
|
|
306
|
+
if var_name not in existing_names:
|
|
307
|
+
parsed.setdefault("environment_variables", []).append(
|
|
308
|
+
{"name": var_name, "description": "", "required": True}
|
|
309
|
+
)
|
|
310
|
+
existing_names.add(var_name)
|
|
311
|
+
if dollar_vars:
|
|
312
|
+
parsed["_dollar_vars_detected"] = dollar_vars
|
|
313
|
+
|
|
314
|
+
if inner.get("autoApprove"):
|
|
315
|
+
parsed["auto_approve"] = inner["autoApprove"]
|
|
316
|
+
|
|
317
|
+
return parsed
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def _build_config_preview(server_name: str, parsed: dict) -> dict:
|
|
321
|
+
"""Build a mcp.json-style preview dict for display during submit."""
|
|
322
|
+
preview: dict = {}
|
|
323
|
+
|
|
324
|
+
if parsed.get("url"):
|
|
325
|
+
# SSE / streamable-http preview
|
|
326
|
+
preview["type"] = parsed.get("transport", "sse")
|
|
327
|
+
preview["url"] = parsed["url"]
|
|
328
|
+
if parsed.get("headers"):
|
|
329
|
+
preview["headers"] = {h["name"]: f"<{h['name']}>" for h in parsed["headers"]}
|
|
330
|
+
env_vars = parsed.get("environment_variables") or []
|
|
331
|
+
if env_vars:
|
|
332
|
+
preview["env"] = {ev["name"]: f"<{ev['name']}>" for ev in env_vars}
|
|
333
|
+
if parsed.get("auto_approve"):
|
|
334
|
+
preview["autoApprove"] = parsed["auto_approve"]
|
|
335
|
+
preview["disabled"] = False
|
|
336
|
+
else:
|
|
337
|
+
# stdio preview
|
|
338
|
+
command = parsed.get("command", "")
|
|
339
|
+
args = list(parsed.get("args") or [])
|
|
340
|
+
|
|
341
|
+
# Inject -e flags for docker env vars
|
|
342
|
+
env_vars = parsed.get("environment_variables") or []
|
|
343
|
+
if command == "docker" and env_vars:
|
|
344
|
+
# Find the image position (last non-flag arg) and inject -e before it
|
|
345
|
+
insert_idx = len(args)
|
|
346
|
+
for i in range(len(args) - 1, -1, -1):
|
|
347
|
+
if not args[i].startswith("-"):
|
|
348
|
+
insert_idx = i
|
|
349
|
+
break
|
|
350
|
+
for ev in reversed(env_vars):
|
|
351
|
+
args.insert(insert_idx, f"{ev['name']}=<{ev['name']}>")
|
|
352
|
+
args.insert(insert_idx, "-e")
|
|
353
|
+
|
|
354
|
+
preview["command"] = command
|
|
355
|
+
preview["args"] = args
|
|
356
|
+
if env_vars:
|
|
357
|
+
preview["env"] = {ev["name"]: f"<{ev['name']}>" for ev in env_vars}
|
|
358
|
+
|
|
359
|
+
return {server_name: preview}
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
# ── Implementation functions (shared by canonical + deprecated) ──
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
def _submit_impl(git_url, name, category, yes, direct_config=False, draft=False):
|
|
366
|
+
# ── Path B/C: Direct JSON config (no git URL needed) ─────
|
|
367
|
+
if direct_config:
|
|
368
|
+
rprint("[bold]Paste your MCP server JSON config below.[/bold]")
|
|
369
|
+
rprint("[dim]Press Enter on an empty line when done.[/dim]\n")
|
|
370
|
+
lines: list[str] = []
|
|
371
|
+
has_content = False
|
|
372
|
+
while True:
|
|
373
|
+
try:
|
|
374
|
+
line = input()
|
|
375
|
+
except EOFError:
|
|
376
|
+
break
|
|
377
|
+
if line.strip() == "":
|
|
378
|
+
if has_content:
|
|
379
|
+
break
|
|
380
|
+
else:
|
|
381
|
+
has_content = True
|
|
382
|
+
lines.append(line)
|
|
383
|
+
raw_text = "\n".join(lines).strip()
|
|
384
|
+
if not raw_text:
|
|
385
|
+
rprint("[red]No input received.[/red]")
|
|
386
|
+
raise typer.Exit(1)
|
|
387
|
+
try:
|
|
388
|
+
cfg = json.loads(raw_text)
|
|
389
|
+
except json.JSONDecodeError:
|
|
390
|
+
# Long single-line pastes can get split by the terminal — retry without newlines
|
|
391
|
+
try:
|
|
392
|
+
cfg = json.loads("".join(part.strip() for part in lines))
|
|
393
|
+
except json.JSONDecodeError as e:
|
|
394
|
+
rprint(f"[red]Invalid JSON:[/red] {e}")
|
|
395
|
+
raise typer.Exit(1)
|
|
396
|
+
|
|
397
|
+
parsed = _parse_direct_config(cfg)
|
|
398
|
+
_name = name or parsed.pop("_server_name", None) or "my-mcp-server"
|
|
399
|
+
|
|
400
|
+
# Notify about dollar-sign input variables
|
|
401
|
+
dollar_vars = parsed.pop("_dollar_vars_detected", None)
|
|
402
|
+
if dollar_vars:
|
|
403
|
+
rprint("\n[bold yellow]Input variables detected:[/bold yellow]")
|
|
404
|
+
rprint(
|
|
405
|
+
"[dim]Dollar-sign variables in args/env will become install-time"
|
|
406
|
+
" dependencies — users will be prompted for these values.[/dim]\n"
|
|
407
|
+
)
|
|
408
|
+
for var in dollar_vars:
|
|
409
|
+
rprint(f" [cyan]$[/cyan]{var}")
|
|
410
|
+
rprint()
|
|
411
|
+
|
|
412
|
+
rprint("\n[bold]Config preview:[/bold]")
|
|
413
|
+
console.print_json(json.dumps(_build_config_preview(_name, parsed), indent=2))
|
|
414
|
+
|
|
415
|
+
if not yes:
|
|
416
|
+
if not typer.confirm("\nSubmit this config?", default=True):
|
|
417
|
+
raise typer.Abort()
|
|
418
|
+
|
|
419
|
+
# Let creator review/confirm input dependencies
|
|
420
|
+
if dollar_vars:
|
|
421
|
+
rprint("\n[bold]Confirm input dependencies:[/bold]")
|
|
422
|
+
parsed["environment_variables"] = _review_env_vars(parsed.get("environment_variables", []))
|
|
423
|
+
|
|
424
|
+
_name = name or typer.prompt("Server name", default=_name)
|
|
425
|
+
_desc = typer.prompt("Description (what does this server do?)", default="")
|
|
426
|
+
_owner = typer.prompt("Owner / Team (e.g. your GitHub username)", default="default")
|
|
427
|
+
_category = category or select_one("Category", VALID_MCP_CATEGORIES, default="general")
|
|
428
|
+
else:
|
|
429
|
+
if dollar_vars:
|
|
430
|
+
rprint(f"\n[dim]Auto-detected {len(dollar_vars)} input variable(s) from $VAR patterns.[/dim]")
|
|
431
|
+
_desc = ""
|
|
432
|
+
_owner = "default"
|
|
433
|
+
_category = category or "general"
|
|
434
|
+
|
|
435
|
+
supported_ides = list(VALID_IDES)
|
|
436
|
+
submit_payload: dict = {
|
|
437
|
+
"name": _name,
|
|
438
|
+
"version": "0.1.0",
|
|
439
|
+
"category": _category,
|
|
440
|
+
"description": _desc,
|
|
441
|
+
"owner": _owner,
|
|
442
|
+
"supported_ides": supported_ides,
|
|
443
|
+
"environment_variables": parsed.get("environment_variables", []),
|
|
444
|
+
}
|
|
445
|
+
if parsed.get("command"):
|
|
446
|
+
submit_payload["command"] = parsed["command"]
|
|
447
|
+
if parsed.get("args") is not None:
|
|
448
|
+
submit_payload["args"] = parsed["args"]
|
|
449
|
+
if parsed.get("url"):
|
|
450
|
+
submit_payload["url"] = parsed["url"]
|
|
451
|
+
if parsed.get("headers"):
|
|
452
|
+
submit_payload["headers"] = parsed["headers"]
|
|
453
|
+
if parsed.get("auto_approve"):
|
|
454
|
+
submit_payload["auto_approve"] = parsed["auto_approve"]
|
|
455
|
+
if parsed.get("transport"):
|
|
456
|
+
submit_payload["transport"] = parsed["transport"]
|
|
457
|
+
if parsed.get("framework"):
|
|
458
|
+
submit_payload["framework"] = parsed["framework"]
|
|
459
|
+
if parsed.get("docker_image"):
|
|
460
|
+
submit_payload["docker_image"] = parsed["docker_image"]
|
|
461
|
+
|
|
462
|
+
endpoint = "/api/v1/mcps/draft" if draft else "/api/v1/mcps/submit"
|
|
463
|
+
label = "Saving draft..." if draft else "Submitting..."
|
|
464
|
+
with spinner(label):
|
|
465
|
+
result = client.post(endpoint, submit_payload)
|
|
466
|
+
msg = "Draft saved!" if draft else "Submitted!"
|
|
467
|
+
rprint(f"\n[green]{msg}[/green] ID: [bold]{result['id']}[/bold]")
|
|
468
|
+
rprint(f" Status: {status_badge(result.get('status', 'pending'))}")
|
|
469
|
+
return
|
|
470
|
+
|
|
471
|
+
# ── Path A: Git URL analysis ─────────────────────────────
|
|
472
|
+
analyzed_locally = False
|
|
473
|
+
with spinner("Analyzing repository..."):
|
|
474
|
+
try:
|
|
475
|
+
prefill = analyze_local(git_url)
|
|
476
|
+
if prefill.get("error"):
|
|
477
|
+
rprint(f"[yellow]Local analysis issue:[/yellow] {prefill['error']}")
|
|
478
|
+
rprint("[dim]Falling back to server-side analysis...[/dim]")
|
|
479
|
+
try:
|
|
480
|
+
prefill = client.post("/api/v1/mcps/analyze", {"git_url": git_url})
|
|
481
|
+
except (Exception, SystemExit):
|
|
482
|
+
rprint("[yellow]Server analysis also failed. Fill in details manually.[/yellow]")
|
|
483
|
+
prefill = {}
|
|
484
|
+
else:
|
|
485
|
+
analyzed_locally = True
|
|
486
|
+
except Exception:
|
|
487
|
+
try:
|
|
488
|
+
prefill = client.post("/api/v1/mcps/analyze", {"git_url": git_url})
|
|
489
|
+
except (Exception, SystemExit):
|
|
490
|
+
rprint("[yellow]Could not analyze repo. Fill in details manually.[/yellow]")
|
|
491
|
+
prefill = {}
|
|
492
|
+
|
|
493
|
+
# ── Analysis summary ──────────────────────────────────────
|
|
494
|
+
detected_name = prefill.get("name", "")
|
|
495
|
+
detected_desc = prefill.get("description", "")
|
|
496
|
+
detected_ver = prefill.get("version", "0.1.0")
|
|
497
|
+
detected_framework = prefill.get("framework", "")
|
|
498
|
+
tools = prefill.get("tools", [])
|
|
499
|
+
|
|
500
|
+
detected_env_vars = prefill.get("environment_variables", [])
|
|
501
|
+
issues = prefill.get("issues", [])
|
|
502
|
+
error = prefill.get("error", "")
|
|
503
|
+
|
|
504
|
+
# Extract command/args/docker fields from analysis
|
|
505
|
+
detected_command = prefill.get("command")
|
|
506
|
+
detected_args = prefill.get("args")
|
|
507
|
+
detected_docker_image = prefill.get("docker_image")
|
|
508
|
+
detected_docker_suggested = prefill.get("docker_image_suggested", False)
|
|
509
|
+
|
|
510
|
+
rprint("\n[bold]--- Analysis Results ---[/bold]")
|
|
511
|
+
|
|
512
|
+
if error:
|
|
513
|
+
rprint(f" [bold red]Error:[/bold red] {error}")
|
|
514
|
+
rprint(" [dim]You can still submit manually, but the server could not be analyzed.[/dim]")
|
|
515
|
+
if not yes and not typer.confirm("Continue with manual submission?", default=False):
|
|
516
|
+
raise typer.Abort()
|
|
517
|
+
else:
|
|
518
|
+
if detected_name:
|
|
519
|
+
rprint(f" Server name: [cyan]{detected_name}[/cyan]")
|
|
520
|
+
if detected_desc:
|
|
521
|
+
rprint(f" Description: [dim]{detected_desc[:80]}{'...' if len(detected_desc) > 80 else ''}[/dim]")
|
|
522
|
+
if tools:
|
|
523
|
+
rprint(f" Tools found: [green]{len(tools)}[/green]")
|
|
524
|
+
for t in tools[:10]:
|
|
525
|
+
doc = t.get("docstring", t.get("description", ""))
|
|
526
|
+
rprint(f" [cyan]*[/cyan] {t.get('name', '?')}: {doc[:60] if doc else '[dim](no description)[/dim]'}")
|
|
527
|
+
if len(tools) > 10:
|
|
528
|
+
rprint(f" [dim]...and {len(tools) - 10} more[/dim]")
|
|
529
|
+
if detected_env_vars:
|
|
530
|
+
rprint(f" Env vars: [green]{len(detected_env_vars)}[/green]")
|
|
531
|
+
for ev in detected_env_vars:
|
|
532
|
+
ev_name = ev.get("name", ev) if isinstance(ev, dict) else ev
|
|
533
|
+
rprint(f" [cyan]*[/cyan] {ev_name}")
|
|
534
|
+
if not detected_name and not tools:
|
|
535
|
+
rprint(" [dim]No MCP metadata detected. You will need to fill in all fields manually.[/dim]")
|
|
536
|
+
|
|
537
|
+
if issues:
|
|
538
|
+
rprint(f"\n [bold yellow]Warnings ({len(issues)}):[/bold yellow]")
|
|
539
|
+
for issue in issues:
|
|
540
|
+
rprint(f" [yellow]![/yellow] {issue}")
|
|
541
|
+
rprint()
|
|
542
|
+
if not yes and not typer.confirm("This server has quality issues. Submit anyway?", default=False):
|
|
543
|
+
raise typer.Abort()
|
|
544
|
+
|
|
545
|
+
rprint("[bold]------------------------[/bold]\n")
|
|
546
|
+
|
|
547
|
+
# ── Auto-accept detected fields, only prompt for missing/required ──
|
|
548
|
+
# MCP servers are IDE-agnostic — config generation handles all IDEs.
|
|
549
|
+
supported_ides = list(VALID_IDES)
|
|
550
|
+
|
|
551
|
+
# Build parsed dict from analysis for config preview
|
|
552
|
+
parsed: dict = {}
|
|
553
|
+
if detected_command:
|
|
554
|
+
parsed["command"] = detected_command
|
|
555
|
+
parsed["args"] = detected_args or []
|
|
556
|
+
parsed["transport"] = "stdio"
|
|
557
|
+
parsed["environment_variables"] = detected_env_vars
|
|
558
|
+
if detected_docker_image:
|
|
559
|
+
parsed["docker_image"] = detected_docker_image
|
|
560
|
+
|
|
561
|
+
# Derive framework from command
|
|
562
|
+
_framework: str | None = None
|
|
563
|
+
if detected_command:
|
|
564
|
+
if detected_command == "docker":
|
|
565
|
+
_framework = "docker"
|
|
566
|
+
elif detected_command in ("python", "python3"):
|
|
567
|
+
_framework = "python"
|
|
568
|
+
elif detected_command in ("npx", "node"):
|
|
569
|
+
_framework = "typescript"
|
|
570
|
+
elif detected_framework:
|
|
571
|
+
fw_lower = detected_framework.lower()
|
|
572
|
+
if "typescript" in fw_lower or "ts" in fw_lower:
|
|
573
|
+
_framework = "typescript"
|
|
574
|
+
elif "go" in fw_lower:
|
|
575
|
+
_framework = "go"
|
|
576
|
+
elif "docker" in fw_lower:
|
|
577
|
+
_framework = "docker"
|
|
578
|
+
else:
|
|
579
|
+
_framework = "python"
|
|
580
|
+
elif detected_framework:
|
|
581
|
+
fw_lower = detected_framework.lower()
|
|
582
|
+
if "typescript" in fw_lower or "ts" in fw_lower:
|
|
583
|
+
_framework = "typescript"
|
|
584
|
+
elif "go" in fw_lower:
|
|
585
|
+
_framework = "go"
|
|
586
|
+
elif "docker" in fw_lower:
|
|
587
|
+
_framework = "docker"
|
|
588
|
+
else:
|
|
589
|
+
_framework = "python"
|
|
590
|
+
elif prefill.get("entry_point"):
|
|
591
|
+
_framework = "python"
|
|
592
|
+
|
|
593
|
+
# Command/args confirmation
|
|
594
|
+
_command = detected_command
|
|
595
|
+
_args = detected_args
|
|
596
|
+
_docker_image = detected_docker_image
|
|
597
|
+
|
|
598
|
+
if yes:
|
|
599
|
+
_name = name or detected_name
|
|
600
|
+
_version = detected_ver
|
|
601
|
+
_desc = detected_desc
|
|
602
|
+
_owner = "default"
|
|
603
|
+
_category = category or "general"
|
|
604
|
+
if not _framework:
|
|
605
|
+
_framework = "python"
|
|
606
|
+
_setup = ""
|
|
607
|
+
_changelog = "Initial release"
|
|
608
|
+
# Detect $VAR patterns in args and merge into env vars
|
|
609
|
+
dollar_vars = _extract_dollar_vars(_args or [], {})
|
|
610
|
+
existing_names = {(ev.get("name", ev) if isinstance(ev, dict) else ev) for ev in detected_env_vars}
|
|
611
|
+
for var_name in dollar_vars:
|
|
612
|
+
if var_name not in existing_names:
|
|
613
|
+
detected_env_vars.append({"name": var_name, "description": "", "required": True})
|
|
614
|
+
existing_names.add(var_name)
|
|
615
|
+
if dollar_vars:
|
|
616
|
+
rprint(f"\n[dim]Auto-detected {len(dollar_vars)} input variable(s) from $VAR patterns in args.[/dim]")
|
|
617
|
+
env_vars = detected_env_vars
|
|
618
|
+
else:
|
|
619
|
+
# Show config preview if command was detected
|
|
620
|
+
if detected_command:
|
|
621
|
+
preview_name = name or detected_name or "my-server"
|
|
622
|
+
rprint("[bold]Startup config:[/bold]")
|
|
623
|
+
console.print_json(json.dumps(_build_config_preview(preview_name, parsed), indent=2))
|
|
624
|
+
if detected_docker_suggested:
|
|
625
|
+
rprint(
|
|
626
|
+
f" [dim](Docker image [cyan]{detected_docker_image}[/cyan]"
|
|
627
|
+
" was inferred from the GitHub URL — verify it exists)[/dim]"
|
|
628
|
+
)
|
|
629
|
+
choice = (
|
|
630
|
+
typer.prompt(
|
|
631
|
+
"Startup config looks correct? [Y/n/edit]",
|
|
632
|
+
default="Y",
|
|
633
|
+
show_default=False,
|
|
634
|
+
)
|
|
635
|
+
.strip()
|
|
636
|
+
.lower()
|
|
637
|
+
)
|
|
638
|
+
if choice == "n":
|
|
639
|
+
raise typer.Abort()
|
|
640
|
+
elif choice == "edit":
|
|
641
|
+
_command = typer.prompt("Command", default=detected_command or "")
|
|
642
|
+
raw_args = typer.prompt(
|
|
643
|
+
"Args (space-separated)",
|
|
644
|
+
default=" ".join(detected_args) if detected_args else "",
|
|
645
|
+
)
|
|
646
|
+
_args = raw_args.split() if raw_args.strip() else []
|
|
647
|
+
# Re-derive framework
|
|
648
|
+
if _command == "docker":
|
|
649
|
+
_framework = "docker"
|
|
650
|
+
for arg in reversed(_args):
|
|
651
|
+
if not arg.startswith("-"):
|
|
652
|
+
_docker_image = arg
|
|
653
|
+
break
|
|
654
|
+
elif _command in ("python", "python3"):
|
|
655
|
+
_framework = "python"
|
|
656
|
+
elif _command in ("npx", "node"):
|
|
657
|
+
_framework = "typescript"
|
|
658
|
+
elif not detected_command:
|
|
659
|
+
rprint("[dim]No startup command was detected.[/dim]")
|
|
660
|
+
custom_cmd = typer.prompt("Command (e.g. docker, python, npx — Enter to skip)", default="")
|
|
661
|
+
if custom_cmd:
|
|
662
|
+
_command = custom_cmd
|
|
663
|
+
raw_args = typer.prompt("Args (space-separated)", default="")
|
|
664
|
+
_args = raw_args.split() if raw_args.strip() else []
|
|
665
|
+
if _command == "docker":
|
|
666
|
+
_framework = "docker"
|
|
667
|
+
for arg in reversed(_args):
|
|
668
|
+
if not arg.startswith("-"):
|
|
669
|
+
_docker_image = arg
|
|
670
|
+
break
|
|
671
|
+
elif _command in ("python", "python3"):
|
|
672
|
+
_framework = "python"
|
|
673
|
+
elif _command in ("npx", "node"):
|
|
674
|
+
_framework = "typescript"
|
|
675
|
+
|
|
676
|
+
# Name: auto-accept if detected, otherwise ask
|
|
677
|
+
if name:
|
|
678
|
+
_name = name
|
|
679
|
+
elif detected_name:
|
|
680
|
+
_name = detected_name
|
|
681
|
+
rprint(f" Server name: [cyan]{_name}[/cyan] [dim](from analysis)[/dim]")
|
|
682
|
+
else:
|
|
683
|
+
_name = typer.prompt("Server name")
|
|
684
|
+
|
|
685
|
+
# Version: auto-accept detected
|
|
686
|
+
_version = detected_ver
|
|
687
|
+
rprint(f" Version: [cyan]{_version}[/cyan]")
|
|
688
|
+
|
|
689
|
+
# Description: auto-accept if detected, otherwise ask
|
|
690
|
+
if detected_desc:
|
|
691
|
+
_desc = detected_desc
|
|
692
|
+
rprint(
|
|
693
|
+
f" Description: [cyan]{_desc[:60]}{'...' if len(_desc) > 60 else ''}[/cyan] [dim](from analysis)[/dim]"
|
|
694
|
+
)
|
|
695
|
+
else:
|
|
696
|
+
_desc = typer.prompt("Description (what does this server do?)")
|
|
697
|
+
|
|
698
|
+
_owner = typer.prompt("\nOwner / Team (e.g. your GitHub username)")
|
|
699
|
+
rprint()
|
|
700
|
+
|
|
701
|
+
_category = category or select_one("Category", VALID_MCP_CATEGORIES, default="general")
|
|
702
|
+
|
|
703
|
+
_setup = typer.prompt("Setup instructions (optional, press Enter to skip)", default="")
|
|
704
|
+
_changelog = typer.prompt("Changelog", default="Initial release")
|
|
705
|
+
|
|
706
|
+
# Detect $VAR patterns in final args and merge into detected env vars
|
|
707
|
+
dollar_vars = _extract_dollar_vars(_args or [], {})
|
|
708
|
+
existing_names = {(ev.get("name", ev) if isinstance(ev, dict) else ev) for ev in detected_env_vars}
|
|
709
|
+
for var_name in dollar_vars:
|
|
710
|
+
if var_name not in existing_names:
|
|
711
|
+
detected_env_vars.append({"name": var_name, "description": "", "required": True})
|
|
712
|
+
existing_names.add(var_name)
|
|
713
|
+
if dollar_vars:
|
|
714
|
+
rprint("\n[bold yellow]Input variables detected in args:[/bold yellow]")
|
|
715
|
+
rprint(
|
|
716
|
+
"[dim]Dollar-sign variables will become install-time"
|
|
717
|
+
" dependencies — users will be prompted for these values.[/dim]\n"
|
|
718
|
+
)
|
|
719
|
+
for var in dollar_vars:
|
|
720
|
+
rprint(f" [cyan]$[/cyan]{var}")
|
|
721
|
+
rprint()
|
|
722
|
+
|
|
723
|
+
# Interactive env var configuration — developer reviews, edits,
|
|
724
|
+
# or provides env vars instead of blindly including auto-detected ones.
|
|
725
|
+
env_vars = _configure_env_vars_interactive(detected_env_vars)
|
|
726
|
+
|
|
727
|
+
submit_payload = {
|
|
728
|
+
"git_url": git_url,
|
|
729
|
+
"name": _name,
|
|
730
|
+
"version": _version,
|
|
731
|
+
"category": _category,
|
|
732
|
+
"description": _desc,
|
|
733
|
+
"owner": _owner,
|
|
734
|
+
"supported_ides": supported_ides,
|
|
735
|
+
"environment_variables": env_vars,
|
|
736
|
+
"setup_instructions": _setup,
|
|
737
|
+
"changelog": _changelog,
|
|
738
|
+
}
|
|
739
|
+
if _framework:
|
|
740
|
+
submit_payload["framework"] = _framework
|
|
741
|
+
if _docker_image:
|
|
742
|
+
submit_payload["docker_image"] = _docker_image
|
|
743
|
+
if _command:
|
|
744
|
+
submit_payload["command"] = _command
|
|
745
|
+
if _args is not None:
|
|
746
|
+
submit_payload["args"] = _args
|
|
747
|
+
|
|
748
|
+
if analyzed_locally:
|
|
749
|
+
submit_payload["client_analysis"] = {
|
|
750
|
+
"tools": prefill.get("tools", []),
|
|
751
|
+
"issues": prefill.get("issues", []),
|
|
752
|
+
"framework": prefill.get("framework", ""),
|
|
753
|
+
"entry_point": prefill.get("entry_point", ""),
|
|
754
|
+
"command": prefill.get("command"),
|
|
755
|
+
"args": prefill.get("args"),
|
|
756
|
+
"docker_image": prefill.get("docker_image"),
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
endpoint = "/api/v1/mcps/draft" if draft else "/api/v1/mcps/submit"
|
|
760
|
+
label = "Saving draft..." if draft else "Submitting..."
|
|
761
|
+
with spinner(label):
|
|
762
|
+
result = client.post(endpoint, submit_payload)
|
|
763
|
+
msg = "Draft saved!" if draft else "Submitted!"
|
|
764
|
+
rprint(f"\n[green]{msg}[/green] ID: [bold]{result['id']}[/bold]")
|
|
765
|
+
if _framework:
|
|
766
|
+
rprint(f" Framework: [cyan]{_framework}[/cyan]")
|
|
767
|
+
rprint(f" Status: {status_badge(result.get('status', 'pending'))}")
|
|
768
|
+
|
|
769
|
+
|
|
770
|
+
def _list_impl(category, search, limit, sort, output, interactive=False):
|
|
771
|
+
params = {}
|
|
772
|
+
if category:
|
|
773
|
+
params["category"] = category
|
|
774
|
+
if search:
|
|
775
|
+
params["search"] = search
|
|
776
|
+
|
|
777
|
+
with spinner("Fetching MCP servers..."):
|
|
778
|
+
data = client.get("/api/v1/mcps", params=params)
|
|
779
|
+
|
|
780
|
+
if not data:
|
|
781
|
+
rprint("[dim]No MCP servers found.[/dim]")
|
|
782
|
+
return
|
|
783
|
+
|
|
784
|
+
if interactive:
|
|
785
|
+
|
|
786
|
+
def _display(item: dict) -> str:
|
|
787
|
+
return f"{item['name']} v{item.get('version', '?')} [{item.get('category', '')}] {item.get('owner', '')}"
|
|
788
|
+
|
|
789
|
+
selected = fuzzy_select(data, _display, label="Select MCP server")
|
|
790
|
+
if selected:
|
|
791
|
+
_show_impl(str(selected["id"]), "table")
|
|
792
|
+
return
|
|
793
|
+
|
|
794
|
+
# Sort
|
|
795
|
+
key_map = {"name": "name", "category": "category", "version": "version"}
|
|
796
|
+
sk = key_map.get(sort, "name")
|
|
797
|
+
data = sorted(data, key=lambda x: x.get(sk, ""))[:limit]
|
|
798
|
+
|
|
799
|
+
# Cache IDs for numeric shorthand
|
|
800
|
+
config.save_last_results(data)
|
|
801
|
+
|
|
802
|
+
if output == "json":
|
|
803
|
+
output_json(data)
|
|
804
|
+
return
|
|
805
|
+
|
|
806
|
+
if output == "plain":
|
|
807
|
+
for item in data:
|
|
808
|
+
rprint(f"{item['id']} {item['name']} v{item.get('version', '?')} [{item.get('category', '')}]")
|
|
809
|
+
return
|
|
810
|
+
|
|
811
|
+
table = Table(title=f"MCP Servers ({len(data)})", show_lines=False, padding=(0, 1))
|
|
812
|
+
table.add_column("#", style="dim", width=3)
|
|
813
|
+
table.add_column("Name", style="bold cyan", no_wrap=True)
|
|
814
|
+
table.add_column("Version", style="green")
|
|
815
|
+
table.add_column("Category")
|
|
816
|
+
table.add_column("Owner", style="dim")
|
|
817
|
+
table.add_column("IDEs")
|
|
818
|
+
table.add_column("ID", style="dim", max_width=12)
|
|
819
|
+
for i, item in enumerate(data, 1):
|
|
820
|
+
table.add_row(
|
|
821
|
+
str(i),
|
|
822
|
+
item["name"],
|
|
823
|
+
item.get("version", ""),
|
|
824
|
+
item.get("category", ""),
|
|
825
|
+
item.get("owner", ""),
|
|
826
|
+
ide_tags(item.get("supported_ides", [])),
|
|
827
|
+
str(item["id"])[:8] + "…",
|
|
828
|
+
)
|
|
829
|
+
console.print(table)
|
|
830
|
+
|
|
831
|
+
|
|
832
|
+
def _show_impl(mcp_id, output):
|
|
833
|
+
resolved = config.resolve_alias(mcp_id)
|
|
834
|
+
with spinner():
|
|
835
|
+
item = client.get(f"/api/v1/mcps/{resolved}")
|
|
836
|
+
|
|
837
|
+
if output == "json":
|
|
838
|
+
output_json(item)
|
|
839
|
+
return
|
|
840
|
+
|
|
841
|
+
console.print(
|
|
842
|
+
kv_panel(
|
|
843
|
+
f"{item['name']} v{item.get('version', '?')}",
|
|
844
|
+
[
|
|
845
|
+
("Status", status_badge(item.get("status", ""))),
|
|
846
|
+
("Category", item.get("category", "N/A")),
|
|
847
|
+
("Owner", item.get("owner", "N/A")),
|
|
848
|
+
("Description", item.get("description", "")),
|
|
849
|
+
("IDEs", ide_tags(item.get("supported_ides", []))),
|
|
850
|
+
("Git", f"[link={item.get('git_url', '')}]{item.get('git_url', 'N/A')}[/link]"),
|
|
851
|
+
("Setup", item.get("setup_instructions") or "[dim]none[/dim]"),
|
|
852
|
+
("Changelog", item.get("changelog") or "[dim]none[/dim]"),
|
|
853
|
+
("Created", relative_time(item.get("created_at"))),
|
|
854
|
+
("ID", f"[dim]{item['id']}[/dim]"),
|
|
855
|
+
],
|
|
856
|
+
border_style="cyan",
|
|
857
|
+
)
|
|
858
|
+
)
|
|
859
|
+
|
|
860
|
+
if item.get("validation_results"):
|
|
861
|
+
rprint("\n[bold]Validation:[/bold]")
|
|
862
|
+
for v in item["validation_results"]:
|
|
863
|
+
icon = "[green]✓[/green]" if v["passed"] else "[red]✗[/red]"
|
|
864
|
+
rprint(f" {icon} {v['stage']}: {v.get('details', '') or 'passed'}")
|
|
865
|
+
|
|
866
|
+
|
|
867
|
+
def _install_impl(mcp_id, ide, raw):
|
|
868
|
+
import json as _json
|
|
869
|
+
|
|
870
|
+
resolved = config.resolve_alias(mcp_id)
|
|
871
|
+
|
|
872
|
+
# Fetch listing details to check for required env vars
|
|
873
|
+
with spinner("Fetching server details..."):
|
|
874
|
+
listing = client.get(f"/api/v1/mcps/{resolved}")
|
|
875
|
+
|
|
876
|
+
env_values: dict[str, str] = {}
|
|
877
|
+
env_var_list = listing.get("environment_variables") or []
|
|
878
|
+
if env_var_list and not raw:
|
|
879
|
+
required = [ev for ev in env_var_list if ev.get("required", True)]
|
|
880
|
+
optional = [ev for ev in env_var_list if not ev.get("required", True)]
|
|
881
|
+
|
|
882
|
+
if required:
|
|
883
|
+
rprint(f"\n[bold]This server requires {len(required)} environment variable(s):[/bold]")
|
|
884
|
+
for ev in required:
|
|
885
|
+
desc = f" [dim]({ev['description']})[/dim]" if ev.get("description") else ""
|
|
886
|
+
val = typer.prompt(f" {ev['name']}{desc}")
|
|
887
|
+
env_values[ev["name"]] = val
|
|
888
|
+
|
|
889
|
+
if optional:
|
|
890
|
+
rprint(f"\n[dim]{len(optional)} optional env var(s) available:[/dim]")
|
|
891
|
+
for ev in optional:
|
|
892
|
+
desc = f" [dim]({ev['description']})[/dim]" if ev.get("description") else ""
|
|
893
|
+
val = typer.prompt(f" {ev['name']}{desc} (press Enter to skip)", default="")
|
|
894
|
+
if val:
|
|
895
|
+
env_values[ev["name"]] = val
|
|
896
|
+
elif env_var_list and raw:
|
|
897
|
+
# In raw mode, include placeholders so the user knows what's needed
|
|
898
|
+
for ev in env_var_list:
|
|
899
|
+
env_values[ev["name"]] = f"<{ev['name']}>"
|
|
900
|
+
|
|
901
|
+
# Prompt for headers (SSE/HTTP servers with auth)
|
|
902
|
+
header_values: dict[str, str] = {}
|
|
903
|
+
header_list = listing.get("headers") or []
|
|
904
|
+
if header_list and not raw:
|
|
905
|
+
required_headers = [h for h in header_list if h.get("required", True)]
|
|
906
|
+
optional_headers = [h for h in header_list if not h.get("required", True)]
|
|
907
|
+
if required_headers:
|
|
908
|
+
rprint(f"\n[bold]This server requires {len(required_headers)} header(s):[/bold]")
|
|
909
|
+
for h in required_headers:
|
|
910
|
+
desc = f" [dim]({h['description']})[/dim]" if h.get("description") else ""
|
|
911
|
+
val = typer.prompt(f" {h['name']}{desc}")
|
|
912
|
+
header_values[h["name"]] = val
|
|
913
|
+
if optional_headers:
|
|
914
|
+
rprint(f"\n[dim]{len(optional_headers)} optional header(s) available:[/dim]")
|
|
915
|
+
for h in optional_headers:
|
|
916
|
+
desc = f" [dim]({h['description']})[/dim]" if h.get("description") else ""
|
|
917
|
+
val = typer.prompt(f" {h['name']}{desc} (press Enter to skip)", default="")
|
|
918
|
+
if val:
|
|
919
|
+
header_values[h["name"]] = val
|
|
920
|
+
elif header_list and raw:
|
|
921
|
+
for h in header_list:
|
|
922
|
+
header_values[h["name"]] = f"<{h['name']}>"
|
|
923
|
+
|
|
924
|
+
with spinner(f"Generating {ide} config..."):
|
|
925
|
+
result = client.post(
|
|
926
|
+
f"/api/v1/mcps/{resolved}/install",
|
|
927
|
+
{"ide": ide, "env_values": env_values, "header_values": header_values},
|
|
928
|
+
)
|
|
929
|
+
|
|
930
|
+
snippet = result.get("config_snippet", {})
|
|
931
|
+
if raw:
|
|
932
|
+
print(_json.dumps(snippet, indent=2))
|
|
933
|
+
return
|
|
934
|
+
|
|
935
|
+
ide_config_paths = {
|
|
936
|
+
"kiro": ".kiro/settings/mcp.json",
|
|
937
|
+
"cursor": ".cursor/mcp.json",
|
|
938
|
+
"vscode": ".vscode/mcp.json",
|
|
939
|
+
"claude-code": "(run the command below)",
|
|
940
|
+
"claude_code": "(run the command below)",
|
|
941
|
+
"gemini-cli": ".gemini/settings.json",
|
|
942
|
+
"gemini_cli": ".gemini/settings.json",
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
rprint(f"\n[bold]Config for {ide}:[/bold]\n")
|
|
946
|
+
console.print_json(_json.dumps(snippet, indent=2))
|
|
947
|
+
config_path = ide_config_paths.get(ide, "")
|
|
948
|
+
if config_path and not config_path.startswith("("):
|
|
949
|
+
rprint(f"\n[dim]Add to:[/dim] [bold]{config_path}[/bold]")
|
|
950
|
+
rprint(f"[dim]Or pipe:[/dim] observal install {mcp_id} --ide {ide} --raw > {config_path}")
|
|
951
|
+
|
|
952
|
+
# Warn about any empty env vars the user skipped
|
|
953
|
+
missing = [k for k, v in env_values.items() if not v or v.startswith("<")]
|
|
954
|
+
if missing:
|
|
955
|
+
rprint(f"\n[yellow]Warning: {len(missing)} env var(s) still need values:[/yellow]")
|
|
956
|
+
for m in missing:
|
|
957
|
+
rprint(f" [yellow]![/yellow] {m}")
|
|
958
|
+
rprint("[dim]Set these in your IDE config or shell environment before running the server.[/dim]")
|
|
959
|
+
|
|
960
|
+
|
|
961
|
+
def _delete_impl(mcp_id, yes):
|
|
962
|
+
resolved = config.resolve_alias(mcp_id)
|
|
963
|
+
if not yes:
|
|
964
|
+
with spinner():
|
|
965
|
+
item = client.get(f"/api/v1/mcps/{resolved}")
|
|
966
|
+
if not typer.confirm(f"Delete [bold]{item['name']}[/bold] ({resolved})?"):
|
|
967
|
+
raise typer.Abort()
|
|
968
|
+
with spinner("Deleting..."):
|
|
969
|
+
client.delete(f"/api/v1/mcps/{resolved}")
|
|
970
|
+
rprint(f"[green]✓ Deleted {resolved}[/green]")
|
|
971
|
+
|
|
972
|
+
|
|
973
|
+
# ── Canonical commands (on mcp_app) ─────────────────────────
|
|
974
|
+
|
|
975
|
+
|
|
976
|
+
@mcp_app.command()
|
|
977
|
+
def submit(
|
|
978
|
+
git_url: str = typer.Argument(None, help="Git repository URL (optional if --config used)"),
|
|
979
|
+
name: str = typer.Option(None, "--name", "-n", help="Skip name prompt"),
|
|
980
|
+
category: str = typer.Option(None, "--category", "-c", help="Skip category prompt"),
|
|
981
|
+
yes: bool = typer.Option(False, "--yes", "-y", help="Accept defaults from repo analysis"),
|
|
982
|
+
config: bool = typer.Option(False, "--config", help="Submit via direct JSON config (paste mode)"),
|
|
983
|
+
draft: bool = typer.Option(False, "--draft", help="Save as draft instead of submitting for review"),
|
|
984
|
+
submit_draft: str | None = typer.Option(None, "--submit", help="Submit a draft for review (MCP ID)"),
|
|
985
|
+
):
|
|
986
|
+
"""Submit an MCP server for review."""
|
|
987
|
+
if draft and submit_draft:
|
|
988
|
+
rprint(
|
|
989
|
+
"[red]Cannot use --draft and --submit together.[/red] Use --draft to save a new draft, or --submit to submit an existing draft."
|
|
990
|
+
)
|
|
991
|
+
raise typer.Exit(code=1)
|
|
992
|
+
if submit_draft:
|
|
993
|
+
from observal_cli import config as cfg
|
|
994
|
+
|
|
995
|
+
resolved = cfg.resolve_alias(submit_draft)
|
|
996
|
+
with spinner("Submitting draft for review..."):
|
|
997
|
+
result = client.post(f"/api/v1/mcps/{resolved}/submit")
|
|
998
|
+
rprint(f"[green]✓ Draft submitted for review![/green] ID: [bold]{result['id']}[/bold]")
|
|
999
|
+
return
|
|
1000
|
+
if not git_url and not config:
|
|
1001
|
+
rprint("[red]Provide a git URL or use --config[/red]")
|
|
1002
|
+
raise typer.Exit(1)
|
|
1003
|
+
_submit_impl(git_url, name, category, yes, config, draft=draft)
|
|
1004
|
+
|
|
1005
|
+
|
|
1006
|
+
@mcp_app.command(name="list")
|
|
1007
|
+
def list_mcps(
|
|
1008
|
+
category: str | None = typer.Option(None, "--category", "-c", help="Filter by category"),
|
|
1009
|
+
search: str | None = typer.Option(None, "--search", "-s", help="Search by name/description"),
|
|
1010
|
+
interactive: bool = typer.Option(False, "--interactive", "-i", help="Interactive search mode"),
|
|
1011
|
+
limit: int = typer.Option(50, "--limit", "-n", help="Max results"),
|
|
1012
|
+
sort: str = typer.Option("name", "--sort", help="Sort by: name, category, version"),
|
|
1013
|
+
output: str = typer.Option("table", "--output", "-o", help="Output: table, json, plain"),
|
|
1014
|
+
):
|
|
1015
|
+
"""List approved MCP servers."""
|
|
1016
|
+
_list_impl(category, search, limit, sort, output, interactive=interactive)
|
|
1017
|
+
|
|
1018
|
+
|
|
1019
|
+
@mcp_app.command()
|
|
1020
|
+
def show(
|
|
1021
|
+
mcp_id: str = typer.Argument(..., help="ID, name, row number, or @alias"),
|
|
1022
|
+
output: str = typer.Option("table", "--output", "-o", help="Output: table, json"),
|
|
1023
|
+
):
|
|
1024
|
+
"""Show full details of an MCP server."""
|
|
1025
|
+
_show_impl(mcp_id, output)
|
|
1026
|
+
|
|
1027
|
+
|
|
1028
|
+
@mcp_app.command()
|
|
1029
|
+
def install(
|
|
1030
|
+
mcp_id: str = typer.Argument(..., help="ID, name, row number, or @alias"),
|
|
1031
|
+
ide: str = typer.Option(..., "--ide", "-i", help="Target IDE"),
|
|
1032
|
+
raw: bool = typer.Option(False, "--raw", help="Output raw JSON only (for piping)"),
|
|
1033
|
+
):
|
|
1034
|
+
"""Get install config snippet for an MCP server."""
|
|
1035
|
+
_install_impl(mcp_id, ide, raw)
|
|
1036
|
+
|
|
1037
|
+
|
|
1038
|
+
@mcp_app.command(name="delete")
|
|
1039
|
+
def delete_mcp(
|
|
1040
|
+
mcp_id: str = typer.Argument(..., help="ID, name, row number, or @alias"),
|
|
1041
|
+
yes: bool = typer.Option(False, "--yes", "-y", help="Skip confirmation"),
|
|
1042
|
+
):
|
|
1043
|
+
"""Delete an MCP server."""
|
|
1044
|
+
_delete_impl(mcp_id, yes)
|