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/analyzer.py
ADDED
|
@@ -0,0 +1,565 @@
|
|
|
1
|
+
"""Local repository analysis for MCP server submissions.
|
|
2
|
+
|
|
3
|
+
Clones the repo using the system git (which inherits the user's credential
|
|
4
|
+
helpers, SSO sessions, SSH keys, etc.) and runs the same analysis that the
|
|
5
|
+
server performs: MCP pattern detection, AST parsing, env-var scanning.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import ast
|
|
11
|
+
import json
|
|
12
|
+
import re
|
|
13
|
+
import shutil
|
|
14
|
+
import subprocess
|
|
15
|
+
import tempfile
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from urllib.parse import urlparse
|
|
18
|
+
|
|
19
|
+
_CLONE_TIMEOUT = 120 # seconds
|
|
20
|
+
|
|
21
|
+
# ---------------------------------------------------------------------------
|
|
22
|
+
# Patterns (mirrored from observal-server/services/mcp_validator.py)
|
|
23
|
+
# ---------------------------------------------------------------------------
|
|
24
|
+
|
|
25
|
+
_PYTHON_MCP_PATTERN = re.compile(
|
|
26
|
+
r"FastMCP\("
|
|
27
|
+
r"|@mcp\.server"
|
|
28
|
+
r"|from\s+mcp\.server\s+import\s+Server"
|
|
29
|
+
r"|from\s+mcp\s+import"
|
|
30
|
+
r"|import\s+mcp\b"
|
|
31
|
+
r"|McpServer\("
|
|
32
|
+
r"|MCPServer\("
|
|
33
|
+
r"|@app\.tool\b"
|
|
34
|
+
r"|@server\.tool\b"
|
|
35
|
+
r"|Server\(\s*name\s*="
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
_ENV_VAR_PATTERN_PYTHON = re.compile(
|
|
39
|
+
r"""os\.environ\s*(?:\.get\s*\(\s*|\.?\[?\s*\[?\s*)["']([A-Z][A-Z0-9_]+)["']"""
|
|
40
|
+
r"""|os\.getenv\s*\(\s*["']([A-Z][A-Z0-9_]+)["']"""
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
_ENV_VAR_PATTERN_GO = re.compile(r"""os\.Getenv\(\s*"([A-Z][A-Z0-9_]+)"\s*\)""")
|
|
44
|
+
|
|
45
|
+
# README patterns: docker -e flags, export statements, JSON config keys
|
|
46
|
+
_README_PATTERNS = [
|
|
47
|
+
re.compile(r"""-e\s+([A-Z][A-Z0-9_]+)"""),
|
|
48
|
+
re.compile(r"""export\s+([A-Z][A-Z0-9_]+)="""),
|
|
49
|
+
re.compile(r""""([A-Z][A-Z0-9_]+)"\s*:\s*\""""),
|
|
50
|
+
]
|
|
51
|
+
|
|
52
|
+
_ENV_VAR_PATTERN_TS = re.compile(
|
|
53
|
+
r"""process\.env\.([A-Z][A-Z0-9_]+)"""
|
|
54
|
+
r"""|process\.env\[\s*["']([A-Z][A-Z0-9_]+)["']\s*\]"""
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
_INTERNAL_ENV_VARS = frozenset(
|
|
58
|
+
{
|
|
59
|
+
"PATH",
|
|
60
|
+
"HOME",
|
|
61
|
+
"USER",
|
|
62
|
+
"SHELL",
|
|
63
|
+
"LANG",
|
|
64
|
+
"TERM",
|
|
65
|
+
"PWD",
|
|
66
|
+
"TMPDIR",
|
|
67
|
+
"PYTHONPATH",
|
|
68
|
+
"PYTHONDONTWRITEBYTECODE",
|
|
69
|
+
"PYTHONUSERBASE",
|
|
70
|
+
"PYTHONHOME",
|
|
71
|
+
"PYTHONUNBUFFERED",
|
|
72
|
+
"VIRTUAL_ENV",
|
|
73
|
+
"NODE_ENV",
|
|
74
|
+
"NODE_PATH",
|
|
75
|
+
"NODE_OPTIONS",
|
|
76
|
+
"PORT",
|
|
77
|
+
"HOST",
|
|
78
|
+
"DEBUG",
|
|
79
|
+
"APP",
|
|
80
|
+
"LOG_LEVEL",
|
|
81
|
+
"LOGGING_LEVEL",
|
|
82
|
+
"HOSTNAME",
|
|
83
|
+
"DISPLAY",
|
|
84
|
+
"EDITOR",
|
|
85
|
+
"PAGER",
|
|
86
|
+
"TZ",
|
|
87
|
+
"LC_ALL",
|
|
88
|
+
"LC_CTYPE",
|
|
89
|
+
}
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
# User-facing env vars that match a filtered prefix but should still be detected
|
|
93
|
+
_ALLOWED_ENV_VARS = frozenset(
|
|
94
|
+
{
|
|
95
|
+
"GITHUB_TOKEN",
|
|
96
|
+
"GITHUB_PERSONAL_ACCESS_TOKEN",
|
|
97
|
+
"DOCKER_HOST",
|
|
98
|
+
}
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
# Prefix patterns for build/CI/infrastructure env vars that are never user-facing
|
|
102
|
+
_FILTERED_PREFIXES = (
|
|
103
|
+
"CI_",
|
|
104
|
+
"GITHUB_",
|
|
105
|
+
"GITLAB_",
|
|
106
|
+
"CIRCLECI_",
|
|
107
|
+
"TRAVIS_",
|
|
108
|
+
"JENKINS_",
|
|
109
|
+
"BUILDKITE_",
|
|
110
|
+
"DOCKER_",
|
|
111
|
+
"BUILDKIT_",
|
|
112
|
+
"COMPOSE_",
|
|
113
|
+
"NPM_",
|
|
114
|
+
"PIP_",
|
|
115
|
+
"UV_",
|
|
116
|
+
"OTEL_",
|
|
117
|
+
"MCP_LOG_",
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
# ---------------------------------------------------------------------------
|
|
121
|
+
# Helpers
|
|
122
|
+
# ---------------------------------------------------------------------------
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _clone_repo(git_url: str, dest: str) -> str | None:
|
|
126
|
+
"""Shallow-clone a repo using system git. Returns error string or None on success."""
|
|
127
|
+
try:
|
|
128
|
+
result = subprocess.run(
|
|
129
|
+
["git", "clone", "--depth", "1", git_url, dest],
|
|
130
|
+
capture_output=True,
|
|
131
|
+
text=True,
|
|
132
|
+
timeout=_CLONE_TIMEOUT,
|
|
133
|
+
)
|
|
134
|
+
except FileNotFoundError:
|
|
135
|
+
return "git is not installed or not on PATH"
|
|
136
|
+
except subprocess.TimeoutExpired:
|
|
137
|
+
return f"Clone timed out after {_CLONE_TIMEOUT}s"
|
|
138
|
+
|
|
139
|
+
if result.returncode != 0:
|
|
140
|
+
stderr = result.stderr.strip().lower()
|
|
141
|
+
auth_hints = ("authentication", "403", "404", "could not read username", "terminal prompts disabled")
|
|
142
|
+
if any(h in stderr for h in auth_hints):
|
|
143
|
+
return "Repository is private or not accessible."
|
|
144
|
+
if "not found" in stderr or "does not exist" in stderr:
|
|
145
|
+
return "Repository not found. Check the URL."
|
|
146
|
+
return f"git clone failed: {result.stderr.strip()}"
|
|
147
|
+
return None
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _is_filtered_env_var(name: str) -> bool:
|
|
151
|
+
"""Return True if the env var is internal/infrastructure and should not be prompted."""
|
|
152
|
+
if name in _ALLOWED_ENV_VARS:
|
|
153
|
+
return False
|
|
154
|
+
if name in _INTERNAL_ENV_VARS:
|
|
155
|
+
return True
|
|
156
|
+
return any(name.startswith(prefix) for prefix in _FILTERED_PREFIXES)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
# Directories that contain test / internal / build code — not user-facing config
|
|
160
|
+
_SKIP_DIRS = frozenset(
|
|
161
|
+
{
|
|
162
|
+
"test",
|
|
163
|
+
"tests",
|
|
164
|
+
"e2e",
|
|
165
|
+
"internal",
|
|
166
|
+
"testdata",
|
|
167
|
+
"vendor",
|
|
168
|
+
"node_modules",
|
|
169
|
+
"__pycache__",
|
|
170
|
+
".git",
|
|
171
|
+
}
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _is_test_file(path: Path) -> bool:
|
|
176
|
+
"""Return True if the file is in a test/internal directory or is a test file."""
|
|
177
|
+
if any(part in _SKIP_DIRS for part in path.parts):
|
|
178
|
+
return True
|
|
179
|
+
name = path.name
|
|
180
|
+
return name.endswith("_test.go") or name.startswith("test_") or name.endswith("_test.py")
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _scan_files_for_env_vars(root: Path, glob: str, pattern: re.Pattern, found: dict[str, str]) -> None:
|
|
184
|
+
"""Scan files matching *glob* for env var references using *pattern*."""
|
|
185
|
+
for path in root.rglob(glob):
|
|
186
|
+
if _is_test_file(path.relative_to(root)):
|
|
187
|
+
continue
|
|
188
|
+
try:
|
|
189
|
+
content = path.read_text(errors="ignore")
|
|
190
|
+
for m in pattern.finditer(content):
|
|
191
|
+
name = next((g for g in m.groups() if g), None)
|
|
192
|
+
if name and not _is_filtered_env_var(name):
|
|
193
|
+
found.setdefault(name, "")
|
|
194
|
+
except Exception:
|
|
195
|
+
continue
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def _scan_readme_for_env_vars(root: Path, found: dict[str, str]) -> None:
|
|
199
|
+
"""Extract env vars from README files (docker -e, export, JSON config)."""
|
|
200
|
+
for name in ("README.md", "README.rst", "README.txt", "README"):
|
|
201
|
+
readme = root / name
|
|
202
|
+
if not readme.exists():
|
|
203
|
+
continue
|
|
204
|
+
try:
|
|
205
|
+
content = readme.read_text(errors="ignore")
|
|
206
|
+
except Exception:
|
|
207
|
+
continue
|
|
208
|
+
for pattern in _README_PATTERNS:
|
|
209
|
+
for m in pattern.finditer(content):
|
|
210
|
+
var = m.group(1)
|
|
211
|
+
if var and not _is_filtered_env_var(var):
|
|
212
|
+
found.setdefault(var, "")
|
|
213
|
+
break # only scan the first README found
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def _extract_manifest_env_vars(root: Path, found: dict[str, str]) -> bool:
|
|
217
|
+
"""Extract env vars from a server.json MCP manifest (authoritative source).
|
|
218
|
+
|
|
219
|
+
The manifest is the standard MCP server descriptor. Env vars declared here
|
|
220
|
+
are always included — they bypass the prefix filter since the author
|
|
221
|
+
explicitly listed them as required.
|
|
222
|
+
|
|
223
|
+
Returns True if a valid server.json was found (even if it declares no env vars).
|
|
224
|
+
"""
|
|
225
|
+
manifest = root / "server.json"
|
|
226
|
+
if not manifest.exists():
|
|
227
|
+
return False
|
|
228
|
+
try:
|
|
229
|
+
data = json.loads(manifest.read_text(errors="ignore"))
|
|
230
|
+
except Exception:
|
|
231
|
+
return False
|
|
232
|
+
# packages[].runtimeArguments — Docker -e flags (e.g. GitHub MCP server)
|
|
233
|
+
for pkg in data.get("packages", []):
|
|
234
|
+
for arg in pkg.get("runtimeArguments", []):
|
|
235
|
+
value = arg.get("value", "")
|
|
236
|
+
# Pattern: "ENV_VAR={placeholder}" — extract the var name before '='
|
|
237
|
+
if "=" in value:
|
|
238
|
+
var_name = value.split("=", 1)[0]
|
|
239
|
+
if var_name and var_name == var_name.upper():
|
|
240
|
+
desc = arg.get("description", "")
|
|
241
|
+
found.setdefault(var_name, desc)
|
|
242
|
+
|
|
243
|
+
# remotes[].variables — URL-interpolated secrets (e.g. ?api_key={key})
|
|
244
|
+
for remote in data.get("remotes", []):
|
|
245
|
+
for var_key, var_meta in (remote.get("variables") or {}).items():
|
|
246
|
+
desc = var_meta.get("description", "") if isinstance(var_meta, dict) else ""
|
|
247
|
+
found.setdefault(var_key, desc)
|
|
248
|
+
return True
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def _scan_env_example(root: Path, found: dict[str, str]) -> None:
|
|
252
|
+
"""Scan .env.example / .env.sample files for documented env vars."""
|
|
253
|
+
for env_file in root.glob(".env*"):
|
|
254
|
+
if env_file.name in (".env", ".env.local"):
|
|
255
|
+
continue # skip actual secrets
|
|
256
|
+
try:
|
|
257
|
+
for line in env_file.read_text(errors="ignore").splitlines():
|
|
258
|
+
line = line.strip()
|
|
259
|
+
if not line or line.startswith("#"):
|
|
260
|
+
continue
|
|
261
|
+
key = line.split("=", 1)[0].strip()
|
|
262
|
+
if key and key == key.upper() and not _is_filtered_env_var(key):
|
|
263
|
+
found.setdefault(key, "")
|
|
264
|
+
except Exception:
|
|
265
|
+
continue
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def _detect_env_vars(tmp_dir: str) -> list[dict]:
|
|
269
|
+
"""Scan repo files for required environment variables.
|
|
270
|
+
|
|
271
|
+
Tiered detection (stops at first tier that finds results):
|
|
272
|
+
1. server.json manifest (authoritative — author's explicit declaration)
|
|
273
|
+
2. README + .env.example (author's documentation)
|
|
274
|
+
3. Source code scanning (last resort — catches os.Getenv / process.env / etc.)
|
|
275
|
+
"""
|
|
276
|
+
root = Path(tmp_dir)
|
|
277
|
+
found: dict[str, str] = {}
|
|
278
|
+
|
|
279
|
+
# Tier 1: MCP server manifest — authoritative, skip everything else
|
|
280
|
+
if _extract_manifest_env_vars(root, found):
|
|
281
|
+
return [{"name": k, "description": v, "required": True} for k, v in sorted(found.items())]
|
|
282
|
+
|
|
283
|
+
# Tier 2: README — author's documented config (export, docker -e, JSON examples)
|
|
284
|
+
_scan_readme_for_env_vars(root, found)
|
|
285
|
+
if found:
|
|
286
|
+
return [{"name": k, "description": v, "required": True} for k, v in sorted(found.items())]
|
|
287
|
+
|
|
288
|
+
# Tier 3: .env.example — explicit config template
|
|
289
|
+
_scan_env_example(root, found)
|
|
290
|
+
if found:
|
|
291
|
+
return [{"name": k, "description": v, "required": True} for k, v in sorted(found.items())]
|
|
292
|
+
|
|
293
|
+
# Tier 4: Source code scanning — last resort
|
|
294
|
+
_scan_files_for_env_vars(root, "*.py", _ENV_VAR_PATTERN_PYTHON, found)
|
|
295
|
+
_scan_files_for_env_vars(root, "*.go", _ENV_VAR_PATTERN_GO, found)
|
|
296
|
+
for ext in ("*.ts", "*.js", "*.mts", "*.mjs"):
|
|
297
|
+
_scan_files_for_env_vars(root, ext, _ENV_VAR_PATTERN_TS, found)
|
|
298
|
+
|
|
299
|
+
return [{"name": k, "description": v, "required": True} for k, v in sorted(found.items())]
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
# Regex for Docker registry image references in README
|
|
303
|
+
_DOCKER_IMAGE_PATTERN = re.compile(
|
|
304
|
+
r"((?:ghcr\.io|docker\.io|registry\.[a-z0-9.-]+\.[a-z]{2,}|[a-z0-9.-]+\.azurecr\.io|[a-z0-9.-]+\.gcr\.io)"
|
|
305
|
+
r"/[a-z0-9_./-]+"
|
|
306
|
+
r"(?::[a-z0-9._-]+)?)"
|
|
307
|
+
)
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def _detect_docker_image(root: Path, git_url: str) -> tuple[str | None, bool]:
|
|
311
|
+
"""Detect Docker image from repo artifacts.
|
|
312
|
+
|
|
313
|
+
Returns (image, is_suggested). is_suggested=True for GHCR inference from git URL.
|
|
314
|
+
|
|
315
|
+
Priority: compose image > README reference > GHCR inference from git URL.
|
|
316
|
+
Dockerfile FROM is not returned (it's the build base, not the published image).
|
|
317
|
+
"""
|
|
318
|
+
# 1. docker-compose / compose files — most authoritative for pre-built images
|
|
319
|
+
for compose_name in ("docker-compose.yml", "docker-compose.yaml", "compose.yml", "compose.yaml"):
|
|
320
|
+
compose_file = root / compose_name
|
|
321
|
+
if compose_file.exists():
|
|
322
|
+
try:
|
|
323
|
+
import yaml
|
|
324
|
+
|
|
325
|
+
data = yaml.safe_load(compose_file.read_text(errors="ignore"))
|
|
326
|
+
for svc in (data.get("services") or {}).values():
|
|
327
|
+
img = svc.get("image")
|
|
328
|
+
if img and isinstance(img, str):
|
|
329
|
+
return (img, False)
|
|
330
|
+
except Exception:
|
|
331
|
+
pass
|
|
332
|
+
|
|
333
|
+
# 2. README — look for registry image references
|
|
334
|
+
for readme_name in ("README.md", "README.rst", "README.txt", "README"):
|
|
335
|
+
readme = root / readme_name
|
|
336
|
+
if not readme.exists():
|
|
337
|
+
continue
|
|
338
|
+
try:
|
|
339
|
+
content = readme.read_text(errors="ignore")
|
|
340
|
+
m = _DOCKER_IMAGE_PATTERN.search(content)
|
|
341
|
+
if m:
|
|
342
|
+
return (m.group(1), False)
|
|
343
|
+
except Exception:
|
|
344
|
+
pass
|
|
345
|
+
break
|
|
346
|
+
|
|
347
|
+
# 3. Infer GHCR from GitHub URL
|
|
348
|
+
_safe_name = re.compile(r"^[a-zA-Z0-9._-]+$")
|
|
349
|
+
try:
|
|
350
|
+
url_parts = urlparse(git_url)
|
|
351
|
+
if url_parts.hostname and "github.com" in url_parts.hostname:
|
|
352
|
+
path = url_parts.path.strip("/")
|
|
353
|
+
if path.endswith(".git"):
|
|
354
|
+
path = path[:-4]
|
|
355
|
+
parts = path.split("/")
|
|
356
|
+
if len(parts) >= 2 and _safe_name.match(parts[0]) and _safe_name.match(parts[1]):
|
|
357
|
+
return (f"ghcr.io/{parts[0]}/{parts[1]}", True)
|
|
358
|
+
except Exception:
|
|
359
|
+
pass
|
|
360
|
+
|
|
361
|
+
return (None, False)
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
def _infer_command_args(
|
|
365
|
+
framework: str | None,
|
|
366
|
+
docker_image: str | None,
|
|
367
|
+
name: str,
|
|
368
|
+
entry_point: str | None = None,
|
|
369
|
+
) -> tuple[str | None, list[str] | None]:
|
|
370
|
+
"""Infer the startup command and args from framework + docker image.
|
|
371
|
+
|
|
372
|
+
Returns (command, args) or (None, None) if nothing can be inferred.
|
|
373
|
+
"""
|
|
374
|
+
if docker_image:
|
|
375
|
+
return ("docker", ["run", "-i", "--rm", docker_image])
|
|
376
|
+
|
|
377
|
+
fw = (framework or "").lower()
|
|
378
|
+
if "typescript" in fw or "ts" in fw:
|
|
379
|
+
return ("npx", ["-y", name])
|
|
380
|
+
if "go" in fw:
|
|
381
|
+
return (name, [])
|
|
382
|
+
if "python" in fw or entry_point:
|
|
383
|
+
return ("python", ["-m", name])
|
|
384
|
+
|
|
385
|
+
return (None, None)
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
def _detect_non_python_mcp(tmp_dir: str) -> str | None:
|
|
389
|
+
"""Check for non-Python MCP frameworks. Returns framework name or None."""
|
|
390
|
+
root = Path(tmp_dir)
|
|
391
|
+
|
|
392
|
+
pkg_json = root / "package.json"
|
|
393
|
+
if pkg_json.exists():
|
|
394
|
+
try:
|
|
395
|
+
data = json.loads(pkg_json.read_text(errors="ignore"))
|
|
396
|
+
all_deps = {}
|
|
397
|
+
all_deps.update(data.get("dependencies", {}))
|
|
398
|
+
all_deps.update(data.get("devDependencies", {}))
|
|
399
|
+
if "@modelcontextprotocol/sdk" in all_deps:
|
|
400
|
+
return "typescript-mcp-sdk"
|
|
401
|
+
except Exception:
|
|
402
|
+
pass
|
|
403
|
+
|
|
404
|
+
for go_file in root.rglob("*.go"):
|
|
405
|
+
try:
|
|
406
|
+
content = go_file.read_text(errors="ignore")
|
|
407
|
+
if "mcp-go" in content or "mcp_go" in content:
|
|
408
|
+
return "go-mcp-sdk"
|
|
409
|
+
except Exception:
|
|
410
|
+
continue
|
|
411
|
+
|
|
412
|
+
return None
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
def _extract_repo_name(git_url: str, tmp_dir: str) -> str:
|
|
416
|
+
"""Extract a usable name from the git URL or directory name as fallback."""
|
|
417
|
+
try:
|
|
418
|
+
parsed = urlparse(git_url)
|
|
419
|
+
path = parsed.path.rstrip("/")
|
|
420
|
+
if path.endswith(".git"):
|
|
421
|
+
path = path[:-4]
|
|
422
|
+
name = path.rsplit("/", 1)[-1]
|
|
423
|
+
if name:
|
|
424
|
+
return name
|
|
425
|
+
except Exception:
|
|
426
|
+
pass
|
|
427
|
+
return Path(tmp_dir).name or "unknown"
|
|
428
|
+
|
|
429
|
+
|
|
430
|
+
def _analyze_python_entry(tree: ast.AST, git_url: str, tmp_dir: str) -> tuple[str, str, list[dict], list[str]]:
|
|
431
|
+
"""Extract server name, description, tools, and issues from an AST.
|
|
432
|
+
|
|
433
|
+
Returns (server_name, server_desc, tools, issues).
|
|
434
|
+
"""
|
|
435
|
+
server_name = ""
|
|
436
|
+
server_desc = ""
|
|
437
|
+
for node in ast.walk(tree):
|
|
438
|
+
if not isinstance(node, ast.Call) or not isinstance(node.func, ast.Name):
|
|
439
|
+
continue
|
|
440
|
+
if node.func.id == "FastMCP":
|
|
441
|
+
if node.args and isinstance(node.args[0], ast.Constant):
|
|
442
|
+
server_name = str(node.args[0].value)
|
|
443
|
+
for kw in node.keywords:
|
|
444
|
+
if kw.arg == "description" and isinstance(kw.value, ast.Constant):
|
|
445
|
+
server_desc = str(kw.value.value)
|
|
446
|
+
if server_name:
|
|
447
|
+
break
|
|
448
|
+
if node.func.id == "Server":
|
|
449
|
+
for kw in node.keywords:
|
|
450
|
+
if kw.arg == "name" and isinstance(kw.value, ast.Constant):
|
|
451
|
+
server_name = str(kw.value.value)
|
|
452
|
+
if kw.arg == "description" and isinstance(kw.value, ast.Constant):
|
|
453
|
+
server_desc = str(kw.value.value)
|
|
454
|
+
if not server_name and node.args and isinstance(node.args[0], ast.Constant):
|
|
455
|
+
server_name = str(node.args[0].value)
|
|
456
|
+
if server_name:
|
|
457
|
+
break
|
|
458
|
+
|
|
459
|
+
if not server_name:
|
|
460
|
+
server_name = _extract_repo_name(git_url, tmp_dir)
|
|
461
|
+
|
|
462
|
+
tools: list[dict] = []
|
|
463
|
+
issues: list[str] = []
|
|
464
|
+
for node in ast.walk(tree):
|
|
465
|
+
if not isinstance(node, ast.FunctionDef | ast.AsyncFunctionDef):
|
|
466
|
+
continue
|
|
467
|
+
is_tool = any(
|
|
468
|
+
(isinstance(d, ast.Attribute) and d.attr == "tool")
|
|
469
|
+
or (isinstance(d, ast.Call) and isinstance(d.func, ast.Attribute) and d.func.attr == "tool")
|
|
470
|
+
for d in node.decorator_list
|
|
471
|
+
)
|
|
472
|
+
if is_tool:
|
|
473
|
+
docstring = ast.get_docstring(node) or ""
|
|
474
|
+
untyped = [a.arg for a in node.args.args if a.arg != "self" and a.annotation is None]
|
|
475
|
+
tools.append({"name": node.name, "docstring": docstring})
|
|
476
|
+
if len(docstring) < 20:
|
|
477
|
+
issues.append(f"Tool '{node.name}': docstring too short ({len(docstring)} chars, need 20+)")
|
|
478
|
+
if untyped:
|
|
479
|
+
issues.append(f"Tool '{node.name}': untyped params: {', '.join(untyped)}")
|
|
480
|
+
|
|
481
|
+
if not tools:
|
|
482
|
+
issues.append("No @tool decorated functions found")
|
|
483
|
+
|
|
484
|
+
return server_name, server_desc, tools, issues
|
|
485
|
+
|
|
486
|
+
|
|
487
|
+
# ---------------------------------------------------------------------------
|
|
488
|
+
# Public API
|
|
489
|
+
# ---------------------------------------------------------------------------
|
|
490
|
+
|
|
491
|
+
|
|
492
|
+
def analyze_local(git_url: str) -> dict:
|
|
493
|
+
"""Clone a repo locally and analyze it for MCP metadata.
|
|
494
|
+
|
|
495
|
+
Returns a dict matching the McpAnalyzeResponse shape:
|
|
496
|
+
{name, description, version, tools, environment_variables, issues, error}
|
|
497
|
+
"""
|
|
498
|
+
_empty: dict = {"name": "", "description": "", "version": "0.1.0", "tools": []}
|
|
499
|
+
|
|
500
|
+
tmp_dir = tempfile.mkdtemp(prefix="observal_cli_analyze_")
|
|
501
|
+
try:
|
|
502
|
+
clone_err = _clone_repo(git_url, tmp_dir)
|
|
503
|
+
if clone_err:
|
|
504
|
+
return {**_empty, "error": clone_err}
|
|
505
|
+
|
|
506
|
+
# Find Python MCP entry point
|
|
507
|
+
entry_point = None
|
|
508
|
+
for py_file in Path(tmp_dir).rglob("*.py"):
|
|
509
|
+
try:
|
|
510
|
+
if _PYTHON_MCP_PATTERN.search(py_file.read_text(errors="ignore")):
|
|
511
|
+
entry_point = py_file
|
|
512
|
+
break
|
|
513
|
+
except Exception:
|
|
514
|
+
continue
|
|
515
|
+
|
|
516
|
+
env_vars = _detect_env_vars(tmp_dir)
|
|
517
|
+
|
|
518
|
+
if not entry_point:
|
|
519
|
+
non_python = _detect_non_python_mcp(tmp_dir)
|
|
520
|
+
name = _extract_repo_name(git_url, tmp_dir)
|
|
521
|
+
docker_image, docker_suggested = _detect_docker_image(Path(tmp_dir), git_url)
|
|
522
|
+
cmd, cmd_args = _infer_command_args(non_python, docker_image, name)
|
|
523
|
+
base: dict = {
|
|
524
|
+
"name": name,
|
|
525
|
+
"description": "",
|
|
526
|
+
"version": "0.1.0",
|
|
527
|
+
"tools": [],
|
|
528
|
+
"environment_variables": env_vars,
|
|
529
|
+
}
|
|
530
|
+
if non_python:
|
|
531
|
+
base["framework"] = non_python
|
|
532
|
+
if docker_image:
|
|
533
|
+
base["docker_image"] = docker_image
|
|
534
|
+
base["docker_image_suggested"] = docker_suggested
|
|
535
|
+
if cmd:
|
|
536
|
+
base["command"] = cmd
|
|
537
|
+
base["args"] = cmd_args
|
|
538
|
+
return base
|
|
539
|
+
|
|
540
|
+
tree = ast.parse(entry_point.read_text(errors="ignore"))
|
|
541
|
+
server_name, server_desc, tools, issues = _analyze_python_entry(tree, git_url, tmp_dir)
|
|
542
|
+
relative_entry = str(entry_point.relative_to(tmp_dir))
|
|
543
|
+
|
|
544
|
+
docker_image, docker_suggested = _detect_docker_image(Path(tmp_dir), git_url)
|
|
545
|
+
cmd, cmd_args = _infer_command_args("python", docker_image, server_name, relative_entry)
|
|
546
|
+
result: dict = {
|
|
547
|
+
"name": server_name,
|
|
548
|
+
"description": server_desc,
|
|
549
|
+
"version": "0.1.0",
|
|
550
|
+
"tools": tools,
|
|
551
|
+
"issues": issues,
|
|
552
|
+
"environment_variables": env_vars,
|
|
553
|
+
"entry_point": relative_entry,
|
|
554
|
+
}
|
|
555
|
+
if docker_image:
|
|
556
|
+
result["docker_image"] = docker_image
|
|
557
|
+
result["docker_image_suggested"] = docker_suggested
|
|
558
|
+
if cmd:
|
|
559
|
+
result["command"] = cmd
|
|
560
|
+
result["args"] = cmd_args
|
|
561
|
+
return result
|
|
562
|
+
except Exception:
|
|
563
|
+
return {**_empty, "error": "Local analysis failed unexpectedly."}
|
|
564
|
+
finally:
|
|
565
|
+
shutil.rmtree(tmp_dir, ignore_errors=True)
|
observal_cli/branding.py
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""Observal CLI branding — ASCII banner and helpers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from rich import print as rprint
|
|
6
|
+
|
|
7
|
+
BANNER = r"""
|
|
8
|
+
██████╗ ██████╗ ███████╗███████╗██████╗ ██╗ ██╗ █████╗ ██╗
|
|
9
|
+
██╔═══██╗██╔══██╗██╔════╝██╔════╝██╔══██╗██║ ██║██╔══██╗██║
|
|
10
|
+
██║ ██║██████╔╝███████╗█████╗ ██████╔╝██║ ██║███████║██║
|
|
11
|
+
██║ ██║██╔══██╗╚════██║██╔══╝ ██╔══██╗╚██╗ ██╔╝██╔══██║██║
|
|
12
|
+
╚██████╔╝██████╔╝███████║███████╗██║ ██║ ╚████╔╝ ██║ ██║███████╗
|
|
13
|
+
╚═════╝ ╚═════╝ ╚══════╝╚══════╝╚═╝ ╚═╝ ╚═══╝ ╚═╝ ╚═╝╚══════╝
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def welcome_banner() -> None:
|
|
18
|
+
"""Print the Observal welcome banner."""
|
|
19
|
+
rprint(f"[bold cyan]{BANNER}[/bold cyan]")
|