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,127 @@
|
|
|
1
|
+
"""MCP Server implementation.
|
|
2
|
+
|
|
3
|
+
Runs an HTTP/SSE server that exposes ADL Drift analysis as MCP tools.
|
|
4
|
+
AI agents (Claude, Cursor, Copilot) connect to this server to get
|
|
5
|
+
context about the codebase.
|
|
6
|
+
|
|
7
|
+
Equivalent to the TypeScript MCP server in Drift v2.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import asyncio
|
|
13
|
+
import json
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Optional
|
|
16
|
+
|
|
17
|
+
from fastapi import FastAPI, Request
|
|
18
|
+
from fastapi.responses import StreamingResponse
|
|
19
|
+
from sse_starlette.sse import EventSourceResponse
|
|
20
|
+
import uvicorn
|
|
21
|
+
|
|
22
|
+
from adl_drift.server.protocol import MCPRequest, MCPResponse
|
|
23
|
+
from adl_drift.server.handlers import MCPHandlers
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class DriftMCPServer:
|
|
27
|
+
"""MCP server for ADL Drift.
|
|
28
|
+
|
|
29
|
+
Supports two transport modes:
|
|
30
|
+
1. SSE (Server-Sent Events) - for real-time streaming
|
|
31
|
+
2. HTTP POST - for request/response
|
|
32
|
+
|
|
33
|
+
The server watches files for changes and updates its index.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
def __init__(self, project_path: Path, port: int = 3000):
|
|
37
|
+
self.project_path = project_path
|
|
38
|
+
self.port = port
|
|
39
|
+
self.handlers = MCPHandlers(project_path)
|
|
40
|
+
self.app = FastAPI(title="ADL Drift MCP")
|
|
41
|
+
self._setup_routes()
|
|
42
|
+
|
|
43
|
+
def _setup_routes(self) -> None:
|
|
44
|
+
"""Setup FastAPI routes."""
|
|
45
|
+
|
|
46
|
+
@self.app.post("/mcp/initialize")
|
|
47
|
+
async def initialize():
|
|
48
|
+
return self.handlers.initialize().model_dump()
|
|
49
|
+
|
|
50
|
+
# P2.11: dict dispatch (replaces the if/elif chain). Kept as an
|
|
51
|
+
# instance attribute (not a local) so a test can assert it stays in
|
|
52
|
+
# parity with initialize()'s tool list -- this list drifted out of
|
|
53
|
+
# sync with mcp_stdio.py's tools before (handlers.py's schema vs.
|
|
54
|
+
# code defaults, see run log 2026-09-29 (2)); nothing previously
|
|
55
|
+
# caught the same class of drift for this dispatch table.
|
|
56
|
+
self._dispatch = {
|
|
57
|
+
"drift_scan": self.handlers.handle_scan,
|
|
58
|
+
"drift_query": self.handlers.handle_query,
|
|
59
|
+
"drift_search": self.handlers.handle_search,
|
|
60
|
+
"drift_node": self.handlers.handle_node,
|
|
61
|
+
"drift_graph": self.handlers.handle_graph,
|
|
62
|
+
"drift_callers": self.handlers.handle_callers,
|
|
63
|
+
"drift_callees": self.handlers.handle_callees,
|
|
64
|
+
"drift_impact": self.handlers.handle_impact,
|
|
65
|
+
"drift_explore": self.handlers.handle_explore,
|
|
66
|
+
"drift_path": self.handlers.handle_path,
|
|
67
|
+
"drift_status": self.handlers.handle_status,
|
|
68
|
+
"drift_communities": self.handlers.handle_communities,
|
|
69
|
+
"drift_why": self.handlers.handle_why,
|
|
70
|
+
"drift_context": self.handlers.handle_context,
|
|
71
|
+
"drift_learn": self.handlers.handle_learn,
|
|
72
|
+
"drift_approve": self.handlers.handle_approve,
|
|
73
|
+
"drift_plan_change": self.handlers.handle_plan_change,
|
|
74
|
+
"drift_verify_change": self.handlers.handle_verify_change,
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
@self.app.post("/mcp/tools/call")
|
|
78
|
+
async def call_tool(request: Request):
|
|
79
|
+
body = await request.json()
|
|
80
|
+
# P2.11 Part 1c: JSON-RPC permits int ids but MCPRequest.id is
|
|
81
|
+
# str-typed — coerce at the boundary instead of 500-ing on
|
|
82
|
+
# pydantic validation.
|
|
83
|
+
if isinstance(body.get("id"), int):
|
|
84
|
+
body["id"] = str(body["id"])
|
|
85
|
+
mcp_request = MCPRequest(**body)
|
|
86
|
+
|
|
87
|
+
params = mcp_request.params or {}
|
|
88
|
+
params["request_id"] = mcp_request.id
|
|
89
|
+
|
|
90
|
+
handler = self._dispatch.get(mcp_request.method)
|
|
91
|
+
if handler is None:
|
|
92
|
+
response = MCPResponse(
|
|
93
|
+
id=mcp_request.id,
|
|
94
|
+
error={"code": -32601, "message": f"Method {mcp_request.method} not found"}
|
|
95
|
+
)
|
|
96
|
+
else:
|
|
97
|
+
response = handler(params)
|
|
98
|
+
|
|
99
|
+
return response.model_dump()
|
|
100
|
+
|
|
101
|
+
@self.app.get("/mcp/sse")
|
|
102
|
+
async def sse_endpoint():
|
|
103
|
+
"""SSE endpoint for streaming updates."""
|
|
104
|
+
async def event_generator():
|
|
105
|
+
while True:
|
|
106
|
+
# Send heartbeat every 30 seconds
|
|
107
|
+
yield {
|
|
108
|
+
"event": "heartbeat",
|
|
109
|
+
"data": json.dumps({"status": "alive"})
|
|
110
|
+
}
|
|
111
|
+
await asyncio.sleep(30)
|
|
112
|
+
|
|
113
|
+
return EventSourceResponse(event_generator())
|
|
114
|
+
|
|
115
|
+
@self.app.get("/health")
|
|
116
|
+
async def health():
|
|
117
|
+
return {"status": "ok", "project": str(self.project_path)}
|
|
118
|
+
|
|
119
|
+
def run(self) -> None:
|
|
120
|
+
"""Start the server."""
|
|
121
|
+
uvicorn.run(self.app, host="0.0.0.0", port=self.port)
|
|
122
|
+
|
|
123
|
+
async def run_async(self) -> None:
|
|
124
|
+
"""Async version for embedding in other apps."""
|
|
125
|
+
config = uvicorn.Config(self.app, host="0.0.0.0", port=self.port)
|
|
126
|
+
server = uvicorn.Server(config)
|
|
127
|
+
await server.serve()
|
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
"""MCP stdio server using the official `mcp` Python SDK (FastMCP).
|
|
2
|
+
|
|
3
|
+
Exposes ADL Drift analysis as MCP tools so Claude Code, Cursor, and Claude Desktop
|
|
4
|
+
can call them. Wraps existing MCPHandlers logic.
|
|
5
|
+
|
|
6
|
+
Run: adl-drift-mcp [--project PATH]
|
|
7
|
+
Or: python -m adl_drift.server.mcp_stdio --project .
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import argparse
|
|
13
|
+
import os
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Any, Dict, List, Optional
|
|
16
|
+
|
|
17
|
+
from mcp.server.fastmcp import FastMCP
|
|
18
|
+
|
|
19
|
+
from adl_drift.server.handlers import MCPHandlers
|
|
20
|
+
|
|
21
|
+
# Surfaced in the MCP `initialize` handshake (InitializeResult.instructions) --
|
|
22
|
+
# many clients inject this straight into the system prompt, separate from the
|
|
23
|
+
# individual tool descriptions. Without it, a client that defers large tool
|
|
24
|
+
# lists (17 tools here) behind an explicit activation step may never notice
|
|
25
|
+
# ADL Drift's tools are worth activating at all -- this is the fix for exactly
|
|
26
|
+
# that: it gives the agent a reason to look before it decides grep is enough.
|
|
27
|
+
DRIFT_INSTRUCTIONS = """\
|
|
28
|
+
ADL Drift: a persistent call-graph index for this codebase, kept current \
|
|
29
|
+
incrementally. Use it before grep/Read for questions about Python or Java \
|
|
30
|
+
symbols; use text search for literals, comments, logs, and unsupported files.
|
|
31
|
+
|
|
32
|
+
- Read-only symbol question -- source/details, callers/blast radius, \
|
|
33
|
+
dependencies, "what breaks if I delete/change X?", or refactor safety? \
|
|
34
|
+
-> drift_explore.
|
|
35
|
+
- About to edit a Python/Java function, method, constructor, class, field, \
|
|
36
|
+
constant, or parameter? -> call drift_plan_change FIRST. It resolves the \
|
|
37
|
+
target itself, so drift_explore is not required first. Save its baseline; \
|
|
38
|
+
after editing call drift_verify_change with that baseline and every modified \
|
|
39
|
+
Python/Java file in changed_files.
|
|
40
|
+
- Target still uncertain? -> inspect drift_explore's resolved/ambiguous \
|
|
41
|
+
matches; use drift_search only when an exact qualified symbol still needs \
|
|
42
|
+
confirmation. Include parameter types for Java overloads.
|
|
43
|
+
- Does a drift_path step order reflect a real call direction? -> check its \
|
|
44
|
+
`factual` field and each hop's `direction`; uncertain hops prove only \
|
|
45
|
+
connectivity.
|
|
46
|
+
- Before the first graph query in a task, call drift_status. If the index is \
|
|
47
|
+
stale or missing, run drift_scan on the project root before exploring or \
|
|
48
|
+
planning. Recheck with drift_status after external source changes; after your own edits, use \
|
|
49
|
+
drift_verify_change with the original baseline. Inspect or report scan_errors.
|
|
50
|
+
- Found a non-obvious constraint? -> drift_learn (future sessions get it via drift_why).
|
|
51
|
+
- A result comes back with `truncated: {...}`? Re-request with a moderate \
|
|
52
|
+
max_tokens bump (e.g. 2-4x) before requesting unbounded output.
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def build_server(project_path: Path) -> FastMCP:
|
|
57
|
+
project_path = Path(project_path).resolve()
|
|
58
|
+
handlers = MCPHandlers(project_path)
|
|
59
|
+
mcp = FastMCP("adl-drift", instructions=DRIFT_INSTRUCTIONS)
|
|
60
|
+
|
|
61
|
+
def _unwrap(resp) -> Dict[str, Any]:
|
|
62
|
+
data = resp.model_dump()
|
|
63
|
+
if data.get("error"):
|
|
64
|
+
return {"error": data["error"]}
|
|
65
|
+
return data.get("result") or {}
|
|
66
|
+
|
|
67
|
+
@mcp.tool()
|
|
68
|
+
def drift_scan(
|
|
69
|
+
path: str,
|
|
70
|
+
category: Optional[str] = None,
|
|
71
|
+
limit: Optional[int] = None,
|
|
72
|
+
max_tokens: Optional[int] = None,
|
|
73
|
+
cursor: Optional[str] = None,
|
|
74
|
+
) -> Dict[str, Any]:
|
|
75
|
+
"""Scan a Python or Java file/directory for supported code patterns."""
|
|
76
|
+
target = Path(path)
|
|
77
|
+
if not target.is_absolute():
|
|
78
|
+
target = (project_path / target).resolve()
|
|
79
|
+
return _unwrap(handlers.handle_scan({
|
|
80
|
+
"path": str(target), "category": category, "limit": limit, "max_tokens": max_tokens,
|
|
81
|
+
"cursor": cursor,
|
|
82
|
+
}))
|
|
83
|
+
|
|
84
|
+
@mcp.tool()
|
|
85
|
+
def drift_query(
|
|
86
|
+
category: Optional[str] = None,
|
|
87
|
+
approved_only: bool = False,
|
|
88
|
+
limit: int = 50,
|
|
89
|
+
max_tokens: Optional[int] = None,
|
|
90
|
+
cursor: Optional[str] = None,
|
|
91
|
+
) -> Dict[str, Any]:
|
|
92
|
+
"""Query stored patterns from Cortex memory."""
|
|
93
|
+
return _unwrap(
|
|
94
|
+
handlers.handle_query({
|
|
95
|
+
"category": category, "approved_only": approved_only,
|
|
96
|
+
"limit": limit, "max_tokens": max_tokens, "cursor": cursor,
|
|
97
|
+
})
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
@mcp.tool()
|
|
101
|
+
def drift_search(
|
|
102
|
+
query: Optional[str] = None,
|
|
103
|
+
queries: Optional[List[str]] = None,
|
|
104
|
+
kind: Optional[str] = None,
|
|
105
|
+
limit: int = 20,
|
|
106
|
+
max_tokens: Optional[int] = None,
|
|
107
|
+
cursor: Optional[str] = None,
|
|
108
|
+
include_source: bool = False,
|
|
109
|
+
snippet_lines: Optional[int] = None,
|
|
110
|
+
) -> Dict[str, Any]:
|
|
111
|
+
"""Search symbols by name. Ranked: exact id > exact name > substring; degree-boosted. If no exact/substring match is found, falls back to a token-overlap guess (set fuzzy_fallback=true) so a behavior-phrased query (e.g. "verify detectViolations") still returns candidates instead of an empty result. kind filters to function/class/module/variable. include_source=true embeds a source snippet on each match (like drift_node) so a single-symbol question skips the follow-up drift_node call — pass a small limit when using it, since every match's snippet counts against the response; snippet_lines, left unset, sizes each snippet to that symbol's real body length (capped at 200 lines) instead of a flat default. max_tokens default 2000, 0 = unbounded; pass next_cursor back as cursor for the next page. Pass queries (list) instead of query to run several searches in one call — the result then has a top-level `results` list instead of a single match list; batch mode does not support cursor."""
|
|
112
|
+
return _unwrap(handlers.handle_search({
|
|
113
|
+
"query": query, "queries": queries, "kind": kind, "limit": limit, "max_tokens": max_tokens,
|
|
114
|
+
"cursor": cursor, "include_source": include_source, "snippet_lines": snippet_lines,
|
|
115
|
+
}))
|
|
116
|
+
|
|
117
|
+
@mcp.tool()
|
|
118
|
+
def drift_node(
|
|
119
|
+
node_id: Optional[str] = None,
|
|
120
|
+
node_ids: Optional[List[str]] = None,
|
|
121
|
+
snippet_lines: Optional[int] = None,
|
|
122
|
+
max_tokens: Optional[int] = None,
|
|
123
|
+
max_source_lines: Optional[int] = None,
|
|
124
|
+
) -> Dict[str, Any]:
|
|
125
|
+
"""Node detail: file/line/signature/docstring, a source snippet, and in/out edge counts by type. node_id can be an exact id/qualified name or a loose symbol name; if nothing matches exactly, a token-overlap guess is used instead (fuzzy_fallback=true, alternates listed in ambiguous_matches). Pass node_ids (list) instead of node_id to fetch several nodes in one call — the result then has a top-level `nodes` list instead of a single node payload; a missing/ambiguous id in the batch doesn't fail the others. snippet_lines, left unset, sizes the snippet to the symbol's real body length (capped at 200 lines) instead of a flat default — pass it explicitly to override. max_tokens trims the snippet(s), split across items in batch mode; max_source_lines caps snippet length."""
|
|
126
|
+
return _unwrap(handlers.handle_node({
|
|
127
|
+
"node_id": node_id, "node_ids": node_ids, "snippet_lines": snippet_lines, "max_tokens": max_tokens,
|
|
128
|
+
"max_source_lines": max_source_lines,
|
|
129
|
+
}))
|
|
130
|
+
|
|
131
|
+
@mcp.tool()
|
|
132
|
+
def drift_graph(
|
|
133
|
+
symbol: Optional[str] = None,
|
|
134
|
+
symbols: Optional[List[str]] = None,
|
|
135
|
+
limit: Optional[int] = None,
|
|
136
|
+
max_tokens: Optional[int] = None,
|
|
137
|
+
) -> Dict[str, Any]:
|
|
138
|
+
"""Get callers + dependencies for a symbol (qualified name), with file/line and edge type/confidence per item. If the symbol doesn't match exactly, a token-overlap guess is used instead (fuzzy_fallback=true, alternates listed in ambiguous_matches). Pass symbols (list) instead of symbol to fetch call graphs for several symbols in one call — the result then has a top-level `graphs` list instead of a single graph payload; a missing/ambiguous symbol in the batch doesn't fail the others. max_tokens default 2000 per list per symbol, 0 = unbounded."""
|
|
139
|
+
return _unwrap(handlers.handle_graph({
|
|
140
|
+
"symbol": symbol, "symbols": symbols, "limit": limit, "max_tokens": max_tokens,
|
|
141
|
+
}))
|
|
142
|
+
|
|
143
|
+
@mcp.tool()
|
|
144
|
+
def drift_callers(
|
|
145
|
+
symbol: str,
|
|
146
|
+
max_depth: int = 1,
|
|
147
|
+
limit: int = 50,
|
|
148
|
+
max_tokens: Optional[int] = None,
|
|
149
|
+
cursor: Optional[str] = None,
|
|
150
|
+
) -> Dict[str, Any]:
|
|
151
|
+
"""Who depends on this symbol: depth-bounded reverse walk over calls/references/type-uses/instantiations/overrides. Falls back to a token-overlap guess when the symbol doesn't match exactly (fuzzy_fallback=true, alternates in ambiguous_matches). Ordered depth asc, confidence desc; max_tokens default 2000, 0 = unbounded."""
|
|
152
|
+
return _unwrap(handlers.handle_callers({
|
|
153
|
+
"symbol": symbol, "max_depth": max_depth,
|
|
154
|
+
"limit": limit, "max_tokens": max_tokens, "cursor": cursor,
|
|
155
|
+
}))
|
|
156
|
+
|
|
157
|
+
@mcp.tool()
|
|
158
|
+
def drift_callees(
|
|
159
|
+
symbol: str,
|
|
160
|
+
max_depth: int = 1,
|
|
161
|
+
limit: int = 50,
|
|
162
|
+
max_tokens: Optional[int] = None,
|
|
163
|
+
cursor: Optional[str] = None,
|
|
164
|
+
) -> Dict[str, Any]:
|
|
165
|
+
"""What this symbol depends on: depth-bounded forward walk over calls + instantiations. Falls back to a token-overlap guess when the symbol doesn't match exactly (fuzzy_fallback=true, alternates in ambiguous_matches). Ordered depth asc, confidence desc; max_tokens default 2000, 0 = unbounded."""
|
|
166
|
+
return _unwrap(handlers.handle_callees({
|
|
167
|
+
"symbol": symbol, "max_depth": max_depth,
|
|
168
|
+
"limit": limit, "max_tokens": max_tokens, "cursor": cursor,
|
|
169
|
+
}))
|
|
170
|
+
|
|
171
|
+
@mcp.tool()
|
|
172
|
+
def drift_path(
|
|
173
|
+
from_symbol: str,
|
|
174
|
+
to_symbol: str,
|
|
175
|
+
max_tokens: Optional[int] = None,
|
|
176
|
+
alternates: int = 0,
|
|
177
|
+
) -> Dict[str, Any]:
|
|
178
|
+
"""Shortest dependency path from one symbol to another (directed first, undirected fallback). Either endpoint falls back to a token-overlap guess when it doesn't match exactly (alternates in ambiguous_matches.from/to). Under max_tokens the middle is elided; `length` stays the true hop count. alternates=N adds up to N further simple paths (ids only)."""
|
|
179
|
+
return _unwrap(handlers.handle_path({
|
|
180
|
+
"from_symbol": from_symbol, "to_symbol": to_symbol, "max_tokens": max_tokens,
|
|
181
|
+
"alternates": alternates,
|
|
182
|
+
}))
|
|
183
|
+
|
|
184
|
+
@mcp.tool()
|
|
185
|
+
def drift_status() -> Dict[str, Any]:
|
|
186
|
+
"""Index health: file/node/edge counts, last scan time, staleness flag. Call before trusting other graph answers."""
|
|
187
|
+
return _unwrap(handlers.handle_status({}))
|
|
188
|
+
|
|
189
|
+
@mcp.tool()
|
|
190
|
+
def drift_communities(
|
|
191
|
+
limit: int = 20,
|
|
192
|
+
max_tokens: Optional[int] = None,
|
|
193
|
+
god_nodes_top_n: int = 10,
|
|
194
|
+
cursor: Optional[str] = None,
|
|
195
|
+
) -> Dict[str, Any]:
|
|
196
|
+
"""Module clusters (Louvain): sizes, top members by degree, dominant module — repo orientation. Also god_nodes (highest-degree linchpins). max_tokens default 2000, 0 = unbounded."""
|
|
197
|
+
return _unwrap(handlers.handle_communities({
|
|
198
|
+
"limit": limit, "max_tokens": max_tokens, "god_nodes_top_n": god_nodes_top_n,
|
|
199
|
+
"cursor": cursor,
|
|
200
|
+
}))
|
|
201
|
+
|
|
202
|
+
@mcp.tool()
|
|
203
|
+
def drift_learn(
|
|
204
|
+
topic: str,
|
|
205
|
+
content: str,
|
|
206
|
+
tags: Optional[list] = None,
|
|
207
|
+
) -> Dict[str, Any]:
|
|
208
|
+
"""Record tribal knowledge for future drift_why queries (append-only DB write)."""
|
|
209
|
+
return _unwrap(handlers.handle_learn({
|
|
210
|
+
"topic": topic, "content": content, "tags": tags,
|
|
211
|
+
}))
|
|
212
|
+
|
|
213
|
+
@mcp.tool()
|
|
214
|
+
def drift_approve(pattern_id: str) -> Dict[str, Any]:
|
|
215
|
+
"""Approve a detected pattern by id (flag-only DB write; id must exist)."""
|
|
216
|
+
return _unwrap(handlers.handle_approve({"pattern_id": pattern_id}))
|
|
217
|
+
|
|
218
|
+
@mcp.tool()
|
|
219
|
+
def drift_impact(
|
|
220
|
+
symbol: str,
|
|
221
|
+
max_tokens: Optional[int] = None,
|
|
222
|
+
limit: Optional[int] = None,
|
|
223
|
+
max_depth: Optional[int] = None,
|
|
224
|
+
max_nodes: Optional[int] = None,
|
|
225
|
+
include_initializers: bool = False,
|
|
226
|
+
cursor: Optional[str] = None,
|
|
227
|
+
) -> Dict[str, Any]:
|
|
228
|
+
"""Blast-radius analysis: direct + transitive callers, affected files/entry points. symbol can be an exact/substring name; if that finds nothing, a token-overlap guess is used instead (fuzzy_fallback=true) -- no need to call drift_search first just to get the exact name. Counts are exact; lists are cut to max_tokens (0 = unbounded) / limit / max_nodes and `truncated` says what was dropped; max_depth bounds the transitive horizon; pass next_cursor back as cursor for the rest."""
|
|
229
|
+
return _unwrap(handlers.handle_impact({
|
|
230
|
+
"symbol": symbol, "max_tokens": max_tokens, "limit": limit, "max_depth": max_depth,
|
|
231
|
+
"max_nodes": max_nodes, "include_initializers": include_initializers,
|
|
232
|
+
"cursor": cursor,
|
|
233
|
+
}))
|
|
234
|
+
|
|
235
|
+
@mcp.tool()
|
|
236
|
+
def drift_explore(
|
|
237
|
+
symbol: str,
|
|
238
|
+
max_tokens: Optional[int] = None,
|
|
239
|
+
limit: Optional[int] = None,
|
|
240
|
+
max_depth: Optional[int] = None,
|
|
241
|
+
max_nodes: Optional[int] = None,
|
|
242
|
+
callees_depth: int = 1,
|
|
243
|
+
include_initializers: bool = False,
|
|
244
|
+
snippet_lines: Optional[int] = None,
|
|
245
|
+
max_source_lines: Optional[int] = None,
|
|
246
|
+
) -> Dict[str, Any]:
|
|
247
|
+
"""Everything about one symbol in a single call: resolved `detail` (signature/docstring/snippet, like drift_node), `impact` (direct + transitive callers, affected files/entry points, like drift_impact), and `dependencies` (what it calls, like drift_graph's dependencies) -- instead of drift_node + drift_impact + drift_graph as 3 separate round trips for the same symbol. symbol falls back to a fuzzy guess like drift_impact when nothing matches exactly (fuzzy_fallback=true). Additive: drift_node/drift_impact/drift_graph/drift_callers/drift_callees are unchanged and still the right call for a single aspect, or to page a huge list with `cursor` (not supported here -- re-request with a larger max_tokens or call the underlying tool directly instead). max_tokens (default 6000; 0 = unbounded, capped at 11000) is split across the three sections, impact getting the largest share; detail_truncated/impact.truncated/dependencies_truncated report what was cut in each. callees_depth bounds the dependencies walk (default 1); max_depth bounds impact's transitive caller horizon."""
|
|
248
|
+
return _unwrap(handlers.handle_explore({
|
|
249
|
+
"symbol": symbol, "max_tokens": max_tokens, "limit": limit, "max_depth": max_depth,
|
|
250
|
+
"max_nodes": max_nodes, "callees_depth": callees_depth,
|
|
251
|
+
"include_initializers": include_initializers,
|
|
252
|
+
"snippet_lines": snippet_lines, "max_source_lines": max_source_lines,
|
|
253
|
+
}))
|
|
254
|
+
|
|
255
|
+
@mcp.tool()
|
|
256
|
+
def drift_why(topic: str) -> Dict[str, Any]:
|
|
257
|
+
"""Retrieve tribal knowledge + similar patterns for a topic."""
|
|
258
|
+
return _unwrap(handlers.handle_why({"topic": topic}))
|
|
259
|
+
|
|
260
|
+
@mcp.tool()
|
|
261
|
+
def drift_context(
|
|
262
|
+
query: str,
|
|
263
|
+
file_path: Optional[str] = None,
|
|
264
|
+
max_tokens: int = 2000,
|
|
265
|
+
) -> Dict[str, Any]:
|
|
266
|
+
"""Graph-ranked project context for a query: seed symbols, 2-hop dependency closure, patterns + tribal knowledge, trimmed to max_tokens."""
|
|
267
|
+
params: Dict[str, Any] = {"query": query, "max_tokens": max_tokens}
|
|
268
|
+
if file_path:
|
|
269
|
+
fp = Path(file_path)
|
|
270
|
+
if not fp.is_absolute():
|
|
271
|
+
fp = (project_path / fp).resolve()
|
|
272
|
+
params["file_path"] = str(fp)
|
|
273
|
+
return _unwrap(handlers.handle_context(params))
|
|
274
|
+
|
|
275
|
+
@mcp.tool()
|
|
276
|
+
def drift_plan_change(
|
|
277
|
+
description: str,
|
|
278
|
+
symbol: Optional[str] = None,
|
|
279
|
+
max_depth: int = 2,
|
|
280
|
+
max_tokens: int = 3000,
|
|
281
|
+
limit: Optional[int] = None,
|
|
282
|
+
max_nodes: Optional[int] = None,
|
|
283
|
+
) -> Dict[str, Any]:
|
|
284
|
+
"""Predict the blast radius of a planned change. Resolves the target symbol from a natural-language description, returns impact (depth/token-bounded) + an editable checklist + a baseline snapshot for drift_verify_change."""
|
|
285
|
+
params: Dict[str, Any] = {
|
|
286
|
+
"description": description, "max_depth": max_depth,
|
|
287
|
+
"max_tokens": max_tokens, "limit": limit, "max_nodes": max_nodes,
|
|
288
|
+
}
|
|
289
|
+
if symbol:
|
|
290
|
+
params["symbol"] = symbol
|
|
291
|
+
return _unwrap(handlers.handle_plan_change(params))
|
|
292
|
+
|
|
293
|
+
@mcp.tool()
|
|
294
|
+
def drift_verify_change(
|
|
295
|
+
baseline: Dict[str, Any],
|
|
296
|
+
changed_files: Optional[list] = None,
|
|
297
|
+
symbol: Optional[str] = None,
|
|
298
|
+
max_tokens: int = 3000,
|
|
299
|
+
limit: Optional[int] = None,
|
|
300
|
+
max_nodes: Optional[int] = None,
|
|
301
|
+
) -> Dict[str, Any]:
|
|
302
|
+
"""Verify a code change: incrementally rescan changed files and compare the new impact against the baseline from drift_plan_change. The embedded impact report is token-bounded; the verdict uses the full graph."""
|
|
303
|
+
params: Dict[str, Any] = {"baseline": baseline, "max_tokens": max_tokens, "limit": limit, "max_nodes": max_nodes}
|
|
304
|
+
if changed_files:
|
|
305
|
+
params["changed_files"] = changed_files
|
|
306
|
+
if symbol:
|
|
307
|
+
params["symbol"] = symbol
|
|
308
|
+
return _unwrap(handlers.handle_verify_change(params))
|
|
309
|
+
|
|
310
|
+
return mcp
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def run_stdio(project_path: Path) -> None:
|
|
314
|
+
# P2.9: headless under an IDE/agent host — keep the worker pool modest
|
|
315
|
+
# unless the user explicitly sized it. Worker stdout is redirected in
|
|
316
|
+
# _init_worker, so the JSON-RPC stream on our stdout stays clean.
|
|
317
|
+
os.environ.setdefault("DRIFT_PARALLEL_WORKERS", "4")
|
|
318
|
+
server = build_server(project_path)
|
|
319
|
+
server.run(transport="stdio")
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def cli_entry() -> None:
|
|
323
|
+
parser = argparse.ArgumentParser(prog="adl-drift-mcp", description="ADL Drift MCP stdio server")
|
|
324
|
+
parser.add_argument(
|
|
325
|
+
"--project",
|
|
326
|
+
"-p",
|
|
327
|
+
default=os.environ.get("DRIFT_PROJECT_PATH", "."),
|
|
328
|
+
help="Project root directory (default: $DRIFT_PROJECT_PATH or cwd)",
|
|
329
|
+
)
|
|
330
|
+
args = parser.parse_args()
|
|
331
|
+
run_stdio(Path(args.project))
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
if __name__ == "__main__":
|
|
335
|
+
cli_entry()
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""MCP (Model Context Protocol) message definitions.
|
|
2
|
+
|
|
3
|
+
Defines the JSON-RPC-like protocol for communicating with AI agents.
|
|
4
|
+
Based on the MCP specification used by Claude, Cursor, etc.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from typing import Optional, List, Dict, Any, Literal
|
|
10
|
+
from pydantic import BaseModel
|
|
11
|
+
from enum import Enum
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class MCPRole(str, Enum):
|
|
15
|
+
"""Message roles in MCP."""
|
|
16
|
+
SYSTEM = "system"
|
|
17
|
+
USER = "user"
|
|
18
|
+
ASSISTANT = "assistant"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class MCPMessage(BaseModel):
|
|
22
|
+
"""A single message in the MCP conversation."""
|
|
23
|
+
role: MCPRole
|
|
24
|
+
content: str
|
|
25
|
+
metadata: Optional[Dict[str, Any]] = None
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class MCPRequest(BaseModel):
|
|
29
|
+
"""Request from client (IDE/AI agent) to server."""
|
|
30
|
+
jsonrpc: Literal["2.0"] = "2.0"
|
|
31
|
+
id: Optional[str] = None
|
|
32
|
+
method: str
|
|
33
|
+
params: Optional[Dict[str, Any]] = None
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class MCPResponse(BaseModel):
|
|
37
|
+
"""Response from server to client."""
|
|
38
|
+
jsonrpc: Literal["2.0"] = "2.0"
|
|
39
|
+
id: Optional[str] = None
|
|
40
|
+
result: Optional[Dict[str, Any]] = None
|
|
41
|
+
error: Optional[Dict[str, Any]] = None
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class MCPToolDefinition(BaseModel):
|
|
45
|
+
"""Definition of a tool the server exposes."""
|
|
46
|
+
name: str
|
|
47
|
+
description: str
|
|
48
|
+
parameters: Dict[str, Any] # JSON Schema
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class MCPContextItem(BaseModel):
|
|
52
|
+
"""A piece of context returned to the AI."""
|
|
53
|
+
type: str # "pattern", "symbol", "graph", "tribal_knowledge"
|
|
54
|
+
content: str
|
|
55
|
+
source: Optional[str] = None # file path
|
|
56
|
+
relevance_score: Optional[float] = None
|
|
57
|
+
metadata: Optional[Dict[str, Any]] = None
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class MCPInitializeRequest(BaseModel):
|
|
61
|
+
"""Client initialization request."""
|
|
62
|
+
protocol_version: str = "2024-11-05"
|
|
63
|
+
client_info: Dict[str, str]
|
|
64
|
+
capabilities: Optional[Dict[str, Any]] = None
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class MCPInitializeResponse(BaseModel):
|
|
68
|
+
"""Server initialization response."""
|
|
69
|
+
protocol_version: str = "2024-11-05"
|
|
70
|
+
server_info: Dict[str, str]
|
|
71
|
+
tools: List[MCPToolDefinition]
|