Skip to content

Commit b61b620

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 d909649 commit b61b620

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
@@ -700,6 +700,82 @@ async def _neighbors_payload(
700700
await g.close()
701701

702702

703+
FIND_SYMBOL_SNIPPET_LINES = 4
704+
FIND_SYMBOL_DB_LIMIT = 200
705+
706+
707+
@app.tool(
708+
name="find_symbol",
709+
description=(
710+
"Resolve a Function/Class NAME to its integer symbol node id — the "
711+
"bridge from a human-readable name to the `symbol_id` that "
712+
"`get_neighbors`, `impact_analysis` and `find_path` require (those tools "
713+
"take an id, NOT a name; `search_code` returns FILES, not symbol ids, so "
714+
"start here for relationship questions). Pass the simple name "
715+
"(e.g. `normalize_cartesian_coordinates`); a dotted qualname "
716+
"(`Grid.normalize_cartesian_coordinates`) is accepted and its last "
717+
"segment is used. Optionally pass `file` (repo-relative path or a "
718+
"substring of it) to disambiguate same-named symbols — matches in that "
719+
"file are flagged `file_match: true` and listed first. Returns "
720+
"[{symbol_id, name, label, file, line, file_match, snippet}] ordered "
721+
"best-scoped first; when more than one remains, disambiguate by `file` "
722+
"and `snippet`. Feed the chosen `symbol_id` to the relationship tools."
723+
),
724+
)
725+
async def find_symbol(
726+
name: str,
727+
project: str,
728+
file: Optional[str] = None,
729+
branch: Optional[str] = None,
730+
limit: int = 20,
731+
) -> list[dict[str, Any]]:
732+
"""Look up Function/Class nodes by their simple name.
733+
734+
Symbol nodes store only the SIMPLE name (``foo``), never the qualname
735+
(``Bar.foo``), so a dotted input is reduced to its last segment. When
736+
``file`` is given we do not silently widen the search: every candidate is
737+
tagged ``file_match`` and the in-file ones sort first, so a wrong/empty
738+
file filter degrades visibly (the agent still sees the global matches but
739+
knows none were in the requested file) rather than routing a relationship
740+
query to an arbitrary same-named symbol.
741+
"""
742+
simple = str(name).strip().split(".")[-1]
743+
g = _project_arg(project, branch)
744+
try:
745+
res = await g._query(
746+
"MATCH (n) WHERE (n:Function OR n:Class) AND n.name = $name "
747+
"RETURN n LIMIT $limit",
748+
{"name": simple, "limit": FIND_SYMBOL_DB_LIMIT},
749+
)
750+
rows = [
751+
_node_summary(
752+
r[0],
753+
rel_to=project,
754+
snippet_lines=FIND_SYMBOL_SNIPPET_LINES,
755+
)
756+
for r in res.result_set
757+
]
758+
finally:
759+
await g.close()
760+
761+
for r in rows:
762+
r["symbol_id"] = r.pop("id")
763+
764+
if file is not None:
765+
needle = str(file).strip().lstrip("/")
766+
767+
def _matches(r: dict[str, Any]) -> bool:
768+
fp = r.get("file") or ""
769+
return bool(needle) and (fp == needle or fp.endswith(needle) or needle in fp)
770+
771+
for r in rows:
772+
r["file_match"] = _matches(r)
773+
# In-file matches first; preserve relative order within each group.
774+
rows.sort(key=lambda r: not r["file_match"])
775+
776+
return rows[:limit]
777+
778+
703779
@app.tool(
704780
name="get_neighbors",
705781
description=(

0 commit comments

Comments
 (0)