Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
HF_TOKEN=
CONTEXT8_DB_HOST=localhost
CONTEXT8_DB_PORT=50051
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -31,3 +31,6 @@ Thumbs.db
# Models cache
.cache/
models/


.env
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ dependencies = [
"mcp>=1.0.0",
"click>=8.0.0",
"rich>=13.0.0",
"ruff>=0.15.11",
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion: Ruff might be better scoped as a dev-only dependency instead of a runtime dependency.

Including ruff in dependencies will install it in all production environments, even though it’s only needed for linting. Unless there’s a runtime requirement, please move it to a dev/extra group (e.g., dev or lint) to avoid bloating production installs.

]

[project.optional-dependencies]
Expand Down
4 changes: 2 additions & 2 deletions src/context8/cli/commands/ops.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
from rich.panel import Panel
from rich.table import Table

from ...config import COLLECTION_NAME, DB_URL
from ...config import COLLECTION_NAME, DB_URL, TEXT_EMBED_DIM
from ..ui import check_actian_sdk, check_db_connection, console


Expand Down Expand Up @@ -142,7 +142,7 @@ def doctor():
import actian_vectorai as _av

_filter = _av.FilterBuilder().must(_av.Field("language").eq("python")).build()
_zero = [0.0] * 384
_zero = [0.0] * TEXT_EMBED_DIM
storage.client.points.search(
COLLECTION_NAME,
vector=_zero,
Expand Down
8 changes: 4 additions & 4 deletions src/context8/embeddings/service.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
import logging
import os

from ..config import CODE_MODEL, TEXT_MODEL
from ..config import CODE_EMBED_DIM, CODE_MODEL, TEXT_EMBED_DIM, TEXT_MODEL
from .tokenizer import BM25Tokenizer

logger = logging.getLogger("context8.embeddings")
Expand Down Expand Up @@ -57,7 +57,7 @@ def code_model(self):
return self._code_model

def _cache_key(self, text: str, model_tag: str) -> str:
return hashlib.md5(f"{model_tag}:{text[:500]}".encode()).hexdigest()
return hashlib.md5(f"{model_tag}:{text}".encode()).hexdigest()

def _get_cached(self, text: str, model_tag: str) -> list[float] | None:
return self._cache.get(self._cache_key(text, model_tag))
Expand All @@ -68,7 +68,7 @@ def _set_cached(self, text: str, model_tag: str, vector: list[float]) -> None:

def embed_text(self, text: str) -> list[float]:
if not text.strip():
return [0.0] * 384
return [0.0] * TEXT_EMBED_DIM

cached = self._get_cached(text, "text")
if cached is not None:
Expand All @@ -81,7 +81,7 @@ def embed_text(self, text: str) -> list[float]:

def embed_code(self, code: str) -> list[float]:
if not code.strip():
dim = 768 if self._use_code_model else 384
dim = CODE_EMBED_DIM if self._use_code_model else TEXT_EMBED_DIM
return [0.0] * dim

cached = self._get_cached(code, "code")
Expand Down
7 changes: 3 additions & 4 deletions src/context8/mcp/server.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from __future__ import annotations

import asyncio
import logging
import threading
from typing import Any
Expand All @@ -25,11 +26,11 @@ async def list_tools() -> list[Tool]:
async def call_tool(name: str, arguments: dict[str, Any]) -> list[TextContent]:
try:
# Try extra tools first (browse, ecosystem)
result = call_extra_tool(name, arguments)
result = await asyncio.to_thread(call_extra_tool, name, arguments)
if result is not None:
return result
# Fall through to core tools
return tools_module.call_tool(name, arguments)
return await asyncio.to_thread(tools_module.call_tool, name, arguments)
except Exception as e:
logger.error(f"Tool '{name}' failed: {e}", exc_info=True)
return [TextContent(type="text", text=f"Context8 error: {str(e)}")]
Expand All @@ -50,6 +51,4 @@ async def run_server():


if __name__ == "__main__":
import asyncio

asyncio.run(run_server())
65 changes: 33 additions & 32 deletions src/context8/mcp/tools_browse.py
Original file line number Diff line number Diff line change
Expand Up @@ -97,39 +97,40 @@ def _handle_browse(args: dict) -> list[TextContent]:
from ..storage import StorageService

storage = StorageService()
try:
records = browse(
storage,
tag=args.get("tag"),
language=args.get("language"),
framework=args.get("framework"),
error_type=args.get("error_type"),
limit=args.get("limit", 20),
)

if not records:
return [TextContent(type="text", text="No records match those filters.")]

lines = [f"Found {len(records)} record(s):\n"]
for i, r in enumerate(records, 1):
meta = []
if r.language:
meta.append(r.language)
if r.framework:
meta.append(r.framework)
if r.error_type:
meta.append(r.error_type)
meta_str = f" ({', '.join(meta)})" if meta else ""

lines.append(f"[{i}] {r.problem_text[:120]}{meta_str}")
lines.append(f" Fix: {r.solution_text[:150]}")
if r.tags:
lines.append(f" Tags: {', '.join(r.tags[:5])}")
lines.append(f" ID: {r.id} Confidence: {r.confidence:.0%}")
lines.append("")

records = browse(
storage,
tag=args.get("tag"),
language=args.get("language"),
framework=args.get("framework"),
error_type=args.get("error_type"),
limit=args.get("limit", 20),
)

if not records:
return [TextContent(type="text", text="No records match those filters.")]

lines = [f"Found {len(records)} record(s):\n"]
for i, r in enumerate(records, 1):
meta = []
if r.language:
meta.append(r.language)
if r.framework:
meta.append(r.framework)
if r.error_type:
meta.append(r.error_type)
meta_str = f" ({', '.join(meta)})" if meta else ""

lines.append(f"[{i}] {r.problem_text[:120]}{meta_str}")
lines.append(f" Fix: {r.solution_text[:150]}")
if r.tags:
lines.append(f" Tags: {', '.join(r.tags[:5])}")
lines.append(f" ID: {r.id} Confidence: {r.confidence:.0%}")
lines.append("")

storage.close()
return [TextContent(type="text", text="\n".join(lines))]
return [TextContent(type="text", text="\n".join(lines))]
finally:
storage.close()
Comment on lines 99 to +133
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Same gRPC leak still present in _handle_ecosystem.

The try/finally fix here is correct and covers both the empty-records early return and any exception during browse()/formatting. However, the sibling _handle_ecosystem (lines 140-164) has the exact same leak class this PR is fixing: storage.close() is only invoked on the happy path at line 164, so any exception raised by either browse() call or in the loop body will leak the gRPC client — the same defect that prompted this PR. Worth applying the same try/finally pattern there for consistency.

🛡️ Proposed fix for `_handle_ecosystem`
 def _handle_ecosystem(args: dict) -> list[TextContent]:
     from ..browse import browse
     from ..storage import StorageService

     storage = StorageService()
+    try:
+        languages = args.get("languages", [])
+        frameworks = args.get("frameworks", [])
+        limit = args.get("limit", 10)

-    languages = args.get("languages", [])
-    frameworks = args.get("frameworks", [])
-    limit = args.get("limit", 10)
+        all_records = []
+        seen_ids: set[str] = set()

-    all_records = []
-    seen_ids: set[str] = set()
+        for lang in languages:
+            records = browse(storage, language=lang, limit=limit)
+            for r in records:
+                if r.id not in seen_ids:
+                    all_records.append(r)
+                    seen_ids.add(r.id)

-    # Collect records across all specified languages and frameworks
-    for lang in languages:
-        records = browse(storage, language=lang, limit=limit)
-        for r in records:
-            if r.id not in seen_ids:
-                all_records.append(r)
-                seen_ids.add(r.id)
-
-    for fw in frameworks:
-        records = browse(storage, framework=fw, limit=limit)
-        for r in records:
-            if r.id not in seen_ids:
-                all_records.append(r)
-                seen_ids.add(r.id)
-
-    storage.close()
+        for fw in frameworks:
+            records = browse(storage, framework=fw, limit=limit)
+            for r in records:
+                if r.id not in seen_ids:
+                    all_records.append(r)
+                    seen_ids.add(r.id)
+    finally:
+        storage.close()

(The remainder of the function, which only formats all_records, can stay outside the try since it no longer touches storage.)

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/context8/mcp/tools_browse.py` around lines 99 - 133, The sibling function
_handle_ecosystem has the same gRPC client leak because StorageService() is
created and storage.close() is only called on the happy path; wrap the lifecycle
of storage in a try/finally (like in the other handler) so that storage.close()
is always executed even if browse() or the formatting loop throws; specifically,
in _handle_ecosystem ensure the StorageService() instantiation and the
browse(...) call that returns all_records are inside a try block and call
storage.close() in the finally, leaving any further formatting of all_records
outside the try if desired.



def _handle_ecosystem(args: dict) -> list[TextContent]:
Expand Down
6 changes: 3 additions & 3 deletions src/context8/search/engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -299,11 +299,11 @@ def _search_sparse(
VectorAIError = av.exceptions.VectorAIError

try:
sparse_vec = av.SparseVector(indices=indices, values=values)
return self.storage.client.points.search(
COLLECTION_NAME,
vector=values,
vector_name="keywords",
sparse_indices=indices,
vector=sparse_vec,
using="keywords",
filter=search_filter,
limit=limit,
with_payload=True,
Expand Down
4 changes: 2 additions & 2 deletions src/context8/storage.py
Original file line number Diff line number Diff line change
Expand Up @@ -103,8 +103,8 @@ def initialize(self) -> bool:
def sparse_supported(self) -> bool:
if self._sparse_supported is None:
try:
self.client.collections.get_info(COLLECTION_NAME)
self._sparse_supported = False
info = self.client.collections.get_info(COLLECTION_NAME)
self._sparse_supported = bool(self._discover_sparse_vectors(info))
except Exception:
self._sparse_supported = False
return self._sparse_supported
Expand Down
Loading