Skip to content

Commit 1ab4749

Browse files
committed
Add fetch_artifacts tool and replace include_content with description_detail (v0.5.0)
New fetch_artifacts tool retrieves full source code for search results by identifier. Search now returns descriptions and content sizes instead of inline content. Bump version to 0.5.0.
1 parent df202f3 commit 1ab4749

10 files changed

Lines changed: 638 additions & 494 deletions

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "codealive-mcp"
3-
version = "0.4.7"
3+
version = "0.5.0"
44
description = "MCP server for the CodeAlive API"
55
readme = "README.md"
66
requires-python = ">=3.11"

smoke_test.py

Lines changed: 3 additions & 3 deletions
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"}
136+
expected_tools = {"codebase_consultant", "get_data_sources", "codebase_search", "fetch_artifacts"}
137137
actual_tools = {tool.name for tool in tools}
138138

139139
if expected_tools == actual_tools:
@@ -187,7 +187,7 @@ async def test_codebase_search(self) -> bool:
187187
"query": "test query",
188188
"data_sources": ["test-repo"],
189189
"mode": "auto",
190-
"include_content": False
190+
"description_detail": "short"
191191
})
192192

193193
if result.isError:
@@ -248,7 +248,7 @@ async def test_parameter_validation(self) -> bool:
248248
"query": "", # Empty query should fail
249249
"data_sources": ["test"],
250250
"mode": "auto",
251-
"include_content": False
251+
"description_detail": "short"
252252
})
253253

254254
# Should get an error about empty query

src/codealive_mcp_server.py

Lines changed: 9 additions & 21 deletions
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, codebase_search
29+
from tools import codebase_consultant, get_data_sources, fetch_artifacts, codebase_search
3030

3131
# Initialize FastMCP server with lifespan and enhanced system instructions
3232
mcp = FastMCP(
@@ -41,36 +41,23 @@
4141
- Answer questions about code implementation details
4242
- Integrate with local git repositories for seamless code exploration
4343
44-
When working with a codebase:
44+
When working with a codebase, follow this workflow:
4545
1. First use `get_data_sources` to identify available repositories and workspaces
46-
2. Then use `codebase_search` to find relevant files and code snippets
47-
3. Finally, use `chat_completions` for in-depth analysis of the code
46+
2. Use `codebase_search` to find relevant files — returns paths, descriptions, and identifiers
47+
3. To get full content:
48+
- For repos in your working directory: use `Read()` on the local files
49+
- For external repos: use `fetch_artifacts` with identifiers from search results
50+
4. Use `codebase_consultant` for in-depth analysis and synthesized answers
4851
4952
For effective code exploration:
5053
- Start with broad queries to understand the overall structure
5154
- Use specific function/class names when looking for particular implementations
5255
- Combine natural language with code patterns in your queries
5356
- Always use "auto" search mode by default; it intelligently selects the appropriate search depth
5457
- IMPORTANT: Only use "deep" search mode for very complex conceptual queries as it's resource-intensive
58+
- Use `description_detail="full"` in search when you need richer descriptions before fetching content
5559
- Remember that context from previous messages is maintained in the same conversation
5660
57-
CRITICAL - include_content parameter usage:
58-
You MUST intelligently determine if searching CURRENT or EXTERNAL repositories:
59-
60-
- CURRENT repository (user's working directory): include_content=false
61-
* You have file access → Get paths from search, then use Read tool for latest content
62-
- EXTERNAL repositories (not in working directory): include_content=true
63-
* No file access → Must include content in search results
64-
65-
Use these heuristics to identify CURRENT vs EXTERNAL (combine multiple signals):
66-
1. Repository/directory name matching (e.g., working in "my-app", repo named "my-app")
67-
2. Description matching observed codebase (tech stack, architecture, features)
68-
3. User's language ("this repo", "our code" = CURRENT; "the X service" = EXTERNAL)
69-
4. URL matching with git remote (when available)
70-
5. Working context (files you've been reading/editing match this repo)
71-
72-
When uncertain, use context: Is user asking about their current work or a different service?
73-
7461
Flexible data source usage:
7562
- You can use a workspace name as a single data source to search or chat across all its repositories at once
7663
- Alternatively, you can use specific repository names for more targeted searches
@@ -111,6 +98,7 @@ async def health_check(request: Request) -> JSONResponse:
11198
mcp.tool()(codebase_consultant)
11299
mcp.tool()(get_data_sources)
113100
mcp.tool()(codebase_search)
101+
mcp.tool()(fetch_artifacts)
114102

115103

116104
def main():

src/tests/test_fetch_artifacts.py

Lines changed: 210 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,210 @@
1+
"""Test suite for fetch_artifacts tool."""
2+
3+
import pytest
4+
from unittest.mock import AsyncMock, MagicMock, patch
5+
from fastmcp import Context
6+
from tools.fetch_artifacts import fetch_artifacts
7+
8+
9+
@pytest.mark.asyncio
10+
@patch('tools.fetch_artifacts.get_api_key_from_context')
11+
async def test_fetch_artifacts_returns_xml(mock_get_api_key):
12+
"""Test that fetch_artifacts returns properly formatted XML."""
13+
mock_get_api_key.return_value = "test_key"
14+
15+
ctx = MagicMock(spec=Context)
16+
ctx.info = AsyncMock()
17+
ctx.warning = AsyncMock()
18+
ctx.error = AsyncMock()
19+
20+
mock_response = MagicMock()
21+
mock_response.json.return_value = {
22+
"artifacts": [
23+
{
24+
"identifier": "owner/repo::src/auth.py::login",
25+
"content": "def login(user, pwd):\n return True",
26+
"contentByteSize": 38
27+
},
28+
{
29+
"identifier": "owner/repo::src/missing.py::func",
30+
"content": None,
31+
"contentByteSize": None
32+
}
33+
]
34+
}
35+
mock_response.raise_for_status = MagicMock()
36+
37+
mock_client = AsyncMock()
38+
mock_client.post.return_value = mock_response
39+
40+
mock_codealive_context = MagicMock()
41+
mock_codealive_context.client = mock_client
42+
mock_codealive_context.base_url = "https://app.codealive.ai"
43+
44+
ctx.request_context.lifespan_context = mock_codealive_context
45+
ctx.request_context.headers = {"authorization": "Bearer test_key"}
46+
47+
result = await fetch_artifacts(
48+
ctx=ctx,
49+
identifiers=["owner/repo::src/auth.py::login", "owner/repo::src/missing.py::func"],
50+
)
51+
52+
assert isinstance(result, str)
53+
assert "<artifacts>" in result
54+
assert "</artifacts>" in result
55+
# Found artifact has content
56+
assert "def login(user, pwd):" in result
57+
assert 'contentByteSize="38"' in result
58+
assert 'identifier="owner/repo::src/auth.py::login"' in result
59+
# Not-found artifact is skipped (not in output)
60+
assert "missing.py" not in result
61+
62+
63+
@pytest.mark.asyncio
64+
async def test_fetch_artifacts_empty_identifiers():
65+
"""Test that empty identifiers list returns an error."""
66+
ctx = MagicMock(spec=Context)
67+
68+
result = await fetch_artifacts(ctx=ctx, identifiers=[])
69+
70+
assert "<error>" in result
71+
assert "At least one identifier" in result
72+
73+
74+
@pytest.mark.asyncio
75+
async def test_fetch_artifacts_exceeds_max_identifiers():
76+
"""Test that more than 20 identifiers returns an error."""
77+
ctx = MagicMock(spec=Context)
78+
79+
identifiers = [f"owner/repo::file{i}.py::func{i}" for i in range(21)]
80+
81+
result = await fetch_artifacts(ctx=ctx, identifiers=identifiers)
82+
83+
assert "<error>" in result
84+
assert "Maximum 20" in result
85+
86+
87+
@pytest.mark.asyncio
88+
@patch('tools.fetch_artifacts.get_api_key_from_context')
89+
async def test_fetch_artifacts_posts_correct_body(mock_get_api_key):
90+
"""Test that fetch_artifacts sends the correct POST body."""
91+
mock_get_api_key.return_value = "test_key"
92+
93+
ctx = MagicMock(spec=Context)
94+
ctx.info = AsyncMock()
95+
ctx.warning = AsyncMock()
96+
ctx.error = AsyncMock()
97+
98+
mock_response = MagicMock()
99+
mock_response.json.return_value = {"artifacts": []}
100+
mock_response.raise_for_status = MagicMock()
101+
102+
mock_client = AsyncMock()
103+
mock_client.post.return_value = mock_response
104+
105+
mock_codealive_context = MagicMock()
106+
mock_codealive_context.client = mock_client
107+
mock_codealive_context.base_url = "https://app.codealive.ai"
108+
109+
ctx.request_context.lifespan_context = mock_codealive_context
110+
ctx.request_context.headers = {"authorization": "Bearer test_key"}
111+
112+
await fetch_artifacts(
113+
ctx=ctx,
114+
identifiers=["id1", "id2"],
115+
)
116+
117+
call_args = mock_client.post.call_args
118+
assert call_args.args[0] == "/api/search/artifacts"
119+
body = call_args.kwargs["json"]
120+
assert body["identifiers"] == ["id1", "id2"]
121+
assert "names" not in body
122+
123+
124+
@pytest.mark.asyncio
125+
@patch('tools.fetch_artifacts.get_api_key_from_context')
126+
async def test_fetch_artifacts_api_error(mock_get_api_key):
127+
"""Test that API errors are handled gracefully."""
128+
import httpx
129+
130+
mock_get_api_key.return_value = "test_key"
131+
132+
ctx = MagicMock(spec=Context)
133+
ctx.info = AsyncMock()
134+
ctx.warning = AsyncMock()
135+
ctx.error = AsyncMock()
136+
137+
mock_response = MagicMock()
138+
mock_response.status_code = 500
139+
mock_response.text = "Internal server error"
140+
141+
def raise_500():
142+
raise httpx.HTTPStatusError(
143+
"Server error",
144+
request=MagicMock(),
145+
response=mock_response
146+
)
147+
148+
mock_response.raise_for_status = raise_500
149+
150+
mock_client = AsyncMock()
151+
mock_client.post.return_value = mock_response
152+
153+
mock_codealive_context = MagicMock()
154+
mock_codealive_context.client = mock_client
155+
mock_codealive_context.base_url = "https://app.codealive.ai"
156+
157+
ctx.request_context.lifespan_context = mock_codealive_context
158+
ctx.request_context.headers = {"authorization": "Bearer test_key"}
159+
160+
result = await fetch_artifacts(
161+
ctx=ctx,
162+
identifiers=["some-id"],
163+
)
164+
165+
assert isinstance(result, str)
166+
assert "<error>" in result
167+
168+
169+
@pytest.mark.asyncio
170+
@patch('tools.fetch_artifacts.get_api_key_from_context')
171+
async def test_fetch_artifacts_escapes_xml(mock_get_api_key):
172+
"""Test that content with XML special characters is properly escaped."""
173+
mock_get_api_key.return_value = "test_key"
174+
175+
ctx = MagicMock(spec=Context)
176+
ctx.info = AsyncMock()
177+
ctx.warning = AsyncMock()
178+
ctx.error = AsyncMock()
179+
180+
mock_response = MagicMock()
181+
mock_response.json.return_value = {
182+
"artifacts": [
183+
{
184+
"identifier": "owner/repo::file.py::func",
185+
"content": 'if x < 10 && y > 5:\n return "<ok>"',
186+
"contentByteSize": 40
187+
}
188+
]
189+
}
190+
mock_response.raise_for_status = MagicMock()
191+
192+
mock_client = AsyncMock()
193+
mock_client.post.return_value = mock_response
194+
195+
mock_codealive_context = MagicMock()
196+
mock_codealive_context.client = mock_client
197+
mock_codealive_context.base_url = "https://app.codealive.ai"
198+
199+
ctx.request_context.lifespan_context = mock_codealive_context
200+
ctx.request_context.headers = {"authorization": "Bearer test_key"}
201+
202+
result = await fetch_artifacts(
203+
ctx=ctx,
204+
identifiers=["owner/repo::file.py::func"],
205+
)
206+
207+
assert "&lt;" in result
208+
assert "&amp;" in result
209+
assert "<artifacts>" in result
210+
assert "</artifacts>" in result

0 commit comments

Comments
 (0)