trelix-mcp 0.5.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- trelix_mcp-0.5.0/.gitignore +58 -0
- trelix_mcp-0.5.0/LICENSE +21 -0
- trelix_mcp-0.5.0/PKG-INFO +93 -0
- trelix_mcp-0.5.0/README.md +53 -0
- trelix_mcp-0.5.0/pyproject.toml +34 -0
- trelix_mcp-0.5.0/src/trelix_mcp/__init__.py +4 -0
- trelix_mcp-0.5.0/src/trelix_mcp/server.py +175 -0
- trelix_mcp-0.5.0/tests/__init__.py +0 -0
- trelix_mcp-0.5.0/tests/test_server.py +215 -0
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# Python
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.pyc
|
|
4
|
+
*.pyo
|
|
5
|
+
*.pyd
|
|
6
|
+
.Python
|
|
7
|
+
*.egg-info/
|
|
8
|
+
*.egg
|
|
9
|
+
build/
|
|
10
|
+
.eggs/
|
|
11
|
+
pip-wheel-metadata/
|
|
12
|
+
|
|
13
|
+
# dist/ — ignore generated packaging artefacts; binaries are attached via GitHub Releases.
|
|
14
|
+
dist/
|
|
15
|
+
|
|
16
|
+
# Virtual environments
|
|
17
|
+
.venv/
|
|
18
|
+
venv/
|
|
19
|
+
env/
|
|
20
|
+
ENV/
|
|
21
|
+
|
|
22
|
+
# Trelix index data — never commit these
|
|
23
|
+
.trelix/
|
|
24
|
+
|
|
25
|
+
# Test / coverage artefacts
|
|
26
|
+
.pytest_cache/
|
|
27
|
+
.coverage
|
|
28
|
+
htmlcov/
|
|
29
|
+
.tox/
|
|
30
|
+
|
|
31
|
+
# Ruff / Mypy caches
|
|
32
|
+
.ruff_cache/
|
|
33
|
+
.mypy_cache/
|
|
34
|
+
|
|
35
|
+
# Environment files — secrets must not be committed
|
|
36
|
+
.env
|
|
37
|
+
.env.local
|
|
38
|
+
.env.*.local
|
|
39
|
+
|
|
40
|
+
# Claude Code internal data
|
|
41
|
+
.claude/
|
|
42
|
+
|
|
43
|
+
# uv lockfile (not committed — project uses pip/hatchling)
|
|
44
|
+
uv.lock
|
|
45
|
+
|
|
46
|
+
# Editor / OS
|
|
47
|
+
.DS_Store
|
|
48
|
+
.idea/
|
|
49
|
+
.vscode/
|
|
50
|
+
*.swp
|
|
51
|
+
*.swo
|
|
52
|
+
Thumbs.db
|
|
53
|
+
|
|
54
|
+
# Distribution / packaging
|
|
55
|
+
*.tar.gz
|
|
56
|
+
*.whl
|
|
57
|
+
MANIFEST
|
|
58
|
+
.superpowers/
|
trelix_mcp-0.5.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Trelix Contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: trelix-mcp
|
|
3
|
+
Version: 0.5.0
|
|
4
|
+
Summary: MCP server for trelix — semantic code search for Claude Code, Cursor, Windsurf, Continue.dev
|
|
5
|
+
Project-URL: Homepage, https://github.com/sairam0424/trelix
|
|
6
|
+
Author: Trelix Contributors
|
|
7
|
+
License: MIT License
|
|
8
|
+
|
|
9
|
+
Copyright (c) 2025 Trelix Contributors
|
|
10
|
+
|
|
11
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
12
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
13
|
+
in the Software without restriction, including without limitation the rights
|
|
14
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
15
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
16
|
+
furnished to do so, subject to the following conditions:
|
|
17
|
+
|
|
18
|
+
The above copyright notice and this permission notice shall be included in all
|
|
19
|
+
copies or substantial portions of the Software.
|
|
20
|
+
|
|
21
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
22
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
23
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
24
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
25
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
26
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
27
|
+
SOFTWARE.
|
|
28
|
+
License-File: LICENSE
|
|
29
|
+
Keywords: ai-agent,claude-code,code-intelligence,code-search,cursor,llm,mcp,model-context-protocol
|
|
30
|
+
Classifier: Development Status :: 4 - Beta
|
|
31
|
+
Classifier: Intended Audience :: Developers
|
|
32
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
33
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
34
|
+
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
35
|
+
Requires-Python: >=3.11
|
|
36
|
+
Requires-Dist: fastmcp>=2.0.0
|
|
37
|
+
Requires-Dist: mcp>=1.0.0
|
|
38
|
+
Requires-Dist: trelix>=0.4.0
|
|
39
|
+
Description-Content-Type: text/markdown
|
|
40
|
+
|
|
41
|
+
# trelix-mcp
|
|
42
|
+
|
|
43
|
+
MCP server for [trelix](https://github.com/sairam0424/trelix) — semantic code search for Claude Code, Cursor, Windsurf, and Continue.dev.
|
|
44
|
+
|
|
45
|
+
## Install
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
pip install trelix-mcp
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
## Usage
|
|
52
|
+
|
|
53
|
+
### Claude Code
|
|
54
|
+
|
|
55
|
+
```bash
|
|
56
|
+
claude mcp add trelix -- trelix-mcp
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
### Cursor (`~/.cursor/mcp.json`)
|
|
60
|
+
|
|
61
|
+
```json
|
|
62
|
+
{
|
|
63
|
+
"mcpServers": {
|
|
64
|
+
"trelix": {
|
|
65
|
+
"command": "trelix-mcp",
|
|
66
|
+
"args": []
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
### Continue.dev (`.continue/config.json`)
|
|
73
|
+
|
|
74
|
+
```json
|
|
75
|
+
{
|
|
76
|
+
"mcpServers": [
|
|
77
|
+
{
|
|
78
|
+
"name": "trelix",
|
|
79
|
+
"command": "trelix-mcp",
|
|
80
|
+
"args": []
|
|
81
|
+
}
|
|
82
|
+
]
|
|
83
|
+
}
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
## Tools
|
|
87
|
+
|
|
88
|
+
| Tool | Description |
|
|
89
|
+
|------|-------------|
|
|
90
|
+
| `search_code` | Semantic hybrid search over an indexed codebase |
|
|
91
|
+
| `index_codebase` | Index a repository so it can be searched |
|
|
92
|
+
| `get_symbol` | Look up a symbol by qualified name |
|
|
93
|
+
| `blast_radius` | Find all files that depend on a symbol |
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# trelix-mcp
|
|
2
|
+
|
|
3
|
+
MCP server for [trelix](https://github.com/sairam0424/trelix) — semantic code search for Claude Code, Cursor, Windsurf, and Continue.dev.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pip install trelix-mcp
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Usage
|
|
12
|
+
|
|
13
|
+
### Claude Code
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
claude mcp add trelix -- trelix-mcp
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
### Cursor (`~/.cursor/mcp.json`)
|
|
20
|
+
|
|
21
|
+
```json
|
|
22
|
+
{
|
|
23
|
+
"mcpServers": {
|
|
24
|
+
"trelix": {
|
|
25
|
+
"command": "trelix-mcp",
|
|
26
|
+
"args": []
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
### Continue.dev (`.continue/config.json`)
|
|
33
|
+
|
|
34
|
+
```json
|
|
35
|
+
{
|
|
36
|
+
"mcpServers": [
|
|
37
|
+
{
|
|
38
|
+
"name": "trelix",
|
|
39
|
+
"command": "trelix-mcp",
|
|
40
|
+
"args": []
|
|
41
|
+
}
|
|
42
|
+
]
|
|
43
|
+
}
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
## Tools
|
|
47
|
+
|
|
48
|
+
| Tool | Description |
|
|
49
|
+
|------|-------------|
|
|
50
|
+
| `search_code` | Semantic hybrid search over an indexed codebase |
|
|
51
|
+
| `index_codebase` | Index a repository so it can be searched |
|
|
52
|
+
| `get_symbol` | Look up a symbol by qualified name |
|
|
53
|
+
| `blast_radius` | Find all files that depend on a symbol |
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[tool.hatch.build.targets.wheel]
|
|
6
|
+
packages = ["src/trelix_mcp"]
|
|
7
|
+
|
|
8
|
+
[project]
|
|
9
|
+
name = "trelix-mcp"
|
|
10
|
+
version = "0.5.0"
|
|
11
|
+
description = "MCP server for trelix — semantic code search for Claude Code, Cursor, Windsurf, Continue.dev"
|
|
12
|
+
readme = "README.md"
|
|
13
|
+
license = { file = "LICENSE" }
|
|
14
|
+
authors = [{ name = "Trelix Contributors" }]
|
|
15
|
+
requires-python = ">=3.11"
|
|
16
|
+
keywords = ["mcp", "model-context-protocol", "code-search", "claude-code", "cursor", "code-intelligence", "llm", "ai-agent"]
|
|
17
|
+
classifiers = [
|
|
18
|
+
"Development Status :: 4 - Beta",
|
|
19
|
+
"Intended Audience :: Developers",
|
|
20
|
+
"License :: OSI Approved :: MIT License",
|
|
21
|
+
"Programming Language :: Python :: 3.11",
|
|
22
|
+
"Topic :: Scientific/Engineering :: Artificial Intelligence",
|
|
23
|
+
]
|
|
24
|
+
dependencies = ["trelix>=0.4.0", "mcp>=1.0.0", "fastmcp>=2.0.0"]
|
|
25
|
+
|
|
26
|
+
[project.scripts]
|
|
27
|
+
trelix-mcp = "trelix_mcp.server:main"
|
|
28
|
+
|
|
29
|
+
[project.urls]
|
|
30
|
+
Homepage = "https://github.com/sairam0424/trelix"
|
|
31
|
+
|
|
32
|
+
[tool.pytest.ini_options]
|
|
33
|
+
testpaths = ["tests"]
|
|
34
|
+
asyncio_mode = "auto"
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import sys
|
|
3
|
+
|
|
4
|
+
logging.basicConfig(
|
|
5
|
+
stream=sys.stderr,
|
|
6
|
+
level=logging.INFO,
|
|
7
|
+
format="[trelix-mcp] %(levelname)s %(message)s",
|
|
8
|
+
)
|
|
9
|
+
|
|
10
|
+
import signal # noqa: E402
|
|
11
|
+
from typing import Any, Literal # noqa: E402
|
|
12
|
+
|
|
13
|
+
from fastmcp import FastMCP # noqa: E402
|
|
14
|
+
|
|
15
|
+
from trelix.core.config import EmbedderConfig, IndexConfig # noqa: E402
|
|
16
|
+
from trelix.indexing.indexer import Indexer # noqa: E402
|
|
17
|
+
from trelix.retrieval.retriever import Retriever # noqa: E402
|
|
18
|
+
from trelix.store.db import Database # noqa: E402
|
|
19
|
+
|
|
20
|
+
mcp = FastMCP("trelix")
|
|
21
|
+
_log = logging.getLogger("trelix_mcp")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@mcp.tool()
|
|
25
|
+
def search_code(query: str, repo_path: str, k: int = 10) -> list[dict[str, Any]]:
|
|
26
|
+
"""Search a codebase for symbols semantically relevant to *query*.
|
|
27
|
+
|
|
28
|
+
Args:
|
|
29
|
+
query: Natural-language or keyword search query.
|
|
30
|
+
repo_path: Absolute path to the repository root (must already be indexed).
|
|
31
|
+
k: Maximum number of results to return (default 10).
|
|
32
|
+
|
|
33
|
+
Returns:
|
|
34
|
+
List of result dicts with keys: file, symbol, kind, lines, score, source,
|
|
35
|
+
body, language.
|
|
36
|
+
"""
|
|
37
|
+
_log.info("search_code query=%r repo_path=%r k=%d", query, repo_path, k)
|
|
38
|
+
config = IndexConfig(repo_path=repo_path)
|
|
39
|
+
retriever = Retriever(config)
|
|
40
|
+
context = retriever.retrieve(query)
|
|
41
|
+
results = context.results[:k]
|
|
42
|
+
return [
|
|
43
|
+
{
|
|
44
|
+
"file": r.file.rel_path,
|
|
45
|
+
"symbol": r.symbol.qualified_name,
|
|
46
|
+
"kind": r.symbol.kind,
|
|
47
|
+
"lines": [r.symbol.line_start, r.symbol.line_end],
|
|
48
|
+
"score": round(r.score, 4),
|
|
49
|
+
"source": r.source,
|
|
50
|
+
"body": r.symbol.body,
|
|
51
|
+
"language": r.file.language,
|
|
52
|
+
}
|
|
53
|
+
for r in results
|
|
54
|
+
]
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@mcp.tool()
|
|
58
|
+
def index_codebase(
|
|
59
|
+
repo_path: str,
|
|
60
|
+
provider: Literal["local", "openai", "azure", "voyage", "local-code"] = "local",
|
|
61
|
+
) -> dict[str, Any]:
|
|
62
|
+
"""Index a codebase so it can be searched with search_code.
|
|
63
|
+
|
|
64
|
+
Args:
|
|
65
|
+
repo_path: Absolute path to the repository root.
|
|
66
|
+
provider: Embedding provider — "local" requires no API key.
|
|
67
|
+
|
|
68
|
+
Returns:
|
|
69
|
+
Indexing statistics dict with keys: files_found, files_indexed,
|
|
70
|
+
files_skipped, symbols_extracted, chunks_total, chunks_embedded,
|
|
71
|
+
errors, elapsed_seconds.
|
|
72
|
+
"""
|
|
73
|
+
_log.info("index_codebase repo_path=%r provider=%r", repo_path, provider)
|
|
74
|
+
embedder_config = EmbedderConfig(provider=provider) # type: ignore[call-arg]
|
|
75
|
+
config = IndexConfig(repo_path=repo_path, embedder=embedder_config)
|
|
76
|
+
stats = Indexer(config, quiet=True).index()
|
|
77
|
+
return stats
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
@mcp.tool()
|
|
81
|
+
def get_symbol(qualified_name: str, repo_path: str) -> dict[str, Any] | None:
|
|
82
|
+
"""Look up a symbol by its fully-qualified name.
|
|
83
|
+
|
|
84
|
+
Args:
|
|
85
|
+
qualified_name: e.g. "MyClass.my_method" or "my_function".
|
|
86
|
+
repo_path: Absolute path to the repository root.
|
|
87
|
+
|
|
88
|
+
Returns:
|
|
89
|
+
Symbol dict or None if not found. Keys: name, qualified_name, kind,
|
|
90
|
+
file, line_start, line_end, signature, docstring, body, language.
|
|
91
|
+
"""
|
|
92
|
+
_log.info("get_symbol qualified_name=%r repo_path=%r", qualified_name, repo_path)
|
|
93
|
+
config = IndexConfig(repo_path=repo_path)
|
|
94
|
+
db = Database(config.db_path_absolute)
|
|
95
|
+
rows = db.get_symbol_by_name(qualified_name)
|
|
96
|
+
if not rows:
|
|
97
|
+
# Fall back to bare name lookup
|
|
98
|
+
name_only = qualified_name.split(".")[-1]
|
|
99
|
+
rows = db.get_symbol_by_name(name_only)
|
|
100
|
+
# Filter to exact qualified_name match when possible
|
|
101
|
+
exact = [s for s in rows if s.qualified_name == qualified_name]
|
|
102
|
+
if exact:
|
|
103
|
+
rows = exact
|
|
104
|
+
|
|
105
|
+
if not rows:
|
|
106
|
+
return None
|
|
107
|
+
|
|
108
|
+
sym = rows[0]
|
|
109
|
+
sym_file = db.get_symbol_with_file(sym.id) # type: ignore[arg-type]
|
|
110
|
+
if sym_file is None:
|
|
111
|
+
return None
|
|
112
|
+
symbol, file = sym_file
|
|
113
|
+
return {
|
|
114
|
+
"name": symbol.name,
|
|
115
|
+
"qualified_name": symbol.qualified_name,
|
|
116
|
+
"kind": symbol.kind,
|
|
117
|
+
"file": file.rel_path,
|
|
118
|
+
"line_start": symbol.line_start,
|
|
119
|
+
"line_end": symbol.line_end,
|
|
120
|
+
"signature": symbol.signature,
|
|
121
|
+
"docstring": symbol.docstring,
|
|
122
|
+
"body": symbol.body,
|
|
123
|
+
"language": file.language,
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
@mcp.tool()
|
|
128
|
+
def blast_radius(symbol_name: str, repo_path: str) -> list[dict[str, Any]]:
|
|
129
|
+
"""Find all symbols that depend on (call or import) a given symbol.
|
|
130
|
+
|
|
131
|
+
Useful for impact analysis: "if I change X, what else might break?"
|
|
132
|
+
|
|
133
|
+
Args:
|
|
134
|
+
symbol_name: Name or qualified name of the symbol to analyse.
|
|
135
|
+
repo_path: Absolute path to the repository root.
|
|
136
|
+
|
|
137
|
+
Returns:
|
|
138
|
+
Deduplicated list of dependent-symbol dicts with keys: file, symbol,
|
|
139
|
+
kind, line_start, language.
|
|
140
|
+
"""
|
|
141
|
+
_log.info("blast_radius symbol_name=%r repo_path=%r", symbol_name, repo_path)
|
|
142
|
+
query = f"blast radius dependencies of {symbol_name}"
|
|
143
|
+
config = IndexConfig(repo_path=repo_path)
|
|
144
|
+
retriever = Retriever(config)
|
|
145
|
+
context = retriever.retrieve(query)
|
|
146
|
+
|
|
147
|
+
seen_files: set[str] = set()
|
|
148
|
+
output: list[dict[str, Any]] = []
|
|
149
|
+
for r in context.results:
|
|
150
|
+
file_key = r.file.rel_path
|
|
151
|
+
if file_key in seen_files:
|
|
152
|
+
continue
|
|
153
|
+
seen_files.add(file_key)
|
|
154
|
+
output.append(
|
|
155
|
+
{
|
|
156
|
+
"file": r.file.rel_path,
|
|
157
|
+
"symbol": r.symbol.qualified_name,
|
|
158
|
+
"kind": r.symbol.kind,
|
|
159
|
+
"line_start": r.symbol.line_start,
|
|
160
|
+
"language": r.file.language,
|
|
161
|
+
}
|
|
162
|
+
)
|
|
163
|
+
return output
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def main() -> None:
|
|
167
|
+
"""Entry point for the trelix-mcp server (stdio transport)."""
|
|
168
|
+
|
|
169
|
+
def _handle_sigterm(signum: int, frame: Any) -> None:
|
|
170
|
+
_log.info("Received SIGTERM — shutting down")
|
|
171
|
+
sys.exit(0)
|
|
172
|
+
|
|
173
|
+
signal.signal(signal.SIGTERM, _handle_sigterm)
|
|
174
|
+
_log.info("trelix-mcp starting (transport=stdio)")
|
|
175
|
+
mcp.run(transport="stdio")
|
|
File without changes
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
"""Tests for trelix_mcp.server.
|
|
2
|
+
|
|
3
|
+
Uses unittest.mock.patch to avoid touching real files or embedding models.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import sys
|
|
9
|
+
from typing import Any
|
|
10
|
+
from unittest.mock import MagicMock, patch
|
|
11
|
+
|
|
12
|
+
import pytest
|
|
13
|
+
|
|
14
|
+
# ---------------------------------------------------------------------------
|
|
15
|
+
# Helpers
|
|
16
|
+
# ---------------------------------------------------------------------------
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _make_mock_result(
|
|
20
|
+
file: str = "src/foo.py",
|
|
21
|
+
symbol: str = "foo.bar",
|
|
22
|
+
kind: str = "function",
|
|
23
|
+
line_start: int = 1,
|
|
24
|
+
line_end: int = 10,
|
|
25
|
+
score: float = 0.9,
|
|
26
|
+
source: str = "vector",
|
|
27
|
+
body: str = "def bar(): pass",
|
|
28
|
+
language: str = "python",
|
|
29
|
+
) -> MagicMock:
|
|
30
|
+
"""Return a mock SearchResult compatible with server.py expectations."""
|
|
31
|
+
r = MagicMock()
|
|
32
|
+
r.file.rel_path = file
|
|
33
|
+
r.file.language = language
|
|
34
|
+
r.symbol.qualified_name = symbol
|
|
35
|
+
r.symbol.kind = kind
|
|
36
|
+
r.symbol.line_start = line_start
|
|
37
|
+
r.symbol.line_end = line_end
|
|
38
|
+
r.symbol.body = body
|
|
39
|
+
r.score = score
|
|
40
|
+
r.source = source
|
|
41
|
+
return r
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _make_mock_context(results: list[MagicMock]) -> MagicMock:
|
|
45
|
+
ctx = MagicMock()
|
|
46
|
+
ctx.results = results
|
|
47
|
+
return ctx
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
# ---------------------------------------------------------------------------
|
|
51
|
+
# Module import + basic structure
|
|
52
|
+
# ---------------------------------------------------------------------------
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def test_server_importable() -> None:
|
|
56
|
+
import trelix_mcp.server as srv # noqa: F401
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def test_mcp_attribute_exists() -> None:
|
|
60
|
+
import trelix_mcp.server as srv
|
|
61
|
+
|
|
62
|
+
assert hasattr(srv, "mcp"), "server.py must expose a top-level `mcp` object"
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def test_main_callable() -> None:
|
|
66
|
+
import trelix_mcp.server as srv
|
|
67
|
+
|
|
68
|
+
assert callable(srv.main)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
# ---------------------------------------------------------------------------
|
|
72
|
+
# 4 tools registered
|
|
73
|
+
# ---------------------------------------------------------------------------
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@pytest.mark.asyncio
|
|
77
|
+
async def test_four_tools_registered() -> None:
|
|
78
|
+
import trelix_mcp.server as srv
|
|
79
|
+
|
|
80
|
+
tools = await srv.mcp.list_tools()
|
|
81
|
+
names = {t.name for t in tools}
|
|
82
|
+
assert {"search_code", "index_codebase", "get_symbol", "blast_radius"} == names, (
|
|
83
|
+
f"Expected exactly 4 tools, got: {names}"
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
# ---------------------------------------------------------------------------
|
|
88
|
+
# search_code
|
|
89
|
+
# ---------------------------------------------------------------------------
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def test_search_code_returns_list_of_dicts() -> None:
|
|
93
|
+
import trelix_mcp.server as srv
|
|
94
|
+
|
|
95
|
+
mock_results = [_make_mock_result()]
|
|
96
|
+
mock_ctx = _make_mock_context(mock_results)
|
|
97
|
+
|
|
98
|
+
with (
|
|
99
|
+
patch("trelix_mcp.server.IndexConfig"),
|
|
100
|
+
patch("trelix_mcp.server.Retriever") as MockRetriever,
|
|
101
|
+
):
|
|
102
|
+
MockRetriever.return_value.retrieve.return_value = mock_ctx
|
|
103
|
+
results = srv.search_code("authentication", "/fake/repo", k=10)
|
|
104
|
+
|
|
105
|
+
assert isinstance(results, list)
|
|
106
|
+
assert len(results) == 1
|
|
107
|
+
item = results[0]
|
|
108
|
+
assert set(item.keys()) >= {
|
|
109
|
+
"file",
|
|
110
|
+
"symbol",
|
|
111
|
+
"kind",
|
|
112
|
+
"lines",
|
|
113
|
+
"score",
|
|
114
|
+
"source",
|
|
115
|
+
"body",
|
|
116
|
+
"language",
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def test_search_code_respects_k_limit() -> None:
|
|
121
|
+
"""k=3 must truncate 20 mock results to 3."""
|
|
122
|
+
import trelix_mcp.server as srv
|
|
123
|
+
|
|
124
|
+
mock_results = [_make_mock_result(symbol=f"sym.{i}") for i in range(20)]
|
|
125
|
+
mock_ctx = _make_mock_context(mock_results)
|
|
126
|
+
|
|
127
|
+
with (
|
|
128
|
+
patch("trelix_mcp.server.IndexConfig"),
|
|
129
|
+
patch("trelix_mcp.server.Retriever") as MockRetriever,
|
|
130
|
+
):
|
|
131
|
+
MockRetriever.return_value.retrieve.return_value = mock_ctx
|
|
132
|
+
results = srv.search_code("auth", "/fake/repo", k=3)
|
|
133
|
+
|
|
134
|
+
assert len(results) == 3
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
# ---------------------------------------------------------------------------
|
|
138
|
+
# index_codebase
|
|
139
|
+
# ---------------------------------------------------------------------------
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def test_index_codebase_returns_dict() -> None:
|
|
143
|
+
import trelix_mcp.server as srv
|
|
144
|
+
|
|
145
|
+
fake_stats: dict[str, Any] = {
|
|
146
|
+
"files_found": 10,
|
|
147
|
+
"files_indexed": 8,
|
|
148
|
+
"files_skipped": 2,
|
|
149
|
+
"symbols_extracted": 50,
|
|
150
|
+
"chunks_total": 50,
|
|
151
|
+
"chunks_embedded": 50,
|
|
152
|
+
"errors": 0,
|
|
153
|
+
"elapsed_seconds": 1.23,
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
with (
|
|
157
|
+
patch("trelix_mcp.server.IndexConfig"),
|
|
158
|
+
patch("trelix_mcp.server.EmbedderConfig"),
|
|
159
|
+
patch("trelix_mcp.server.Indexer") as MockIndexer,
|
|
160
|
+
):
|
|
161
|
+
MockIndexer.return_value.index.return_value = fake_stats
|
|
162
|
+
result = srv.index_codebase("/fake/repo", provider="local")
|
|
163
|
+
|
|
164
|
+
assert isinstance(result, dict)
|
|
165
|
+
assert result["files_found"] == 10
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
# ---------------------------------------------------------------------------
|
|
169
|
+
# blast_radius deduplication
|
|
170
|
+
# ---------------------------------------------------------------------------
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def test_blast_radius_deduplicates_files() -> None:
|
|
174
|
+
"""Two results sharing the same file should produce only one output entry."""
|
|
175
|
+
import trelix_mcp.server as srv
|
|
176
|
+
|
|
177
|
+
r1 = _make_mock_result(file="src/auth.py", symbol="auth.login")
|
|
178
|
+
r2 = _make_mock_result(file="src/auth.py", symbol="auth.logout") # same file
|
|
179
|
+
r3 = _make_mock_result(file="src/db.py", symbol="db.connect")
|
|
180
|
+
|
|
181
|
+
mock_ctx = _make_mock_context([r1, r2, r3])
|
|
182
|
+
|
|
183
|
+
with (
|
|
184
|
+
patch("trelix_mcp.server.IndexConfig"),
|
|
185
|
+
patch("trelix_mcp.server.Retriever") as MockRetriever,
|
|
186
|
+
):
|
|
187
|
+
MockRetriever.return_value.retrieve.return_value = mock_ctx
|
|
188
|
+
results = srv.blast_radius("auth", "/fake/repo")
|
|
189
|
+
|
|
190
|
+
files = [r["file"] for r in results]
|
|
191
|
+
assert len(files) == 2, f"Expected 2 unique files, got {files}"
|
|
192
|
+
assert "src/auth.py" in files
|
|
193
|
+
assert "src/db.py" in files
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
# ---------------------------------------------------------------------------
|
|
197
|
+
# CRITICAL: no stdout bytes on import
|
|
198
|
+
# ---------------------------------------------------------------------------
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def test_server_import_produces_no_stdout(capsys: pytest.CaptureFixture[str]) -> None:
|
|
202
|
+
"""Importing server.py must not write anything to stdout.
|
|
203
|
+
|
|
204
|
+
stdout is the MCP JSON protocol pipe — any spurious bytes corrupt the stream.
|
|
205
|
+
"""
|
|
206
|
+
# Force a fresh import to catch any module-level print/write
|
|
207
|
+
if "trelix_mcp.server" in sys.modules:
|
|
208
|
+
del sys.modules["trelix_mcp.server"]
|
|
209
|
+
|
|
210
|
+
import trelix_mcp.server # noqa: F401
|
|
211
|
+
|
|
212
|
+
captured = capsys.readouterr()
|
|
213
|
+
assert captured.out == "", (
|
|
214
|
+
f"server.py wrote {len(captured.out)} bytes to stdout on import: {captured.out!r}"
|
|
215
|
+
)
|