Skip to content

Commit 29e9751

Browse files
DvirDukhanCopilot
andcommitted
MCP smoke harness + template fixes from end-to-end run
Added scripts/mcp_smoke.py — drives cgraph-mcp over real stdio (mcp SDK ClientSession + StdioServerParameters) and exercises tool listing, index_repo, search_code, get_callers, impact_analysis. Findings folded back into claude_mcp_section.md: - index_repo takes path_or_url (not path) and derives project name from the folder/URL — agents must read it back from the response. - Collection-returning tools land their array in structuredContent.result, not under a {results: [...]} wrapper. Smoke result on api/ subgraph: 8 tools listed, 6324 nodes / 6228 edges indexed, all calls returned expected payloads. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 4e1223c commit 29e9751

2 files changed

Lines changed: 186 additions & 1 deletion

File tree

api/mcp/templates/claude_mcp_section.md

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ need to understand how symbols connect.
88

99
| Tool | Call this when… | Example |
1010
|---|---|---|
11-
| `index_repo(path)` | **First** thing in a new repo; or after large changes outside your edits. | `index_repo(path=".")` |
11+
| `index_repo(path_or_url, branch?)` | **First** thing in a new repo; or after large changes outside your edits. Project name is **derived from the folder or repo URL** — read it back from the response. | `index_repo(path_or_url=".")` |
1212
| `search_code(prefix, project)` | You know part of a symbol name and need its id. | `search_code(prefix="processPay", project="myrepo")` |
1313
| `get_callers(symbol_id, project)` | "Who calls this?" — refactoring a function, tracking down a regression. | `get_callers(symbol_id=42, project="myrepo")` |
1414
| `get_callees(symbol_id, project)` | "What does this call?" — understanding a function before editing it. | `get_callees(symbol_id=42, project="myrepo")` |
@@ -27,6 +27,11 @@ need to understand how symbols connect.
2727
the answer — the transitive closure often surprises you.
2828
4. **`branch` is optional** but pass it when working on a feature branch
2929
so you query the right per-branch index.
30+
5. **Response shape.** Tools that return collections (`search_code`,
31+
`get_callers`, `get_callees`, `get_dependencies`, `find_path`,
32+
`impact_analysis`) put the array in `structuredContent.result` per
33+
the MCP spec. The text content is the same JSON for convenience.
34+
`index_repo` and `ask` return a single object.
3035

3136
## Environment
3237

scripts/mcp_smoke.py

Lines changed: 180 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,180 @@
1+
"""End-to-end MCP smoke test.
2+
3+
Spawns `cgraph-mcp` over stdio, lists tools, indexes the
4+
code-graph repo itself, and exercises `search_code`,
5+
`get_callers`, and `impact_analysis`. Prints a compact pass/fail line per
6+
tool.
7+
"""
8+
9+
import asyncio
10+
import json
11+
import os
12+
import sys
13+
from pathlib import Path
14+
15+
from mcp import ClientSession, StdioServerParameters
16+
from mcp.client.stdio import stdio_client
17+
18+
19+
REPO_ROOT = Path(__file__).resolve().parent.parent
20+
INDEX_PATH = REPO_ROOT / "api"
21+
PROJECT_NAME = "code-graph-mcp-smoke"
22+
BRANCH = "smoke"
23+
24+
25+
def _pretty(result):
26+
"""Pull the first text payload out of a CallToolResult."""
27+
for chunk in result.content:
28+
if hasattr(chunk, "text"):
29+
try:
30+
return json.loads(chunk.text)
31+
except Exception:
32+
return chunk.text
33+
# No text chunks — show structured content if MCP put the payload there.
34+
if hasattr(result, "structuredContent") and result.structuredContent is not None:
35+
return result.structuredContent
36+
return None
37+
38+
39+
async def main() -> int:
40+
env = {
41+
**os.environ,
42+
"FALKORDB_HOST": os.environ.get("FALKORDB_HOST", "127.0.0.1"),
43+
"FALKORDB_PORT": os.environ.get("FALKORDB_PORT", "6390"),
44+
}
45+
46+
params = StdioServerParameters(
47+
command="cgraph-mcp",
48+
args=[],
49+
env=env,
50+
)
51+
52+
fails = 0
53+
54+
async with stdio_client(params) as (read, write):
55+
async with ClientSession(read, write) as session:
56+
await session.initialize()
57+
58+
tools = await session.list_tools()
59+
tool_names = sorted(t.name for t in tools.tools)
60+
print(f"[tools] {len(tool_names)}: {tool_names}")
61+
expected = {
62+
"index_repo",
63+
"search_code",
64+
"get_callers",
65+
"get_callees",
66+
"get_dependencies",
67+
"impact_analysis",
68+
"find_path",
69+
"ask",
70+
}
71+
missing = expected - set(tool_names)
72+
if missing:
73+
print(f"[FAIL] missing tools: {missing}")
74+
fails += 1
75+
76+
print(f"[index_repo] indexing {INDEX_PATH} ...")
77+
idx = await session.call_tool(
78+
"index_repo",
79+
{
80+
"path_or_url": str(INDEX_PATH),
81+
"branch": BRANCH,
82+
"ignore": [".venv", "node_modules", ".git", "app/dist"],
83+
},
84+
)
85+
idx_payload = _pretty(idx)
86+
print(f"[index_repo] -> {json.dumps(idx_payload)[:200]}")
87+
if not isinstance(idx_payload, dict) or idx_payload.get("error"):
88+
print("[FAIL] index_repo did not return ok payload")
89+
fails += 1
90+
return 1
91+
project_name = idx_payload["project_name"]
92+
branch_name = idx_payload["branch"]
93+
print(f"[index_repo] graph={idx_payload['graph_name']} project={project_name}")
94+
95+
print("[search_code] prefix='index_repo'")
96+
sr = await session.call_tool(
97+
"search_code",
98+
{"prefix": "index_repo", "project": project_name, "branch": branch_name},
99+
)
100+
sr_payload = _pretty(sr)
101+
print(f"[search_code] -> {json.dumps(sr_payload)[:300]}")
102+
if isinstance(sr_payload, list):
103+
hits = sr_payload
104+
elif isinstance(sr_payload, dict) and "results" in sr_payload:
105+
hits = sr_payload["results"]
106+
elif isinstance(sr_payload, dict) and "id" in sr_payload:
107+
hits = [sr_payload]
108+
else:
109+
hits = []
110+
if not hits:
111+
print("[FAIL] search_code returned no hits for index_repo")
112+
fails += 1
113+
first_id = None
114+
else:
115+
first_id = hits[0].get("id")
116+
print(f"[search_code] picked id={first_id} name={hits[0].get('name')}")
117+
118+
if first_id is not None:
119+
print(f"[get_callers] id={first_id}")
120+
gc = await session.call_tool(
121+
"get_callers",
122+
{
123+
"symbol_id": first_id,
124+
"project": project_name,
125+
"branch": branch_name,
126+
},
127+
)
128+
gc_payload = _pretty(gc)
129+
# Some MCP servers return list payloads in structuredContent only.
130+
gc_struct = getattr(gc, "structuredContent", None)
131+
print(f"[get_callers] -> {json.dumps(gc_payload)[:300]} struct={json.dumps(gc_struct)[:200]}")
132+
# Acceptable shapes: list of caller dicts, or {"callers": [...]}.
133+
callers = None
134+
if isinstance(gc_payload, list):
135+
callers = gc_payload
136+
elif isinstance(gc_payload, dict) and "callers" in gc_payload:
137+
callers = gc_payload["callers"]
138+
elif isinstance(gc_struct, dict) and "result" in gc_struct:
139+
callers = gc_struct["result"]
140+
if callers is None:
141+
print("[FAIL] get_callers returned no recognizable payload")
142+
fails += 1
143+
else:
144+
print(f"[get_callers] {len(callers)} callers")
145+
146+
print("[impact_analysis] depth=2")
147+
ia = await session.call_tool(
148+
"impact_analysis",
149+
{
150+
"symbol_id": first_id,
151+
"depth": 2,
152+
"project": project_name,
153+
"branch": branch_name,
154+
},
155+
)
156+
ia_payload = _pretty(ia)
157+
ia_struct = getattr(ia, "structuredContent", None)
158+
print(f"[impact_analysis] -> {json.dumps(ia_payload)[:300]} struct={json.dumps(ia_struct)[:200]}")
159+
impacted = None
160+
if isinstance(ia_payload, dict) and "impacted" in ia_payload:
161+
impacted = ia_payload["impacted"]
162+
elif isinstance(ia_struct, dict) and "impacted" in ia_struct:
163+
impacted = ia_struct["impacted"]
164+
elif isinstance(ia_struct, dict) and "result" in ia_struct:
165+
impacted = ia_struct["result"]
166+
if impacted is None:
167+
print("[FAIL] impact_analysis no 'impacted' field")
168+
fails += 1
169+
else:
170+
print(f"[impact_analysis] {len(impacted)} impacted")
171+
172+
if fails:
173+
print(f"\n=== {fails} FAILED ===")
174+
return 1
175+
print("\n=== ALL OK ===")
176+
return 0
177+
178+
179+
if __name__ == "__main__":
180+
sys.exit(asyncio.run(main()))

0 commit comments

Comments
 (0)