Skip to content

Commit 0957e3e

Browse files
DvirDukhanCopilot
andcommitted
feat(mcp): find_symbol — resolve Function/Class name to symbol_id
Bridges human-readable names to the integer symbol_id that get_neighbors, impact_analysis and find_path require (those take an id, not a name; search_code returns files). Accepts a simple name or dotted qualname (last segment used); optional file arg flags/orders in-file matches first so a wrong file filter degrades visibly rather than silently routing to an arbitrary same-named symbol. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 7d990de commit 0957e3e

1 file changed

Lines changed: 76 additions & 0 deletions

File tree

api/mcp/tools/structural.py

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -654,6 +654,82 @@ async def _neighbors_payload(
654654
await g.close()
655655

656656

657+
FIND_SYMBOL_SNIPPET_LINES = 4
658+
FIND_SYMBOL_DB_LIMIT = 200
659+
660+
661+
@app.tool(
662+
name="find_symbol",
663+
description=(
664+
"Resolve a Function/Class NAME to its integer symbol node id — the "
665+
"bridge from a human-readable name to the `symbol_id` that "
666+
"`get_neighbors`, `impact_analysis` and `find_path` require (those tools "
667+
"take an id, NOT a name; `search_code` returns FILES, not symbol ids, so "
668+
"start here for relationship questions). Pass the simple name "
669+
"(e.g. `normalize_cartesian_coordinates`); a dotted qualname "
670+
"(`Grid.normalize_cartesian_coordinates`) is accepted and its last "
671+
"segment is used. Optionally pass `file` (repo-relative path or a "
672+
"substring of it) to disambiguate same-named symbols — matches in that "
673+
"file are flagged `file_match: true` and listed first. Returns "
674+
"[{symbol_id, name, label, file, line, file_match, snippet}] ordered "
675+
"best-scoped first; when more than one remains, disambiguate by `file` "
676+
"and `snippet`. Feed the chosen `symbol_id` to the relationship tools."
677+
),
678+
)
679+
async def find_symbol(
680+
name: str,
681+
project: str,
682+
file: Optional[str] = None,
683+
branch: Optional[str] = None,
684+
limit: int = 20,
685+
) -> list[dict[str, Any]]:
686+
"""Look up Function/Class nodes by their simple name.
687+
688+
Symbol nodes store only the SIMPLE name (``foo``), never the qualname
689+
(``Bar.foo``), so a dotted input is reduced to its last segment. When
690+
``file`` is given we do not silently widen the search: every candidate is
691+
tagged ``file_match`` and the in-file ones sort first, so a wrong/empty
692+
file filter degrades visibly (the agent still sees the global matches but
693+
knows none were in the requested file) rather than routing a relationship
694+
query to an arbitrary same-named symbol.
695+
"""
696+
simple = str(name).strip().split(".")[-1]
697+
g = _project_arg(project, branch)
698+
try:
699+
res = await g._query(
700+
"MATCH (n) WHERE (n:Function OR n:Class) AND n.name = $name "
701+
"RETURN n LIMIT $limit",
702+
{"name": simple, "limit": FIND_SYMBOL_DB_LIMIT},
703+
)
704+
rows = [
705+
_node_summary(
706+
r[0],
707+
rel_to=project,
708+
snippet_lines=FIND_SYMBOL_SNIPPET_LINES,
709+
)
710+
for r in res.result_set
711+
]
712+
finally:
713+
await g.close()
714+
715+
for r in rows:
716+
r["symbol_id"] = r.pop("id")
717+
718+
if file is not None:
719+
needle = str(file).strip().lstrip("/")
720+
721+
def _matches(r: dict[str, Any]) -> bool:
722+
fp = r.get("file") or ""
723+
return bool(needle) and (fp == needle or fp.endswith(needle) or needle in fp)
724+
725+
for r in rows:
726+
r["file_match"] = _matches(r)
727+
# In-file matches first; preserve relative order within each group.
728+
rows.sort(key=lambda r: not r["file_match"])
729+
730+
return rows[:limit]
731+
732+
657733
@app.tool(
658734
name="get_neighbors",
659735
description=(

0 commit comments

Comments
 (0)