adl-toolkit 0.0.0-stage → 0.1.3
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.
- package/INSTALL_TOOLKIT.md +163 -0
- package/README.md +269 -2
- package/adl_drift/__init__.py +6 -0
- package/adl_drift/cli/__init__.py +1 -0
- package/adl_drift/cli/commands.py +993 -0
- package/adl_drift/cli/main.py +744 -0
- package/adl_drift/cortex/__init__.py +1 -0
- package/adl_drift/cortex/embeddings.py +96 -0
- package/adl_drift/cortex/memory.py +1749 -0
- package/adl_drift/cortex/patterns.py +89 -0
- package/adl_drift/cortex/telemetry.py +192 -0
- package/adl_drift/detectors/__init__.py +1 -0
- package/adl_drift/detectors/async_patterns.py +115 -0
- package/adl_drift/detectors/base.py +121 -0
- package/adl_drift/detectors/config.py +122 -0
- package/adl_drift/detectors/data_access.py +128 -0
- package/adl_drift/detectors/decorators.py +122 -0
- package/adl_drift/detectors/dynamic_patterns.py +260 -0
- package/adl_drift/detectors/errors.py +134 -0
- package/adl_drift/detectors/imports.py +132 -0
- package/adl_drift/detectors/logging_patterns.py +108 -0
- package/adl_drift/detectors/routes.py +257 -0
- package/adl_drift/detectors/security.py +236 -0
- package/adl_drift/detectors/taint.py +406 -0
- package/adl_drift/detectors/tests.py +95 -0
- package/adl_drift/detectors/type_conventions.py +81 -0
- package/adl_drift/graph/__init__.py +1 -0
- package/adl_drift/graph/builder.py +2670 -0
- package/adl_drift/graph/cfg.py +461 -0
- package/adl_drift/graph/cluster.py +40 -0
- package/adl_drift/graph/edges.py +86 -0
- package/adl_drift/graph/node.py +90 -0
- package/adl_drift/harness/__init__.py +10 -0
- package/adl_drift/harness/setup.py +832 -0
- package/adl_drift/harness/skills/adl-drift.md +25 -0
- package/adl_drift/harness/skills/drift-antigravity.md +19 -0
- package/adl_drift/harness/skills/drift-generic.md +19 -0
- package/adl_drift/harness/watch.py +84 -0
- package/adl_drift/lang/__init__.py +8 -0
- package/adl_drift/lang/base.py +464 -0
- package/adl_drift/lang/java/__init__.py +6 -0
- package/adl_drift/lang/java/backend.py +97 -0
- package/adl_drift/lang/java/cfg.py +1424 -0
- package/adl_drift/lang/java/config.py +707 -0
- package/adl_drift/lang/java/detectors/__init__.py +29 -0
- package/adl_drift/lang/java/detectors/additional.py +445 -0
- package/adl_drift/lang/java/detectors/base.py +154 -0
- package/adl_drift/lang/java/detectors/data_access.py +148 -0
- package/adl_drift/lang/java/detectors/imports.py +53 -0
- package/adl_drift/lang/java/detectors/routes.py +159 -0
- package/adl_drift/lang/java/detectors/security.py +386 -0
- package/adl_drift/lang/java/detectors/tests.py +74 -0
- package/adl_drift/lang/java/extractor.py +582 -0
- package/adl_drift/lang/java/ids.py +99 -0
- package/adl_drift/lang/java/parser.py +49 -0
- package/adl_drift/lang/java/resolution/__init__.py +1 -0
- package/adl_drift/lang/java/resolution/constants.py +1619 -0
- package/adl_drift/lang/java/resolution/constructors.py +278 -0
- package/adl_drift/lang/java/resolution/context.py +305 -0
- package/adl_drift/lang/java/resolution/dispatch.py +540 -0
- package/adl_drift/lang/java/resolution/functional.py +1318 -0
- package/adl_drift/lang/java/resolution/generated.py +95 -0
- package/adl_drift/lang/java/resolution/model.py +153 -0
- package/adl_drift/lang/java/resolution/orchestration.py +866 -0
- package/adl_drift/lang/java/resolution/overloads.py +1275 -0
- package/adl_drift/lang/java/resolution/scopes.py +1111 -0
- package/adl_drift/lang/java/resolution/types.py +2947 -0
- package/adl_drift/lang/java/resolver.py +1401 -0
- package/adl_drift/lang/java/scopes.py +69 -0
- package/adl_drift/lang/java/taint.py +500 -0
- package/adl_drift/lang/java/type_provenance.py +342 -0
- package/adl_drift/lang/python/__init__.py +8 -0
- package/adl_drift/lang/python/backend.py +94 -0
- package/adl_drift/lang/python/cfg.py +537 -0
- package/adl_drift/lang/python/extractor.py +454 -0
- package/adl_drift/lang/python/parser.py +41 -0
- package/adl_drift/lang/python/resolver.py +1789 -0
- package/adl_drift/lang/python/taint.py +365 -0
- package/adl_drift/lang/registry.py +117 -0
- package/adl_drift/parser/__init__.py +1 -0
- package/adl_drift/parser/cache.py +38 -0
- package/adl_drift/parser/core.py +33 -0
- package/adl_drift/parser/extractor.py +17 -0
- package/adl_drift/parser/literal_eval.py +326 -0
- package/adl_drift/scanner/__init__.py +1 -0
- package/adl_drift/scanner/discovery.py +338 -0
- package/adl_drift/scanner/incremental.py +261 -0
- package/adl_drift/scanner/parallel.py +224 -0
- package/adl_drift/server/__init__.py +1 -0
- package/adl_drift/server/handlers.py +2849 -0
- package/adl_drift/server/mcp_server.py +127 -0
- package/adl_drift/server/mcp_stdio.py +335 -0
- package/adl_drift/server/protocol.py +71 -0
- package/adl_drift/server/static/index.html +696 -0
- package/adl_drift/server/web.py +350 -0
- package/adl_drift/utils/__init__.py +1 -0
- package/adl_drift/utils/config.py +77 -0
- package/adl_drift/utils/fswalk.py +52 -0
- package/adl_drift/utils/types.py +345 -0
- package/bin/adl-drift.mjs +35 -0
- package/bin/adl-toolkit.mjs +488 -0
- package/package.json +29 -4
- package/pyproject.toml +49 -0
- package/scripts/install_toolkit.py +3584 -0
|
@@ -0,0 +1,832 @@
|
|
|
1
|
+
"""Auto-configuration for MCP clients + the `doctor` diagnostic.
|
|
2
|
+
|
|
3
|
+
`adl-drift mcp setup` writes the client configs that point at the stdio
|
|
4
|
+
server and installs the skill file that teaches the agent when to use the
|
|
5
|
+
tools. `adl-drift mcp doctor` spawns the real server and runs the JSON-RPC
|
|
6
|
+
handshake so "it doesn't work" is diagnosable in one command.
|
|
7
|
+
|
|
8
|
+
Config writes merge — existing servers/keys in a config file are preserved;
|
|
9
|
+
the legacy `drift` entry is migrated to `adl-drift`.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import json
|
|
15
|
+
import queue
|
|
16
|
+
import re
|
|
17
|
+
import subprocess
|
|
18
|
+
import sys
|
|
19
|
+
import tempfile
|
|
20
|
+
import threading
|
|
21
|
+
from importlib import resources
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
from typing import Any, Dict, List, Optional
|
|
24
|
+
|
|
25
|
+
SERVER_NAME = "adl-drift"
|
|
26
|
+
LEGACY_SERVER_NAME = "drift"
|
|
27
|
+
SKILL_PACKAGE = "adl_drift.harness"
|
|
28
|
+
|
|
29
|
+
# Where each client looks for things, relative to the project root.
|
|
30
|
+
CLAUDE_MCP_CONFIG = ".mcp.json"
|
|
31
|
+
CLAUDE_SKILL_PATH = ".claude/skills/adl-drift/SKILL.md"
|
|
32
|
+
CURSOR_MCP_CONFIG = ".cursor/mcp.json"
|
|
33
|
+
CURSOR_RULE_PATH = ".cursor/rules/drift.mdc"
|
|
34
|
+
CODEX_MCP_CONFIG = ".codex/config.toml"
|
|
35
|
+
|
|
36
|
+
# Antigravity (Google's VS Code-derived IDE) keeps MCP config under
|
|
37
|
+
# ~/.gemini/, with a recent migration from a legacy path to a unified path.
|
|
38
|
+
# We detect the migration marker at install time and sweep both paths on
|
|
39
|
+
# uninstall for safety. Antigravity has no project-local config concept.
|
|
40
|
+
ANTIGRAVITY_UNIFIED_DIR = ".gemini/config"
|
|
41
|
+
ANTIGRAVITY_LEGACY_DIR = ".gemini/antigravity"
|
|
42
|
+
ANTIGRAVITY_MCP_CONFIG = "mcp_config.json"
|
|
43
|
+
ANTIGRAVITY_MIGRATED_MARKER = ".migrated"
|
|
44
|
+
|
|
45
|
+
_CURSOR_RULE_FRONTMATTER = (
|
|
46
|
+
"---\n"
|
|
47
|
+
"description: Call-graph and pattern index for this repo (ADL Drift MCP tools).\n"
|
|
48
|
+
"alwaysApply: false\n"
|
|
49
|
+
"---\n\n"
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def server_entry() -> Dict[str, Any]:
|
|
54
|
+
"""The mcpServers entry both clients share.
|
|
55
|
+
|
|
56
|
+
`sys.executable` is the venv Python running `adl-drift mcp setup` — the
|
|
57
|
+
one place the right interpreter is always known. `--project .` resolves
|
|
58
|
+
against the client's cwd, which for project-scoped configs is the
|
|
59
|
+
project root, so the file stays committable across machines only if the
|
|
60
|
+
venv path matches; `doctor` flags a dangling interpreter either way.
|
|
61
|
+
"""
|
|
62
|
+
return {
|
|
63
|
+
"command": sys.executable,
|
|
64
|
+
"args": ["-m", "adl_drift.server.mcp_stdio", "--project", "."],
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def antigravity_server_entry(project_path: Path) -> Dict[str, Any]:
|
|
69
|
+
"""The MCP-server entry for Antigravity IDE.
|
|
70
|
+
|
|
71
|
+
Antigravity rejects the `type: "stdio"` field that other clients accept,
|
|
72
|
+
so this entry omits it. Because Antigravity config is global (not
|
|
73
|
+
project-scoped), we pin the absolute project path so the server always
|
|
74
|
+
indexes the project that was configured.
|
|
75
|
+
"""
|
|
76
|
+
return {
|
|
77
|
+
"command": sys.executable,
|
|
78
|
+
"args": [
|
|
79
|
+
"-m", "adl_drift.server.mcp_stdio",
|
|
80
|
+
"--project", str(Path(project_path).resolve()),
|
|
81
|
+
],
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def codex_server_entry(project_path: Path) -> Dict[str, Any]:
|
|
86
|
+
"""Project-scoped MCP entry for Codex CLI and its VS Code extension."""
|
|
87
|
+
return {
|
|
88
|
+
"command": sys.executable,
|
|
89
|
+
"args": [
|
|
90
|
+
"-m", "adl_drift.server.mcp_stdio",
|
|
91
|
+
"--project", str(Path(project_path).resolve()),
|
|
92
|
+
],
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _codex_table(entry: Dict[str, Any]) -> str:
|
|
97
|
+
return (
|
|
98
|
+
"[mcp_servers.adl-drift]\n"
|
|
99
|
+
f"command = {json.dumps(entry['command'])}\n"
|
|
100
|
+
f"args = {json.dumps(entry['args'])}\n"
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _replace_codex_table(text: str, replacement: str = "") -> str:
|
|
105
|
+
"""Replace ADL Drift and legacy TOML tables, preserving other settings."""
|
|
106
|
+
return re.sub(
|
|
107
|
+
r"(?ms)^\[mcp_servers\.(?:adl-drift|drift)(?:\.[^\]]+)?\]\n.*?"
|
|
108
|
+
r"(?=^\[(?!mcp_servers\.(?:adl-drift|drift)(?:\.|\]))|\Z)",
|
|
109
|
+
replacement,
|
|
110
|
+
text,
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _merge_codex_config(config_path: Path, project_path: Path) -> tuple[str, str]:
|
|
115
|
+
existing = config_path.read_text(encoding="utf-8") if config_path.exists() else ""
|
|
116
|
+
updated = _replace_codex_table(existing).rstrip()
|
|
117
|
+
table = _codex_table(codex_server_entry(project_path))
|
|
118
|
+
updated = f"{updated}\n\n{table}" if updated else table
|
|
119
|
+
action = (
|
|
120
|
+
"created" if not config_path.exists()
|
|
121
|
+
else "unchanged" if existing == updated else "updated"
|
|
122
|
+
)
|
|
123
|
+
return updated, action
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _remove_codex_entry(config_path: Path) -> Optional[Dict[str, str]]:
|
|
127
|
+
if not config_path.exists():
|
|
128
|
+
return None
|
|
129
|
+
existing = config_path.read_text(encoding="utf-8")
|
|
130
|
+
updated = _replace_codex_table(existing).strip()
|
|
131
|
+
if updated == existing.strip():
|
|
132
|
+
return None
|
|
133
|
+
if updated:
|
|
134
|
+
config_path.write_text(updated + "\n", encoding="utf-8")
|
|
135
|
+
else:
|
|
136
|
+
config_path.unlink()
|
|
137
|
+
return {"path": CODEX_MCP_CONFIG, "action": "removed"}
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _codex_config_entry(config_path: Path) -> Optional[Dict[str, Any]]:
|
|
141
|
+
if not config_path.exists():
|
|
142
|
+
return None
|
|
143
|
+
text = config_path.read_text(encoding="utf-8")
|
|
144
|
+
match = None
|
|
145
|
+
for name in (SERVER_NAME, LEGACY_SERVER_NAME):
|
|
146
|
+
match = re.search(
|
|
147
|
+
rf"(?ms)^\[mcp_servers\.{re.escape(name)}\]\n(.*?)"
|
|
148
|
+
rf"(?=^\[(?!mcp_servers\.{re.escape(name)}(?:\.|\]))|\Z)",
|
|
149
|
+
text,
|
|
150
|
+
)
|
|
151
|
+
if match:
|
|
152
|
+
break
|
|
153
|
+
if match is None:
|
|
154
|
+
return None
|
|
155
|
+
values = dict(re.findall(r"(?m)^(command|args)\s*=\s*(.+)$", match.group(1)))
|
|
156
|
+
try:
|
|
157
|
+
return {"command": json.loads(values["command"]), "args": json.loads(values["args"])}
|
|
158
|
+
except (KeyError, json.JSONDecodeError):
|
|
159
|
+
return None
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _antigravity_paths(home: Optional[Path] = None) -> Dict[str, Path]:
|
|
163
|
+
home = home or Path.home()
|
|
164
|
+
unified_dir = home / ANTIGRAVITY_UNIFIED_DIR
|
|
165
|
+
legacy_dir = home / ANTIGRAVITY_LEGACY_DIR
|
|
166
|
+
return {
|
|
167
|
+
"unified_dir": unified_dir,
|
|
168
|
+
"unified_file": unified_dir / ANTIGRAVITY_MCP_CONFIG,
|
|
169
|
+
"legacy_dir": legacy_dir,
|
|
170
|
+
"legacy_file": legacy_dir / ANTIGRAVITY_MCP_CONFIG,
|
|
171
|
+
"migrated_marker": unified_dir / ANTIGRAVITY_MIGRATED_MARKER,
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _antigravity_preferred_config_path(home: Optional[Path] = None) -> Path:
|
|
176
|
+
"""Return the Antigravity MCP config path to write to.
|
|
177
|
+
|
|
178
|
+
Prefer the unified path when Antigravity has signalled migration (marker
|
|
179
|
+
file present) or when the unified file already exists; otherwise fall
|
|
180
|
+
back to the legacy path.
|
|
181
|
+
"""
|
|
182
|
+
paths = _antigravity_paths(home)
|
|
183
|
+
if paths["migrated_marker"].exists() or paths["unified_file"].exists():
|
|
184
|
+
return paths["unified_file"]
|
|
185
|
+
return paths["legacy_file"]
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def read_skill(name: str = "adl-drift.md") -> str:
|
|
189
|
+
return (resources.files(SKILL_PACKAGE) / "skills" / name).read_text(encoding="utf-8")
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
# AGENTS.md marker-fenced section installed for Codex and Antigravity. The
|
|
193
|
+
# fenced block lets setup merge updates without overwriting hand-written
|
|
194
|
+
# content, and lets uninstall remove only the workflow that ADL Drift owns.
|
|
195
|
+
AGENTS_MD_PATH = "AGENTS.md"
|
|
196
|
+
ANTIGRAVITY_RULE_PATH = ".agents/rules/drift.md"
|
|
197
|
+
AGENTS_MD_MARKER_START = "<!-- BEGIN DRIFT WORKFLOW -->"
|
|
198
|
+
AGENTS_MD_MARKER_END = "<!-- END DRIFT WORKFLOW -->"
|
|
199
|
+
_LEGACY_AGENTS_MD_MARKER_START = "<!-- BEGIN DRIFT ANTIGRAVITY WORKFLOW -->"
|
|
200
|
+
_LEGACY_AGENTS_MD_MARKER_END = "<!-- END DRIFT ANTIGRAVITY WORKFLOW -->"
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _agents_workflow_section() -> str:
|
|
204
|
+
"""Return the marker-fenced ADL Drift workflow section for AGENTS.md."""
|
|
205
|
+
body = read_skill("drift-antigravity.md")
|
|
206
|
+
return (
|
|
207
|
+
f"{AGENTS_MD_MARKER_START}\n"
|
|
208
|
+
f"{body}\n"
|
|
209
|
+
f"{AGENTS_MD_MARKER_END}\n"
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def _antigravity_configured_for_project(project_path: Path) -> bool:
|
|
214
|
+
"""Return whether Antigravity has an ADL Drift entry for this project."""
|
|
215
|
+
expected_args = antigravity_server_entry(project_path)["args"]
|
|
216
|
+
paths = _antigravity_paths()
|
|
217
|
+
for config_path in (paths["unified_file"], paths["legacy_file"]):
|
|
218
|
+
if not config_path.exists():
|
|
219
|
+
continue
|
|
220
|
+
try:
|
|
221
|
+
config = json.loads(config_path.read_text(encoding="utf-8"))
|
|
222
|
+
except (json.JSONDecodeError, OSError):
|
|
223
|
+
continue
|
|
224
|
+
if not isinstance(config, dict):
|
|
225
|
+
continue
|
|
226
|
+
servers = config.get("mcpServers", {})
|
|
227
|
+
if isinstance(servers, dict) and any(
|
|
228
|
+
isinstance(servers.get(name), dict)
|
|
229
|
+
and servers[name].get("args") == expected_args
|
|
230
|
+
for name in (SERVER_NAME, LEGACY_SERVER_NAME)
|
|
231
|
+
):
|
|
232
|
+
return True
|
|
233
|
+
return False
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def _agents_md_block(existing: str) -> tuple[int, int, str] | None:
|
|
237
|
+
"""Find the current or legacy ADL Drift workflow fence in ``existing``."""
|
|
238
|
+
for start, end in (
|
|
239
|
+
(AGENTS_MD_MARKER_START, AGENTS_MD_MARKER_END),
|
|
240
|
+
(_LEGACY_AGENTS_MD_MARKER_START, _LEGACY_AGENTS_MD_MARKER_END),
|
|
241
|
+
):
|
|
242
|
+
start_idx = existing.find(start)
|
|
243
|
+
end_idx = existing.find(end)
|
|
244
|
+
if start_idx != -1 and end_idx > start_idx:
|
|
245
|
+
return start_idx, end_idx + len(end), start
|
|
246
|
+
return None
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def _merge_agents_md(project_path: Path, section: str) -> tuple[str, str]:
|
|
250
|
+
"""Return (merged content, action) for AGENTS.md without writing.
|
|
251
|
+
|
|
252
|
+
The section is treated as an opaque block bounded by the marker comments.
|
|
253
|
+
Re-running setup with the same section produces an unchanged action.
|
|
254
|
+
"""
|
|
255
|
+
path = project_path / AGENTS_MD_PATH
|
|
256
|
+
existing = path.read_text(encoding="utf-8") if path.exists() else ""
|
|
257
|
+
|
|
258
|
+
block = _agents_md_block(existing)
|
|
259
|
+
if block:
|
|
260
|
+
start_idx, end_idx, _ = block
|
|
261
|
+
before = existing[:start_idx].rstrip("\n")
|
|
262
|
+
after = existing[end_idx:].lstrip("\n")
|
|
263
|
+
merged = ""
|
|
264
|
+
if before:
|
|
265
|
+
merged += before + "\n\n"
|
|
266
|
+
merged += section
|
|
267
|
+
if after:
|
|
268
|
+
merged += "\n" + after + "\n"
|
|
269
|
+
action = "updated" if merged != existing else "unchanged"
|
|
270
|
+
elif not existing:
|
|
271
|
+
merged = f"# Project Agents\n\n{section}"
|
|
272
|
+
action = "created"
|
|
273
|
+
else:
|
|
274
|
+
merged = existing.rstrip("\n") + "\n\n" + section
|
|
275
|
+
action = "updated"
|
|
276
|
+
return merged, action
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def detect_clients(project_path: Path) -> List[str]:
|
|
280
|
+
"""Config-dir probing: project dirs first, then home dirs."""
|
|
281
|
+
found = []
|
|
282
|
+
home = Path.home()
|
|
283
|
+
if (project_path / ".claude").is_dir() or (home / ".claude").is_dir():
|
|
284
|
+
found.append("claude")
|
|
285
|
+
if (project_path / ".cursor").is_dir() or (home / ".cursor").is_dir():
|
|
286
|
+
found.append("cursor")
|
|
287
|
+
if (project_path / CODEX_MCP_CONFIG).exists():
|
|
288
|
+
found.append("codex")
|
|
289
|
+
paths = _antigravity_paths(home)
|
|
290
|
+
if (
|
|
291
|
+
paths["unified_dir"].is_dir()
|
|
292
|
+
or paths["legacy_dir"].is_dir()
|
|
293
|
+
or paths["unified_file"].exists()
|
|
294
|
+
or paths["legacy_file"].exists()
|
|
295
|
+
):
|
|
296
|
+
found.append("antigravity")
|
|
297
|
+
return found
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def _load_config_json(config_path: Path) -> Dict[str, Any]:
|
|
301
|
+
"""Load a JSON config file, treating an empty/whitespace file as `{}`."""
|
|
302
|
+
if not config_path.exists():
|
|
303
|
+
return {}
|
|
304
|
+
text = config_path.read_text(encoding="utf-8").strip()
|
|
305
|
+
if not text:
|
|
306
|
+
return {}
|
|
307
|
+
try:
|
|
308
|
+
config = json.loads(text)
|
|
309
|
+
except json.JSONDecodeError as e:
|
|
310
|
+
raise ValueError(f"{config_path} is not valid JSON ({e}); fix or remove it") from e
|
|
311
|
+
if not isinstance(config, dict):
|
|
312
|
+
raise ValueError(f"{config_path} top level must be a JSON object")
|
|
313
|
+
return config
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def _merge_server_config(config_path: Path) -> tuple[Dict[str, Any], str]:
|
|
317
|
+
"""Return (merged config, action) without writing."""
|
|
318
|
+
config = _load_config_json(config_path)
|
|
319
|
+
|
|
320
|
+
servers = config.setdefault("mcpServers", {})
|
|
321
|
+
entry = server_entry()
|
|
322
|
+
legacy_removed = servers.pop(LEGACY_SERVER_NAME, None) is not None
|
|
323
|
+
if servers.get(SERVER_NAME) == entry and not legacy_removed:
|
|
324
|
+
return config, "unchanged"
|
|
325
|
+
action = "updated" if config_path.exists() else "created"
|
|
326
|
+
servers[SERVER_NAME] = entry
|
|
327
|
+
return config, action
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def _merge_antigravity_config(
|
|
331
|
+
config_path: Path, project_path: Path
|
|
332
|
+
) -> tuple[Dict[str, Any], str]:
|
|
333
|
+
"""Merge the ADL Drift entry into an Antigravity `mcp_config.json`.
|
|
334
|
+
|
|
335
|
+
Antigravity's top-level key is also `mcpServers`, but the entry omits
|
|
336
|
+
the `type` field used by Claude/Cursor. Existing keys other than the
|
|
337
|
+
ADL Drift entry are preserved. Empty/whitespace files are treated as `{}`.
|
|
338
|
+
"""
|
|
339
|
+
config = _load_config_json(config_path)
|
|
340
|
+
|
|
341
|
+
servers = config.setdefault("mcpServers", {})
|
|
342
|
+
entry = antigravity_server_entry(project_path)
|
|
343
|
+
legacy_removed = servers.pop(LEGACY_SERVER_NAME, None) is not None
|
|
344
|
+
if servers.get(SERVER_NAME) == entry and not legacy_removed:
|
|
345
|
+
return config, "unchanged"
|
|
346
|
+
action = "updated" if config_path.exists() else "created"
|
|
347
|
+
servers[SERVER_NAME] = entry
|
|
348
|
+
return config, action
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def _plan_file(path: Path, content: str) -> str:
|
|
352
|
+
if not path.exists():
|
|
353
|
+
return "created"
|
|
354
|
+
return "unchanged" if path.read_text(encoding="utf-8") == content else "updated"
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
def _remove_antigravity_entry(config_path: Path, unlink_empty: bool = False) -> Optional[Dict[str, str]]:
|
|
358
|
+
"""Remove ADL Drift entries from an Antigravity config file.
|
|
359
|
+
|
|
360
|
+
Returns an action record if the file existed and contained a drift entry,
|
|
361
|
+
otherwise None. Empty `mcpServers` objects are pruned. By default the file
|
|
362
|
+
is left in place (Antigravity manages it); pass `unlink_empty=True` to
|
|
363
|
+
delete the file when it becomes empty.
|
|
364
|
+
"""
|
|
365
|
+
if not config_path.exists():
|
|
366
|
+
return None
|
|
367
|
+
try:
|
|
368
|
+
config = json.loads(config_path.read_text(encoding="utf-8"))
|
|
369
|
+
except (json.JSONDecodeError, ValueError):
|
|
370
|
+
return None
|
|
371
|
+
if not isinstance(config, dict):
|
|
372
|
+
return None
|
|
373
|
+
servers = config.get("mcpServers", {})
|
|
374
|
+
names = {SERVER_NAME, LEGACY_SERVER_NAME} & set(servers) if isinstance(servers, dict) else set()
|
|
375
|
+
if not names:
|
|
376
|
+
return None
|
|
377
|
+
for name in names:
|
|
378
|
+
del servers[name]
|
|
379
|
+
if not servers:
|
|
380
|
+
config.pop("mcpServers", None)
|
|
381
|
+
if unlink_empty and not config:
|
|
382
|
+
config_path.unlink()
|
|
383
|
+
else:
|
|
384
|
+
config_path.write_text(json.dumps(config, indent=2) + "\n", encoding="utf-8")
|
|
385
|
+
return {"path": str(config_path), "action": "removed"}
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
def setup(
|
|
389
|
+
project_path: Path,
|
|
390
|
+
claude: bool = False,
|
|
391
|
+
cursor: bool = False,
|
|
392
|
+
antigravity: bool = False,
|
|
393
|
+
codex: bool = False,
|
|
394
|
+
dry_run: bool = False,
|
|
395
|
+
) -> List[Dict[str, str]]:
|
|
396
|
+
"""Configure MCP clients. No flags = configure every detected client
|
|
397
|
+
(generic .mcp.json if none detected). Returns one action record per
|
|
398
|
+
file: {path, action: created|updated|unchanged|removed}."""
|
|
399
|
+
project_path = Path(project_path).resolve()
|
|
400
|
+
if not claude and not cursor and not antigravity and not codex:
|
|
401
|
+
detected = detect_clients(project_path)
|
|
402
|
+
claude = "claude" in detected
|
|
403
|
+
cursor = "cursor" in detected
|
|
404
|
+
antigravity = "antigravity" in detected
|
|
405
|
+
codex = True
|
|
406
|
+
if not detected:
|
|
407
|
+
claude = True # generic fallback: .mcp.json is the open format
|
|
408
|
+
|
|
409
|
+
actions: List[Dict[str, str]] = []
|
|
410
|
+
|
|
411
|
+
def _write_json(rel: str) -> None:
|
|
412
|
+
path = project_path / rel
|
|
413
|
+
config, action = _merge_server_config(path)
|
|
414
|
+
actions.append({"path": rel, "action": action})
|
|
415
|
+
if not dry_run and action != "unchanged":
|
|
416
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
417
|
+
path.write_text(json.dumps(config, indent=2) + "\n", encoding="utf-8")
|
|
418
|
+
|
|
419
|
+
def _write_text(rel: str, content: str) -> None:
|
|
420
|
+
path = project_path / rel
|
|
421
|
+
action = _plan_file(path, content)
|
|
422
|
+
actions.append({"path": rel, "action": action})
|
|
423
|
+
if not dry_run and action != "unchanged":
|
|
424
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
425
|
+
path.write_text(content, encoding="utf-8")
|
|
426
|
+
|
|
427
|
+
if claude:
|
|
428
|
+
_write_json(CLAUDE_MCP_CONFIG)
|
|
429
|
+
_write_text(CLAUDE_SKILL_PATH, read_skill())
|
|
430
|
+
if cursor:
|
|
431
|
+
_write_json(CURSOR_MCP_CONFIG)
|
|
432
|
+
_write_text(CURSOR_RULE_PATH, _CURSOR_RULE_FRONTMATTER + read_skill("drift-generic.md"))
|
|
433
|
+
if codex:
|
|
434
|
+
config_path = project_path / CODEX_MCP_CONFIG
|
|
435
|
+
config, _ = _merge_codex_config(config_path, project_path)
|
|
436
|
+
_write_text(CODEX_MCP_CONFIG, config)
|
|
437
|
+
if codex or antigravity:
|
|
438
|
+
agents_content, agents_action = _merge_agents_md(
|
|
439
|
+
project_path, _agents_workflow_section()
|
|
440
|
+
)
|
|
441
|
+
actions.append({"path": AGENTS_MD_PATH, "action": agents_action})
|
|
442
|
+
if not dry_run and agents_action != "unchanged":
|
|
443
|
+
(project_path / AGENTS_MD_PATH).write_text(agents_content, encoding="utf-8")
|
|
444
|
+
if antigravity:
|
|
445
|
+
preferred = _antigravity_preferred_config_path()
|
|
446
|
+
if dry_run:
|
|
447
|
+
# Report what would happen without touching disk.
|
|
448
|
+
actions.append({"path": str(preferred), "action": "created"})
|
|
449
|
+
actions.append({"path": ANTIGRAVITY_RULE_PATH, "action": "created"})
|
|
450
|
+
else:
|
|
451
|
+
config, action = _merge_antigravity_config(preferred, project_path)
|
|
452
|
+
preferred.parent.mkdir(parents=True, exist_ok=True)
|
|
453
|
+
preferred.write_text(json.dumps(config, indent=2) + "\n", encoding="utf-8")
|
|
454
|
+
actions.append({"path": str(preferred), "action": action})
|
|
455
|
+
|
|
456
|
+
# If the user originally installed on the legacy path and Antigravity
|
|
457
|
+
# has since migrated, strip the stale legacy entry so they don't wind
|
|
458
|
+
# up with two competing ADL Drift configs.
|
|
459
|
+
paths = _antigravity_paths()
|
|
460
|
+
if preferred == paths["unified_file"] and paths["legacy_file"].exists():
|
|
461
|
+
removed = _remove_antigravity_entry(paths["legacy_file"])
|
|
462
|
+
if removed:
|
|
463
|
+
actions.append(removed)
|
|
464
|
+
|
|
465
|
+
# Native Antigravity workspace rules are evaluated for every
|
|
466
|
+
# prompt; AGENTS.md remains installed for compatibility.
|
|
467
|
+
_write_text(ANTIGRAVITY_RULE_PATH, read_skill("drift-antigravity.md"))
|
|
468
|
+
|
|
469
|
+
return actions
|
|
470
|
+
|
|
471
|
+
|
|
472
|
+
# ---------------------------------------------------------------- uninstall
|
|
473
|
+
|
|
474
|
+
|
|
475
|
+
def _remove_server_entry(config_path: Path) -> Optional[Dict[str, str]]:
|
|
476
|
+
"""Remove ADL Drift entries from a Claude/Cursor style config file.
|
|
477
|
+
|
|
478
|
+
Returns an action record if the file existed and contained a drift entry,
|
|
479
|
+
otherwise None. Empty `mcpServers` objects are pruned; if the whole file
|
|
480
|
+
becomes empty it is removed.
|
|
481
|
+
"""
|
|
482
|
+
if not config_path.exists():
|
|
483
|
+
return None
|
|
484
|
+
try:
|
|
485
|
+
config = json.loads(config_path.read_text(encoding="utf-8"))
|
|
486
|
+
except (json.JSONDecodeError, ValueError):
|
|
487
|
+
return None
|
|
488
|
+
if not isinstance(config, dict):
|
|
489
|
+
return None
|
|
490
|
+
servers = config.get("mcpServers", {})
|
|
491
|
+
names = {SERVER_NAME, LEGACY_SERVER_NAME} & set(servers) if isinstance(servers, dict) else set()
|
|
492
|
+
if not names:
|
|
493
|
+
return None
|
|
494
|
+
for name in names:
|
|
495
|
+
del servers[name]
|
|
496
|
+
if not servers:
|
|
497
|
+
config.pop("mcpServers", None)
|
|
498
|
+
if not config:
|
|
499
|
+
config_path.unlink()
|
|
500
|
+
else:
|
|
501
|
+
config_path.write_text(json.dumps(config, indent=2) + "\n", encoding="utf-8")
|
|
502
|
+
return {"path": str(config_path), "action": "removed"}
|
|
503
|
+
|
|
504
|
+
|
|
505
|
+
def _remove_text_file_if_matches(path: Path, expected_content: str) -> Optional[Dict[str, str]]:
|
|
506
|
+
"""Remove a skill/rule file if its current content exactly matches ours."""
|
|
507
|
+
if not path.exists():
|
|
508
|
+
return None
|
|
509
|
+
try:
|
|
510
|
+
current = path.read_text(encoding="utf-8")
|
|
511
|
+
except OSError:
|
|
512
|
+
return None
|
|
513
|
+
if current != expected_content:
|
|
514
|
+
return {"path": str(path), "action": "unchanged"}
|
|
515
|
+
path.unlink()
|
|
516
|
+
return {"path": str(path), "action": "removed"}
|
|
517
|
+
|
|
518
|
+
|
|
519
|
+
def _remove_agents_md_section(project_path: Path) -> Optional[Dict[str, str]]:
|
|
520
|
+
"""Remove the marker-fenced ADL Drift section from AGENTS.md.
|
|
521
|
+
|
|
522
|
+
Returns an action record if AGENTS.md existed and contained the section.
|
|
523
|
+
If removing the section leaves only whitespace or the generic header we
|
|
524
|
+
created, the whole file is removed.
|
|
525
|
+
"""
|
|
526
|
+
path = project_path / AGENTS_MD_PATH
|
|
527
|
+
if not path.exists():
|
|
528
|
+
return None
|
|
529
|
+
existing = path.read_text(encoding="utf-8")
|
|
530
|
+
block = _agents_md_block(existing)
|
|
531
|
+
if not block:
|
|
532
|
+
return {"path": AGENTS_MD_PATH, "action": "unchanged"}
|
|
533
|
+
start_idx, end_idx, _ = block
|
|
534
|
+
before = existing[:start_idx].rstrip("\n")
|
|
535
|
+
after = existing[end_idx:].lstrip("\n")
|
|
536
|
+
merged = ""
|
|
537
|
+
if before:
|
|
538
|
+
merged += before + "\n"
|
|
539
|
+
if after:
|
|
540
|
+
merged += after + "\n"
|
|
541
|
+
remaining = merged.strip()
|
|
542
|
+
# If the file only contains our generic header (or nothing), remove it.
|
|
543
|
+
if not remaining or remaining == "# Project Agents":
|
|
544
|
+
path.unlink()
|
|
545
|
+
else:
|
|
546
|
+
path.write_text(merged, encoding="utf-8")
|
|
547
|
+
return {"path": AGENTS_MD_PATH, "action": "removed"}
|
|
548
|
+
|
|
549
|
+
|
|
550
|
+
def uninstall(
|
|
551
|
+
project_path: Path,
|
|
552
|
+
claude: bool = False,
|
|
553
|
+
cursor: bool = False,
|
|
554
|
+
antigravity: bool = False,
|
|
555
|
+
codex: bool = False,
|
|
556
|
+
dry_run: bool = False,
|
|
557
|
+
) -> List[Dict[str, str]]:
|
|
558
|
+
"""Remove ADL Drift MCP client configuration. No flags = uninstall every
|
|
559
|
+
detected client. Returns one action record per touched file."""
|
|
560
|
+
project_path = Path(project_path).resolve()
|
|
561
|
+
if not claude and not cursor and not antigravity and not codex:
|
|
562
|
+
detected = detect_clients(project_path)
|
|
563
|
+
claude = "claude" in detected
|
|
564
|
+
cursor = "cursor" in detected
|
|
565
|
+
antigravity = "antigravity" in detected
|
|
566
|
+
codex = "codex" in detected
|
|
567
|
+
if not detected:
|
|
568
|
+
claude = True # generic fallback
|
|
569
|
+
|
|
570
|
+
actions: List[Dict[str, str]] = []
|
|
571
|
+
remove_agents_workflow = (codex or antigravity) and not (
|
|
572
|
+
(not codex and _codex_config_entry(project_path / CODEX_MCP_CONFIG))
|
|
573
|
+
or (not antigravity and _antigravity_configured_for_project(project_path))
|
|
574
|
+
)
|
|
575
|
+
|
|
576
|
+
if claude:
|
|
577
|
+
if dry_run:
|
|
578
|
+
actions.append({"path": CLAUDE_MCP_CONFIG, "action": "removed"})
|
|
579
|
+
actions.append({"path": CLAUDE_SKILL_PATH, "action": "removed"})
|
|
580
|
+
else:
|
|
581
|
+
removed = _remove_server_entry(project_path / CLAUDE_MCP_CONFIG)
|
|
582
|
+
if removed:
|
|
583
|
+
removed["path"] = CLAUDE_MCP_CONFIG
|
|
584
|
+
actions.append(removed)
|
|
585
|
+
removed = _remove_text_file_if_matches(
|
|
586
|
+
project_path / CLAUDE_SKILL_PATH, read_skill()
|
|
587
|
+
)
|
|
588
|
+
if removed:
|
|
589
|
+
removed["path"] = CLAUDE_SKILL_PATH
|
|
590
|
+
actions.append(removed)
|
|
591
|
+
|
|
592
|
+
if cursor:
|
|
593
|
+
if dry_run:
|
|
594
|
+
actions.append({"path": CURSOR_MCP_CONFIG, "action": "removed"})
|
|
595
|
+
actions.append({"path": CURSOR_RULE_PATH, "action": "removed"})
|
|
596
|
+
else:
|
|
597
|
+
removed = _remove_server_entry(project_path / CURSOR_MCP_CONFIG)
|
|
598
|
+
if removed:
|
|
599
|
+
removed["path"] = CURSOR_MCP_CONFIG
|
|
600
|
+
actions.append(removed)
|
|
601
|
+
removed = _remove_text_file_if_matches(
|
|
602
|
+
project_path / CURSOR_RULE_PATH,
|
|
603
|
+
_CURSOR_RULE_FRONTMATTER + read_skill("drift-generic.md"),
|
|
604
|
+
)
|
|
605
|
+
if removed:
|
|
606
|
+
removed["path"] = CURSOR_RULE_PATH
|
|
607
|
+
actions.append(removed)
|
|
608
|
+
|
|
609
|
+
if codex:
|
|
610
|
+
if dry_run:
|
|
611
|
+
actions.append({"path": CODEX_MCP_CONFIG, "action": "removed"})
|
|
612
|
+
else:
|
|
613
|
+
removed = _remove_codex_entry(project_path / CODEX_MCP_CONFIG)
|
|
614
|
+
if removed:
|
|
615
|
+
actions.append(removed)
|
|
616
|
+
|
|
617
|
+
if remove_agents_workflow:
|
|
618
|
+
if dry_run:
|
|
619
|
+
actions.append({"path": AGENTS_MD_PATH, "action": "removed"})
|
|
620
|
+
else:
|
|
621
|
+
removed = _remove_agents_md_section(project_path)
|
|
622
|
+
if removed:
|
|
623
|
+
actions.append(removed)
|
|
624
|
+
|
|
625
|
+
if antigravity:
|
|
626
|
+
paths = _antigravity_paths()
|
|
627
|
+
preferred = _antigravity_preferred_config_path()
|
|
628
|
+
if dry_run:
|
|
629
|
+
actions.append({"path": str(preferred), "action": "removed"})
|
|
630
|
+
actions.append({"path": ANTIGRAVITY_RULE_PATH, "action": "removed"})
|
|
631
|
+
else:
|
|
632
|
+
for config_path in (paths["unified_file"], paths["legacy_file"]):
|
|
633
|
+
removed = _remove_antigravity_entry(config_path, unlink_empty=True)
|
|
634
|
+
if removed:
|
|
635
|
+
actions.append(removed)
|
|
636
|
+
removed = _remove_text_file_if_matches(
|
|
637
|
+
project_path / ANTIGRAVITY_RULE_PATH,
|
|
638
|
+
read_skill("drift-antigravity.md"),
|
|
639
|
+
)
|
|
640
|
+
if removed:
|
|
641
|
+
removed["path"] = ANTIGRAVITY_RULE_PATH
|
|
642
|
+
actions.append(removed)
|
|
643
|
+
|
|
644
|
+
return actions
|
|
645
|
+
|
|
646
|
+
|
|
647
|
+
# ---------------------------------------------------------------- doctor
|
|
648
|
+
|
|
649
|
+
|
|
650
|
+
def _configured_entry(project_path: Path) -> Optional[Dict[str, Any]]:
|
|
651
|
+
"""The ADL Drift entry from a supported project or Antigravity config, if any."""
|
|
652
|
+
for rel in (CLAUDE_MCP_CONFIG, CURSOR_MCP_CONFIG):
|
|
653
|
+
path = project_path / rel
|
|
654
|
+
if not path.exists():
|
|
655
|
+
continue
|
|
656
|
+
try:
|
|
657
|
+
servers = json.loads(path.read_text(encoding="utf-8")).get("mcpServers", {})
|
|
658
|
+
except (json.JSONDecodeError, AttributeError):
|
|
659
|
+
continue
|
|
660
|
+
for name in (SERVER_NAME, LEGACY_SERVER_NAME):
|
|
661
|
+
if name in servers:
|
|
662
|
+
return servers[name]
|
|
663
|
+
codex_entry = _codex_config_entry(project_path / CODEX_MCP_CONFIG)
|
|
664
|
+
if codex_entry:
|
|
665
|
+
return codex_entry
|
|
666
|
+
# Antigravity config lives in the home dir, not the project.
|
|
667
|
+
antigravity_path = _antigravity_preferred_config_path()
|
|
668
|
+
if antigravity_path.exists():
|
|
669
|
+
try:
|
|
670
|
+
servers = json.loads(antigravity_path.read_text(encoding="utf-8")).get("mcpServers", {})
|
|
671
|
+
except (json.JSONDecodeError, AttributeError):
|
|
672
|
+
servers = {}
|
|
673
|
+
for name in (SERVER_NAME, LEGACY_SERVER_NAME):
|
|
674
|
+
if name in servers:
|
|
675
|
+
return servers[name]
|
|
676
|
+
return None
|
|
677
|
+
|
|
678
|
+
|
|
679
|
+
def _handshake(command: List[str], cwd: Path, timeout: float) -> Dict[str, Any]:
|
|
680
|
+
"""Spawn the stdio server, run initialize + tools/list, return findings."""
|
|
681
|
+
# A file drains startup tracebacks without blocking the server's stderr pipe.
|
|
682
|
+
with tempfile.TemporaryFile() as stderr:
|
|
683
|
+
proc = subprocess.Popen(
|
|
684
|
+
command, cwd=cwd, text=True, encoding="utf-8", errors="replace",
|
|
685
|
+
stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=stderr,
|
|
686
|
+
)
|
|
687
|
+
|
|
688
|
+
def send(msg: Dict[str, Any]) -> None:
|
|
689
|
+
assert proc.stdin is not None
|
|
690
|
+
proc.stdin.write(json.dumps(msg) + "\n")
|
|
691
|
+
proc.stdin.flush()
|
|
692
|
+
|
|
693
|
+
def recv(want_id: int) -> Dict[str, Any]:
|
|
694
|
+
assert proc.stdout is not None
|
|
695
|
+
while True:
|
|
696
|
+
line = proc.stdout.readline()
|
|
697
|
+
if not line:
|
|
698
|
+
raise RuntimeError("server closed stdout before responding")
|
|
699
|
+
try:
|
|
700
|
+
msg = json.loads(line)
|
|
701
|
+
except json.JSONDecodeError:
|
|
702
|
+
raise RuntimeError(f"non-JSON on stdout (framing corruption): {line[:120]!r}")
|
|
703
|
+
if not isinstance(msg, dict):
|
|
704
|
+
raise TypeError("invalid JSON-RPC response: expected an object")
|
|
705
|
+
if msg.get("id") == want_id:
|
|
706
|
+
if "error" in msg:
|
|
707
|
+
raise RuntimeError(f"JSON-RPC request {want_id} failed: {msg['error']}")
|
|
708
|
+
if not isinstance(msg.get("result"), dict):
|
|
709
|
+
raise RuntimeError(f"JSON-RPC request {want_id} returned an invalid result")
|
|
710
|
+
return msg
|
|
711
|
+
|
|
712
|
+
responses: queue.Queue[tuple[dict[str, Any] | None, Exception | None]] = queue.Queue()
|
|
713
|
+
|
|
714
|
+
def exchange() -> None:
|
|
715
|
+
try:
|
|
716
|
+
send({
|
|
717
|
+
"jsonrpc": "2.0", "id": 1, "method": "initialize",
|
|
718
|
+
"params": {
|
|
719
|
+
"protocolVersion": "2024-11-05", "capabilities": {},
|
|
720
|
+
"clientInfo": {"name": "adl-drift-doctor", "version": "1.0"},
|
|
721
|
+
},
|
|
722
|
+
})
|
|
723
|
+
init = recv(1)["result"]
|
|
724
|
+
info = init.get("serverInfo")
|
|
725
|
+
if not isinstance(info, dict) or info.get("name") != SERVER_NAME:
|
|
726
|
+
raise RuntimeError("initialize did not identify the adl-drift server")
|
|
727
|
+
send({"jsonrpc": "2.0", "method": "notifications/initialized"})
|
|
728
|
+
send({"jsonrpc": "2.0", "id": 2, "method": "tools/list"})
|
|
729
|
+
tools = recv(2)["result"].get("tools")
|
|
730
|
+
if (not isinstance(tools, list) or not tools
|
|
731
|
+
or any(not isinstance(t, dict) or not isinstance(t.get("name"), str) for t in tools)):
|
|
732
|
+
raise RuntimeError("tools/list returned no valid tools")
|
|
733
|
+
responses.put(({"server_info": info, "tools": [t["name"] for t in tools]}, None))
|
|
734
|
+
except (RuntimeError, OSError, ValueError, TypeError, KeyError) as error:
|
|
735
|
+
responses.put((None, error))
|
|
736
|
+
|
|
737
|
+
worker = threading.Thread(target=exchange, daemon=True)
|
|
738
|
+
try:
|
|
739
|
+
worker.start()
|
|
740
|
+
try:
|
|
741
|
+
result, error = responses.get(timeout=timeout)
|
|
742
|
+
except queue.Empty:
|
|
743
|
+
result, error = None, RuntimeError(f"MCP handshake timed out after {timeout:g}s")
|
|
744
|
+
if error is not None and proc.poll() is None:
|
|
745
|
+
proc.kill()
|
|
746
|
+
worker.join(timeout=1)
|
|
747
|
+
if proc.stdin is not None:
|
|
748
|
+
try:
|
|
749
|
+
proc.stdin.close()
|
|
750
|
+
except OSError:
|
|
751
|
+
pass
|
|
752
|
+
try:
|
|
753
|
+
proc.wait(timeout=1)
|
|
754
|
+
except subprocess.TimeoutExpired:
|
|
755
|
+
proc.kill()
|
|
756
|
+
proc.wait()
|
|
757
|
+
if error is not None:
|
|
758
|
+
stderr.seek(0, 2)
|
|
759
|
+
stderr.seek(max(0, stderr.tell() - 4096))
|
|
760
|
+
detail = stderr.read().decode("utf-8", errors="replace").strip()
|
|
761
|
+
raise RuntimeError(str(error) + (f"\nServer stderr:\n{detail}" if detail else "")) from error
|
|
762
|
+
assert result is not None
|
|
763
|
+
return result
|
|
764
|
+
finally:
|
|
765
|
+
if proc.poll() is None:
|
|
766
|
+
proc.kill()
|
|
767
|
+
proc.wait()
|
|
768
|
+
worker.join(timeout=1)
|
|
769
|
+
if proc.stdin is not None:
|
|
770
|
+
try:
|
|
771
|
+
proc.stdin.close()
|
|
772
|
+
except OSError:
|
|
773
|
+
pass
|
|
774
|
+
if proc.stdout is not None:
|
|
775
|
+
proc.stdout.close()
|
|
776
|
+
|
|
777
|
+
|
|
778
|
+
def doctor(project_path: Path, timeout: float = 30.0) -> Dict[str, Any]:
|
|
779
|
+
"""Verify the wiring end-to-end. Returns {ok, checks: [...]}; `ok`
|
|
780
|
+
covers the required chain (interpreter → SDK → live handshake);
|
|
781
|
+
config/skill presence is reported but only required for client pickup."""
|
|
782
|
+
project_path = Path(project_path).resolve()
|
|
783
|
+
checks: List[Dict[str, Any]] = []
|
|
784
|
+
|
|
785
|
+
def check(name: str, ok: bool, detail: str, required: bool = True) -> None:
|
|
786
|
+
checks.append({"name": name, "ok": ok, "detail": detail, "required": required})
|
|
787
|
+
|
|
788
|
+
entry = _configured_entry(project_path)
|
|
789
|
+
if entry is None:
|
|
790
|
+
check("config", False,
|
|
791
|
+
"no adl-drift entry in .mcp.json, .cursor/mcp.json, .codex/config.toml, or Antigravity config — run `adl-drift mcp setup`",
|
|
792
|
+
required=False)
|
|
793
|
+
command = [sys.executable, "-m", "adl_drift.server.mcp_stdio", "--project", "."]
|
|
794
|
+
else:
|
|
795
|
+
check("config", True, f"adl-drift entry found (command: {entry.get('command')})",
|
|
796
|
+
required=False)
|
|
797
|
+
command = [entry["command"], *entry.get("args", [])]
|
|
798
|
+
|
|
799
|
+
interpreter = Path(command[0])
|
|
800
|
+
if interpreter.exists():
|
|
801
|
+
check("interpreter", True, str(interpreter))
|
|
802
|
+
else:
|
|
803
|
+
check("interpreter", False, f"{interpreter} does not exist — re-run `adl-drift mcp setup`")
|
|
804
|
+
|
|
805
|
+
probe = subprocess.run(
|
|
806
|
+
[command[0], "-c", "import mcp"], capture_output=True, text=True,
|
|
807
|
+
) if interpreter.exists() else None
|
|
808
|
+
if probe is not None and probe.returncode == 0:
|
|
809
|
+
check("mcp-sdk", True, "`import mcp` ok in the configured interpreter")
|
|
810
|
+
else:
|
|
811
|
+
detail = probe.stderr.strip().splitlines()[-1] if probe and probe.stderr else "interpreter missing"
|
|
812
|
+
check("mcp-sdk", probe is not None and probe.returncode == 0, detail)
|
|
813
|
+
|
|
814
|
+
if all(c["ok"] for c in checks if c["required"]):
|
|
815
|
+
try:
|
|
816
|
+
result = _handshake(command, cwd=project_path, timeout=timeout)
|
|
817
|
+
names = result["tools"]
|
|
818
|
+
check("handshake", True,
|
|
819
|
+
f"{result['server_info'].get('name')} {result['server_info'].get('version')} "
|
|
820
|
+
f"— {len(names)} tools: {', '.join(names)}")
|
|
821
|
+
except (RuntimeError, OSError, KeyError, subprocess.SubprocessError) as e:
|
|
822
|
+
check("handshake", False, str(e))
|
|
823
|
+
else:
|
|
824
|
+
check("handshake", False, "skipped — fix the checks above first")
|
|
825
|
+
|
|
826
|
+
skill = project_path / CLAUDE_SKILL_PATH
|
|
827
|
+
check("skill", skill.exists(),
|
|
828
|
+
str(skill) if skill.exists()
|
|
829
|
+
else "no skill installed (fine for MCP-only mode; `mcp setup --claude` adds it)",
|
|
830
|
+
required=False)
|
|
831
|
+
|
|
832
|
+
return {"ok": all(c["ok"] for c in checks if c["required"]), "checks": checks}
|