Skip to content
Open
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
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -348,6 +348,8 @@ For `/reindex-history` the `phase` value is `discovery|embedding|upserting` and
| `QDRANT_COMMITS_COLLECTION` | `git_commits` | Collection name for commit message vectors |
| `EMBEDDINGS_PROVIDER` | `jina` | One of `jina`, `jina-api`, `voyage`, `openai`, `ollama` — see *Embedding providers* below |
| `GIT_HISTORY_MAX_COMMITS` | `500` | Max commits indexed per service |
| `CODE_CONTEXT_CACHE_SIZE` | `128` | Files cached in memory for `get_code_context` (keyed on blob SHA); `0` disables |
| `CODE_CONTEXT_CACHE_TTL` | `900` | Seconds a cached file content stays valid |
| `MCP_TRANSPORT` | `streamable-http` | One of `streamable-http`, `sse`, `stdio` |
| `MCP_HOST` / `MCP_PORT` | `127.0.0.1` / `8090` | Server bind address |
| `CONFIG_PATH` | `./config.yaml` | Path to the services config file |
Expand Down
2 changes: 2 additions & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,8 @@ Used when `EMBEDDINGS_PROVIDER=ollama`. Requires a running [Ollama](https://olla
| `GITHUB_TOKEN` | `""` | GitHub personal access token (or GitHub App installation token). Required for all indexing operations. Without it, GitHub API calls return 403. A single token is used for every service, whether from `config.yaml` or the dynamic registry — see the note in [Dynamic Service Registration](#dynamic-service-registration). |
| `CONFIG_PATH` | `./config.yaml` | Path to the optional services config file. Relative to the working directory at server start. A missing file means zero `config.yaml`-defined services, not an error. |
| `GIT_HISTORY_MAX_COMMITS` | `500` | Maximum number of commits fetched per service for git history indexing. |
| `CODE_CONTEXT_CACHE_SIZE` | `128` | Number of file contents `get_code_context` keeps cached in memory, keyed on the indexed git blob SHA. Repeated context fetches for the same file are served locally instead of hitting the GitHub API. Set to `0` to disable caching. |
| `CODE_CONTEXT_CACHE_TTL` | `900` | Seconds a cached file content stays valid. Entries are keyed on content (blob SHA), so the TTL only bounds memory for files that fall out of use. |
| `EMBEDDING_MAX_CHARS` | provider-aware — see below | Max characters of a symbol's dense-embedding text (preamble + signature + docstring + source). Oversized symbols are truncated (with a logged `WARNING`). Set this explicitly to override the derived default for any provider. |

Since providers differ hugely in context window (2K–32K tokens), `EMBEDDING_MAX_CHARS` defaults to a value derived from `EMBEDDINGS_PROVIDER`'s default model (~3 chars/token, ~10% safety margin for the preamble). This is a per-provider default, not per-model — if you change `*_MODEL` to a model with a smaller or larger window than the provider's default, set `EMBEDDING_MAX_CHARS` explicitly.
Expand Down
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ dependencies = [
"pyyaml>=6.0",
"httpx>=0.28.0",
"fastembed>=0.4.0",
"cachetools>=7.1.6",
]

[dependency-groups]
Expand Down
5 changes: 5 additions & 0 deletions server/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,11 @@ def _apply_default_embedding_max_chars(self) -> Settings:
mcp_host: str = Field(default="127.0.0.1", alias="MCP_HOST")
mcp_port: int = Field(default=8090, alias="MCP_PORT")

# get_code_context fetches file contents from GitHub. Contents are cached by git
# blob SHA so repeated calls for the same file in a session are served locally.
code_context_cache_size: int = Field(default=128, alias="CODE_CONTEXT_CACHE_SIZE")
code_context_cache_ttl: float = Field(default=900.0, alias="CODE_CONTEXT_CACHE_TTL")

config_path: str = Field(default="./config.yaml", alias="CONFIG_PATH")
github_token: str = Field(default="", alias="GITHUB_TOKEN")

Expand Down
37 changes: 37 additions & 0 deletions server/tools/file_cache.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
from __future__ import annotations

from cachetools import TTLCache


class BlobContentCache:
"""LRU + TTL cache of file contents keyed on ``(repo, blob_sha)``.

A git blob SHA is a content fingerprint, so an entry can only go stale in the
sense that the *index* may have moved on to a newer blob — in which case the
key changes and the old entry is simply never read again. The TTL exists only
to bound memory for keys that fall out of use.

Eviction and expiry are ``cachetools.TTLCache``; this wrapper only adds typed
accessors and treats ``max_entries <= 0`` as "caching disabled" (TTLCache
itself raises on insert when ``maxsize`` is 0). Not thread-safe — callers are
expected to share one event loop, and nothing awaits between get and put.
"""

def __init__(self, max_entries: int, ttl_seconds: float) -> None:
self._cache: TTLCache[tuple[str, str], str] | None = (
TTLCache(maxsize=max_entries, ttl=ttl_seconds) if max_entries > 0 else None
)

def get(self, repo: str, blob_sha: str) -> str | None:
if self._cache is None:
return None
return self._cache.get((repo, blob_sha))

def put(self, repo: str, blob_sha: str, content: str) -> None:
if self._cache is None:
return
self._cache[(repo, blob_sha)] = content

def clear(self) -> None:
if self._cache is not None:
self._cache.clear()
41 changes: 30 additions & 11 deletions server/tools/search.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,16 @@
from server.indexer.github_source import fetch_file_content
from server.state import get_service_registry, get_sparse_provider, get_store
from server.store.service_registry import load_effective_services
from server.tools.file_cache import BlobContentCache

logger = logging.getLogger(__name__)

# Shared across all get_code_context calls for the process lifetime.
file_content_cache = BlobContentCache(
max_entries=settings.code_context_cache_size,
ttl_seconds=settings.code_context_cache_ttl,
)

# find_usages filters the symbol's own definition(s) out of the fused results.
# Over-fetch by this many hits so those removals don't eat into the caller's
# requested limit.
Expand Down Expand Up @@ -212,18 +219,30 @@ async def get_code_context(
# path matches the actual location in the GitHub repo tree.
rel_path = file_path[len(svc.name) + 1 :]
path_in_repo = f"{svc.root.rstrip('/')}/{rel_path}" if svc.root else rel_path
try:
raw = await fetch_file_content(
settings.github_token, svc.github_repo, path_in_repo, svc.github_ref
)

# The indexed file_hash IS the git blob SHA, so it doubles as a content
# fingerprint for the cache. Entries indexed before file_hash existed skip
# the cache rather than risk keying on a path whose content can change.
blob_sha = file_info.get("file_hash")
content = (
file_content_cache.get(svc.github_repo, blob_sha) if blob_sha else None
)
if content is None:
try:
raw = await fetch_file_content(
settings.github_token, svc.github_repo, path_in_repo, svc.github_ref
)
except httpx.HTTPError as exc:
logger.exception(
"Failed to fetch %s from GitHub (%s)", file_path, svc.github_repo
)
return (
f"Failed to fetch `{file_path}` from GitHub "
f"({svc.github_repo}): {exc}"
)
content = raw.decode("utf-8", errors="replace")
except httpx.HTTPError as exc:
logger.exception(
"Failed to fetch %s from GitHub (%s)", file_path, svc.github_repo
)
return (
f"Failed to fetch `{file_path}` from GitHub ({svc.github_repo}): {exc}"
)
if blob_sha:
file_content_cache.put(svc.github_repo, blob_sha, content)

if symbol_name is None:
return f"```\n{content}\n```"
Expand Down
11 changes: 11 additions & 0 deletions tests/tools/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,19 @@
from dataclasses import dataclass, field
from typing import Any

import pytest
from mcp.server.fastmcp import FastMCP

from server.tools.search import file_content_cache


@pytest.fixture(autouse=True)
def clear_file_content_cache():
"""The get_code_context content cache is process-wide — keep tests isolated."""
file_content_cache.clear()
yield
file_content_cache.clear()


def get_tool(register: Callable[[FastMCP], None], name: str) -> Callable:
"""Register a tool module against a throwaway FastMCP instance and return
Expand Down
57 changes: 57 additions & 0 deletions tests/tools/test_file_cache.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
from __future__ import annotations

from server.tools.file_cache import BlobContentCache


def test_put_then_get_returns_content() -> None:
cache = BlobContentCache(max_entries=2, ttl_seconds=60)
cache.put("org/orders", "blob1", "class Order {}")

assert cache.get("org/orders", "blob1") == "class Order {}"


def test_get_missing_key_returns_none() -> None:
cache = BlobContentCache(max_entries=2, ttl_seconds=60)

assert cache.get("org/orders", "blob1") is None


def test_same_blob_sha_in_different_repos_is_a_separate_entry() -> None:
cache = BlobContentCache(max_entries=2, ttl_seconds=60)
cache.put("org/orders", "blob1", "orders")

assert cache.get("org/billing", "blob1") is None


def test_expired_entry_is_evicted_on_read() -> None:
cache = BlobContentCache(max_entries=2, ttl_seconds=0)
cache.put("org/orders", "blob1", "class Order {}")

assert cache.get("org/orders", "blob1") is None


def test_least_recently_used_entry_is_evicted_when_full() -> None:
cache = BlobContentCache(max_entries=2, ttl_seconds=60)
cache.put("org/orders", "blob1", "one")
cache.put("org/orders", "blob2", "two")
cache.get("org/orders", "blob1") # blob2 is now least recently used
cache.put("org/orders", "blob3", "three")

assert cache.get("org/orders", "blob2") is None
assert cache.get("org/orders", "blob1") == "one"
assert cache.get("org/orders", "blob3") == "three"


def test_zero_max_entries_disables_caching() -> None:
cache = BlobContentCache(max_entries=0, ttl_seconds=60)
cache.put("org/orders", "blob1", "one")

assert cache.get("org/orders", "blob1") is None


def test_clear_drops_all_entries() -> None:
cache = BlobContentCache(max_entries=2, ttl_seconds=60)
cache.put("org/orders", "blob1", "one")
cache.clear()

assert cache.get("org/orders", "blob1") is None
118 changes: 118 additions & 0 deletions tests/tools/test_search.py
Original file line number Diff line number Diff line change
Expand Up @@ -462,6 +462,124 @@ async def test_get_code_context_falls_back_to_text_search_when_symbol_not_in_ind
assert "Found `placeOrder` near line 2" in result


async def test_get_code_context_serves_repeat_calls_from_cache() -> None:
get_code_context = _tool("get_code_context")
store = AsyncMock()
store.get_file_info.return_value = {"service": "orders", "file_hash": "blob1"}
svc = ServiceConfig(name="orders", github_repo="org/orders", exclude=[])

with (
patch("server.tools.search.get_store", return_value=store),
patch("server.tools.search.get_service_registry", return_value=AsyncMock()),
patch(
"server.tools.search.load_effective_services",
new_callable=AsyncMock,
return_value=[svc],
),
patch("server.tools.search.settings") as mock_settings,
patch(
"server.tools.search.fetch_file_content", new_callable=AsyncMock
) as mock_fetch,
):
mock_settings.github_token = "token"
mock_fetch.return_value = b"class Order {}"

first = await get_code_context("orders/Order.java")
second = await get_code_context("orders/Order.java")

assert first == second == "```\nclass Order {}\n```"
mock_fetch.assert_awaited_once()


async def test_get_code_context_refetches_when_blob_sha_changed() -> None:
get_code_context = _tool("get_code_context")
store = AsyncMock()
svc = ServiceConfig(name="orders", github_repo="org/orders", exclude=[])

with (
patch("server.tools.search.get_store", return_value=store),
patch("server.tools.search.get_service_registry", return_value=AsyncMock()),
patch(
"server.tools.search.load_effective_services",
new_callable=AsyncMock,
return_value=[svc],
),
patch("server.tools.search.settings") as mock_settings,
patch(
"server.tools.search.fetch_file_content", new_callable=AsyncMock
) as mock_fetch,
):
mock_settings.github_token = "token"

store.get_file_info.return_value = {"service": "orders", "file_hash": "blob1"}
mock_fetch.return_value = b"class Order {}"
await get_code_context("orders/Order.java")

store.get_file_info.return_value = {"service": "orders", "file_hash": "blob2"}
mock_fetch.return_value = b"class Order { int id; }"
result = await get_code_context("orders/Order.java")

assert result == "```\nclass Order { int id; }\n```"
assert mock_fetch.await_count == 2


async def test_get_code_context_skips_cache_when_file_hash_missing() -> None:
get_code_context = _tool("get_code_context")
store = AsyncMock()
store.get_file_info.return_value = {"service": "orders"}
svc = ServiceConfig(name="orders", github_repo="org/orders", exclude=[])

with (
patch("server.tools.search.get_store", return_value=store),
patch("server.tools.search.get_service_registry", return_value=AsyncMock()),
patch(
"server.tools.search.load_effective_services",
new_callable=AsyncMock,
return_value=[svc],
),
patch("server.tools.search.settings") as mock_settings,
patch(
"server.tools.search.fetch_file_content", new_callable=AsyncMock
) as mock_fetch,
):
mock_settings.github_token = "token"
mock_fetch.return_value = b"class Order {}"

await get_code_context("orders/Order.java")
await get_code_context("orders/Order.java")

assert mock_fetch.await_count == 2


async def test_get_code_context_failed_fetch_is_not_cached() -> None:
get_code_context = _tool("get_code_context")
store = AsyncMock()
store.get_file_info.return_value = {"service": "orders", "file_hash": "blob1"}
svc = ServiceConfig(name="orders", github_repo="org/orders", exclude=[])

with (
patch("server.tools.search.get_store", return_value=store),
patch("server.tools.search.get_service_registry", return_value=AsyncMock()),
patch(
"server.tools.search.load_effective_services",
new_callable=AsyncMock,
return_value=[svc],
),
patch("server.tools.search.settings") as mock_settings,
patch(
"server.tools.search.fetch_file_content", new_callable=AsyncMock
) as mock_fetch,
):
mock_settings.github_token = "token"
mock_fetch.side_effect = [httpx.HTTPError("boom"), b"class Order {}"]

failed = await get_code_context("orders/Order.java")
recovered = await get_code_context("orders/Order.java")

assert "Failed to fetch" in failed
assert recovered == "```\nclass Order {}\n```"


async def test_get_code_context_symbol_not_found_anywhere() -> None:
get_code_context = _tool("get_code_context")
store = AsyncMock()
Expand Down
11 changes: 11 additions & 0 deletions uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.