Skip to content

Commit 0ba88a4

Browse files
committed
Align MCP relationship guidance with agent profiles
1 parent 92cc94c commit 0ba88a4

4 files changed

Lines changed: 244 additions & 4 deletions

File tree

src/tests/test_artifact_relationships.py

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,13 @@ def test_found_with_grouped_relationships(self):
3838
"sourceIdentifier": "org/repo::path::Symbol",
3939
"profile": "CallsOnly",
4040
"found": True,
41+
"availableRelationshipCounts": {
42+
"outgoingCalls": 57,
43+
"incomingCalls": 3,
44+
"ancestors": 0,
45+
"descendants": 2,
46+
"references": 11,
47+
},
4148
"relationships": [
4249
{
4350
"relationType": "OutgoingCalls",
@@ -73,6 +80,10 @@ def test_found_with_grouped_relationships(self):
7380
assert parsed["sourceIdentifier"] == "org/repo::path::Symbol"
7481
assert parsed["profile"] == "callsOnly"
7582
assert parsed["found"] is True
83+
assert parsed["availableRelationshipCounts"]["outgoingCalls"] == 57
84+
assert parsed["availableRelationshipCounts"]["references"] == 11
85+
assert "truncated" in parsed["hint"]
86+
assert "higher max_count_per_type" in parsed["hint"]
7687

7788
outgoing = parsed["relationships"][0]
7889
assert outgoing["type"] == "outgoing_calls"
@@ -100,6 +111,8 @@ def test_not_found_omits_relationships(self):
100111
parsed = _build_relationships_dict(data)
101112
assert parsed["found"] is False
102113
assert "relationships" not in parsed
114+
assert "availableRelationshipCounts" not in parsed
115+
assert "fresh identifier" in parsed["hint"]
103116

104117
def test_empty_groups_still_rendered(self):
105118
data = {
@@ -130,6 +143,7 @@ def test_empty_groups_still_rendered(self):
130143
for g in parsed["relationships"]:
131144
assert g["totalCount"] == 0
132145
assert g["items"] == []
146+
assert "No relationships were found for this profile" in parsed["hint"]
133147

134148
def test_optional_fields_omitted_when_null(self):
135149
data = {
@@ -159,6 +173,92 @@ def test_optional_fields_omitted_when_null(self):
159173
assert "startLine" not in item
160174
assert "shortSummary" not in item
161175

176+
def test_empty_profile_hint_uses_available_counts(self):
177+
data = {
178+
"sourceIdentifier": "org/repo::path::Command",
179+
"profile": "CallsOnly",
180+
"found": True,
181+
"availableRelationshipCounts": {
182+
"outgoingCalls": 0,
183+
"incomingCalls": 0,
184+
"ancestors": 0,
185+
"descendants": 0,
186+
"references": 7,
187+
},
188+
"relationships": [
189+
{
190+
"relationType": "OutgoingCalls",
191+
"totalCount": 0,
192+
"returnedCount": 0,
193+
"truncated": False,
194+
"items": [],
195+
},
196+
{
197+
"relationType": "IncomingCalls",
198+
"totalCount": 0,
199+
"returnedCount": 0,
200+
"truncated": False,
201+
"items": [],
202+
},
203+
],
204+
}
205+
206+
parsed = _build_relationships_dict(data)
207+
208+
assert parsed["availableRelationshipCounts"]["references"] == 7
209+
assert "referencesOnly" in parsed["hint"]
210+
assert "where-used" in parsed["hint"]
211+
212+
def test_all_relevant_empty_profile_hint_says_references_are_excluded(self):
213+
data = {
214+
"sourceIdentifier": "org/repo::path::Message",
215+
"profile": "AllRelevant",
216+
"found": True,
217+
"availableRelationshipCounts": {
218+
"outgoingCalls": 0,
219+
"incomingCalls": 0,
220+
"ancestors": 0,
221+
"descendants": 0,
222+
"references": 4,
223+
},
224+
"relationships": [
225+
{
226+
"relationType": "OutgoingCalls",
227+
"totalCount": 0,
228+
"returnedCount": 0,
229+
"truncated": False,
230+
"items": [],
231+
},
232+
{
233+
"relationType": "IncomingCalls",
234+
"totalCount": 0,
235+
"returnedCount": 0,
236+
"truncated": False,
237+
"items": [],
238+
},
239+
{
240+
"relationType": "Ancestors",
241+
"totalCount": 0,
242+
"returnedCount": 0,
243+
"truncated": False,
244+
"items": [],
245+
},
246+
{
247+
"relationType": "Descendants",
248+
"totalCount": 0,
249+
"returnedCount": 0,
250+
"truncated": False,
251+
"items": [],
252+
},
253+
],
254+
}
255+
256+
parsed = _build_relationships_dict(data)
257+
258+
assert parsed["profile"] == "allRelevant"
259+
assert "excludes references" in parsed["hint"]
260+
assert "referencesOnly" in parsed["hint"]
261+
162262
def test_quotes_and_specials_pass_through_unchanged(self):
163263
"""Special chars (<, >, &, ") are preserved as-is in the dict — no HTML encoding."""
164264
data = {

src/tests/test_e2e_tools.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1118,6 +1118,13 @@ class TestGetArtifactRelationshipsE2E:
11181118
"sourceIdentifier": "org/repo::src/svc.py::Service",
11191119
"profile": "CallsOnly",
11201120
"found": True,
1121+
"availableRelationshipCounts": {
1122+
"outgoingCalls": 3,
1123+
"incomingCalls": 1,
1124+
"ancestors": 0,
1125+
"descendants": 0,
1126+
"references": 2,
1127+
},
11211128
"relationships": [
11221129
{
11231130
"relationType": "OutgoingCalls",
@@ -1162,6 +1169,8 @@ def handler(req):
11621169
# FastMCP serializes via pydantic_core.to_json — compact, UTF-8.
11631170
assert text == json.dumps(data, separators=(",", ":"), ensure_ascii=False)
11641171
assert data["found"] is True
1172+
assert data["availableRelationshipCounts"]["references"] == 2
1173+
assert "Fetch promising related artifacts" in data["hint"]
11651174
types = [g["type"] for g in data["relationships"]]
11661175
assert "outgoing_calls" in types
11671176
assert "incoming_calls" in types

src/tests/test_tool_metadata.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,3 +41,5 @@ async def test_all_tools_are_marked_read_only_with_titles():
4141
assert "exact artifact identifier" in relationships_description
4242
assert "not a search tool" in relationships_description
4343
assert "fetch_artifacts" in relationships_description
44+
assert "excludes references" in relationships_description
45+
assert "Mediated or dynamic frameworks" in relationships_description

src/tools/artifact_relationships.py

Lines changed: 133 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,17 @@ async def get_artifact_relationships(
6161
- The response contains relationship metadata and short summaries, not
6262
full source code. Use `fetch_artifacts` on returned identifiers when
6363
exact source content is needed.
64+
- Choose `profile` by artifact shape: `callsOnly` for function/method
65+
callers and callees; `inheritanceOnly` for hierarchy; `allRelevant`
66+
for calls plus inheritance only (it excludes references);
67+
`referencesOnly` for where-used checks on types, containers, fields,
68+
commands, events, interfaces, and other non-call usage.
69+
- Mediated or dynamic frameworks such as command buses, event buses,
70+
dependency injection, reflection, route binding, subscriptions,
71+
schedulers, or generated dispatch may not expose a direct call edge.
72+
When graph context is missing or insufficient, use targeted
73+
`grep_search` for construction, registration, dispatch, route,
74+
subscription, or scheduler text surfaced by source you've already read.
6475
- If any relationship group has `truncated=true`, increase
6576
`max_count_per_type` up to 1000 or narrow the investigation with a
6677
more specific `profile`.
@@ -69,9 +80,9 @@ async def get_artifact_relationships(
6980
identifier: Fully qualified artifact identifier from search or fetch results.
7081
profile: Relationship profile to expand. One of:
7182
- "callsOnly" (default): outgoing and incoming calls
72-
- "inheritanceOnly": ancestors and descendants
73-
- "allRelevant": calls + inheritance (4 groups)
74-
- "referencesOnly": symbol references
83+
- "inheritanceOnly": ancestors, descendants, implementations, and derived types
84+
- "allRelevant": calls + inheritance only; references are excluded
85+
- "referencesOnly": where-used LSP references for non-call usage
7586
max_count_per_type: Maximum related artifacts per relationship type (1–1000, default 50).
7687
7788
Returns:
@@ -191,7 +202,15 @@ def _build_relationships_dict(data: dict) -> Dict[str, Any]:
191202

192203
if found:
193204
relationships = data.get("relationships") or []
194-
payload["relationships"] = [_build_group(group) for group in relationships]
205+
groups = [_build_group(group) for group in relationships]
206+
payload["relationships"] = groups
207+
208+
counts = _build_counts(data.get("availableRelationshipCounts"))
209+
if counts is not None:
210+
payload["availableRelationshipCounts"] = counts
211+
payload["hint"] = _build_relationship_hint(found, mcp_profile, groups, counts)
212+
else:
213+
payload["hint"] = _build_relationship_hint(found, mcp_profile, [], None)
195214

196215
return payload
197216

@@ -226,3 +245,113 @@ def _build_group(group: dict) -> Dict[str, Any]:
226245
"truncated": bool(group.get("truncated")),
227246
"items": items,
228247
}
248+
249+
250+
def _build_counts(counts: Any) -> Dict[str, int] | None:
251+
"""Preserve backend relationship counts that guide profile recovery."""
252+
if not isinstance(counts, dict):
253+
return None
254+
255+
return {
256+
"outgoingCalls": int(counts.get("outgoingCalls") or counts.get("OutgoingCalls") or 0),
257+
"incomingCalls": int(counts.get("incomingCalls") or counts.get("IncomingCalls") or 0),
258+
"ancestors": int(counts.get("ancestors") or counts.get("Ancestors") or 0),
259+
"descendants": int(counts.get("descendants") or counts.get("Descendants") or 0),
260+
"references": int(counts.get("references") or counts.get("References") or 0),
261+
}
262+
263+
264+
def _build_relationship_hint(
265+
found: bool,
266+
profile: str,
267+
groups: List[Dict[str, Any]],
268+
counts: Dict[str, int] | None,
269+
) -> str:
270+
"""Give model-facing next-step guidance for graph traversal results."""
271+
if not found:
272+
return (
273+
"No relationship data was found for this identifier. Verify that the identifier came from "
274+
"a recent search/fetch result and points to a symbol-level artifact; otherwise re-run "
275+
"semantic_search or grep_search to get a fresh identifier."
276+
)
277+
278+
if any(group["truncated"] for group in groups):
279+
return (
280+
"Some relationship groups are truncated. If the user asked for all usages or full graph "
281+
"scope, call get_artifact_relationships again with a higher max_count_per_type, then "
282+
"fetch promising related artifacts before making broad claims."
283+
)
284+
285+
if all(group["totalCount"] == 0 for group in groups):
286+
return _build_empty_profile_hint(profile, counts)
287+
288+
return (
289+
"Fetch promising related artifacts before making claims about behavior, concrete applications, "
290+
"or how broadly this mechanism is used."
291+
)
292+
293+
294+
def _build_empty_profile_hint(profile: str, counts: Dict[str, int] | None) -> str:
295+
has_calls = (counts or {}).get("outgoingCalls", 0) > 0 or (counts or {}).get("incomingCalls", 0) > 0
296+
has_inheritance = (counts or {}).get("ancestors", 0) > 0 or (counts or {}).get("descendants", 0) > 0
297+
has_references = (counts or {}).get("references", 0) > 0
298+
299+
if profile == "referencesOnly" and has_calls and has_inheritance:
300+
return (
301+
"No references were found for this profile, but call and inheritance relationships exist. "
302+
"Use callsOnly for function/method callers or callees, or inheritanceOnly for base classes, "
303+
"interfaces, overrides, implementations, or derived types."
304+
)
305+
if profile == "referencesOnly" and has_calls:
306+
return (
307+
"No references were found for this profile, but call relationships exist. Use callsOnly "
308+
"for function/method callers or callees. Use referencesOnly for where-used checks on "
309+
"types, containers, fields, commands, events, interfaces, and other non-call usage."
310+
)
311+
if profile == "referencesOnly" and has_inheritance:
312+
return (
313+
"No references were found for this profile, but inheritance relationships exist. Use "
314+
"inheritanceOnly for base classes, interfaces, overrides, implementations, or derived types."
315+
)
316+
if profile == "callsOnly" and has_references and has_inheritance:
317+
return (
318+
"No call relationships were found for this profile, but references and inheritance "
319+
"relationships exist. Try referencesOnly for where-used checks or inheritanceOnly for hierarchy."
320+
)
321+
if profile == "callsOnly" and has_references:
322+
return (
323+
"No call relationships were found for this profile, but references exist. Use referencesOnly "
324+
"for where-used checks on types, containers, fields, commands, events, interfaces, or mediated dispatch symbols."
325+
)
326+
if profile == "callsOnly" and has_inheritance:
327+
return (
328+
"No call relationships were found for this profile, but inheritance relationships exist. "
329+
"Use inheritanceOnly for base classes, interfaces, overrides, implementations, or derived types."
330+
)
331+
if profile == "allRelevant" and has_references:
332+
return (
333+
"No calls or inheritance relationships were found for allRelevant. allRelevant excludes "
334+
"references by design; use referencesOnly for where-used checks."
335+
)
336+
if profile == "inheritanceOnly" and has_calls and has_references:
337+
return (
338+
"No inheritance relationships were found for this profile. Use callsOnly for function "
339+
"callers/callees, or referencesOnly for where-used checks on types, commands, events, fields, containers, or interfaces."
340+
)
341+
if profile == "inheritanceOnly" and has_calls:
342+
return (
343+
"No inheritance relationships were found for this profile, but call relationships exist. "
344+
"Use callsOnly for function/method callers or callees."
345+
)
346+
if profile == "inheritanceOnly" and has_references:
347+
return (
348+
"No inheritance relationships were found for this profile, but references exist. Use "
349+
"referencesOnly for where-used checks on types, containers, fields, commands, events, interfaces, or mediated dispatch symbols."
350+
)
351+
352+
return (
353+
"No relationships were found for this profile. Empty profile results do not mean the artifact "
354+
"has no graph data. Use callsOnly for function/method callers and callees, inheritanceOnly for "
355+
"hierarchy, allRelevant for calls plus inheritance, and referencesOnly for where-used checks on "
356+
"types, containers, fields, commands, events, interfaces, and other non-call usage."
357+
)

0 commit comments

Comments
 (0)