From 61472a6ff0b6d8514af96fd3cd39ea59fbad3699 Mon Sep 17 00:00:00 2001 From: 0neStep <146049978+AperturePlus@users.noreply.github.com> Date: Thu, 26 Feb 2026 00:03:24 +0800 Subject: [PATCH 1/6] fix(tokenizer): support configurable chunk token strategies Why: - Indexing against Ollama WordPiece-based embedding models can exceed model context limits because chunk sizing used only cl100k_base token counts. - Ollama error payloads using "input length exceeds the context length" were not consistently classified as token-limit failures. What: - Added tokenizer strategies (tiktoken, character, simple) and strategy-based default tokenizer factory. - Added ACI_TOKENIZER config/env support and wired tokenizer selection into service initialization so chunking and summary generation share the chosen tokenizer. - Expanded token-limit error detection to include context-length style 400 responses. - Updated unit/property tests for tokenizer strategy selection, offline-safe tokenizer tests, config strategy generation, and token-limit message coverage. Test: - uv run pytest tests/unit/test_tokenizer.py tests/property/test_embedding_client_properties.py tests/property/test_config_properties.py -q (pass) - uv run ruff check src tests (pass) - uv run pytest tests/ -v --tb=short -q --durations=10 --maxfail=1 (fails in this environment due proxy blocking tiktoken encoding download) - uv run mypy src --ignore-missing-imports --no-error-summary (existing repo-wide mypy errors) --- src/aci/core/__init__.py | 22 +-- src/aci/core/config.py | 2 + src/aci/core/tokenizer.py | 98 +++++++++++-- .../embedding/response_parser.py | 4 +- src/aci/services/container.py | 7 +- tests/property/test_config_properties.py | 1 + .../test_embedding_client_properties.py | 1 + tests/unit/test_tokenizer.py | 134 ++++++++---------- 8 files changed, 175 insertions(+), 94 deletions(-) diff --git a/src/aci/core/__init__.py b/src/aci/core/__init__.py index 0f14bc0..753288b 100644 --- a/src/aci/core/__init__.py +++ b/src/aci/core/__init__.py @@ -41,11 +41,13 @@ ScannedFile, get_default_registry, ) -from aci.core.tokenizer import ( - TiktokenTokenizer, - TokenizerInterface, - get_default_tokenizer, -) +from aci.core.tokenizer import ( + CharacterTokenizer, + SimpleTokenizer, + TiktokenTokenizer, + TokenizerInterface, + get_default_tokenizer, +) from aci.core.watch_config import WatchConfig __all__ = [ @@ -70,10 +72,12 @@ "TreeSitterParser", "SUPPORTED_LANGUAGES", "check_tree_sitter_setup", - # Tokenizer - "TokenizerInterface", - "TiktokenTokenizer", - "get_default_tokenizer", + # Tokenizer + "TokenizerInterface", + "TiktokenTokenizer", + "CharacterTokenizer", + "SimpleTokenizer", + "get_default_tokenizer", # Chunker "CodeChunk", "ChunkerConfig", diff --git a/src/aci/core/config.py b/src/aci/core/config.py index 0b160c6..8fc9914 100644 --- a/src/aci/core/config.py +++ b/src/aci/core/config.py @@ -133,6 +133,7 @@ class IndexingConfig: default_factory=lambda: _get_default("indexing", "chunk_overlap_lines", 2) ) max_workers: int = field(default_factory=lambda: _get_default("indexing", "max_workers", 4)) + tokenizer: str = field(default_factory=lambda: _get_default("indexing", "tokenizer", "tiktoken")) @dataclass @@ -226,6 +227,7 @@ def apply_env_overrides(self) -> "ACIConfig": "ACI_INDEXING_MAX_CHUNK_TOKENS": ("indexing", "max_chunk_tokens", int), "ACI_INDEXING_CHUNK_OVERLAP_LINES": ("indexing", "chunk_overlap_lines", int), "ACI_INDEXING_MAX_WORKERS": ("indexing", "max_workers", int), + "ACI_TOKENIZER": ("indexing", "tokenizer", str), "ACI_INDEXING_FILE_EXTENSIONS": ( "indexing", "file_extensions", diff --git a/src/aci/core/tokenizer.py b/src/aci/core/tokenizer.py index e477d68..4562ff6 100644 --- a/src/aci/core/tokenizer.py +++ b/src/aci/core/tokenizer.py @@ -4,7 +4,8 @@ Uses tiktoken library for accurate token counting compatible with OpenAI models. """ -from abc import ABC, abstractmethod +from abc import ABC, abstractmethod +from math import ceil import tiktoken @@ -44,7 +45,7 @@ def truncate_to_tokens(self, text: str, max_tokens: int) -> str: pass -class TiktokenTokenizer(TokenizerInterface): +class TiktokenTokenizer(TokenizerInterface): """ Tokenizer implementation using tiktoken library. @@ -134,14 +135,93 @@ def truncate_to_tokens(self, text: str, max_tokens: int) -> str: result_lines.append(line) current_tokens += line_tokens - return "\n".join(result_lines) - - -def get_default_tokenizer() -> TokenizerInterface: + return "\n".join(result_lines) + + +class CharacterTokenizer(TokenizerInterface): + """Conservative tokenizer that estimates tokens using character length.""" + + def __init__(self, chars_per_token: int = 4): + if chars_per_token <= 0: + raise ValueError("chars_per_token must be greater than 0") + self._chars_per_token = chars_per_token + + def count_tokens(self, text: str) -> int: + if not text: + return 0 + return ceil(len(text) / self._chars_per_token) + + def truncate_to_tokens(self, text: str, max_tokens: int) -> str: + if not text or max_tokens <= 0: + return "" + + if self.count_tokens(text) <= max_tokens: + return text + + lines = text.split("\n") + result_lines: list[str] = [] + current_tokens = 0 + + for line in lines: + line_with_newline = f"\n{line}" if result_lines else line + line_tokens = self.count_tokens(line_with_newline) + + if current_tokens + line_tokens > max_tokens: + break + + result_lines.append(line) + current_tokens += line_tokens + + return "\n".join(result_lines) + + +class SimpleTokenizer(TokenizerInterface): + """Simple whitespace tokenizer primarily for generic non-BPE models.""" + + def count_tokens(self, text: str) -> int: + if not text: + return 0 + return len(text.split()) + + def truncate_to_tokens(self, text: str, max_tokens: int) -> str: + if not text or max_tokens <= 0: + return "" + + if self.count_tokens(text) <= max_tokens: + return text + + lines = text.split("\n") + result_lines: list[str] = [] + current_tokens = 0 + + for line in lines: + line_with_newline = f"\n{line}" if result_lines else line + line_tokens = self.count_tokens(line_with_newline) + + if current_tokens + line_tokens > max_tokens: + break + + result_lines.append(line) + current_tokens += line_tokens + + return "\n".join(result_lines) + + +def get_default_tokenizer(strategy: str = "tiktoken") -> TokenizerInterface: """ Get the default tokenizer instance. Returns: - A TiktokenTokenizer with cl100k_base encoding. - """ - return TiktokenTokenizer(encoding_name="cl100k_base") + A tokenizer implementation matching the configured strategy. + """ + normalized = strategy.strip().lower() + if normalized == "tiktoken": + return TiktokenTokenizer(encoding_name="cl100k_base") + if normalized == "character": + return CharacterTokenizer(chars_per_token=4) + if normalized == "simple": + return SimpleTokenizer() + raise ValueError( + f"Unsupported tokenizer strategy '{strategy}'. " + "Expected one of: tiktoken, character, simple" + ) diff --git a/src/aci/infrastructure/embedding/response_parser.py b/src/aci/infrastructure/embedding/response_parser.py index ceecb77..d95c0c7 100644 --- a/src/aci/infrastructure/embedding/response_parser.py +++ b/src/aci/infrastructure/embedding/response_parser.py @@ -73,9 +73,9 @@ def is_token_limit_error(status_code: int, response_text: str) -> bool: if status_code == 400: response_lower = response_text.lower() # Check for common token limit error patterns - if "token" in response_lower: + if any(pattern in response_lower for pattern in ["token", "input length", "context length"]): if any(pattern in response_lower for pattern in [ - "limit", "8192", "exceed", "maximum", "many" + "limit", "8192", "exceed", "maximum", "many", "context length" ]): return True # Check for SiliconFlow specific error code diff --git a/src/aci/services/container.py b/src/aci/services/container.py index d3305fb..9dd38b5 100644 --- a/src/aci/services/container.py +++ b/src/aci/services/container.py @@ -14,6 +14,7 @@ from aci.core.file_scanner import FileScanner from aci.core.qdrant_launcher import ensure_qdrant_running from aci.core.summary_generator import SummaryGenerator +from aci.core.tokenizer import get_default_tokenizer from aci.infrastructure import ( EmbeddingClientInterface, IndexMetadataStore, @@ -120,11 +121,13 @@ def create_services( ignore_patterns=config.indexing.ignore_patterns, ) - # Create summary generator for multi-granularity indexing - summary_generator = SummaryGenerator() + # Create tokenizer and summary generator for multi-granularity indexing + tokenizer = get_default_tokenizer(config.indexing.tokenizer) + summary_generator = SummaryGenerator(tokenizer=tokenizer) # Create chunker with config-driven settings chunker = create_chunker( + tokenizer=tokenizer, max_tokens=config.indexing.max_chunk_tokens, overlap_lines=config.indexing.chunk_overlap_lines, summary_generator=summary_generator, diff --git a/tests/property/test_config_properties.py b/tests/property/test_config_properties.py index 43fdb02..66fb7dd 100644 --- a/tests/property/test_config_properties.py +++ b/tests/property/test_config_properties.py @@ -78,6 +78,7 @@ def indexing_config_strategy(draw): max_chunk_tokens=draw(st.integers(min_value=100, max_value=32000)), chunk_overlap_lines=draw(st.integers(min_value=0, max_value=50)), max_workers=draw(st.integers(min_value=1, max_value=32)), + tokenizer=draw(st.sampled_from(["tiktoken", "character", "simple"])), ) diff --git a/tests/property/test_embedding_client_properties.py b/tests/property/test_embedding_client_properties.py index 0c60ca1..07e2dd7 100644 --- a/tests/property/test_embedding_client_properties.py +++ b/tests/property/test_embedding_client_properties.py @@ -179,6 +179,7 @@ async def run_test(): "token limit exceeded", "maximum token limit", "too many tokens", + "the input length exceeds the context length", '{"code":20042,"message":"input must have less than 8192 tokens"}', ] diff --git a/tests/unit/test_tokenizer.py b/tests/unit/test_tokenizer.py index 64545dd..f59dd5e 100644 --- a/tests/unit/test_tokenizer.py +++ b/tests/unit/test_tokenizer.py @@ -2,131 +2,121 @@ Tests for the Tokenizer module. """ +import pytest + from aci.core.tokenizer import ( + CharacterTokenizer, + SimpleTokenizer, TiktokenTokenizer, TokenizerInterface, get_default_tokenizer, ) +class FakeEncoding: + """Offline-safe encoding stub for unit tests.""" + + def encode(self, text: str) -> list[str]: + if not text: + return [] + # Approximate tokenization: split on whitespace boundaries + return text.replace("\n", " \n ").split() + + +def make_tiktoken_tokenizer() -> TiktokenTokenizer: + tokenizer = TiktokenTokenizer() + tokenizer._encoding = FakeEncoding() + return tokenizer + + class TestTiktokenTokenizer: """Unit tests for TiktokenTokenizer.""" def test_implements_interface(self): - """Verify TiktokenTokenizer implements TokenizerInterface.""" - tokenizer = TiktokenTokenizer() + tokenizer = make_tiktoken_tokenizer() assert isinstance(tokenizer, TokenizerInterface) def test_count_tokens_empty_string(self): - """Empty string should return 0 tokens.""" - tokenizer = TiktokenTokenizer() + tokenizer = make_tiktoken_tokenizer() assert tokenizer.count_tokens("") == 0 def test_count_tokens_simple_text(self): - """Simple text should return positive token count.""" - tokenizer = TiktokenTokenizer() - count = tokenizer.count_tokens("Hello, world!") - assert count > 0 + tokenizer = make_tiktoken_tokenizer() + assert tokenizer.count_tokens("Hello, world!") > 0 def test_count_tokens_code(self): - """Code should be tokenized correctly.""" - tokenizer = TiktokenTokenizer() - code = "def hello():\n print('Hello')" - count = tokenizer.count_tokens(code) - assert count > 0 + tokenizer = make_tiktoken_tokenizer() + assert tokenizer.count_tokens("def hello():\n print('Hello')") > 0 def test_truncate_empty_string(self): - """Empty string should return empty string.""" - tokenizer = TiktokenTokenizer() + tokenizer = make_tiktoken_tokenizer() assert tokenizer.truncate_to_tokens("", 100) == "" def test_truncate_zero_max_tokens(self): - """Zero max_tokens should return empty string.""" - tokenizer = TiktokenTokenizer() + tokenizer = make_tiktoken_tokenizer() assert tokenizer.truncate_to_tokens("Hello, world!", 0) == "" def test_truncate_negative_max_tokens(self): - """Negative max_tokens should return empty string.""" - tokenizer = TiktokenTokenizer() + tokenizer = make_tiktoken_tokenizer() assert tokenizer.truncate_to_tokens("Hello, world!", -5) == "" def test_truncate_text_fits(self): - """Text that fits should be returned unchanged.""" - tokenizer = TiktokenTokenizer() + tokenizer = make_tiktoken_tokenizer() text = "Hello, world!" - result = tokenizer.truncate_to_tokens(text, 1000) - assert result == text + assert tokenizer.truncate_to_tokens(text, 1000) == text def test_truncate_preserves_line_integrity(self): - """Truncation should not cut in the middle of a line.""" - tokenizer = TiktokenTokenizer() + tokenizer = make_tiktoken_tokenizer() text = "Line 1\nLine 2\nLine 3\nLine 4\nLine 5" - - # Get a max_tokens that will require truncation - total_tokens = tokenizer.count_tokens(text) - max_tokens = total_tokens // 2 + max_tokens = max(1, tokenizer.count_tokens(text) // 2) result = tokenizer.truncate_to_tokens(text, max_tokens) - - # Result should end with a complete line (no partial lines) - assert result.endswith("Line 1") or result.endswith("Line 2") or result.endswith("Line 3") - # Result should not contain partial text for line in result.split("\n"): assert line.startswith("Line ") def test_truncate_respects_token_limit(self): - """Truncated text should not exceed max_tokens.""" - tokenizer = TiktokenTokenizer() + tokenizer = make_tiktoken_tokenizer() text = "\n".join([f"This is line number {i} with some content" for i in range(100)]) max_tokens = 50 - result = tokenizer.truncate_to_tokens(text, max_tokens) - result_tokens = tokenizer.count_tokens(result) - - assert result_tokens <= max_tokens - - def test_truncate_multiline_code(self): - """Truncation should work correctly with code.""" - tokenizer = TiktokenTokenizer() - code = """def function_one(): - print("Hello") - return 1 + assert tokenizer.count_tokens(result) <= max_tokens -def function_two(): - print("World") - return 2 -def function_three(): - print("Test") - return 3 -""" - # Use a small token limit - max_tokens = 20 - result = tokenizer.truncate_to_tokens(code, max_tokens) +class TestAlternativeTokenizers: + def test_character_tokenizer_counts_and_truncates(self): + tokenizer = CharacterTokenizer(chars_per_token=4) + text = "abcd\nefgh\nijkl" + assert tokenizer.count_tokens(text) == 4 + truncated = tokenizer.truncate_to_tokens(text, 2) + assert truncated == "abcd" + assert tokenizer.count_tokens(truncated) <= 2 - # Should not exceed limit - assert tokenizer.count_tokens(result) <= max_tokens - # Should contain complete lines only - lines = result.split("\n") - for line in lines: - # Each line should be a valid Python line (not cut mid-statement) - assert not line.endswith("pri") # Not cut in middle of "print" + def test_simple_tokenizer_counts_and_truncates(self): + tokenizer = SimpleTokenizer() + text = "one two\nthree four five" + assert tokenizer.count_tokens(text) == 5 + truncated = tokenizer.truncate_to_tokens(text, 2) + assert truncated == "one two" + assert tokenizer.count_tokens(truncated) <= 2 class TestGetDefaultTokenizer: - """Tests for get_default_tokenizer factory function.""" - def test_returns_tokenizer_interface(self): - """Should return a TokenizerInterface instance.""" - tokenizer = get_default_tokenizer() - assert isinstance(tokenizer, TokenizerInterface) + assert isinstance(get_default_tokenizer(), TokenizerInterface) def test_returns_tiktoken_tokenizer(self): - """Should return a TiktokenTokenizer instance.""" - tokenizer = get_default_tokenizer() - assert isinstance(tokenizer, TiktokenTokenizer) + assert isinstance(get_default_tokenizer("tiktoken"), TiktokenTokenizer) + + def test_returns_character_tokenizer(self): + assert isinstance(get_default_tokenizer("character"), CharacterTokenizer) + + def test_returns_simple_tokenizer(self): + assert isinstance(get_default_tokenizer("simple"), SimpleTokenizer) def test_uses_cl100k_base_encoding(self): - """Should use cl100k_base encoding by default.""" tokenizer = get_default_tokenizer() assert tokenizer._encoding_name == "cl100k_base" + + def test_invalid_strategy_raises(self): + with pytest.raises(ValueError, match="Unsupported tokenizer strategy"): + get_default_tokenizer("bert") From c0bd2258512b6462269db5425299fdf5b4f1ee50 Mon Sep 17 00:00:00 2001 From: ACI Bot Date: Fri, 6 Mar 2026 21:49:55 +0800 Subject: [PATCH 2/6] fix(mcp): resolve mapped runtime paths correctly Why: MCP runtime path mappings were bypassed for absolute host paths on Windows, which broke the new MCP path-resolution flow and caused pytest failures. The latest MCP changes also expanded the context contract and exposed a flaky Hypothesis deadline in file scanner tests. What: apply runtime path mappings before native absolute-path fallback, add runtime path resolution coverage for Windows and POSIX host paths, update MCP/qdrant/property tests to match the new contract, and disable the flaky deadline on the ignore-pattern property test. Test: uv run ruff check src tests (passed) Test: uv run pytest tests/ -v --tb=short -q --durations=10 (passed, 660 passed / 16 skipped) --- src/aci/core/path_utils.py | 240 +++++++++++++++++- src/aci/core/qdrant_launcher.py | 16 ++ src/aci/mcp/context.py | 15 ++ src/aci/mcp/handlers.py | 95 +++++-- .../property/test_file_scanner_properties.py | 2 +- tests/property/test_mcp_context_properties.py | 2 + .../property/test_mcp_path_security_errors.py | 13 +- tests/unit/test_qdrant_launcher.py | 17 ++ tests/unit/test_runtime_path_resolution.py | 109 ++++++++ 9 files changed, 476 insertions(+), 33 deletions(-) create mode 100644 tests/unit/test_qdrant_launcher.py create mode 100644 tests/unit/test_runtime_path_resolution.py diff --git a/src/aci/core/path_utils.py b/src/aci/core/path_utils.py index a7f8b68..a1ad9ac 100644 --- a/src/aci/core/path_utils.py +++ b/src/aci/core/path_utils.py @@ -1,16 +1,17 @@ """ Path validation utilities for ACI. -Provides centralized path validation, system directory detection, -directory creation utilities, and collection name generation -used across CLI, REPL, and HTTP layers. +Provides centralized path validation, runtime path resolution, +system directory detection, directory creation utilities, and +collection name generation used across CLI, REPL, HTTP, and MCP layers. """ import hashlib import re import sys +from collections.abc import Sequence from dataclasses import dataclass -from pathlib import Path, PureWindowsPath +from pathlib import Path, PurePosixPath, PureWindowsPath @dataclass @@ -25,6 +26,25 @@ class PathValidationResult: error_message: str | None = None +@dataclass(frozen=True) +class RuntimePathMapping: + """Maps a host-side path prefix to a runtime-accessible path prefix.""" + + source_prefix: str + target_prefix: Path + + +@dataclass +class RuntimePathResolutionResult: + """Result of resolving a user-supplied path inside the current runtime.""" + + valid: bool + original_path: str + resolved_path: Path | None = None + mapped: bool = False + error_message: str | None = None + + # POSIX system directories that should not be indexed POSIX_SYSTEM_DIRS = frozenset([ "/etc", @@ -51,6 +71,218 @@ class PathValidationResult: "syswow64", ]) +_WINDOWS_ABSOLUTE_PATH_RE = re.compile(r"^[a-zA-Z]:[\\/]") + + +@dataclass(frozen=True) +class _ComparablePath: + style: str + absolute: bool + normalized_parts: tuple[str, ...] + raw_parts: tuple[str, ...] + + +def _looks_like_windows_path(path_str: str) -> bool: + """Return True when a string looks like a Windows absolute path.""" + return bool(_WINDOWS_ABSOLUTE_PATH_RE.match(path_str)) or path_str.startswith("\\\\") + + +def _split_windows_parts(path_str: str) -> _ComparablePath: + """Split a Windows path into comparable parts.""" + pure_path = PureWindowsPath(path_str) + raw_parts: list[str] = [] + + if pure_path.drive: + raw_parts.append(pure_path.drive) + + anchor = pure_path.anchor.rstrip("\\/") + for part in pure_path.parts: + cleaned = part.rstrip("\\/") + if not cleaned or cleaned == anchor or cleaned == pure_path.drive: + continue + raw_parts.append(cleaned) + + return _ComparablePath( + style="windows", + absolute=pure_path.is_absolute(), + normalized_parts=tuple(part.lower() for part in raw_parts), + raw_parts=tuple(raw_parts), + ) + + +def _split_posix_parts(path_str: str) -> _ComparablePath: + """Split a POSIX path into comparable parts.""" + pure_path = PurePosixPath(path_str.replace("\\", "/")) + parts = [part for part in pure_path.parts if part not in ("", ".")] + if pure_path.is_absolute(): + raw_parts = tuple(["/"] + [part for part in parts if part != "/"]) + else: + raw_parts = tuple(part for part in parts if part != "/") + + return _ComparablePath( + style="posix", + absolute=pure_path.is_absolute(), + normalized_parts=raw_parts, + raw_parts=raw_parts, + ) + + +def _to_comparable_path(path_str: str) -> _ComparablePath: + """Convert a raw path string to comparable parts.""" + if _looks_like_windows_path(path_str): + return _split_windows_parts(path_str) + return _split_posix_parts(path_str) + + +def parse_runtime_path_mappings(raw_value: str | None) -> list[RuntimePathMapping]: + """Parse semicolon-separated runtime path mappings. + + Expected format: + source_prefix=target_prefix;source_prefix=target_prefix + + Examples: + D:\\=/host/d;/Users/alice=/host/users/alice + /=/hostfs + """ + if raw_value is None or not raw_value.strip(): + return [] + + mappings: list[RuntimePathMapping] = [] + for item in raw_value.split(";"): + pair = item.strip() + if not pair: + continue + if "=" not in pair: + raise ValueError( + "Invalid path mapping entry. Expected 'source=target' pairs separated by ';'." + ) + + source_prefix, target_prefix = pair.split("=", 1) + source_prefix = source_prefix.strip() + target_prefix = target_prefix.strip() + if not source_prefix or not target_prefix: + raise ValueError("Path mapping source and target must be non-empty.") + + mappings.append( + RuntimePathMapping( + source_prefix=source_prefix, + target_prefix=Path(target_prefix), + ) + ) + + return mappings + + +def _apply_runtime_path_mapping( + path_str: str, + path_mappings: Sequence[RuntimePathMapping], +) -> Path | None: + """Apply the first matching runtime path mapping.""" + input_path = _to_comparable_path(path_str) + + for mapping in path_mappings: + source = _to_comparable_path(mapping.source_prefix) + if input_path.style != source.style or input_path.absolute != source.absolute: + continue + if len(input_path.normalized_parts) < len(source.normalized_parts): + continue + if input_path.normalized_parts[: len(source.normalized_parts)] != source.normalized_parts: + continue + + target_path = mapping.target_prefix + remainder = input_path.raw_parts[len(source.raw_parts) :] + for part in remainder: + target_path = target_path / part + return target_path + + return None + + +def _resolve_mapped_runtime_path( + original_path: str, + path_mappings: Sequence[RuntimePathMapping], +) -> RuntimePathResolutionResult | None: + """Resolve a path via configured runtime mappings when available.""" + mapped_path = _apply_runtime_path_mapping(original_path, path_mappings) + if mapped_path is None: + return None + + if mapped_path.exists(): + return RuntimePathResolutionResult( + valid=True, + original_path=original_path, + resolved_path=mapped_path.resolve(), + mapped=True, + ) + + return RuntimePathResolutionResult( + valid=False, + original_path=original_path, + error_message=( + f"Path '{original_path}' is not accessible inside this runtime. " + f"Mapped to '{mapped_path}', but that path does not exist. " + "Check the container bind mount and ACI_MCP_PATH_MAPPINGS." + ), + ) + + +def resolve_runtime_path( + path: str | Path, + workspace_root: str | Path | None = None, + path_mappings: Sequence[RuntimePathMapping] | None = None, +) -> RuntimePathResolutionResult: + """Resolve a user-supplied path within the current runtime environment.""" + original_path = str(path) + path_str = original_path.strip() + mappings = path_mappings or () + + if not path_str: + return RuntimePathResolutionResult( + valid=False, + original_path=original_path, + error_message="Path cannot be empty", + ) + + is_windows_absolute = _looks_like_windows_path(path_str) + is_posix_absolute = path_str.startswith("/") + is_absolute = is_windows_absolute or is_posix_absolute + + if not is_absolute: + base_dir = Path(workspace_root) if workspace_root is not None else Path.cwd() + return RuntimePathResolutionResult( + valid=True, + original_path=original_path, + resolved_path=(base_dir / path_str).resolve(), + mapped=workspace_root is not None, + ) + + mapped_resolution = _resolve_mapped_runtime_path(original_path, mappings) + if mapped_resolution is not None: + return mapped_resolution + + if is_windows_absolute and sys.platform == "win32": + return RuntimePathResolutionResult( + valid=True, + original_path=original_path, + resolved_path=Path(path_str), + ) + + if is_posix_absolute and sys.platform != "win32": + return RuntimePathResolutionResult( + valid=True, + original_path=original_path, + resolved_path=Path(path_str), + ) + + return RuntimePathResolutionResult( + valid=False, + original_path=original_path, + error_message=( + f"Path '{original_path}' is not accessible inside this runtime. " + "Configure ACI_MCP_PATH_MAPPINGS or mount the host path into the container." + ), + ) + def is_system_directory(path: Path) -> bool: """ diff --git a/src/aci/core/qdrant_launcher.py b/src/aci/core/qdrant_launcher.py index 58fb142..d487816 100644 --- a/src/aci/core/qdrant_launcher.py +++ b/src/aci/core/qdrant_launcher.py @@ -6,8 +6,10 @@ """ import logging +import os import socket import subprocess +from pathlib import Path from urllib.parse import urlparse logger = logging.getLogger(__name__) @@ -24,6 +26,11 @@ def _is_port_open(host: str, port: int) -> bool: return False +def _is_running_in_container() -> bool: + """Detect whether the current process is running inside a container.""" + return Path("/.dockerenv").exists() or os.environ.get("container", "") == "docker" + + def ensure_qdrant_running( host: str = "localhost", port: int = 6333, @@ -64,6 +71,15 @@ def ensure_qdrant_running( ) return + if _is_running_in_container(): + logger.warning( + "Qdrant endpoint %s:%s is unreachable; skipping Docker auto-start inside container. " + "Run Qdrant as a separate local container or set ACI_VECTOR_STORE_URL.", + check_host, + check_port, + ) + return + try: # Check if the container already exists (including stopped containers) # Use -aq to include all containers, not just running ones diff --git a/src/aci/mcp/context.py b/src/aci/mcp/context.py index b8b7ee0..5003845 100644 --- a/src/aci/mcp/context.py +++ b/src/aci/mcp/context.py @@ -6,10 +6,12 @@ """ import asyncio +import os from dataclasses import dataclass, field from pathlib import Path from aci.core.config import ACIConfig +from aci.core.path_utils import RuntimePathMapping, parse_runtime_path_mappings from aci.infrastructure.embedding import EmbeddingClientInterface from aci.infrastructure.metadata_store import IndexMetadataStore from aci.infrastructure.vector_store import VectorStoreInterface @@ -43,6 +45,8 @@ class MCPContext: vector_store: VectorStoreInterface indexing_lock: asyncio.Lock indexing_locks: dict[str, asyncio.Lock] = field(default_factory=dict) + workspace_root: Path | None = None + path_mappings: tuple[RuntimePathMapping, ...] = field(default_factory=tuple) # These are stored for cleanup purposes only reranker: RerankerInterface | None = None embedding_client: EmbeddingClientInterface | None = None @@ -65,6 +69,15 @@ def create_mcp_context() -> MCPContext: from aci.infrastructure.grep_searcher import GrepSearcher from aci.services.container import create_services + workspace_root_env = ( + os.environ.get("ACI_MCP_WORKSPACE_ROOT") or os.environ.get("ACI_WORKSPACE_ROOT") + ) + raw_path_mappings = ( + os.environ.get("ACI_MCP_PATH_MAPPINGS") or os.environ.get("ACI_PATH_MAPPINGS") + ) + workspace_root = Path(workspace_root_env).resolve() if workspace_root_env else None + path_mappings = tuple(parse_runtime_path_mappings(raw_path_mappings)) + # Create infrastructure services using centralized factory services = create_services() @@ -98,6 +111,8 @@ def create_mcp_context() -> MCPContext: metadata_store=services.metadata_store, vector_store=services.vector_store, indexing_lock=asyncio.Lock(), + workspace_root=workspace_root, + path_mappings=path_mappings, reranker=services.reranker, embedding_client=services.embedding_client, ) diff --git a/src/aci/mcp/handlers.py b/src/aci/mcp/handlers.py index 88d76ff..3f3981d 100644 --- a/src/aci/mcp/handlers.py +++ b/src/aci/mcp/handlers.py @@ -4,12 +4,16 @@ import os import time from collections.abc import Awaitable, Callable -from pathlib import Path from typing import Any from mcp.types import TextContent -from aci.core.path_utils import get_collection_name_for_path, validate_indexable_path +from aci.core.path_utils import ( + RuntimePathResolutionResult, + get_collection_name_for_path, + resolve_runtime_path, + validate_indexable_path, +) from aci.infrastructure.codebase_registry import best_effort_update_registry from aci.mcp.context import MCPContext from aci.mcp.services import MAX_WORKERS @@ -46,6 +50,34 @@ def _get_repo_index_lock(ctx: MCPContext, repo_key: str) -> asyncio.Lock: return lock +def _resolve_mcp_path(path_str: str, ctx: MCPContext) -> RuntimePathResolutionResult: + """Resolve a client-supplied path inside the MCP runtime.""" + return resolve_runtime_path( + path_str, + workspace_root=ctx.workspace_root, + path_mappings=ctx.path_mappings, + ) + + +def _validate_mcp_directory_path(path_str: str, ctx: MCPContext) -> RuntimePathResolutionResult: + """Resolve and validate a directory path for MCP operations.""" + resolution = _resolve_mcp_path(path_str, ctx) + if not resolution.valid: + return resolution + + validation = validate_indexable_path(resolution.resolved_path) + if validation.valid: + return resolution + + return RuntimePathResolutionResult( + valid=False, + original_path=path_str, + resolved_path=resolution.resolved_path, + mapped=resolution.mapped, + error_message=validation.error_message, + ) + + async def call_tool(name: str, arguments: Any, ctx: MCPContext) -> list[TextContent]: """ Handle tool calls from MCP clients. @@ -76,16 +108,15 @@ async def _handle_index_codebase(arguments: dict, ctx: MCPContext) -> list[TextC start_time = time.time() _debug(f"index_codebase called with path: {path_str}, workers: {workers}") - # Validate path before any indexing operation - validation = validate_indexable_path(path_str) - if not validation.valid: - _debug(f"Path validation failed: {validation.error_message}") + resolution = _validate_mcp_directory_path(path_str, ctx) + if not resolution.valid: + _debug(f"Path validation failed: {resolution.error_message}") return [TextContent( type="text", - text=f"Error: {validation.error_message} (path: {path_str})" + text=f"Error: {resolution.error_message} (path: {path_str})" )] - path = Path(path_str) + path = resolution.resolved_path _debug(f"Resolved path: {path.resolve()}") cfg = ctx.config @@ -127,9 +158,15 @@ async def _handle_index_codebase(arguments: dict, ctx: MCPContext) -> list[TextC collection_name=get_collection_name_for_path(path), ) - response = {"status": "success", "total_files": result.total_files, - "total_chunks": result.total_chunks, "duration_seconds": result.duration_seconds, - "failed_files": result.failed_files[:10] if result.failed_files else []} + response = { + "status": "success", + "requested_path": path_str, + "indexed_path": str(path), + "total_files": result.total_files, + "total_chunks": result.total_chunks, + "duration_seconds": result.duration_seconds, + "failed_files": result.failed_files[:10] if result.failed_files else [], + } if result.failed_files and len(result.failed_files) > 10: response["failed_files_truncated"] = len(result.failed_files) - 10 _debug(f"Result: files={result.total_files}, chunks={result.total_chunks}") @@ -139,7 +176,7 @@ async def _handle_index_codebase(arguments: dict, ctx: MCPContext) -> list[TextC @_register("search_code") async def _handle_search_code(arguments: dict, ctx: MCPContext) -> list[TextContent]: query = arguments["query"] - search_path = Path(arguments["path"]) + path_str = arguments["path"] limit = arguments.get("limit") file_filter = arguments.get("file_filter") use_rerank = arguments.get("use_rerank") @@ -155,6 +192,12 @@ async def _handle_search_code(arguments: dict, ctx: MCPContext) -> list[TextCont search_service = ctx.search_service metadata_store = ctx.metadata_store + resolution = _validate_mcp_directory_path(path_str, ctx) + if not resolution.valid: + return [TextContent(type="text", text=f"Error: {resolution.error_message} (path: {path_str})")] + + search_path = resolution.resolved_path + # Use centralized repository resolution resolution = resolve_repository(search_path, metadata_store) if not resolution.valid: @@ -247,6 +290,8 @@ async def _handle_search_code(arguments: dict, ctx: MCPContext) -> list[TextCont response = { "query": query, + "requested_path": path_str, + "resolved_path": str(search_path), "total_results": len(results), "results": formatted_results, } @@ -265,12 +310,12 @@ async def _handle_get_status(arguments: dict, ctx: MCPContext) -> list[TextConte collection_name = None if path_str: + validation = _validate_mcp_directory_path(path_str, ctx) + if not validation.valid: + return [TextContent(type="text", text=f"Error: {validation.error_message} (path: {path_str})")] + # Get collection name for the specific repository - search_path = Path(path_str) - if not search_path.exists(): - return [TextContent(type="text", text=f"Error: Path does not exist: {search_path}")] - if not search_path.is_dir(): - return [TextContent(type="text", text=f"Error: Path is not a directory: {search_path}")] + search_path = validation.resolved_path search_path_abs = str(search_path.resolve()) index_info = metadata_store.get_index_info(search_path_abs) @@ -318,7 +363,8 @@ async def _handle_get_status(arguments: dict, ctx: MCPContext) -> list[TextConte # Add repository info if a specific path was requested if path_str: response["repository"] = { - "path": path_str, + "requested_path": path_str, + "resolved_path": str(search_path), "collection_name": collection_name, } @@ -331,16 +377,15 @@ async def _handle_update_index(arguments: dict, ctx: MCPContext) -> list[TextCon start_time = time.time() _debug(f"update_index called with path: {path_str}") - # Validate path before any indexing operation - validation = validate_indexable_path(path_str) - if not validation.valid: - _debug(f"Path validation failed: {validation.error_message}") + resolution = _validate_mcp_directory_path(path_str, ctx) + if not resolution.valid: + _debug(f"Path validation failed: {resolution.error_message}") return [TextContent( type="text", - text=f"Error: {validation.error_message} (path: {path_str})" + text=f"Error: {resolution.error_message} (path: {path_str})" )] - path = Path(path_str) + path = resolution.resolved_path _debug(f"Resolved path: {path.resolve()}") indexing_service = ctx.indexing_service @@ -389,6 +434,8 @@ async def _handle_update_index(arguments: dict, ctx: MCPContext) -> list[TextCon response = { "status": "success", + "requested_path": path_str, + "updated_path": str(path), "new_files": result.new_files, "modified_files": result.modified_files, "deleted_files": result.deleted_files, diff --git a/tests/property/test_file_scanner_properties.py b/tests/property/test_file_scanner_properties.py index b2addd9..f0bffab 100644 --- a/tests/property/test_file_scanner_properties.py +++ b/tests/property/test_file_scanner_properties.py @@ -204,7 +204,7 @@ def ignore_pattern_strategy(draw): @given(data=ignore_pattern_strategy()) -@settings(max_examples=100) +@settings(max_examples=100, deadline=None) def test_ignore_pattern_exclusion(data): """ **Feature: codebase-semantic-search, Property 3: Ignore Pattern Exclusion** diff --git a/tests/property/test_mcp_context_properties.py b/tests/property/test_mcp_context_properties.py index ac85254..fe623a0 100644 --- a/tests/property/test_mcp_context_properties.py +++ b/tests/property/test_mcp_context_properties.py @@ -170,6 +170,8 @@ def test_mcp_context_dataclass_has_expected_fields(): "vector_store", "indexing_lock", "indexing_locks", + "workspace_root", + "path_mappings", "reranker", "embedding_client", } diff --git a/tests/property/test_mcp_path_security_errors.py b/tests/property/test_mcp_path_security_errors.py index a70dd90..15d8393 100644 --- a/tests/property/test_mcp_path_security_errors.py +++ b/tests/property/test_mcp_path_security_errors.py @@ -71,15 +71,20 @@ def test_nonexistent_path_error_includes_path(self, dirname: str): nonexistent_path = f"/nonexistent_test_dir_{dirname}/subdir" ctx = _create_mock_context() + def _assert_missing_path_error(error_text: str) -> None: + assert "Error" in error_text and nonexistent_path in error_text + assert ( + "does not exist" in error_text + or "not accessible inside this runtime" in error_text + ) + result = run_async(_handle_index_codebase({"path": nonexistent_path}, ctx)) assert len(result) == 1 - error_text = result[0].text - assert "Error" in error_text and nonexistent_path in error_text and "does not exist" in error_text + _assert_missing_path_error(result[0].text) result = run_async(_handle_update_index({"path": nonexistent_path}, ctx)) assert len(result) == 1 - error_text = result[0].text - assert "Error" in error_text and nonexistent_path in error_text and "does not exist" in error_text + _assert_missing_path_error(result[0].text) @settings(max_examples=100, deadline=None) @given( diff --git a/tests/unit/test_qdrant_launcher.py b/tests/unit/test_qdrant_launcher.py new file mode 100644 index 0000000..77c2d20 --- /dev/null +++ b/tests/unit/test_qdrant_launcher.py @@ -0,0 +1,17 @@ +from unittest.mock import patch + +from aci.core.qdrant_launcher import ensure_qdrant_running + + +def test_ensure_qdrant_running_skips_nested_docker_inside_container(): + with patch("aci.core.qdrant_launcher._is_port_open", return_value=False), patch( + "aci.core.qdrant_launcher.os.environ", + {"container": "docker"}, + ), patch("aci.core.qdrant_launcher.subprocess.run") as mock_run, patch( + "aci.core.qdrant_launcher.logger.warning" + ) as mock_warning: + ensure_qdrant_running(host="localhost", port=6333) + + mock_run.assert_not_called() + mock_warning.assert_called_once() + assert "inside container" in mock_warning.call_args.args[0] diff --git a/tests/unit/test_runtime_path_resolution.py b/tests/unit/test_runtime_path_resolution.py new file mode 100644 index 0000000..2749f0b --- /dev/null +++ b/tests/unit/test_runtime_path_resolution.py @@ -0,0 +1,109 @@ +import asyncio +import json +from pathlib import Path +from unittest.mock import AsyncMock, MagicMock + +from aci.core.config import ACIConfig +from aci.core.path_utils import parse_runtime_path_mappings, resolve_runtime_path +from aci.mcp.context import MCPContext + + +def test_parse_runtime_path_mappings_supports_multiple_entries(): + mappings = parse_runtime_path_mappings(r"D:\=/host/d;/Users/alice=/host/users/alice") + + assert len(mappings) == 2 + assert mappings[0].source_prefix == "D:\\" + assert mappings[0].target_prefix == Path("/host/d") + assert mappings[1].source_prefix == "/Users/alice" + assert mappings[1].target_prefix == Path("/host/users/alice") + + +def test_resolve_runtime_path_maps_windows_host_path_into_container(tmp_path: Path): + mounted_repo = tmp_path / "mounted-repo" + mounted_repo.mkdir() + (mounted_repo / "src").mkdir() + + resolution = resolve_runtime_path( + r"D:\projects\demo\src", + path_mappings=parse_runtime_path_mappings( + f"D:\\projects\\demo={mounted_repo.as_posix()}" + ), + ) + + assert resolution.valid is True + assert resolution.mapped is True + assert resolution.resolved_path == (mounted_repo / "src").resolve() + + +def test_resolve_runtime_path_maps_posix_host_path_into_container(tmp_path: Path): + mounted_repo = tmp_path / "mounted-repo" + mounted_repo.mkdir() + (mounted_repo / "src").mkdir() + + resolution = resolve_runtime_path( + "/Users/alice/demo/src", + path_mappings=parse_runtime_path_mappings( + f"/Users/alice/demo={mounted_repo.as_posix()}" + ), + ) + + assert resolution.valid is True + assert resolution.mapped is True + assert resolution.resolved_path == (mounted_repo / "src").resolve() + + +def test_resolve_runtime_path_uses_workspace_root_for_relative_path(tmp_path: Path): + workspace_root = tmp_path / "workspace" + target = workspace_root / "repo" + target.mkdir(parents=True) + + resolution = resolve_runtime_path("repo", workspace_root=workspace_root) + + assert resolution.valid is True + assert resolution.resolved_path == target.resolve() + + +def test_resolve_runtime_path_reports_missing_container_mapping_target(tmp_path: Path): + resolution = resolve_runtime_path( + r"D:\projects\missing", + path_mappings=parse_runtime_path_mappings( + f"D:\\projects={tmp_path.as_posix()}" + ), + ) + + assert resolution.valid is False + assert "not accessible inside this runtime" in resolution.error_message + assert "ACI_MCP_PATH_MAPPINGS" in resolution.error_message + + +def test_mcp_index_codebase_uses_resolved_runtime_path(tmp_path: Path): + from aci.mcp.handlers import _handle_index_codebase + + mounted_repo = tmp_path / "repo" + mounted_repo.mkdir() + + indexing_service = MagicMock() + indexing_service.index_directory = AsyncMock( + return_value=MagicMock(total_files=1, total_chunks=2, duration_seconds=0.1, failed_files=[]) + ) + + ctx = MCPContext( + config=ACIConfig(), + search_service=MagicMock(), + indexing_service=indexing_service, + metadata_store=MagicMock(db_path=tmp_path / "index.db"), + vector_store=MagicMock(), + indexing_lock=asyncio.Lock(), + workspace_root=None, + path_mappings=tuple( + parse_runtime_path_mappings(f"D:\\workspace={mounted_repo.as_posix()}") + ), + ) + + result = asyncio.run(_handle_index_codebase({"path": r"D:\workspace"}, ctx)) + + assert len(result) == 1 + indexing_service.index_directory.assert_awaited_once_with(mounted_repo.resolve(), max_workers=4) + payload = json.loads(result[0].text) + assert payload["requested_path"] == r"D:\workspace" + assert payload["indexed_path"] == str(mounted_repo.resolve()) From f8107596542864d66260fceae09a4ca5c1a85a5e Mon Sep 17 00:00:00 2001 From: ACI Bot Date: Fri, 6 Mar 2026 21:52:48 +0800 Subject: [PATCH 3/6] fix markdown violations in readme --- README.md | 43 +++++++++++++++++++++++++++++++++++++++ doc/README.zh-CN.md | 49 +++++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 90 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 1aaf422..9eb2ff4 100644 --- a/README.md +++ b/README.md @@ -197,6 +197,44 @@ ACI supports the Model Context Protocol (MCP), allowing LLMs to directly interac - "Search for authentication functions" - "Show me the index status" +### Docker Sidecar Delivery + +For agentic coding tools, the recommended deployment model is a local Docker sidecar: +- The code repository stays on the user's machine +- The MCP server runs in a local container +- Qdrant runs either as another local container or as a cloud endpoint +- The embedding API uses the user's own API key + +Build the image: + +```bash +docker build -t aci-mcp:latest . +``` + +If you want a local Qdrant container, start it separately: + +```bash +docker run -d --name aci-qdrant -p 6333:6333 qdrant/qdrant:latest +``` + +Then configure your MCP client to launch ACI through Docker. A complete template is available in `mcp-config.docker.example.json`. + +Important runtime rules: +- Mount the host source tree read-only into the container, for example `/workspace` +- Persist `/data` as a Docker volume so `.aci/index.db` survives container restarts +- Set `ACI_MCP_WORKSPACE_ROOT` for relative paths +- Set `ACI_MCP_PATH_MAPPINGS` when the MCP client sends host-native absolute paths such as `D:\repo` or `/Users/alice/repo` + +Example mapping values: + +```text +ACI_MCP_WORKSPACE_ROOT=/workspace +ACI_MCP_PATH_MAPPINGS=D:\repo=/workspace +ACI_MCP_PATH_MAPPINGS=/Users/alice/repo=/workspace +``` + +When path mappings are configured, MCP tools can accept the host path provided by the client and resolve it to the mounted container path automatically. + ### Available MCP Tools | Tool | Description | @@ -275,6 +313,8 @@ Key settings: | `ACI_VECTOR_STORE_API_KEY` | Qdrant API key (for Qdrant Cloud) | No | | `ACI_VECTOR_STORE_HOST` | Qdrant host | No (defaults to localhost) | | `ACI_VECTOR_STORE_PORT` | Qdrant port | No (defaults to 6333) | +| `ACI_MCP_WORKSPACE_ROOT` | Base directory for relative MCP paths inside the container/runtime | No | +| `ACI_MCP_PATH_MAPPINGS` | Host-to-container path prefix mappings for MCP, separated by `;` | No | | `ACI_SERVER_HOST` | HTTP server host | No (defaults to 0.0.0.0) | | `ACI_SERVER_PORT` | HTTP server port | No (defaults to 8000) | | `ACI_ENV` | Environment (development/production) | No | @@ -284,3 +324,6 @@ See `.env.example` for the full list of options. The CLI and HTTP server will attempt to auto-start a local Qdrant Docker container only when targeting a local endpoint (`localhost` / `127.0.0.1`). For cloud Qdrant (`ACI_VECTOR_STORE_URL`), it will not run Docker. + +When ACI itself is running inside a container, it will not attempt to launch nested Docker for Qdrant. +In that setup, run Qdrant as a separate local container or point `ACI_VECTOR_STORE_URL` to Qdrant Cloud. diff --git a/doc/README.zh-CN.md b/doc/README.zh-CN.md index d873977..8b5b93a 100644 --- a/doc/README.zh-CN.md +++ b/doc/README.zh-CN.md @@ -82,6 +82,7 @@ aci shell ``` 启动后会进入 REPL(Read-Eval-Print Loop),包含: + - 命令历史(方向键上下浏览) - 命令自动补全(Tab) - 跨会话持久化历史 @@ -190,13 +191,53 @@ ACI 支持 Model Context Protocol(MCP),使 LLM 可直接调用你的代码 } ``` -2. 确保工作目录存在 `.env` 且配置完整(参考 `../.env.example`) +1. 确保工作目录存在 `.env` 且配置完整(参考 `../.env.example`) -3. 可用自然语言与代码库交互,例如: +2. 可用自然语言与代码库交互,例如: - “索引当前目录” - “搜索认证相关函数” - “查看当前索引状态” +### 以 Docker Sidecar 交付 MCP + +对于 Agentic coding tools,推荐的交付模型是本地 Docker sidecar: + +- 代码仓库保留在用户机器上 +- MCP server 运行在本地容器里 +- Qdrant 可以是另一个本地容器,也可以是云端地址 +- Embedding API 使用用户自己的 key + +构建镜像: + +```bash +docker build -t aci-mcp:latest . +``` + +如果使用本地 Qdrant 容器,单独启动它: + +```bash +docker run -d --name aci-qdrant -p 6333:6333 qdrant/qdrant:latest +``` + +然后让 MCP 客户端通过 Docker 拉起 ACI。完整模板见 `mcp-config.docker.example.json`。 + +运行时约定: + +- 把宿主机源码目录以只读方式挂载进容器,例如 `/workspace` +- 把 `/data` 挂成 Docker volume,这样 `.aci/index.db` 不会随容器重建丢失 +- 相对路径场景设置 `ACI_MCP_WORKSPACE_ROOT` +- 如果 MCP 客户端传的是宿主机绝对路径,例如 `D:\repo` 或 `/Users/alice/repo`,设置 `ACI_MCP_PATH_MAPPINGS` + +示例: + +```text +ACI_MCP_WORKSPACE_ROOT=/workspace +ACI_MCP_PATH_MAPPINGS=D:\repo=/workspace +ACI_MCP_PATH_MAPPINGS=/Users/alice/repo=/workspace +``` + +配置后,MCP 工具可以接受客户端传来的宿主机路径,并自动解析到容器内的挂载路径。 + ### MCP 可用工具 | 工具 | 说明 | @@ -277,6 +318,8 @@ cp .env.example .env | `ACI_VECTOR_STORE_API_KEY` | Qdrant API Key(Qdrant Cloud) | 否 | | `ACI_VECTOR_STORE_HOST` | Qdrant 主机地址 | 否(默认 localhost) | | `ACI_VECTOR_STORE_PORT` | Qdrant 端口 | 否(默认 6333) | +| `ACI_MCP_WORKSPACE_ROOT` | MCP 在容器/运行时中解析相对路径时使用的基础目录 | 否 | +| `ACI_MCP_PATH_MAPPINGS` | MCP 使用的宿主路径前缀到容器路径前缀映射,使用 `;` 分隔 | 否 | | `ACI_SERVER_HOST` | HTTP 服务主机地址 | 否(默认 0.0.0.0) | | `ACI_SERVER_PORT` | HTTP 服务端口 | 否(默认 8000) | | `ACI_ENV` | 运行环境(development/production) | 否 | @@ -284,3 +327,5 @@ cp .env.example .env 完整配置请查看 `../.env.example`。 CLI 和 HTTP 服务仅在目标为本地端点(`localhost` / `127.0.0.1`)时尝试自动启动本地 Qdrant Docker 容器。若使用云端 Qdrant(`ACI_VECTOR_STORE_URL`),不会启动 Docker。 + +当 ACI 自身运行在容器内时,不会再尝试嵌套启动 Docker 来拉起 Qdrant。此时请把 Qdrant 作为独立本地容器运行,或直接配置 `ACI_VECTOR_STORE_URL` 指向 Qdrant Cloud。 From 06b9ed16b763b37b75e1ca5a596d9085b8c2205d Mon Sep 17 00:00:00 2001 From: ACI Bot Date: Fri, 6 Mar 2026 21:53:33 +0800 Subject: [PATCH 4/6] fix markdown violations in readme --- README.md | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 9eb2ff4..127db8c 100644 --- a/README.md +++ b/README.md @@ -83,6 +83,7 @@ aci shell ``` This launches an interactive REPL (Read-Eval-Print Loop) with: + - Command history (up/down arrows to navigate) - Tab completion for commands - Persistent history across sessions @@ -142,6 +143,7 @@ Search queries support inline modifiers to filter results: | `exclude:` | Alias for `-path:` | `exclude:fixtures` | Multiple exclusions can be combined: + ```bash aci search "database query -path:tests -path:fixtures" ``` @@ -190,9 +192,9 @@ ACI supports the Model Context Protocol (MCP), allowing LLMs to directly interac } ``` -2. Ensure `.env` exists in the working directory with required settings (see `.env.example`) +1. Ensure `.env` exists in the working directory with required settings (see `.env.example`) -3. Use natural language to interact with your codebase: +2. Use natural language to interact with your codebase: - "Index the current directory" - "Search for authentication functions" - "Show me the index status" @@ -200,6 +202,7 @@ ACI supports the Model Context Protocol (MCP), allowing LLMs to directly interac ### Docker Sidecar Delivery For agentic coding tools, the recommended deployment model is a local Docker sidecar: + - The code repository stays on the user's machine - The MCP server runs in a local container - Qdrant runs either as another local container or as a cloud endpoint @@ -220,6 +223,7 @@ docker run -d --name aci-qdrant -p 6333:6333 qdrant/qdrant:latest Then configure your MCP client to launch ACI through Docker. A complete template is available in `mcp-config.docker.example.json`. Important runtime rules: + - Mount the host source tree read-only into the container, for example `/workspace` - Persist `/data` as a Docker volume so `.aci/index.db` survives container restarts - Set `ACI_MCP_WORKSPACE_ROOT` for relative paths @@ -273,6 +277,7 @@ REINDEX=1 uv run python scripts/measure_mcp_search.py ### Debug Mode Set `ACI_ENV=development` in `.env` to enable debug logging: + ``` ACI_ENV=development ``` @@ -304,6 +309,7 @@ cp .env.example .env ``` Key settings: + | Variable | Description | Required | |----------|-------------|----------| | `ACI_EMBEDDING_API_KEY` | API key for embedding service | Yes | From a5092803cf47452075d887b6bd4311836e54b7c7 Mon Sep 17 00:00:00 2001 From: ACI Bot Date: Fri, 6 Mar 2026 21:54:14 +0800 Subject: [PATCH 5/6] update pyproject toml --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 9a59837..b49b6c8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -22,7 +22,7 @@ classifiers = [ "Topic :: Utilities", ] -# 运行时核心依赖(字母顺序) +# runtime dependencies dependencies = [ "fastapi>=0.111.0", "httpx>=0.25.0", From 8215245d089a64edf911aadbc47f3dfe00749dcc Mon Sep 17 00:00:00 2001 From: ACI Bot Date: Fri, 6 Mar 2026 21:58:03 +0800 Subject: [PATCH 6/6] add: docker files --- .dockerignore | 19 +++++++++++++++++ Dockerfile | 16 ++++++++++++++ README.md | 2 +- mcp-config.docker.example.json | 39 ++++++++++++++++++++++++++++++++++ 4 files changed, 75 insertions(+), 1 deletion(-) create mode 100644 .dockerignore create mode 100644 Dockerfile create mode 100644 mcp-config.docker.example.json diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..175e409 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,19 @@ +.git +.github +.hypothesis +.mypy_cache +.pytest_cache +.ruff_cache +.venv +.aci +.compare +__pycache__ +dist +build +tests +doc +*.pyc +*.pyo +*.pyd +*.log +uv.lock \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..24d3615 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,16 @@ +FROM python:3.12-slim + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 + +WORKDIR /build + +COPY pyproject.toml README.md ./ +COPY src ./src + +RUN pip install --no-cache-dir uv \ + && uv pip install --system . + +WORKDIR /data + +ENTRYPOINT ["aci-mcp"] \ No newline at end of file diff --git a/README.md b/README.md index 127db8c..de42311 100644 --- a/README.md +++ b/README.md @@ -103,7 +103,7 @@ This launches an interactive REPL (Read-Eval-Print Loop) with: ### Example Session -``` +```text $ aci shell _ ____ ___ ____ _ _ _ diff --git a/mcp-config.docker.example.json b/mcp-config.docker.example.json new file mode 100644 index 0000000..63d2f46 --- /dev/null +++ b/mcp-config.docker.example.json @@ -0,0 +1,39 @@ +{ + "mcpServers": { + "aci": { + "command": "docker", + "args": [ + "run", + "-i", + "--rm", + "-v", + "HOST_SOURCE_PATH:/workspace:ro", + "-v", + "aci-mcp-data:/data", + "-e", + "ACI_MCP_WORKSPACE_ROOT=/workspace", + "-e", + "ACI_MCP_PATH_MAPPINGS=HOST_SOURCE_PATH=/workspace", + "-e", + "ACI_EMBEDDING_API_URL", + "-e", + "ACI_EMBEDDING_API_KEY", + "-e", + "ACI_EMBEDDING_MODEL", + "-e", + "ACI_EMBEDDING_DIMENSION", + "-e", + "ACI_VECTOR_STORE_URL=http://host.docker.internal:6333", + "-e", + "ACI_VECTOR_STORE_VECTOR_SIZE=1024", + "aci-mcp:latest" + ], + "env": { + "ACI_EMBEDDING_API_URL": "https://api.openai.com/v1/embeddings", + "ACI_EMBEDDING_API_KEY": "your_api_key_here", + "ACI_EMBEDDING_MODEL": "text-embedding-3-small", + "ACI_EMBEDDING_DIMENSION": "1024" + } + } + } +} \ No newline at end of file