Skip to content

Commit fae0bf3

Browse files
authored
feat(cli): Add vectorise tool to MCP server (#198)
* refactor(cli): Refactor prompt descriptions for ls and query tools * feat(cli): Expose vectorise tool in MCP server * Auto generate docs * tests(cli): Fix query and vectorise tool edge cases --------- Co-authored-by: Davidyz <Davidyz@users.noreply.github.com>
1 parent 62f6454 commit fae0bf3

6 files changed

Lines changed: 250 additions & 15 deletions

File tree

doc/VectorCode-cli.txt

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -781,7 +781,8 @@ features:
781781

782782
- `ls`list local collections, similar to the `ls` subcommand in the CLI;
783783
- `query`query from a given collection, similar to the `query` subcommand in
784-
the CLI.
784+
the CLI;
785+
- `vectorise`vectorise files into a given project.
785786

786787
To try it out, install the `vectorcode[mcp]` dependency group and the MCP
787788
server is available in the shell as `vectorcode-mcp-server`, and make sure

docs/cli.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -706,7 +706,8 @@ features:
706706
707707
- `ls`: list local collections, similar to the `ls` subcommand in the CLI;
708708
- `query`: query from a given collection, similar to the `query` subcommand in
709-
the CLI.
709+
the CLI;
710+
- `vectorise`: vectorise files into a given project.
710711
711712
To try it out, install the `vectorcode[mcp]` dependency group and the MCP server
712713
is available in the shell as `vectorcode-mcp-server`, and make sure you're using

src/vectorcode/mcp_main.py

Lines changed: 85 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,13 @@
1212
from chromadb.api.models.AsyncCollection import AsyncCollection
1313
from chromadb.errors import InvalidCollectionException
1414

15+
from vectorcode.subcommands.vectorise import (
16+
VectoriseStats,
17+
chunked_add,
18+
exclude_paths_by_spec,
19+
find_exclude_specs,
20+
)
21+
1522
try: # pragma: nocover
1623
from mcp import ErrorData, McpError
1724
from mcp.server.fastmcp import FastMCP
@@ -26,6 +33,7 @@
2633
Config,
2734
cleanup_path,
2835
config_logging,
36+
expand_globs,
2937
find_project_config_dir,
3038
get_project_config,
3139
load_config_file,
@@ -89,6 +97,69 @@ async def list_collections() -> list[str]:
8997
return names
9098

9199

100+
async def vectorise_files(paths: list[str], project_root: str) -> dict[str, int]:
101+
logger.info(
102+
f"vectorise tool called with the following args: {paths=}, {project_root=}"
103+
)
104+
project_root = os.path.expanduser(project_root)
105+
if not os.path.isdir(project_root):
106+
logger.error(f"Invalid project root: {project_root}")
107+
raise McpError(
108+
ErrorData(code=1, message=f"{project_root} is not a valid path.")
109+
)
110+
config = await get_project_config(project_root)
111+
try:
112+
client = await get_client(config)
113+
collection = await get_collection(client, config, True)
114+
except Exception as e:
115+
logger.error("Failed to access collection at %s", project_root)
116+
raise McpError(
117+
ErrorData(
118+
code=1,
119+
message=f"{e.__class__.__name__}: Failed to create the collection at {project_root}.",
120+
)
121+
)
122+
if collection is None: # pragma: nocover
123+
raise McpError(
124+
ErrorData(
125+
code=1,
126+
message=f"Failed to access the collection at {project_root}. Use `list_collections` tool to get a list of valid paths for this field.",
127+
)
128+
)
129+
130+
paths = [os.path.expanduser(i) for i in await expand_globs(paths)]
131+
final_config = await config.merge_from(
132+
Config(files=[i for i in paths if os.path.isfile(i)], project_root=project_root)
133+
)
134+
for ignore_spec in find_exclude_specs(final_config):
135+
if os.path.isfile(ignore_spec):
136+
logger.info(f"Loading ignore specs from {ignore_spec}.")
137+
paths = exclude_paths_by_spec((str(i) for i in paths), ignore_spec)
138+
stats = VectoriseStats()
139+
collection_lock = asyncio.Lock()
140+
stats_lock = asyncio.Lock()
141+
max_batch_size = await client.get_max_batch_size()
142+
semaphore = asyncio.Semaphore(os.cpu_count() or 1)
143+
tasks = [
144+
asyncio.create_task(
145+
chunked_add(
146+
str(file),
147+
collection,
148+
collection_lock,
149+
stats,
150+
stats_lock,
151+
final_config,
152+
max_batch_size,
153+
semaphore,
154+
)
155+
)
156+
for file in paths
157+
]
158+
for i, task in enumerate(asyncio.as_completed(tasks), start=1):
159+
await task
160+
return stats.to_dict()
161+
162+
92163
async def query_tool(
93164
n_query: int, query_messages: list[str], project_root: str
94165
) -> list[str]:
@@ -186,18 +257,25 @@ async def mcp_server():
186257
mcp.add_tool(
187258
fn=list_collections,
188259
name="ls",
189-
description="List all projects indexed by VectorCode. Call this before making queries.",
260+
description="\n".join(
261+
prompt_by_categories["ls"] + prompt_by_categories["general"]
262+
),
190263
)
191264

192265
mcp.add_tool(
193266
fn=query_tool,
194267
name="query",
195-
description=f"""
196-
Use VectorCode to perform vector similarity search on repositories and return a list of relevant file paths and contents.
197-
Make sure `project_root` is one of the values from the `ls` tool.
198-
Unless the user requested otherwise, start your retrievals by {mcp_config.n_results} files.
199-
The result contains the relative paths for the files and their corresponding contents.
200-
""",
268+
description="\n".join(
269+
prompt_by_categories["query"] + prompt_by_categories["general"]
270+
),
271+
)
272+
273+
mcp.add_tool(
274+
fn=vectorise_files,
275+
name="vectorise",
276+
description="\n".join(
277+
prompt_by_categories["vectorise"] + prompt_by_categories["general"]
278+
),
201279
)
202280

203281
return mcp

src/vectorcode/subcommands/query/__init__.py

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import json
22
import logging
33
import os
4-
from typing import cast
4+
from typing import Any, cast
55

66
from chromadb import GetResult, Where
77
from chromadb.api.models.AsyncCollection import AsyncCollection
@@ -49,12 +49,15 @@ async def get_query_result_files(
4949
try:
5050
if len(configs.query_exclude):
5151
logger.info(f"Excluding {len(configs.query_exclude)} files from the query.")
52-
filter: dict[str, dict] = {"path": {"$nin": configs.query_exclude}}
52+
filter: dict[str, Any] = {"path": {"$nin": configs.query_exclude}}
5353
else:
5454
filter = {}
5555
num_query = configs.n_result
5656
if QueryInclude.chunk in configs.include:
57-
filter["start"] = {"$gte": 0}
57+
if filter:
58+
filter = {"$and": [filter.copy(), {"$gte": 0}]}
59+
else:
60+
filter["start"] = {"$gte": 0}
5861
else:
5962
num_query = await collection.count()
6063
if configs.query_multiplier > 0:

tests/subcommands/query/test_query.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -205,6 +205,36 @@ async def test_get_query_result_files_with_query_exclude(mock_collection, mock_c
205205
assert kwargs["where"] == {"path": {"$nin": ["/excluded/path.py"]}}
206206

207207

208+
@pytest.mark.asyncio
209+
async def test_get_query_result_chunks_with_query_exclude(mock_collection, mock_config):
210+
# Setup query_exclude
211+
mock_config.query_exclude = ["/excluded/path.py"]
212+
mock_config.include = [QueryInclude.chunk, QueryInclude.path]
213+
214+
with (
215+
patch("vectorcode.subcommands.query.expand_path") as mock_expand_path,
216+
patch("vectorcode.subcommands.query.expand_globs") as mock_expand_globs,
217+
patch("vectorcode.subcommands.query.reranker.NaiveReranker") as MockReranker,
218+
patch("os.path.isfile", return_value=True), # Add this line to mock isfile
219+
):
220+
mock_expand_globs.return_value = ["/excluded/path.py"]
221+
mock_expand_path.return_value = "/excluded/path.py"
222+
223+
mock_reranker_instance = MagicMock()
224+
mock_reranker_instance.rerank = AsyncMock(return_value=["file1.py", "file2.py"])
225+
MockReranker.return_value = mock_reranker_instance
226+
227+
# Call the function
228+
await get_query_result_files(mock_collection, mock_config)
229+
230+
# Check that query was called with the right parameters including the where clause
231+
mock_collection.query.assert_called_once()
232+
_, kwargs = mock_collection.query.call_args
233+
assert kwargs["where"] == {
234+
"$and": [{"path": {"$nin": ["/excluded/path.py"]}}, {"$gte": 0}]
235+
}
236+
237+
208238
@pytest.mark.asyncio
209239
async def test_get_query_reranker_initialisation_error(mock_collection, mock_config):
210240
# Configure to use CrossEncoder reranker

tests/test_mcp.py

Lines changed: 125 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
1+
import os
2+
import tempfile
13
from argparse import ArgumentParser
2-
from unittest.mock import AsyncMock, MagicMock, patch
4+
from unittest.mock import AsyncMock, MagicMock, mock_open, patch
35

46
import pytest
57
from mcp import McpError
@@ -11,6 +13,7 @@
1113
mcp_server,
1214
parse_cli_args,
1315
query_tool,
16+
vectorise_files,
1417
)
1518

1619

@@ -168,6 +171,125 @@ async def test_query_tool_no_collection():
168171
)
169172

170173

174+
@pytest.mark.asyncio
175+
async def test_vectorise_tool_invalid_project_root():
176+
with (
177+
patch("os.path.isdir", return_value=False),
178+
):
179+
with pytest.raises(McpError):
180+
await vectorise_files(paths=["foo.bar"], project_root=".")
181+
182+
183+
@pytest.mark.asyncio
184+
async def test_vectorise_files_success():
185+
with tempfile.TemporaryDirectory() as temp_dir:
186+
file_path = f"{temp_dir}/test_file.py"
187+
with open(file_path, "w") as f:
188+
f.write("def func(): pass")
189+
190+
with (
191+
patch("os.path.isdir", return_value=True),
192+
patch("vectorcode.mcp_main.get_project_config") as mock_get_project_config,
193+
patch("vectorcode.mcp_main.get_client") as mock_get_client,
194+
patch("vectorcode.mcp_main.get_collection") as mock_get_collection,
195+
patch("vectorcode.subcommands.vectorise.chunked_add"),
196+
patch(
197+
"vectorcode.subcommands.vectorise.hash_file", return_value="test_hash"
198+
),
199+
):
200+
mock_config = Config(project_root=temp_dir)
201+
mock_get_project_config.return_value = mock_config
202+
mock_client = AsyncMock()
203+
mock_get_client.return_value = mock_client
204+
mock_collection = AsyncMock()
205+
mock_collection.get.return_value = {"ids": [], "metadatas": []}
206+
mock_get_collection.return_value = mock_collection
207+
mock_client.get_max_batch_size.return_value = 100
208+
209+
result = await vectorise_files(paths=[file_path], project_root=temp_dir)
210+
211+
assert result["add"] == 1
212+
mock_get_project_config.assert_called_once_with(temp_dir)
213+
mock_get_client.assert_called_once_with(mock_config)
214+
mock_get_collection.assert_called_once_with(mock_client, mock_config, True)
215+
216+
217+
@pytest.mark.asyncio
218+
async def test_vectorise_files_collection_access_failure():
219+
with (
220+
patch("os.path.isdir", return_value=True),
221+
patch("vectorcode.mcp_main.get_project_config"),
222+
patch("vectorcode.mcp_main.get_client", side_effect=Exception("Client error")),
223+
patch("vectorcode.mcp_main.get_collection"),
224+
):
225+
with pytest.raises(McpError) as exc_info:
226+
await vectorise_files(paths=["file.py"], project_root="/valid/path")
227+
228+
assert exc_info.value.error.code == 1
229+
assert (
230+
"Failed to create the collection at /valid/path"
231+
in exc_info.value.error.message
232+
)
233+
234+
235+
@pytest.mark.asyncio
236+
async def test_vectorise_files_with_exclude_spec():
237+
with tempfile.TemporaryDirectory() as temp_dir:
238+
file1 = f"{temp_dir}/file1.py"
239+
excluded_file = f"{temp_dir}/excluded.py"
240+
exclude_spec_file = f"{temp_dir}/.vectorcode/vectorcode.exclude"
241+
242+
os.makedirs(f"{temp_dir}/.vectorcode")
243+
with open(file1, "w") as f:
244+
f.write("content1")
245+
with open(excluded_file, "w") as f:
246+
f.write("content_excluded")
247+
248+
# Create mock file handles for specific file contents
249+
mock_exclude_file_handle = mock_open(read_data="excluded.py").return_value
250+
251+
def mock_open_side_effect(filename, *args, **kwargs):
252+
if filename == exclude_spec_file:
253+
return mock_exclude_file_handle
254+
# For other files that might be opened, return a generic mock
255+
return MagicMock()
256+
257+
with (
258+
patch("os.path.isdir", return_value=True),
259+
patch("vectorcode.mcp_main.get_project_config") as mock_get_project_config,
260+
patch("vectorcode.mcp_main.get_client") as mock_get_client,
261+
patch("vectorcode.mcp_main.get_collection") as mock_get_collection,
262+
patch("vectorcode.subcommands.vectorise.chunked_add") as mock_chunked_add,
263+
patch(
264+
"vectorcode.subcommands.vectorise.hash_file", return_value="test_hash"
265+
),
266+
# Patch builtins.open with the custom side effect
267+
patch("builtins.open", side_effect=mock_open_side_effect),
268+
# Patch os.path.isfile to control which files "exist"
269+
patch(
270+
"os.path.isfile",
271+
side_effect=lambda x: x in [file1, excluded_file, exclude_spec_file],
272+
),
273+
):
274+
mock_config = Config(project_root=temp_dir)
275+
mock_get_project_config.return_value = mock_config
276+
mock_client = AsyncMock()
277+
mock_get_client.return_value = mock_client
278+
mock_collection = AsyncMock()
279+
mock_collection.get.return_value = {"ids": [], "metadatas": []}
280+
mock_get_collection.return_value = mock_collection
281+
mock_client.get_max_batch_size.return_value = 100
282+
283+
result = await vectorise_files(
284+
paths=[file1, excluded_file], project_root=temp_dir
285+
)
286+
287+
assert result["add"] == 0
288+
assert mock_chunked_add.call_count == 0
289+
call_args = [call[0][0] for call in mock_chunked_add.call_args_list]
290+
assert excluded_file not in call_args
291+
292+
171293
@pytest.mark.asyncio
172294
async def test_mcp_server():
173295
with (
@@ -188,7 +310,7 @@ async def test_mcp_server():
188310

189311
await mcp_server()
190312

191-
assert mock_add_tool.call_count == 2
313+
assert mock_add_tool.call_count == 3
192314

193315

194316
@pytest.mark.asyncio
@@ -223,7 +345,7 @@ async def new_get_collections(clients):
223345

224346
await mcp_server()
225347

226-
assert mock_add_tool.call_count == 2
348+
assert mock_add_tool.call_count == 3
227349
mock_get_collections.assert_called()
228350

229351

0 commit comments

Comments
 (0)