Skip to content

Commit 9f1fbe2

Browse files
authored
Add artifact relations tool and enrich fetch_artifacts with relation metadata (#11)
* COD-661: Add artifact_relations tool * COD-661: Rename relations to relationships * COD-661: Add explicit notes about tool usage * COD-661: Update server.json
1 parent b284419 commit 9f1fbe2

8 files changed

Lines changed: 728 additions & 8 deletions

server.json

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,14 @@
6161
{
6262
"name": "codebase_consultant",
6363
"description": "Get comprehensive AI-powered analysis, explanations, and insights about your codebase. Ask complex questions about architecture, patterns, dependencies, and implementation details."
64+
},
65+
{
66+
"name": "fetch_artifacts",
67+
"description": "Retrieve full source code of artifacts by their identifiers from search results. Returns content with line numbers and a preview of call relationships (outgoing and incoming calls)."
68+
},
69+
{
70+
"name": "get_artifact_relationships",
71+
"description": "Explore an artifact's relationships — call graph, inheritance hierarchy, or references. Drill down after search or fetch to understand how code connects across the codebase."
6472
}
6573
]
6674
}

smoke_test.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -133,7 +133,7 @@ async def test_list_tools(self) -> bool:
133133
result = await self.session.list_tools()
134134
tools = result.tools
135135

136-
expected_tools = {"codebase_consultant", "get_data_sources", "codebase_search", "fetch_artifacts"}
136+
expected_tools = {"codebase_consultant", "get_data_sources", "codebase_search", "fetch_artifacts", "get_artifact_relationships"}
137137
actual_tools = {tool.name for tool in tools}
138138

139139
if expected_tools == actual_tools:

src/codealive_mcp_server.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@
2626
# Import core components
2727
from core import codealive_lifespan, setup_debug_logging
2828
from middleware import N8NRemoveParametersMiddleware
29-
from tools import codebase_consultant, get_data_sources, fetch_artifacts, codebase_search
29+
from tools import codebase_consultant, get_data_sources, fetch_artifacts, codebase_search, get_artifact_relationships
3030

3131
# Initialize FastMCP server with lifespan and enhanced system instructions
3232
mcp = FastMCP(
@@ -99,6 +99,7 @@ async def health_check(request: Request) -> JSONResponse:
9999
mcp.tool()(get_data_sources)
100100
mcp.tool()(codebase_search)
101101
mcp.tool()(fetch_artifacts)
102+
mcp.tool()(get_artifact_relationships)
102103

103104

104105
def main():
Lines changed: 346 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,346 @@
1+
"""Tests for the get_artifact_relationships tool."""
2+
3+
import pytest
4+
from unittest.mock import AsyncMock, MagicMock, patch
5+
6+
from fastmcp import Context
7+
8+
from tools.artifact_relationships import get_artifact_relationships, _build_relationships_xml, PROFILE_MAP
9+
10+
11+
class TestProfileMapping:
12+
"""Test MCP profile names map to backend enum values."""
13+
14+
def test_default_profile_is_calls_only(self):
15+
"""callsOnly is the default and maps to CallsOnly."""
16+
assert PROFILE_MAP["callsOnly"] == "CallsOnly"
17+
18+
def test_inheritance_only_maps_correctly(self):
19+
assert PROFILE_MAP["inheritanceOnly"] == "InheritanceOnly"
20+
21+
def test_all_relevant_maps_correctly(self):
22+
assert PROFILE_MAP["allRelevant"] == "AllRelevant"
23+
24+
def test_references_only_maps_correctly(self):
25+
assert PROFILE_MAP["referencesOnly"] == "ReferencesOnly"
26+
27+
28+
class TestBuildRelationshipsXml:
29+
"""Test XML rendering of relationship responses."""
30+
31+
def test_found_with_grouped_relationships(self):
32+
data = {
33+
"sourceIdentifier": "org/repo::path::Symbol",
34+
"profile": "CallsOnly",
35+
"found": True,
36+
"relationships": [
37+
{
38+
"relationType": "OutgoingCalls",
39+
"totalCount": 57,
40+
"returnedCount": 50,
41+
"truncated": True,
42+
"items": [
43+
{
44+
"identifier": "org/repo::path::Dep",
45+
"filePath": "src/Data/Repository.cs",
46+
"startLine": 88,
47+
"shortSummary": "Stores the aggregate",
48+
}
49+
],
50+
},
51+
{
52+
"relationType": "IncomingCalls",
53+
"totalCount": 3,
54+
"returnedCount": 3,
55+
"truncated": False,
56+
"items": [
57+
{
58+
"identifier": "org/repo::path::Caller",
59+
"filePath": "src/Services/Worker.cs",
60+
"startLine": 142,
61+
}
62+
],
63+
},
64+
],
65+
}
66+
67+
result = _build_relationships_xml(data)
68+
69+
assert 'sourceIdentifier="org/repo::path::Symbol"' in result
70+
assert 'profile="callsOnly"' in result
71+
assert 'found="true"' in result
72+
assert 'type="outgoing_calls"' in result
73+
assert 'totalCount="57"' in result
74+
assert 'returnedCount="50"' in result
75+
assert 'truncated="true"' in result
76+
assert 'filePath="src/Data/Repository.cs"' in result
77+
assert 'startLine="88"' in result
78+
assert 'shortSummary="Stores the aggregate"' in result
79+
assert 'type="incoming_calls"' in result
80+
assert 'truncated="false"' in result
81+
# Incoming call has no shortSummary
82+
assert result.count("shortSummary") == 1
83+
84+
def test_not_found_renders_self_closing(self):
85+
data = {
86+
"sourceIdentifier": "org/repo::path::Missing",
87+
"profile": "CallsOnly",
88+
"found": False,
89+
"relationships": [],
90+
}
91+
92+
result = _build_relationships_xml(data)
93+
94+
assert 'found="false"' in result
95+
assert result.endswith("/>")
96+
assert "<relationship_group" not in result
97+
98+
def test_empty_group_still_rendered(self):
99+
data = {
100+
"sourceIdentifier": "org/repo::path::Symbol",
101+
"profile": "InheritanceOnly",
102+
"found": True,
103+
"relationships": [
104+
{
105+
"relationType": "Ancestors",
106+
"totalCount": 0,
107+
"returnedCount": 0,
108+
"truncated": False,
109+
"items": [],
110+
},
111+
{
112+
"relationType": "Descendants",
113+
"totalCount": 0,
114+
"returnedCount": 0,
115+
"truncated": False,
116+
"items": [],
117+
},
118+
],
119+
}
120+
121+
result = _build_relationships_xml(data)
122+
123+
assert 'type="ancestors"' in result
124+
assert 'type="descendants"' in result
125+
assert 'totalCount="0"' in result
126+
127+
def test_optional_fields_omitted_when_null(self):
128+
data = {
129+
"sourceIdentifier": "org/repo::path::Symbol",
130+
"profile": "CallsOnly",
131+
"found": True,
132+
"relationships": [
133+
{
134+
"relationType": "OutgoingCalls",
135+
"totalCount": 1,
136+
"returnedCount": 1,
137+
"truncated": False,
138+
"items": [
139+
{
140+
"identifier": "org/repo::path::Target",
141+
# filePath, startLine, shortSummary all absent
142+
}
143+
],
144+
},
145+
],
146+
}
147+
148+
result = _build_relationships_xml(data)
149+
150+
assert 'identifier="org/repo::path::Target"' in result
151+
assert "filePath" not in result
152+
assert "startLine" not in result
153+
assert "shortSummary" not in result
154+
155+
def test_html_entities_escaped(self):
156+
data = {
157+
"sourceIdentifier": "org/repo::path::Class<T>",
158+
"profile": "CallsOnly",
159+
"found": True,
160+
"relationships": [
161+
{
162+
"relationType": "OutgoingCalls",
163+
"totalCount": 1,
164+
"returnedCount": 1,
165+
"truncated": False,
166+
"items": [
167+
{
168+
"identifier": "org/repo::path::Method<T>",
169+
"shortSummary": 'Returns "value" & more',
170+
}
171+
],
172+
},
173+
],
174+
}
175+
176+
result = _build_relationships_xml(data)
177+
178+
assert "Class&lt;T&gt;" in result
179+
assert "Method&lt;T&gt;" in result
180+
assert "&amp;" in result
181+
assert "&quot;" in result
182+
183+
def test_profile_mapped_back_to_mcp_name(self):
184+
"""Backend profile enum names are mapped back to MCP-friendly names."""
185+
for mcp_name, api_name in PROFILE_MAP.items():
186+
data = {
187+
"sourceIdentifier": "id",
188+
"profile": api_name,
189+
"found": False,
190+
"relationships": [],
191+
}
192+
result = _build_relationships_xml(data)
193+
assert f'profile="{mcp_name}"' in result
194+
195+
196+
class TestGetArtifactRelationshipsTool:
197+
"""Test the async tool function."""
198+
199+
@pytest.mark.asyncio
200+
@patch("tools.artifact_relationships.get_api_key_from_context")
201+
async def test_default_profile_sends_calls_only(self, mock_get_api_key):
202+
mock_get_api_key.return_value = "test_key"
203+
204+
ctx = MagicMock(spec=Context)
205+
ctx.info = AsyncMock()
206+
ctx.error = AsyncMock()
207+
208+
mock_response = MagicMock()
209+
mock_response.json.return_value = {
210+
"sourceIdentifier": "org/repo::path::Symbol",
211+
"profile": "CallsOnly",
212+
"found": True,
213+
"relationships": [],
214+
}
215+
mock_response.raise_for_status = MagicMock()
216+
217+
mock_client = AsyncMock()
218+
mock_client.post.return_value = mock_response
219+
220+
mock_context = MagicMock()
221+
mock_context.client = mock_client
222+
mock_context.base_url = "https://app.codealive.ai"
223+
ctx.request_context.lifespan_context = mock_context
224+
225+
result = await get_artifact_relationships(
226+
ctx=ctx,
227+
identifier="org/repo::path::Symbol",
228+
)
229+
230+
# Verify the API was called with CallsOnly profile
231+
call_args = mock_client.post.call_args
232+
assert call_args[1]["json"]["profile"] == "CallsOnly"
233+
assert 'found="true"' in result
234+
235+
@pytest.mark.asyncio
236+
@patch("tools.artifact_relationships.get_api_key_from_context")
237+
async def test_explicit_profile_maps_correctly(self, mock_get_api_key):
238+
mock_get_api_key.return_value = "test_key"
239+
240+
ctx = MagicMock(spec=Context)
241+
ctx.info = AsyncMock()
242+
ctx.error = AsyncMock()
243+
244+
mock_response = MagicMock()
245+
mock_response.json.return_value = {
246+
"sourceIdentifier": "id",
247+
"profile": "InheritanceOnly",
248+
"found": True,
249+
"relationships": [],
250+
}
251+
mock_response.raise_for_status = MagicMock()
252+
253+
mock_client = AsyncMock()
254+
mock_client.post.return_value = mock_response
255+
256+
mock_context = MagicMock()
257+
mock_context.client = mock_client
258+
mock_context.base_url = "https://app.codealive.ai"
259+
ctx.request_context.lifespan_context = mock_context
260+
261+
await get_artifact_relationships(
262+
ctx=ctx,
263+
identifier="id",
264+
profile="inheritanceOnly",
265+
)
266+
267+
call_args = mock_client.post.call_args
268+
assert call_args[1]["json"]["profile"] == "InheritanceOnly"
269+
270+
@pytest.mark.asyncio
271+
async def test_empty_identifier_returns_error(self):
272+
ctx = MagicMock(spec=Context)
273+
result = await get_artifact_relationships(ctx=ctx, identifier="")
274+
assert "<error>" in result
275+
assert "required" in result
276+
277+
@pytest.mark.asyncio
278+
async def test_unsupported_profile_returns_error(self):
279+
ctx = MagicMock(spec=Context)
280+
result = await get_artifact_relationships(
281+
ctx=ctx, identifier="id", profile="invalidProfile"
282+
)
283+
assert "<error>" in result
284+
assert "Unsupported profile" in result
285+
286+
@pytest.mark.asyncio
287+
@patch("tools.artifact_relationships.get_api_key_from_context")
288+
async def test_api_error_returns_error_xml(self, mock_get_api_key):
289+
import httpx
290+
291+
mock_get_api_key.return_value = "test_key"
292+
293+
ctx = MagicMock(spec=Context)
294+
ctx.info = AsyncMock()
295+
ctx.error = AsyncMock()
296+
297+
mock_response = MagicMock()
298+
mock_response.status_code = 401
299+
mock_response.text = "Unauthorized"
300+
mock_response.raise_for_status.side_effect = httpx.HTTPStatusError(
301+
"Unauthorized", request=MagicMock(), response=mock_response
302+
)
303+
304+
mock_client = AsyncMock()
305+
mock_client.post.return_value = mock_response
306+
307+
mock_context = MagicMock()
308+
mock_context.client = mock_client
309+
mock_context.base_url = "https://app.codealive.ai"
310+
ctx.request_context.lifespan_context = mock_context
311+
312+
result = await get_artifact_relationships(ctx=ctx, identifier="id")
313+
314+
assert "<error>" in result
315+
assert "401" in result
316+
317+
@pytest.mark.asyncio
318+
@patch("tools.artifact_relationships.get_api_key_from_context")
319+
async def test_not_found_response_renders_correctly(self, mock_get_api_key):
320+
mock_get_api_key.return_value = "test_key"
321+
322+
ctx = MagicMock(spec=Context)
323+
ctx.info = AsyncMock()
324+
ctx.error = AsyncMock()
325+
326+
mock_response = MagicMock()
327+
mock_response.json.return_value = {
328+
"sourceIdentifier": "org/repo::path::Missing",
329+
"profile": "CallsOnly",
330+
"found": False,
331+
"relationships": [],
332+
}
333+
mock_response.raise_for_status = MagicMock()
334+
335+
mock_client = AsyncMock()
336+
mock_client.post.return_value = mock_response
337+
338+
mock_context = MagicMock()
339+
mock_context.client = mock_client
340+
mock_context.base_url = "https://app.codealive.ai"
341+
ctx.request_context.lifespan_context = mock_context
342+
343+
result = await get_artifact_relationships(ctx=ctx, identifier="org/repo::path::Missing")
344+
345+
assert 'found="false"' in result
346+
assert "<relationship_group" not in result

0 commit comments

Comments
 (0)