diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8fbb426..be77014 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -29,12 +29,15 @@ jobs: - name: Install dependencies run: | python -m pip install --upgrade pip - pip install -e ".[test]" - pip install jsonschema + python -m pip install "uv~=0.11.0" + uv sync --locked --extra test + + - name: Audit locked dependencies + run: uv audit - name: Run tests run: | - python -m pytest src/tests/ -v --cov=src --cov-report=term-missing --cov-report=xml --junitxml=junit/test-results.xml + uv run python -m pytest src/tests/ -v --cov=src --cov-report=term-missing --cov-report=xml --junitxml=junit/test-results.xml - name: Upload test results uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 diff --git a/CLAUDE.md b/CLAUDE.md index 0c1cc7b..f4b561f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -95,7 +95,7 @@ This is a Model Context Protocol (MCP) server that provides AI clients with acce ### Core Components - **`codealive_mcp_server.py`**: Main entry point — bootstraps logging, tracing, registers tools and middleware -- **Eight tools**: `get_data_sources`, `semantic_search`, `grep_search`, `fetch_artifacts`, `get_artifact_relationships`, `chat`, `codebase_search`, `codebase_consultant` +- **Eleven tools**: `get_data_sources`, `semantic_search`, `grep_search`, `get_repository_ontology`, `get_file_tree`, `read_file`, `fetch_artifacts`, `get_artifact_relationships`, `get_artifact_query_schema`, `query_artifact_metadata`, `chat` - **`core/client.py`**: `CodeAliveContext` dataclass + `codealive_lifespan` (httpx.AsyncClient lifecycle, `_server_ready` flag) - **`core/logging.py`**: loguru structured JSON logging + PII masking + OTel context injection - **`core/observability.py`**: OpenTelemetry TracerProvider setup with OTLP export @@ -106,7 +106,7 @@ This is a Model Context Protocol (MCP) server that provides AI clients with acce 1. **FastMCP Framework**: Uses FastMCP 3.x with lifespan context, middleware hooks, and built-in `Client` for testing 2. **HTTP Auth via `get_http_headers`**: FastMCP 3.x strips the `authorization` header by default (to prevent accidental credential forwarding to downstream services). Our `get_api_key_from_context()` in `core/client.py` must use `get_http_headers(include={"authorization"})` to read Bearer tokens from HTTP/streamable-http clients. **Do not remove the `include=` parameter** — without it, all HTTP-transport clients (LibreChat, n8n, etc.) will fail with a misleading STDIO-mode error. 3. **HTTP Client Management**: Single persistent `httpx.AsyncClient` with connection pooling, created in lifespan -3. **Streaming Support**: `chat` and the deprecated `codebase_consultant` alias use SSE streaming (`response.aiter_lines()`) for chat completions +3. **Tool API v3 Backend Contract**: every MCP tool delegates to `POST /api/tools/{name}` and requests `output_format=agentic` 4. **Environment Configuration**: Supports both .env files and command-line arguments with precedence 5. **Error Handling**: Centralized in `utils/errors.py` — all tools use `handle_api_error()` with `method=` prefix 6. **N8N Middleware**: Strips extra parameters (sessionId, action, chatInput, toolCallId) from n8n tool calls before validation @@ -158,7 +158,7 @@ This project uses **loguru** for structured JSON logging. All logs go to **stder 2. **All logs go to stderr.** The stdio MCP transport uses stdout for protocol messages. Any stray `print()` or stdout write will corrupt the MCP protocol and break the client. If you add a new log sink, it must target `sys.stderr`. -3. **Never call `response.text` without a debug guard.** `log_api_response()` is protected by `_is_debug_enabled()` because reading `response.text` consumes the response body. The `chat` tool and deprecated `codebase_consultant` alias stream SSE via `response.aiter_lines()` — calling `.text` first would silently consume the stream and produce empty results. If you add new response logging, always check `_is_debug_enabled()` first: +3. **Never call `response.text` without a debug guard.** `log_api_response()` is protected by `_is_debug_enabled()` because reading `response.text` consumes the response body. If you add new response logging, always check `_is_debug_enabled()` first: ```python if not _is_debug_enabled(): return # Do NOT touch response body at INFO level @@ -264,8 +264,10 @@ Tools that return **structured metadata** (identifiers, match counts, line numbers, relationship groups, data source listings) return a `dict` (or list of dicts). FastMCP serializes it automatically via `pydantic_core.to_json`, which preserves Unicode — no manual `json.dumps()` needed. Examples: -`semantic_search`, `grep_search`, `codebase_search`, `get_data_sources`, -`get_artifact_relationships`. +`semantic_search`, `grep_search`, `get_data_sources`, +`get_repository_ontology`, `get_file_tree`, `read_file`, +`get_artifact_relationships`, `get_artifact_query_schema`, and +`query_artifact_metadata`. **Never call `json.dumps(...)` from a tool's return path.** Python's `json.dumps` defaults to `ensure_ascii=True` and escapes Cyrillic/CJK/etc. to `\uXXXX`. @@ -289,7 +291,7 @@ description alone — descriptions are not always re-read mid-conversation, but the response is always in front of the model when it decides what to do next. Examples in this repo: -- `codebase_search` returns a `hint` field telling the agent that `description` +- `semantic_search` and `grep_search` return a `hint` field telling the agent that `description` is a triage pointer only and that real understanding must come from `fetch_artifacts(identifier)` or a local `Read(path)`. Implementation: `_SEARCH_HINT` in `src/utils/response_transformer.py`. @@ -352,7 +354,7 @@ Key points: - Custom lifespan yields a real `CodeAliveContext` with a mock-backed httpx client - `monkeypatch.setenv("CODEALIVE_API_KEY", ...)` for `get_api_key_from_context` fallback - Use `raise_on_error=False` when testing error paths, then assert on `result.content[0].text` -- For SSE streaming (`chat` / `codebase_consultant`), return `httpx.Response(200, text=sse_body)` — `aiter_lines()` works on buffered responses +- For chat-style buffered responses, return `httpx.Response(200, json=payload)` and assert against the Tool API v3 envelope content ### Unit Test Patterns diff --git a/Makefile b/Makefile index 6982d61..bde81f6 100644 --- a/Makefile +++ b/Makefile @@ -1,23 +1,26 @@ -.PHONY: help install test smoke-test unit-test clean run dev +.PHONY: help install audit test smoke-test unit-test integration-test clean run dev help: ## Show this help message @echo "Available commands:" @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | awk 'BEGIN {FS = ":.*?## "}; {printf " \033[36m%-15s\033[0m %s\n", $$1, $$2}' install: ## Install dependencies - uv pip install -e ".[test]" + uv sync --locked --extra test + +audit: ## Check the locked environment for known vulnerabilities + uv audit smoke-test: ## Run smoke tests (quick sanity check) @echo "Running smoke tests..." - python smoke_test.py + uv run python smoke_test.py unit-test: ## Run unit tests with pytest @echo "Running unit tests..." - pytest src/tests/ -v + uv run pytest src/tests/ -v -integration-test: ## Run integration tests against live backend (requires CODEALIVE_API_KEY) +integration-test: ## Run live smoke tests (requires CODEALIVE_API_KEY and CODEALIVE_TEST_DATA_SOURCE) @echo "Running integration tests..." - python integration_test.py + uv run python integration_test.py test: unit-test smoke-test ## Run all tests (unit + smoke) @@ -32,7 +35,7 @@ clean: ## Clean up cache and temp files rm -rf htmlcov run: ## Run the MCP server with stdio transport - python src/codealive_mcp_server.py + uv run python src/codealive_mcp_server.py dev: ## Run the MCP server in debug mode - python src/codealive_mcp_server.py --debug + uv run python src/codealive_mcp_server.py --debug diff --git a/README.md b/README.md index 304bf72..95e99f5 100644 --- a/README.md +++ b/README.md @@ -28,11 +28,14 @@ Once connected, you'll have access to these powerful tools: 1. **`get_data_sources`** - List your indexed repositories and workspaces 2. **`semantic_search`** - Canonical semantic search across indexed artifacts 3. **`grep_search`** - Exact literal or regex text search inside file content, plus literal file-name/path matching (returns files like `Form.xml` even when their content never mentions the name), with line-level previews for content matches -4. **`fetch_artifacts`** - Load the full source for relevant search hits (missing or inaccessible identifiers are reported back in a `` block, not silently dropped) -5. **`get_artifact_relationships`** - Expand call graph, inheritance, and reference relationships for one artifact -6. **`chat`** - Slower synthesized codebase Q&A, typically only after search -7. **`codebase_search`** - Deprecated legacy semantic search alias kept for backward compatibility -8. **`codebase_consultant`** - Deprecated alias for `chat` +4. **`get_repository_ontology`** - Get repository-level orientation for one selected repository +5. **`get_file_tree`** - Inspect a bounded file tree for one repository +6. **`read_file`** - Read a repository-relative file path, optionally with a line range +7. **`fetch_artifacts`** - Load the full source for relevant search hits (missing or inaccessible identifiers are reported back, not silently dropped) +8. **`get_artifact_relationships`** - Expand call graph, inheritance, and reference relationships for one artifact +9. **`get_artifact_query_schema`** - Inspect supported ArtifactQuery entities, fields, and examples +10. **`query_artifact_metadata`** - Run read-only metadata analytics across selected repositories +11. **`chat`** - Stateless, slower synthesized codebase Q&A; call only when explicitly requested ## 🎯 Usage Examples @@ -43,7 +46,7 @@ After setup, try these commands with your AI assistant: - *"Find the exact regex that matches JWT tokens"* → Uses `grep_search` - *"Explain how the payment flow works in this codebase"* → Usually starts with `semantic_search`/`grep_search`, then optionally uses `chat` -`semantic_search` and `grep_search` should be the default tools for most agents. `chat` is a slower synthesis fallback, can take up to 30 seconds, and is usually unnecessary when an agent can run a multi-step workflow with search, fetch, relationships, and local file reads. If your agent supports subagents, the highest-confidence path is to delegate a focused subagent that orchestrates `semantic_search` and `grep_search` first. +`semantic_search` and `grep_search` should be the default tools for most agents. `chat` is a slower stateless synthesis fallback that can take substantially longer than retrieval, and is usually unnecessary when an agent can run a multi-step workflow with ontology, search, fetch/read, relationships, ArtifactQuery, and local file reads. If your agent supports subagents, the highest-confidence path is to delegate a focused subagent that orchestrates `semantic_search` and `grep_search` first. ## 📚 Agent Skill @@ -840,10 +843,14 @@ See [JetBrains MCP Documentation](https://www.jetbrains.com/help/ai-assistant/mc - `get_data_sources` - List available repositories - `semantic_search` - Search code semantically - `grep_search` - Search by exact text or regex + - `get_repository_ontology` - Orient around one repository + - `get_file_tree` - Inspect repository files + - `read_file` - Read one repository-relative file + - `fetch_artifacts` - Fetch source for search result identifiers - `get_artifact_relationships` - Expand relationships for one artifact - - `chat` - Slower synthesized codebase Q&A, usually after search - - `codebase_search` - Legacy semantic search alias - - `codebase_consultant` - Deprecated alias for `chat` + - `get_artifact_query_schema` - Inspect metadata query schema + - `query_artifact_metadata` - Run metadata analytics + - `chat` - Stateless synthesized codebase Q&A, only when explicitly requested **Example Workflow:** ``` @@ -915,6 +922,20 @@ python src/codealive_mcp_server.py --transport http --host localhost --port 8000 curl http://localhost:8000/health ``` +HTTP transport validates `Host` and browser `Origin` headers. Loopback hosts +(`localhost`, `127.0.0.1`, `::1`) work without extra configuration. For a +shared hostname, configure an exact allowlist: + +```bash +export CODEALIVE_MCP_ALLOWED_HOSTS="mcp.codealive.yourcompany.com" +# Only for browser callers; ordinary MCP clients do not send Origin. +export CODEALIVE_MCP_ALLOWED_ORIGINS="https://mcp.codealive.yourcompany.com" +python src/codealive_mcp_server.py --transport http --host 0.0.0.0 --port 8000 +``` + +The equivalent repeatable CLI options are `--allowed-host` and +`--allowed-origin`. Do not use `*` for an Internet-facing server. + ### Testing Your Local Installation After making changes, quickly verify everything works: @@ -1016,6 +1037,7 @@ curl http://localhost:8000/health 2. **For Self-Hosted CodeAlive:** - Set `CODEALIVE_BASE_URL` to your CodeAlive instance URL (e.g., `https://codealive.yourcompany.com`) + - Set `CODEALIVE_MCP_ALLOWED_HOSTS` to the exact hostname clients use for this MCP server - Clients must provide their API key via `Authorization: Bearer YOUR_KEY` header See `docker-compose.example.yml` for the complete configuration template. diff --git a/docker-compose.example.yml b/docker-compose.example.yml index caca56b..7869750 100644 --- a/docker-compose.example.yml +++ b/docker-compose.example.yml @@ -42,6 +42,14 @@ services: # ========================================== # OPTIONAL SETTINGS # ========================================== + # Required when clients use a hostname other than localhost/127.0.0.1. + # Use a comma-separated exact allowlist; do not use "*" on public servers. + # CODEALIVE_MCP_ALLOWED_HOSTS: "mcp.codealive.yourcompany.com" + + # Usually unnecessary for non-browser MCP clients. Set exact origins only + # when browser JavaScript calls the MCP endpoint. + # CODEALIVE_MCP_ALLOWED_ORIGINS: "https://mcp.codealive.yourcompany.com" + # Enable debug mode (verbose logging) # DEBUG_MODE: "false" diff --git a/integration_test.py b/integration_test.py index b2a5962..cbafc9c 100644 --- a/integration_test.py +++ b/integration_test.py @@ -1,664 +1,256 @@ #!/usr/bin/env python3 -""" -Integration tests for CodeAlive MCP Server against a live backend. - -Unlike e2e tests (which use httpx.MockTransport), these tests hit the real -CodeAlive API and verify that the backend correctly handles every filtering -parameter. This catches backend regressions that mock-based tests cannot. +"""Live MCP smoke tests against a real CodeAlive Backend Tool API v3. -Requires: - CODEALIVE_API_KEY — a valid API key with at least one indexed repository. +The MCP surface deliberately returns backend-rendered agentic text, so this +script validates protocol error state and rendered content instead of parsing +tool results as JSON. -Usage: - CODEALIVE_API_KEY=ca_... python integration_test.py - CODEALIVE_API_KEY=ca_... python integration_test.py --target codealive-app +Required environment: + CODEALIVE_API_KEY -The test automatically picks a CodeAlive-owned repository as the target, -or falls back to the first ready repo. Override with --target . +Examples: + python integration_test.py --target CodeAlive-AI/backend + python integration_test.py --target CodeAlive-AI/backend \ + --artifact-identifier 'CodeAlive-AI/backend::README.md' --include-chat """ +__test__ = False # Executable live smoke script, not a pytest module. + import argparse import asyncio -import json import os import sys from pathlib import Path -from typing import Any, Optional - -sys.path.insert(0, str(Path(__file__).parent / "src")) +from typing import Any from mcp import ClientSession, StdioServerParameters from mcp.client.stdio import stdio_client -# ── Helpers ────────────────────────────────────────────────────────────────── -C = { - "G": "\033[92m", "R": "\033[91m", "Y": "\033[93m", - "B": "\033[94m", "W": "\033[1m", "E": "\033[0m", +EXPECTED_TOOLS = { + "get_data_sources", + "semantic_search", + "grep_search", + "get_repository_ontology", + "get_file_tree", + "read_file", + "fetch_artifacts", + "get_artifact_relationships", + "get_artifact_query_schema", + "query_artifact_metadata", + "chat", } -_results: list[tuple[str, bool, str]] = [] - - -def ok(msg: str) -> None: - print(f" {C['G']}PASS{C['E']} {msg}") - - -def fail(msg: str) -> None: - print(f" {C['R']}FAIL{C['E']} {msg}") - - -def info(msg: str) -> None: - print(f" {C['Y']}{msg}{C['E']}") - - -def header(msg: str) -> None: - print(f"\n{C['W']}{C['B']}{'─' * 60}") - print(f" {msg}") - print(f"{'─' * 60}{C['E']}") - - -def record(name: str, passed: bool, detail: str = "") -> None: - _results.append((name, passed, detail)) - text = f"{name}" + (f" — {detail}" if detail else "") - ok(text) if passed else fail(text) - - -def parse(text: str) -> tuple[Optional[dict], Optional[str]]: - try: - return json.loads(text), None - except json.JSONDecodeError as exc: - return None, str(exc) - - -def _all_match(items: list[dict], field: str, substring: str) -> bool: - """True if every item contains *substring* in its *field* or identifier.""" - for item in items: - value = item.get(field, "") or item.get("identifier", "") - if substring.lower() not in value.lower(): - return False - return True - - -# ── Target introspection ──────────────────────────────────────────────────── - -async def _discover_target_content(s: ClientSession, target: str) -> dict: - """Probe the target repo to find an extension, a path prefix, and a grep - pattern that actually exist. This makes the tests work against *any* - indexed repository, not just codealive-app.""" - - r = await s.call_tool("semantic_search", { - "query": "main implementation", - "data_sources": [target], - "max_results": 30, - }) - data, _ = parse(r.content[0].text) - results = data.get("results", []) if data else [] - - # Collect extensions that appear in identifiers / paths - from collections import Counter - ext_counter: Counter[str] = Counter() - paths: list[str] = [] - for item in results: - ident = item.get("identifier", "") - # identifier looks like "repo::path/to/file.ext::chunk" - parts = ident.split("::") - if len(parts) >= 2: - fpath = parts[1] - paths.append(fpath) - dot = fpath.rfind(".") - if dot != -1: - ext_counter[fpath[dot:]] += 1 - - # Pick the most common extension (skip .md — too generic) - ext = None - for candidate, _ in ext_counter.most_common(): - if candidate != ".md": - ext = candidate - break - if ext is None and ext_counter: - ext = ext_counter.most_common(1)[0][0] - - # Pick a path prefix from files that have the chosen extension, - # so grep tests (which search for code patterns) also find matches - prefix = None - if paths and ext: - dir_counter: Counter[str] = Counter() - for p in paths: - if not p.endswith(ext): - continue - parts = p.split("/") - if len(parts) >= 2: - dir_counter[parts[0]] += 1 - if len(parts) >= 3: - dir_counter["/".join(parts[:2])] += 1 - for candidate, cnt in dir_counter.most_common(): - if 2 <= cnt <= 20: - prefix = candidate - break - if prefix is None and dir_counter: - prefix = dir_counter.most_common(1)[0][0] - # Fallback: any directory with multiple files - if prefix is None and paths: - dir_counter = Counter() - for p in paths: - parts = p.split("/") - if len(parts) >= 2: - dir_counter[parts[0]] += 1 - for candidate, cnt in dir_counter.most_common(): - if 2 <= cnt <= 20: - prefix = candidate - break - if prefix is None and dir_counter: - prefix = dir_counter.most_common(1)[0][0] - - info(f"Discovered: ext={ext}, path_prefix={prefix}") - return {"ext": ext, "path_prefix": prefix} - - -# ── Test groups ────────────────────────────────────────────────────────────── - -async def test_get_data_sources(s: ClientSession) -> None: - header("get_data_sources: alive_only parameter") - - r = await s.call_tool("get_data_sources", {"alive_only": True}) - ready, _ = parse(r.content[0].text) - ready_count = len(ready) if isinstance(ready, list) else 0 - - r = await s.call_tool("get_data_sources", {"alive_only": False}) - all_ds, _ = parse(r.content[0].text) - all_count = len(all_ds) if isinstance(all_ds, list) else 0 - - record("alive_only=True vs False", all_count >= ready_count, - f"ready={ready_count}, all={all_count}") - - if isinstance(all_ds, list): - non_ready = [d for d in all_ds if d.get("readiness") != "Ready"] - record("alive_only=False includes non-ready", True, - f"{len(non_ready)} non-ready" if non_ready else "all ready") - - -async def test_semantic_search_filtering(s: ClientSession, target: str, ctx: dict) -> None: - ext = ctx["ext"] - path_prefix = ctx["path_prefix"] - - header("semantic_search: max_results") - - for limit in (1, 5, 50): - r = await s.call_tool("semantic_search", { - "query": "repository processing", - "data_sources": [target], - "max_results": limit, - }) - data, _ = parse(r.content[0].text) - count = len(data.get("results", [])) if data else 0 - record(f"semantic max_results={limit}", count <= limit, - f"got {count} (limit {limit})") - - header("semantic_search: extensions filter") - - if ext: - r = await s.call_tool("semantic_search", { - "query": "implementation", - "data_sources": [target], - "extensions": [ext], - }) - data, _ = parse(r.content[0].text) - items = data.get("results", []) if data else [] - all_ext = _all_match(items, "identifier", ext) - record(f"semantic extensions=[{ext}]", - len(items) > 0 and all_ext, - f"{len(items)} results, all {ext}: {all_ext}") - else: - record("semantic extensions filter", False, "no extension discovered") - - header("semantic_search: paths filter") - - if path_prefix: - r = await s.call_tool("semantic_search", { - "query": "service", - "data_sources": [target], - "paths": [path_prefix], - }) - data, _ = parse(r.content[0].text) - items = data.get("results", []) if data else [] - all_in_path = _all_match(items, "identifier", path_prefix) - record(f"semantic paths=[{path_prefix}]", - len(items) > 0 and all_in_path, - f"{len(items)} results, all in {path_prefix}: {all_in_path}") - else: - record("semantic paths filter", False, "no path prefix discovered") - - header("semantic_search: combined filters") - - if ext and path_prefix: - r = await s.call_tool("semantic_search", { - "query": "processing", - "data_sources": [target], - "paths": [path_prefix], - "extensions": [ext], - "max_results": 3, - }) - data, _ = parse(r.content[0].text) - items = data.get("results", []) if data else [] - all_path = _all_match(items, "identifier", path_prefix) - all_ext = _all_match(items, "identifier", ext) - record("semantic combined: paths+ext+max", - len(items) <= 3 and all_path and all_ext, - f"{len(items)} results (max 3), in {path_prefix}: {all_path}, {ext}: {all_ext}") - else: - record("semantic combined: paths+ext+max", True, "skipped — no ext or path") - - -async def test_grep_search_filtering(s: ClientSession, target: str, ctx: dict) -> None: - ext = ctx["ext"] - path_prefix = ctx["path_prefix"] - - # Find a grep-friendly term that exists in the repo - grep_term = "import" if ext in (".py", ".ts", ".js") else "def" if ext == ".py" else "function" - # A regex pattern that matches in any code repo - regex_pattern = r"import\s+\w+" if ext in (".py", ".ts", ".js") else r"def\s+\w+" - - header("grep_search: max_results") - - r = await s.call_tool("grep_search", { - "query": grep_term, - "data_sources": [target], - "max_results": 3, - }) - data, _ = parse(r.content[0].text) - count_3 = len(data.get("results", [])) if data else 0 - record("grep max_results=3", count_3 <= 3, f"got {count_3}") - - r = await s.call_tool("grep_search", { - "query": grep_term, - "data_sources": [target], - "max_results": 100, - }) - data, _ = parse(r.content[0].text) - count_100 = len(data.get("results", [])) if data else 0 - record("grep max_results=100", count_100 >= count_3, - f"got {count_100} (was {count_3} with limit=3)") - - header("grep_search: extensions filter") - - if ext: - r = await s.call_tool("grep_search", { - "query": grep_term, - "data_sources": [target], - "extensions": [ext], - }) - data, _ = parse(r.content[0].text) - items = data.get("results", []) if data else [] - all_ext = _all_match(items, "path", ext) - record(f"grep extensions=[{ext}]", - len(items) > 0 and all_ext, - f"{len(items)} results, all {ext}: {all_ext}") - - # Search with a wrong extension should return fewer or zero results - wrong_ext = ".rs" if ext != ".rs" else ".go" - r = await s.call_tool("grep_search", { - "query": grep_term, - "data_sources": [target], - "extensions": [wrong_ext], - }) - data, _ = parse(r.content[0].text) - items = data.get("results", []) if data else [] - all_wrong = _all_match(items, "path", wrong_ext) - record(f"grep extensions=[{wrong_ext}]", all_wrong, - f"{len(items)} results, all {wrong_ext}: {all_wrong}") - else: - record("grep extensions filter", False, "no extension discovered") - - header("grep_search: paths filter") - - if path_prefix: - r = await s.call_tool("grep_search", { - "query": grep_term, - "data_sources": [target], - "paths": [path_prefix], - }) - data, _ = parse(r.content[0].text) - items = data.get("results", []) if data else [] - all_in_path = _all_match(items, "path", path_prefix) - record(f"grep paths=[{path_prefix}]", - len(items) > 0 and all_in_path, - f"{len(items)} results, all in {path_prefix}: {all_in_path}") - else: - record("grep paths filter", False, "no path prefix discovered") - - header("grep_search: regex") - - r = await s.call_tool("grep_search", { - "query": regex_pattern, - "data_sources": [target], - "regex": True, - }) - data, _ = parse(r.content[0].text) - regex_count = len(data.get("results", [])) if data else 0 - - r = await s.call_tool("grep_search", { - "query": regex_pattern, - "data_sources": [target], - "regex": False, - }) - data, _ = parse(r.content[0].text) - literal_count = len(data.get("results", [])) if data else 0 - record("grep regex=True vs False", - regex_count > literal_count, - f"regex={regex_count}, literal={literal_count}") - - header("grep_search: combined filters") - - if ext and path_prefix: - r = await s.call_tool("grep_search", { - "query": regex_pattern, - "data_sources": [target], - "paths": [path_prefix], - "extensions": [ext], - "regex": True, - "max_results": 5, - }) - data, _ = parse(r.content[0].text) - items = data.get("results", []) if data else [] - record("grep combined: all filters", - len(items) <= 5, - f"{len(items)} results (max 5)") - else: - record("grep combined: all filters", True, "skipped — no ext or path") - - -async def test_relationships_profiles(s: ClientSession, target: str, ctx: dict) -> None: - header("get_artifact_relationships: profiles") - - # Find any symbol with a class-level identifier (repo::path::symbol) - r = await s.call_tool("semantic_search", { - "query": "main service class", - "data_sources": [target], - "max_results": 10, - }) - data, _ = parse(r.content[0].text) - symbol_id = None - if data: - for item in data.get("results", []): - ident = item.get("identifier", "") - if ident.count("::") >= 2: - symbol_id = ident - break - - if not symbol_id: - record("profile tests", False, "no symbol found") - return - - info(f"symbol: {symbol_id}") - - for profile in ("callsOnly", "inheritanceOnly", "allRelevant", "referencesOnly"): - r = await s.call_tool("get_artifact_relationships", { - "identifier": symbol_id, - "profile": profile, - }) - data, _ = parse(r.content[0].text) - if data and data.get("found"): - types = [rel.get("type") for rel in data.get("relationships", [])] - total = sum(rel.get("totalCount", 0) for rel in data.get("relationships", [])) - record(f"profile={profile}", True, f"types={types}, total={total}") - elif data and not data.get("found"): - record(f"profile={profile}", True, "found=False (valid)") - else: - record(f"profile={profile}", False, str(data)[:100]) - - # Invalid profile - r = await s.call_tool("get_artifact_relationships", { - "identifier": symbol_id, - "profile": "invalidProfile", - }) - is_rejected = r.isError or "error" in r.content[0].text.lower() - record("invalid profile rejected", - is_rejected, - "correctly rejected") - - # max_count_per_type - header("get_artifact_relationships: max_count_per_type") - - r = await s.call_tool("get_artifact_relationships", { - "identifier": symbol_id, - "profile": "callsOnly", - "max_count_per_type": 2, - }) - data, _ = parse(r.content[0].text) - if data and data.get("found"): - for rel in data.get("relationships", []): - returned = rel.get("returnedCount", 0) - record(f"max_count_per_type=2 ({rel['type']})", - returned <= 2, - f"returned={returned}") - - -async def test_fetch_artifacts_edges(s: ClientSession, target: str) -> None: - header("fetch_artifacts: edge cases") - - r = await s.call_tool("fetch_artifacts", {"identifiers": []}) - record("empty identifiers rejected", - "required" in r.content[0].text.lower(), - "correctly rejected") - - r = await s.call_tool("fetch_artifacts", { - "identifiers": [f"fake::id::{i}" for i in range(21)] - }) - record(">20 identifiers rejected", - "20" in r.content[0].text or "maximum" in r.content[0].text.lower(), - "correctly rejected") - - r = await s.call_tool("fetch_artifacts", { - "identifiers": ["nonexistent::file.py::NoClass"] - }) - record("nonexistent identifier", - not r.isError, - f"len={len(r.content[0].text)}") - - # Multiple valid - r = await s.call_tool("semantic_search", { - "query": "service", - "data_sources": [target], - "max_results": 3, - }) - data, _ = parse(r.content[0].text) - ids = [i["identifier"] for i in (data.get("results", []) if data else []) - if i.get("identifier")][:3] - if ids: - r = await s.call_tool("fetch_artifacts", {"identifiers": ids}) - record(f"fetch {len(ids)} artifacts", - " None: - header("Validation edge cases") - - for val, label in [(0, "0"), (501, "501")]: - r = await s.call_tool("semantic_search", { - "query": "test", "data_sources": [target], "max_results": val, - }) - record(f"max_results={label} rejected", - r.isError or "error" in r.content[0].text.lower(), - "correctly rejected") - - r = await s.call_tool("semantic_search", { - "query": "test", "data_sources": [target], "max_results": 500, - }) - data, _ = parse(r.content[0].text) - has_results = data is not None and "results" in data - record("max_results=500 accepted", - has_results, - "accepted" if has_results else f"unexpected: {r.content[0].text[:100]}") - - r = await s.call_tool("semantic_search", { - "query": "test", "data_sources": [], - }) - # Empty data_sources causes a 404 from the backend — the MCP server - # surfaces this as isError=True with a helpful hint to call get_data_sources. - record("empty data_sources=[] returns error with hint", - r.isError and "get_data_sources" in r.content[0].text, - "correctly returns actionable error") - - r = await s.call_tool("semantic_search", { - "query": "service", "data_sources": target, - }) - data, _ = parse(r.content[0].text) - record("data_sources as string", - data is not None and "results" in data, - f"{len(data.get('results', []))} results" if data and "results" in data else "fail") - - -async def test_agent_workflow(s: ClientSession, target: str) -> None: - header("Full agent workflow: search -> fetch -> relationships -> chat") - - # 1. semantic_search - r = await s.call_tool("semantic_search", { - "query": "repository indexing pipeline", - "data_sources": [target], - "max_results": 5, - }) - data, _ = parse(r.content[0].text) - results = data.get("results", []) if data else [] - record("workflow: semantic_search", len(results) > 0, f"{len(results)} results") - - artifact_id = None - for item in results: - ident = item.get("identifier", "") - if ident.count("::") >= 2: - artifact_id = ident - break - - # 2. fetch_artifacts - if artifact_id: - r = await s.call_tool("fetch_artifacts", {"identifiers": [artifact_id]}) - record("workflow: fetch_artifacts", - " 100 and not r.isError, - f"len={len(text)}") - - # 5. deprecated aliases - r = await s.call_tool("codebase_consultant", { - "question": "What testing patterns are used?", - "data_sources": [target], - }) - record("workflow: codebase_consultant (deprecated)", - len(r.content[0].text) > 50 and not r.isError, - f"len={len(r.content[0].text)}") - - r = await s.call_tool("codebase_search", { - "query": "error handling", - "data_sources": [target], - }) - record("workflow: codebase_search (deprecated)", - not r.isError, - f"len={len(r.content[0].text)}") - - -# ── Main ───────────────────────────────────────────────────────────────────── - -async def main(target_override: Optional[str] = None) -> int: + +class SmokeResults: + def __init__(self) -> None: + self._results: list[tuple[str, bool, str]] = [] + + def record(self, name: str, passed: bool, detail: str = "") -> None: + self._results.append((name, passed, detail)) + status = "PASS" if passed else "FAIL" + suffix = f" — {detail}" if detail else "" + print(f"{status:4} {name}{suffix}") + + def skip(self, name: str, detail: str) -> None: + print(f"SKIP {name} — {detail}") + + def exit_code(self) -> int: + failed = [result for result in self._results if not result[1]] + print(f"\n{len(self._results) - len(failed)} passed, {len(failed)} failed") + return 1 if failed else 0 + + +def rendered_text(result: Any) -> str: + if not result.content: + return "" + return getattr(result.content[0], "text", "") + + +async def expect_rendered_success( + session: ClientSession, + results: SmokeResults, + tool_name: str, + arguments: dict[str, Any], +) -> str: + result = await session.call_tool(tool_name, arguments) + text = rendered_text(result) + results.record( + tool_name, + not result.isError and bool(text.strip()), + f"isError={result.isError}, chars={len(text)}", + ) + return text + + +async def run_smoke( + target: str, + file_path: str, + grep_query: str, + artifact_identifier: str | None, + include_chat: bool, +) -> int: api_key = os.environ.get("CODEALIVE_API_KEY", "") if not api_key: - print(f"{C['R']}CODEALIVE_API_KEY not set{C['E']}") - return 1 + print("CODEALIVE_API_KEY is required", file=sys.stderr) + return 2 - server_script = str(Path(__file__).parent / "src" / "codealive_mcp_server.py") + results = SmokeResults() + server_script = Path(__file__).parent / "src" / "codealive_mcp_server.py" server_params = StdioServerParameters( - command=str(Path(__file__).parent / ".venv" / "bin" / "python"), - args=[server_script], + command=sys.executable, + args=[str(server_script)], env={**os.environ, "CODEALIVE_API_KEY": api_key}, ) async with stdio_client(server_params) as (read, write): - async with ClientSession(read, write) as s: - await s.initialize() - print(f"{C['G']}Server connected{C['E']}") - - # Resolve target data source - if target_override: - target = target_override - info(f"Using explicit target: {target}") + async with ClientSession(read, write) as session: + await session.initialize() + + listed_tools = {tool.name for tool in (await session.list_tools()).tools} + results.record( + "tools/list contract", + listed_tools == EXPECTED_TOOLS, + f"listed={len(listed_tools)}", + ) + + await expect_rendered_success( + session, + results, + "get_data_sources", + {"ready_only": True}, + ) + await expect_rendered_success( + session, + results, + "semantic_search", + { + "question": "How is application startup configured?", + "data_sources": [target], + "max_results": 3, + }, + ) + await expect_rendered_success( + session, + results, + "grep_search", + { + "query": grep_query, + "data_sources": [target], + "max_results": 3, + "regex": False, + }, + ) + await expect_rendered_success( + session, + results, + "get_repository_ontology", + {"data_source": target}, + ) + await expect_rendered_success( + session, + results, + "get_file_tree", + {"data_source": target, "max_depth": 1, "max_nodes": 30}, + ) + await expect_rendered_success( + session, + results, + "read_file", + {"data_source": target, "path": file_path, "start_line": 1, "end_line": 20}, + ) + + if artifact_identifier: + await expect_rendered_success( + session, + results, + "fetch_artifacts", + {"identifiers": [artifact_identifier], "data_source": target}, + ) + await expect_rendered_success( + session, + results, + "get_artifact_relationships", + { + "identifier": artifact_identifier, + "profile": "all_relevant", + "max_count_per_type": 10, + "data_source": target, + }, + ) + else: + results.skip( + "artifact tools", + "pass --artifact-identifier from a search result to exercise both tools", + ) + + await expect_rendered_success( + session, + results, + "get_artifact_query_schema", + {"entity": "files", "include_examples": False}, + ) + await expect_rendered_success( + session, + results, + "query_artifact_metadata", + { + "statement": "SELECT path FROM files LIMIT 1", + "data_sources": [target], + }, + ) + + if include_chat: + await expect_rendered_success( + session, + results, + "chat", + { + "question": "Summarize the repository startup flow. Prior context: none.", + "data_sources": [target], + }, + ) else: - r = await s.call_tool("get_data_sources", {}) - data_sources = json.loads(r.content[0].text) - target = None - for ds in data_sources if isinstance(data_sources, list) else []: - if ds.get("readiness") != "Ready": - continue - if "CodeAlive" in ds.get("fullName", "") and ds.get("type") == "Repository": - # Prefer the backend — it's the largest and most diverse - if "backend" in ds.get("fullName", "").lower(): - target = ds["name"] - break - if not target: - target = ds["name"] - - if not target and isinstance(data_sources, list) and data_sources: - target = data_sources[0]["name"] - - if not target: - print(f"{C['R']}No usable data source found{C['E']}") - return 1 - - info(f"Auto-selected target: {target}") - - # Discover what the target repo actually contains - ctx = await _discover_target_content(s, target) - - # Run test groups - await test_get_data_sources(s) - await test_semantic_search_filtering(s, target, ctx) - await test_grep_search_filtering(s, target, ctx) - await test_relationships_profiles(s, target, ctx) - await test_fetch_artifacts_edges(s, target) - await test_validation_edges(s, target) - await test_agent_workflow(s, target) - - # Summary - header("SUMMARY") - passed = sum(1 for _, p, _ in _results if p) - failed = sum(1 for _, p, _ in _results if not p) - - for name, p, detail in _results: - status = f"{C['G']}PASS{C['E']}" if p else f"{C['R']}FAIL{C['E']}" - print(f" {status} {name}") - if not p and detail: - print(f" {C['R']}{detail}{C['E']}") - - print(f"\n {C['W']}{passed} passed{C['E']}, ", end="") - print(f"{C['R']}{failed} failed{C['E']}" if failed else f"{C['G']}0 failed{C['E']}", end="") - print(f" / {passed + failed} total") - - return 1 if failed else 0 + results.skip("chat", "pass --include-chat to run the billable, potentially long call") + + repairable = await session.call_tool( + "read_file", + {"data_source": target, "path": "../outside-repository"}, + ) + repairable_text = rendered_text(repairable) + results.record( + "repairable error semantics", + repairable.isError and "" in repairable_text, + f"isError={repairable.isError}, chars={len(repairable_text)}", + ) + + return results.exit_code() + + +def main() -> int: + parser = argparse.ArgumentParser(description="Smoke-test MCP against a live Tool API v3 backend") + parser.add_argument( + "--target", + default=os.environ.get("CODEALIVE_TEST_DATA_SOURCE"), + required=os.environ.get("CODEALIVE_TEST_DATA_SOURCE") is None, + help="Repository name returned by get_data_sources", + ) + parser.add_argument("--file-path", default="README.md") + parser.add_argument("--grep-query", default="class") + parser.add_argument("--artifact-identifier") + parser.add_argument("--include-chat", action="store_true") + arguments = parser.parse_args() + + return asyncio.run( + run_smoke( + target=arguments.target, + file_path=arguments.file_path, + grep_query=arguments.grep_query, + artifact_identifier=arguments.artifact_identifier, + include_chat=arguments.include_chat, + ) + ) if __name__ == "__main__": - parser = argparse.ArgumentParser(description="Integration tests against live backend") - parser.add_argument("--target", help="Data source name to test against") - args = parser.parse_args() - - try: - sys.exit(asyncio.run(main(args.target))) - except KeyboardInterrupt: - print("\nInterrupted") - sys.exit(1) + raise SystemExit(main()) diff --git a/manifest.json b/manifest.json index 63afca3..ab29b02 100644 --- a/manifest.json +++ b/manifest.json @@ -2,7 +2,7 @@ "manifest_version": "0.4", "name": "codealive-mcp", "display_name": "CodeAlive", - "version": "2.0.4", + "version": "3.0.0", "description": "Semantic code search and codebase Q&A for Claude Desktop using your CodeAlive account or self-hosted deployment.", "long_description": "CodeAlive gives Claude Desktop access to semantic code search, artifact fetch, repository discovery, and architecture-aware codebase Q&A. This extension runs locally via MCP and supports both CodeAlive Cloud and self-hosted deployments.", "author": { @@ -53,10 +53,6 @@ "name": "get_data_sources", "description": "List indexed repositories and workspaces that are ready for search and chat." }, - { - "name": "codebase_search", - "description": "Deprecated legacy semantic search tool kept for backward compatibility." - }, { "name": "semantic_search", "description": "Default discovery tool — search by meaning to find code by concepts, behavior, or architecture." @@ -70,16 +66,32 @@ "description": "Synthesized codebase Q&A. Do NOT call unless the user explicitly names this tool (e.g. 'use chat'). 'Ask CodeAlive' means use search tools, not chat. Slow (up to 30 seconds)." }, { - "name": "fetch_artifacts", - "description": "Fetch full source for specific search results when you need the underlying code." + "name": "get_repository_ontology", + "description": "Get repository-level ontology and orientation for a single selected repository." + }, + { + "name": "get_file_tree", + "description": "List repository files and folders for a single selected repository." + }, + { + "name": "read_file", + "description": "Read a repository-relative file path, optionally bounded by line range." }, { - "name": "codebase_consultant", - "description": "Deprecated alias for chat kept for backward compatibility." + "name": "fetch_artifacts", + "description": "Fetch full source for specific search results when you need the underlying code." }, { "name": "get_artifact_relationships", "description": "Inspect relationships between artifacts returned by CodeAlive search." + }, + { + "name": "get_artifact_query_schema", + "description": "Return supported ArtifactQuery entities, fields, operators, and examples." + }, + { + "name": "query_artifact_metadata", + "description": "Run read-only ArtifactQuery metadata analytics across selected repositories." } ], "user_config": { diff --git a/pyproject.toml b/pyproject.toml index 05d7ef1..1c5111e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,7 @@ description = "MCP server for the CodeAlive API" readme = "README.md" requires-python = ">=3.11" dependencies = [ - "fastmcp==3.2.0", + "fastmcp==3.4.4", "httpx==0.28.1", "python-dotenv==1.2.2", "loguru==0.7.3", @@ -37,10 +37,31 @@ packages = ["src"] package-dir = {"" = "."} [tool.setuptools_scm] -fallback_version = "2.0.4" +fallback_version = "3.0.0" [tool.uv] # Relative dates in exclude-newer (e.g. "7 days") require uv ≥ 0.11. # Older versions silently ignore the whole [tool.uv] section on parse error. required-version = "~=0.11.0" exclude-newer = "7 days" +# FastMCP 3.4.4 is an explicitly reviewed security upgrade. Keep the global +# quarantine for transitive dependencies and require an intentional timestamp +# change before a future FastMCP version can enter the lock file. +constraint-dependencies = [ + "cryptography>=48.0.1", + "idna>=3.15", + "pydantic-settings>=2.14.2", + "pyjwt>=2.13.0", + "urllib3>=2.7.0", +] + +[tool.uv.exclude-newer-package] +fastmcp = "2026-07-10T00:00:00Z" +fastmcp-slim = "2026-07-10T00:00:00Z" +cryptography = "2026-07-10T00:00:00Z" +idna = "2026-07-10T00:00:00Z" +pydantic-settings = "2026-07-10T00:00:00Z" +pyjwt = "2026-07-10T00:00:00Z" +pytest = "2026-07-10T00:00:00Z" +python-dotenv = "2026-07-10T00:00:00Z" +urllib3 = "2026-07-10T00:00:00Z" diff --git a/server.json b/server.json index 845c773..0f64d3c 100644 --- a/server.json +++ b/server.json @@ -1,7 +1,7 @@ { "$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json", "name": "io.github.CodeAlive-AI/codealive-mcp", - "version": "2.0.4", + "version": "3.0.0", "description": "Semantic code search and analysis from CodeAlive for AI assistants and agents.", "keywords": [ "context-engineering", @@ -54,10 +54,6 @@ "name": "get_data_sources", "description": "Retrieve all available repositories and workspaces indexed in your CodeAlive account. Use this first to discover what codebases you can search and analyze." }, - { - "name": "codebase_search", - "description": "Deprecated legacy semantic search tool retained for backward compatibility." - }, { "name": "semantic_search", "description": "Default discovery tool — search by meaning to find code by concepts, behavior, or architecture." @@ -71,8 +67,16 @@ "description": "Synthesized codebase Q&A. Do NOT call unless the user explicitly names this tool (e.g. 'use chat'). 'Ask CodeAlive' means use search tools, not chat. Slow (up to 30 seconds)." }, { - "name": "codebase_consultant", - "description": "Deprecated alias for chat retained for backward compatibility." + "name": "get_repository_ontology", + "description": "Get repository-level ontology and orientation for a single selected repository." + }, + { + "name": "get_file_tree", + "description": "List repository files and folders for a single selected repository." + }, + { + "name": "read_file", + "description": "Read a repository-relative file path, optionally bounded by line range." }, { "name": "fetch_artifacts", @@ -81,6 +85,14 @@ { "name": "get_artifact_relationships", "description": "Explore an artifact's relationships — call graph, inheritance hierarchy, or references. Drill down after search or fetch to understand how code connects across the codebase." + }, + { + "name": "get_artifact_query_schema", + "description": "Return supported ArtifactQuery entities, fields, operators, and examples." + }, + { + "name": "query_artifact_metadata", + "description": "Run read-only ArtifactQuery metadata analytics across selected repositories." } ] } diff --git a/smoke_test.py b/smoke_test.py index ebcb133..1b170a1 100644 --- a/smoke_test.py +++ b/smoke_test.py @@ -135,12 +135,15 @@ async def test_list_tools(self) -> bool: expected_tools = { "chat", - "codebase_consultant", - "codebase_search", "fetch_artifacts", "get_artifact_relationships", + "get_artifact_query_schema", "get_data_sources", + "get_file_tree", + "get_repository_ontology", "grep_search", + "query_artifact_metadata", + "read_file", "semantic_search", } actual_tools = {tool.name for tool in tools} @@ -244,35 +247,6 @@ async def test_chat(self) -> bool: self.print_error(f"Tool execution failed: {str(e)}") return False - async def test_codebase_consultant(self) -> bool: - """Test the codebase_consultant tool (deprecated alias).""" - self.print_test("codebase_consultant Tool (deprecated)") - try: - result = await self.session.call_tool("codebase_consultant", { - "question": "test question", - "data_sources": ["test-repo"] - }) - - if result.isError: - # Error is expected if no valid API key - error_str = str(result.content) - if "API key" in error_str or "data source" in error_str or "authorization" in error_str.lower(): - self.print_success("Tool responds correctly (API key/data source required)") - self.print_info("This is expected in smoke test without valid API key") - return True - else: - self.print_error(f"Unexpected error: {result.content}") - return False - - # If we have a valid API key and data source, check response - self.print_success("Tool executed successfully") - self.print_info(f"Response: {str(result.content)[:100]}...") - return True - - except Exception as e: - self.print_error(f"Tool execution failed: {str(e)}") - return False - async def test_parameter_validation(self) -> bool: """Test that tools validate parameters correctly.""" self.print_test("Parameter Validation") @@ -316,7 +290,6 @@ async def run_all_tests(self): await self.test_get_data_sources() await self.test_semantic_search() await self.test_chat() - await self.test_codebase_consultant() await self.test_parameter_validation() except Exception as e: diff --git a/src/codealive_mcp_server.py b/src/codealive_mcp_server.py index 6c597f3..4f75a1b 100644 --- a/src/codealive_mcp_server.py +++ b/src/codealive_mcp_server.py @@ -30,78 +30,43 @@ import core.client as _client_module # for /ready flag access from middleware import N8NRemoveParametersMiddleware, ObservabilityMiddleware from tools import ( - chat, - codebase_consultant, - codebase_search, - fetch_artifacts, - get_artifact_relationships, get_data_sources, - grep_search, semantic_search, + grep_search, + get_repository_ontology, + get_file_tree, + read_file, + fetch_artifacts, + get_artifact_relationships, + get_artifact_query_schema, + query_artifact_metadata, + chat, ) + +def _package_version() -> str: + try: + return version("codealive-mcp") + except PackageNotFoundError: + return "unknown" + + # Initialize FastMCP server with lifespan and enhanced system instructions mcp = FastMCP( name="CodeAlive MCP Server", + version=_package_version(), instructions=""" - This server provides access to the CodeAlive API for AI-powered code assistance and code understanding. - - CodeAlive enables you to: - - Analyze code repositories and workspaces - - Search through code using natural language - - Understand code structure, dependencies, and patterns - - Answer questions about code implementation details - - Integrate with local git repositories for seamless code exploration - - Default workflow (used for ALL tasks unless the user explicitly requests `chat`): - 1. First use `get_data_sources` to identify available repositories and workspaces - 2. Use `semantic_search` for natural-language retrieval by meaning - 3. Use `grep_search` for literal string or regex matching when the pattern matters - 4. To get full content: - - For repos in your working directory: use `Read()` on the local files - - For external repos: use `fetch_artifacts` with identifiers from search results - 5. Use `get_artifact_relationships` or `fetch_artifacts` to drill into the most relevant hits - 6. If your environment supports subagents and you need the highest reliability or depth, - prefer an agentic workflow where a subagent combines `semantic_search`, `grep_search`, - artifact fetches, relationship inspection, and local file reads - - User-invoked tool — `chat`: - - `chat` is disabled by default. Call it ONLY when the user has explicitly - named the tool (e.g. "use chat", "use codebase_consultant", "call the chat tool"). - Phrases like "ask CodeAlive" or "search CodeAlive" do NOT qualify — they - refer to CodeAlive tools in general (semantic_search, grep_search, etc.). - - For every other case — lookups, architecture understanding, debugging, - summaries — use semantic_search, grep_search, fetch_artifacts, and - get_artifact_relationships. Do not treat "after search" as a justification - for calling chat. - - For effective code exploration: - - Start with broad natural-language queries in `semantic_search` to understand the overall structure - - Use `grep_search(regex=false)` for exact strings and `grep_search(regex=true)` for regex patterns - - Use specific function/class names or file path scopes when looking for particular implementations - - Treat `semantic_search` and `grep_search` as the default discovery tools - - Prefer `semantic_search` over the deprecated `codebase_search` legacy alias - - Use `get_artifact_relationships` only with exact artifact identifiers from prior search/fetch results. - It expands a known artifact's relationship graph; it does not search by path, class name, or guessed symbol. - For exact source code, call `fetch_artifacts` on identifiers returned by search or relationships. - - Remember that context from previous messages is maintained in the same conversation - - Flexible data source usage: - - You can use a workspace name as a single data source to search or chat across all its repositories at once - - Alternatively, you can use specific repository names for more targeted searches - - For complex queries, you can combine multiple repository names from different workspaces - - Choose between workspace-level or repository-level access based on the scope of the query - - Repository integration: - - CodeAlive can connect to repositories you've indexed in the system - - If a user is working within a git repository that matches a CodeAlive-indexed repository (by URL), - you can suggest using CodeAlive's search and chat to understand the codebase - - Data sources include repository URLs to help match with local git repositories - - When analyzing search results: - - Pay attention to file paths to understand the project structure - - Look for patterns across multiple matching files - - Consider the relationships between different code components + Use CodeAlive to inspect indexed repositories and workspaces. + + Default workflow: DISCOVER → SEARCH → READ → EXPAND. + 1. Call get_data_sources to identify visible data sources. + 2. Use get_repository_ontology for one-repository orientation. + 3. Use semantic_search for meaning and grep_search for literal or regex matches. + 4. Read evidence with read_file or fetch_artifacts using returned identifiers. + 5. Expand known identifiers with get_artifact_relationships. Use ArtifactQuery tools for metadata analytics. + + Call chat only when the user explicitly requests that tool. Chat is stateless; include prior findings, + identifiers, assumptions, scope, and constraints in every question. Deprecated MCP aliases are absent. """, lifespan=codealive_lifespan ) @@ -111,13 +76,6 @@ mcp.add_middleware(ObservabilityMiddleware()) -def _package_version() -> str: - try: - return version("codealive-mcp") - except PackageNotFoundError: - return "unknown" - - def _runtime_metadata() -> dict[str, str]: return { "version": _package_version(), @@ -154,16 +112,17 @@ async def readiness_check(request: Request) -> JSONResponse: # Register tools with metadata suitable for Claude Desktop and MCP directories. -_READ_ONLY_TOOL = {"readOnlyHint": True} +_READ_ONLY_TOOL = { + "readOnlyHint": True, + "destructiveHint": False, + "idempotentHint": True, + "openWorldHint": True, +} mcp.tool( title="List Data Sources", annotations=_READ_ONLY_TOOL, )(get_data_sources) -mcp.tool( - title="Search Codebase (Deprecated)", - annotations=_READ_ONLY_TOOL, -)(codebase_search) mcp.tool( title="Semantic Search", annotations=_READ_ONLY_TOOL, @@ -173,9 +132,17 @@ async def readiness_check(request: Request) -> JSONResponse: annotations=_READ_ONLY_TOOL, )(grep_search) mcp.tool( - title="Chat About Codebase", + title="Get Repository Ontology", annotations=_READ_ONLY_TOOL, -)(chat) +)(get_repository_ontology) +mcp.tool( + title="Get File Tree", + annotations=_READ_ONLY_TOOL, +)(get_file_tree) +mcp.tool( + title="Read File", + annotations=_READ_ONLY_TOOL, +)(read_file) mcp.tool( title="Fetch Artifacts", annotations=_READ_ONLY_TOOL, @@ -185,9 +152,17 @@ async def readiness_check(request: Request) -> JSONResponse: annotations=_READ_ONLY_TOOL, )(get_artifact_relationships) mcp.tool( - title="Consult Codebase (Deprecated)", + title="Get ArtifactQuery Schema", + annotations=_READ_ONLY_TOOL, +)(get_artifact_query_schema) +mcp.tool( + title="Query Artifact Metadata", + annotations=_READ_ONLY_TOOL, +)(query_artifact_metadata) +mcp.tool( + title="Chat About Codebase", annotations=_READ_ONLY_TOOL, -)(codebase_consultant) +)(chat) def main(): @@ -196,8 +171,18 @@ def main(): parser.add_argument("--api-key", help="CodeAlive API Key") parser.add_argument("--base-url", help="CodeAlive Base URL") parser.add_argument("--transport", help="Transport type (stdio or http)", default="stdio") - parser.add_argument("--host", help="Host for HTTP transport", default="0.0.0.0") + parser.add_argument("--host", help="Host for HTTP transport", default="127.0.0.1") parser.add_argument("--port", help="Port for HTTP transport", type=int, default=8000) + parser.add_argument( + "--allowed-host", + action="append", + help="Host accepted by HTTP transport protection; repeat for multiple hosts", + ) + parser.add_argument( + "--allowed-origin", + action="append", + help="Origin accepted by HTTP transport protection; repeat for multiple origins", + ) parser.add_argument("--debug", action="store_true", help="Enable debug mode for verbose logging") parser.add_argument("--ignore-ssl", action="store_true", help="Ignore SSL certificate validation") @@ -269,6 +254,16 @@ def main(): # Run the server with the selected transport if args.transport == "http": + allowed_hosts = args.allowed_host or [ + value.strip() + for value in os.getenv("CODEALIVE_MCP_ALLOWED_HOSTS", "").split(",") + if value.strip() + ] + allowed_origins = args.allowed_origin or [ + value.strip() + for value in os.getenv("CODEALIVE_MCP_ALLOWED_ORIGINS", "").split(",") + if value.strip() + ] # Use /api path to avoid conflicts with health endpoint mcp.run( transport="http", @@ -276,6 +271,9 @@ def main(): port=args.port, path="/api", stateless_http=True, + host_origin_protection=True, + allowed_hosts=allowed_hosts or None, + allowed_origins=allowed_origins or None, uvicorn_config={ "forwarded_allow_ips": "*", }, diff --git a/src/core/logging.py b/src/core/logging.py index f82206f..e026733 100644 --- a/src/core/logging.py +++ b/src/core/logging.py @@ -15,35 +15,29 @@ from opentelemetry import trace as otel_trace # --------------------------------------------------------------------------- -# PII masking +# Value-shape descriptions # --------------------------------------------------------------------------- -# Request-body keys that may contain user content -_PII_FIELDS = frozenset({"query", "question", "messages", "message"}) -_PII_MAX_LEN = 80 -_RESPONSE_BODY_MAX_LEN = 500 - - -def _mask_pii(value: str, max_len: int = _PII_MAX_LEN) -> str: - if len(value) <= max_len: - return value - return value[:max_len] + f"...[{len(value)} chars]" - - -def _sanitize_body(body: Dict[str, Any]) -> Dict[str, Any]: - """Return a shallow copy with PII fields truncated.""" - sanitized: Dict[str, Any] = {} - for k, v in body.items(): - if k in _PII_FIELDS: - if isinstance(v, str): - sanitized[k] = _mask_pii(v) - elif isinstance(v, list): - sanitized[k] = f"[{len(v)} items]" - else: - sanitized[k] = "" - else: - sanitized[k] = v - return sanitized +def _value_shape(value: Any) -> Dict[str, Any]: + """Describe a value without retaining user-supplied content.""" + if value is None: + return {"type": "null"} + if isinstance(value, bool): + return {"type": "boolean"} + if isinstance(value, str): + return {"type": "string", "length": len(value)} + if isinstance(value, (list, tuple)): + return {"type": "array", "length": len(value)} + if isinstance(value, dict): + return {"type": "object", "keys": sorted(str(key) for key in value)} + if isinstance(value, (int, float)): + return {"type": "number"} + return {"type": type(value).__name__} + + +def _mapping_shape(values: Dict[str, Any]) -> Dict[str, Dict[str, Any]]: + """Describe mapping fields without logging their values.""" + return {key: _value_shape(value) for key, value in values.items()} # --------------------------------------------------------------------------- @@ -142,7 +136,7 @@ def log_api_request( body: Optional[Dict[str, Any]] = None, request_id: Optional[str] = None, ) -> str: - """Log an outgoing API request at DEBUG level with PII masking. + """Log an outgoing API request at DEBUG level without argument values. Returns the ``request_id`` for correlation with the response log. """ @@ -152,22 +146,17 @@ def log_api_request( if not _is_debug_enabled(): return request_id - safe_headers = { - k: ("Bearer ***" if k.lower() == "authorization" else v) - for k, v in headers.items() - } - extra: Dict[str, Any] = { "event": "api_request", "request_id": request_id, "http_method": method, "url": url, - "headers": safe_headers, + "header_names": sorted(headers), } if params: if isinstance(params, dict): - extra["params"] = params + extra["parameter_shape"] = _mapping_shape(params) else: param_dict: Dict[str, Any] = {} for key, value in params: @@ -177,10 +166,10 @@ def log_api_request( param_dict[key].append(value) else: param_dict[key] = value - extra["params"] = param_dict + extra["parameter_shape"] = _mapping_shape(param_dict) if body: - extra["body"] = _sanitize_body(body) + extra["body_shape"] = _mapping_shape(body) logger.bind(**extra).debug("API request: {method} {url}", method=method, url=url) @@ -191,13 +180,7 @@ def log_api_response( response: httpx.Response, request_id: Optional[str] = None, ) -> None: - """Log an API response at DEBUG level with body truncation. - - IMPORTANT: This function reads ``response.text`` which consumes the - response body. It is guarded by a level check so that streaming - responses (e.g. chat completions) are not accidentally consumed at - INFO level. - """ + """Log API response metadata without reading or retaining its body.""" if not _is_debug_enabled(): return @@ -211,17 +194,9 @@ def log_api_response( "url": str(response.url), } - try: - body_text = response.text - if len(body_text) > _RESPONSE_BODY_MAX_LEN: - extra["response_body"] = ( - body_text[:_RESPONSE_BODY_MAX_LEN] - + f"...[{len(body_text)} chars total]" - ) - else: - extra["response_body"] = body_text - except Exception: - extra["response_body"] = "" + content_length = response.headers.get("content-length") + if content_length and content_length.isdigit(): + extra["response_size_bytes"] = int(content_length) logger.bind(**extra).debug( "API response: {status_code} {url}", diff --git a/src/middleware/observability_middleware.py b/src/middleware/observability_middleware.py index ad422b1..e2eab12 100644 --- a/src/middleware/observability_middleware.py +++ b/src/middleware/observability_middleware.py @@ -11,6 +11,7 @@ execution carries the correlation ID. """ +import time from typing import TYPE_CHECKING, Any from loguru import logger @@ -19,6 +20,8 @@ from fastmcp.server.middleware import Middleware +from core.logging import _mapping_shape + if TYPE_CHECKING: from fastmcp.server.middleware import CallNext, MiddlewareContext @@ -61,10 +64,13 @@ class ObservabilityMiddleware(Middleware): async def on_call_tool(self, context: "MiddlewareContext", call_next: "CallNext"): tool_name = getattr(context.message, "name", "unknown") - tool_arguments = _extract_tool_arguments(context) + tool_argument_shape = _mapping_shape(_extract_tool_arguments(context)) + started_at = time.perf_counter() with _tracer.start_as_current_span( f"tool {tool_name}", + record_exception=False, + set_status_on_exception=False, attributes={ "gen_ai.operation.name": "execute_tool", "gen_ai.tool.name": tool_name, @@ -79,22 +85,30 @@ async def on_call_tool(self, context: "MiddlewareContext", call_next: "CallNext" with logger.contextualize( trace_id=trace_id, tool=tool_name, - tool_arguments=tool_arguments, + tool_argument_shape=tool_argument_shape, ): logger.debug("Tool call started: {tool}", tool=tool_name) try: result = await call_next(context) except Exception as exc: - span.set_status(StatusCode.ERROR, str(exc)) - span.record_exception(exc) + duration_ms = (time.perf_counter() - started_at) * 1000 + error_type = type(exc).__name__ + span.set_attribute("mcp.tool.duration_ms", duration_ms) + span.set_attribute("error.type", error_type) + span.set_status(StatusCode.ERROR, error_type) + span.add_event("exception", {"exception.type": error_type}) logger.bind( - error_type=type(exc).__name__, - error=str(exc), - tool_arguments=tool_arguments, - ).opt(exception=exc).warning("Tool call failed: {tool}", tool=tool_name) + duration_ms=duration_ms, + error_type=error_type, + ).warning("Tool call failed: {tool}", tool=tool_name) raise + duration_ms = (time.perf_counter() - started_at) * 1000 + span.set_attribute("mcp.tool.duration_ms", duration_ms) span.set_status(StatusCode.OK) - logger.debug("Tool call completed: {tool}", tool=tool_name) + logger.bind(duration_ms=duration_ms).debug( + "Tool call completed: {tool}", + tool=tool_name, + ) return result diff --git a/src/tests/test_artifact_relationships.py b/src/tests/test_artifact_relationships.py deleted file mode 100644 index ef2a977..0000000 --- a/src/tests/test_artifact_relationships.py +++ /dev/null @@ -1,577 +0,0 @@ -"""Tests for the get_artifact_relationships tool.""" - -import pytest -from unittest.mock import AsyncMock, MagicMock, patch - -from fastmcp import Context -from fastmcp.exceptions import ToolError - -from tools.artifact_relationships import ( - PROFILE_MAP, - _build_relationships_dict, - get_artifact_relationships, -) - - -class TestProfileMapping: - """Test MCP profile names map to backend enum values.""" - - def test_default_profile_is_calls_only(self): - """callsOnly is the default and maps to CallsOnly.""" - assert PROFILE_MAP["callsOnly"] == "CallsOnly" - - def test_inheritance_only_maps_correctly(self): - assert PROFILE_MAP["inheritanceOnly"] == "InheritanceOnly" - - def test_all_relevant_maps_correctly(self): - assert PROFILE_MAP["allRelevant"] == "AllRelevant" - - def test_references_only_maps_correctly(self): - assert PROFILE_MAP["referencesOnly"] == "ReferencesOnly" - - -class TestBuildRelationshipsDict: - """Test dict shape of relationship responses (FastMCP handles serialization).""" - - def test_found_with_grouped_relationships(self): - data = { - "sourceIdentifier": "org/repo::path::Symbol", - "profile": "CallsOnly", - "found": True, - "availableRelationshipCounts": { - "outgoingCalls": 57, - "incomingCalls": 3, - "ancestors": 0, - "descendants": 2, - "references": 11, - }, - "relationships": [ - { - "relationType": "OutgoingCalls", - "totalCount": 57, - "returnedCount": 50, - "truncated": True, - "items": [ - { - "identifier": "org/repo::path::Dep", - "filePath": "src/Data/Repository.cs", - "startLine": 88, - "shortSummary": "Stores the aggregate", - } - ], - }, - { - "relationType": "IncomingCalls", - "totalCount": 3, - "returnedCount": 3, - "truncated": False, - "items": [ - { - "identifier": "org/repo::path::Caller", - "filePath": "src/Services/Worker.cs", - "startLine": 142, - } - ], - }, - ], - } - - parsed = _build_relationships_dict(data) - assert parsed["sourceIdentifier"] == "org/repo::path::Symbol" - assert parsed["profile"] == "callsOnly" - assert parsed["found"] is True - assert parsed["availableRelationshipCounts"]["outgoingCalls"] == 57 - assert parsed["availableRelationshipCounts"]["references"] == 11 - assert "truncated" in parsed["hint"] - assert "higher max_count_per_type" in parsed["hint"] - - outgoing = parsed["relationships"][0] - assert outgoing["type"] == "outgoing_calls" - assert outgoing["totalCount"] == 57 - assert outgoing["returnedCount"] == 50 - assert outgoing["truncated"] is True - assert outgoing["items"][0]["filePath"] == "src/Data/Repository.cs" - assert outgoing["items"][0]["startLine"] == 88 - assert outgoing["items"][0]["shortSummary"] == "Stores the aggregate" - - incoming = parsed["relationships"][1] - assert incoming["type"] == "incoming_calls" - assert incoming["truncated"] is False - # Incoming call has no shortSummary - assert "shortSummary" not in incoming["items"][0] - - def test_not_found_omits_relationships(self): - data = { - "sourceIdentifier": "org/repo::path::Missing", - "profile": "CallsOnly", - "found": False, - "relationships": [], - } - - parsed = _build_relationships_dict(data) - assert parsed["found"] is False - assert "relationships" not in parsed - assert "availableRelationshipCounts" not in parsed - assert "fresh identifier" in parsed["hint"] - - def test_empty_groups_still_rendered(self): - data = { - "sourceIdentifier": "org/repo::path::Symbol", - "profile": "InheritanceOnly", - "found": True, - "relationships": [ - { - "relationType": "Ancestors", - "totalCount": 0, - "returnedCount": 0, - "truncated": False, - "items": [], - }, - { - "relationType": "Descendants", - "totalCount": 0, - "returnedCount": 0, - "truncated": False, - "items": [], - }, - ], - } - - parsed = _build_relationships_dict(data) - types = [g["type"] for g in parsed["relationships"]] - assert types == ["ancestors", "descendants"] - for g in parsed["relationships"]: - assert g["totalCount"] == 0 - assert g["items"] == [] - assert "No relationships were found for this profile" in parsed["hint"] - - def test_optional_fields_omitted_when_null(self): - data = { - "sourceIdentifier": "org/repo::path::Symbol", - "profile": "CallsOnly", - "found": True, - "relationships": [ - { - "relationType": "OutgoingCalls", - "totalCount": 1, - "returnedCount": 1, - "truncated": False, - "items": [ - { - "identifier": "org/repo::path::Target", - # filePath, startLine, shortSummary all absent - } - ], - }, - ], - } - - parsed = _build_relationships_dict(data) - item = parsed["relationships"][0]["items"][0] - assert item["identifier"] == "org/repo::path::Target" - assert "filePath" not in item - assert "startLine" not in item - assert "shortSummary" not in item - - def test_empty_profile_hint_uses_available_counts(self): - data = { - "sourceIdentifier": "org/repo::path::Command", - "profile": "CallsOnly", - "found": True, - "availableRelationshipCounts": { - "outgoingCalls": 0, - "incomingCalls": 0, - "ancestors": 0, - "descendants": 0, - "references": 7, - }, - "relationships": [ - { - "relationType": "OutgoingCalls", - "totalCount": 0, - "returnedCount": 0, - "truncated": False, - "items": [], - }, - { - "relationType": "IncomingCalls", - "totalCount": 0, - "returnedCount": 0, - "truncated": False, - "items": [], - }, - ], - } - - parsed = _build_relationships_dict(data) - - assert parsed["availableRelationshipCounts"]["references"] == 7 - assert "referencesOnly" in parsed["hint"] - assert "where-used" in parsed["hint"] - - def test_all_relevant_empty_profile_hint_says_references_are_excluded(self): - data = { - "sourceIdentifier": "org/repo::path::Message", - "profile": "AllRelevant", - "found": True, - "availableRelationshipCounts": { - "outgoingCalls": 0, - "incomingCalls": 0, - "ancestors": 0, - "descendants": 0, - "references": 4, - }, - "relationships": [ - { - "relationType": "OutgoingCalls", - "totalCount": 0, - "returnedCount": 0, - "truncated": False, - "items": [], - }, - { - "relationType": "IncomingCalls", - "totalCount": 0, - "returnedCount": 0, - "truncated": False, - "items": [], - }, - { - "relationType": "Ancestors", - "totalCount": 0, - "returnedCount": 0, - "truncated": False, - "items": [], - }, - { - "relationType": "Descendants", - "totalCount": 0, - "returnedCount": 0, - "truncated": False, - "items": [], - }, - ], - } - - parsed = _build_relationships_dict(data) - - assert parsed["profile"] == "allRelevant" - assert "excludes references" in parsed["hint"] - assert "referencesOnly" in parsed["hint"] - - def test_quotes_and_specials_pass_through_unchanged(self): - """Special chars (<, >, &, ") are preserved as-is in the dict — no HTML encoding.""" - data = { - "sourceIdentifier": "org/repo::path::Class", - "profile": "CallsOnly", - "found": True, - "relationships": [ - { - "relationType": "OutgoingCalls", - "totalCount": 1, - "returnedCount": 1, - "truncated": False, - "items": [ - { - "identifier": "org/repo::path::Method", - "shortSummary": 'Returns "value" & more', - } - ], - }, - ], - } - - parsed = _build_relationships_dict(data) - assert parsed["sourceIdentifier"] == "org/repo::path::Class" - assert parsed["relationships"][0]["items"][0]["identifier"] == "org/repo::path::Method" - assert parsed["relationships"][0]["items"][0]["shortSummary"] == 'Returns "value" & more' - - def test_profile_mapped_back_to_mcp_name(self): - """Backend profile enum names are mapped back to MCP-friendly names.""" - for mcp_name, api_name in PROFILE_MAP.items(): - data = { - "sourceIdentifier": "id", - "profile": api_name, - "found": False, - "relationships": [], - } - parsed = _build_relationships_dict(data) - assert parsed["profile"] == mcp_name - - -class TestGetArtifactRelationshipsTool: - """Test the async tool function.""" - - @pytest.mark.asyncio - @patch("tools.artifact_relationships.get_api_key_from_context") - async def test_default_profile_sends_calls_only(self, mock_get_api_key): - mock_get_api_key.return_value = "test_key" - - ctx = MagicMock(spec=Context) - ctx.debug = AsyncMock() - ctx.error = AsyncMock() - - mock_response = MagicMock() - mock_response.json.return_value = { - "sourceIdentifier": "org/repo::path::Symbol", - "profile": "CallsOnly", - "found": True, - "relationships": [], - } - mock_response.raise_for_status = MagicMock() - - mock_client = AsyncMock() - mock_client.post.return_value = mock_response - - mock_context = MagicMock() - mock_context.client = mock_client - mock_context.base_url = "https://app.codealive.ai" - ctx.request_context.lifespan_context = mock_context - - result = await get_artifact_relationships( - ctx=ctx, - identifier="org/repo::path::Symbol", - ) - - # Verify the API was called with CallsOnly profile - call_args = mock_client.post.call_args - assert call_args[1]["json"]["profile"] == "CallsOnly" - assert result["found"] is True - - @pytest.mark.asyncio - @patch("tools.artifact_relationships.get_api_key_from_context") - async def test_explicit_profile_maps_correctly(self, mock_get_api_key): - mock_get_api_key.return_value = "test_key" - - ctx = MagicMock(spec=Context) - ctx.debug = AsyncMock() - ctx.error = AsyncMock() - - mock_response = MagicMock() - mock_response.json.return_value = { - "sourceIdentifier": "id", - "profile": "InheritanceOnly", - "found": True, - "relationships": [], - } - mock_response.raise_for_status = MagicMock() - - mock_client = AsyncMock() - mock_client.post.return_value = mock_response - - mock_context = MagicMock() - mock_context.client = mock_client - mock_context.base_url = "https://app.codealive.ai" - ctx.request_context.lifespan_context = mock_context - - await get_artifact_relationships( - ctx=ctx, - identifier="id", - profile="inheritanceOnly", - ) - - call_args = mock_client.post.call_args - assert call_args[1]["json"]["profile"] == "InheritanceOnly" - # No data_source supplied => omitted from the body. - assert "dataSource" not in call_args[1]["json"] - - @pytest.mark.asyncio - @patch("tools.artifact_relationships.get_api_key_from_context") - async def test_forwards_data_source(self, mock_get_api_key): - mock_get_api_key.return_value = "test_key" - - ctx = MagicMock(spec=Context) - ctx.debug = AsyncMock() - ctx.error = AsyncMock() - - mock_response = MagicMock() - mock_response.json.return_value = { - "sourceIdentifier": "id", - "profile": "CallsOnly", - "found": True, - "relationships": [], - } - mock_response.raise_for_status = MagicMock() - - mock_client = AsyncMock() - mock_client.post.return_value = mock_response - - mock_context = MagicMock() - mock_context.client = mock_client - mock_context.base_url = "https://app.codealive.ai" - ctx.request_context.lifespan_context = mock_context - - await get_artifact_relationships( - ctx=ctx, - identifier="id", - data_source="backend", - ) - - assert mock_client.post.call_args[1]["json"]["dataSource"] == "backend" - - @pytest.mark.asyncio - @patch("tools.artifact_relationships.get_api_key_from_context") - async def test_whitespace_data_source_omitted(self, mock_get_api_key): - """A whitespace-only data_source normalizes to None: not sent to the backend - and not echoed in the not-found hint (preserves the 409-on-ambiguity fallback).""" - mock_get_api_key.return_value = "test_key" - - ctx = MagicMock(spec=Context) - ctx.debug = AsyncMock() - ctx.error = AsyncMock() - - mock_response = MagicMock() - mock_response.json.return_value = { - "sourceIdentifier": "id", - "profile": "CallsOnly", - "found": False, - } - mock_response.raise_for_status = MagicMock() - - mock_client = AsyncMock() - mock_client.post.return_value = mock_response - - mock_context = MagicMock() - mock_context.client = mock_client - mock_context.base_url = "https://app.codealive.ai" - ctx.request_context.lifespan_context = mock_context - - result = await get_artifact_relationships( - ctx=ctx, - identifier="id", - data_source=" ", - ) - - assert "dataSource" not in mock_client.post.call_args[1]["json"] - # The confusing `... in data source " "` hint must not appear. - assert '" "' not in result["hint"] - - @pytest.mark.asyncio - @patch("tools.artifact_relationships.get_api_key_from_context") - async def test_ambiguous_409_surfaces_candidate_data_sources(self, mock_get_api_key): - import httpx - - mock_get_api_key.return_value = "test_key" - - ctx = MagicMock(spec=Context) - ctx.debug = AsyncMock() - ctx.error = AsyncMock() - - mock_response = MagicMock() - mock_response.status_code = 409 - mock_response.text = ( - '{"detail": "Identifier matches 2 data sources: ' - "Name='backend' Id='ds-main', Name='backend-legacy' Id='ds-master'\"}" - ) - mock_response.raise_for_status.side_effect = httpx.HTTPStatusError( - "Conflict", request=MagicMock(), response=mock_response - ) - - mock_client = AsyncMock() - mock_client.post.return_value = mock_response - - mock_context = MagicMock() - mock_context.client = mock_client - mock_context.base_url = "https://app.codealive.ai" - ctx.request_context.lifespan_context = mock_context - - with pytest.raises(ToolError) as exc: - await get_artifact_relationships(ctx=ctx, identifier="org/repo::path::Symbol") - - message = str(exc.value) - assert "409" in message - # The candidate data sources from the backend 409 must be surfaced, plus the data_source retry hint. - assert "backend" in message and "backend-legacy" in message - assert "data_source" in message - - @pytest.mark.asyncio - async def test_empty_identifier_raises_tool_error(self): - ctx = MagicMock(spec=Context) - with pytest.raises(ToolError, match="required"): - await get_artifact_relationships(ctx=ctx, identifier="") - - @pytest.mark.asyncio - async def test_unsupported_profile_raises_tool_error(self): - ctx = MagicMock(spec=Context) - with pytest.raises(ToolError, match="Unsupported profile"): - await get_artifact_relationships( - ctx=ctx, identifier="id", profile="invalidProfile" - ) - - @pytest.mark.asyncio - @patch("tools.artifact_relationships.get_api_key_from_context") - async def test_api_error_returns_error_json(self, mock_get_api_key): - import httpx - - mock_get_api_key.return_value = "test_key" - - ctx = MagicMock(spec=Context) - ctx.debug = AsyncMock() - ctx.error = AsyncMock() - - mock_response = MagicMock() - mock_response.status_code = 401 - mock_response.text = "Unauthorized" - mock_response.raise_for_status.side_effect = httpx.HTTPStatusError( - "Unauthorized", request=MagicMock(), response=mock_response - ) - - mock_client = AsyncMock() - mock_client.post.return_value = mock_response - - mock_context = MagicMock() - mock_context.client = mock_client - mock_context.base_url = "https://app.codealive.ai" - ctx.request_context.lifespan_context = mock_context - - with pytest.raises(ToolError, match="401"): - await get_artifact_relationships(ctx=ctx, identifier="id") - - @pytest.mark.asyncio - @patch("tools.artifact_relationships.get_api_key_from_context") - async def test_not_found_response_renders_correctly(self, mock_get_api_key): - mock_get_api_key.return_value = "test_key" - - ctx = MagicMock(spec=Context) - ctx.debug = AsyncMock() - ctx.error = AsyncMock() - - mock_response = MagicMock() - mock_response.json.return_value = { - "sourceIdentifier": "org/repo::path::Missing", - "profile": "CallsOnly", - "found": False, - "relationships": [], - } - mock_response.raise_for_status = MagicMock() - - mock_client = AsyncMock() - mock_client.post.return_value = mock_response - - mock_context = MagicMock() - mock_context.client = mock_client - mock_context.base_url = "https://app.codealive.ai" - ctx.request_context.lifespan_context = mock_context - - data = await get_artifact_relationships(ctx=ctx, identifier="org/repo::path::Missing") - - assert data["found"] is False - assert "relationships" not in data - - def test_not_found_hint_with_data_source_suggests_retry_or_omit(self): - payload = _build_relationships_dict( - {"sourceIdentifier": "org/repo::path::S", "profile": "CallsOnly", "found": False}, - data_source="backend", - ) - hint = payload["hint"] - assert "backend" in hint - assert "data_source" in hint - assert "omit" in hint.lower() - - def test_not_found_hint_without_data_source_is_generic(self): - payload = _build_relationships_dict( - {"sourceIdentifier": "org/repo::path::S", "profile": "CallsOnly", "found": False}, - ) - hint = payload["hint"] - assert "data_source" not in hint - assert "fresh identifier" in hint diff --git a/src/tests/test_chat_tool.py b/src/tests/test_chat_tool.py deleted file mode 100644 index 50c6f30..0000000 --- a/src/tests/test_chat_tool.py +++ /dev/null @@ -1,306 +0,0 @@ -"""Test suite for chat tool and legacy consultant alias.""" - -import pytest -from unittest.mock import AsyncMock, MagicMock, patch -import json -from fastmcp import Context -from fastmcp.exceptions import ToolError -from tools.chat import chat, codebase_consultant - - -@pytest.mark.asyncio -@patch('tools.chat.get_api_key_from_context') -async def test_chat_with_simple_names(mock_get_api_key): - """Test chat with simple string names.""" - mock_get_api_key.return_value = "test_key" - - ctx = MagicMock(spec=Context) - ctx.info = AsyncMock() - ctx.warning = AsyncMock() - ctx.error = AsyncMock() - - # Mock streaming response - mock_response = MagicMock() - mock_response.raise_for_status = MagicMock() - - # Simulate SSE streaming response - async def mock_aiter_lines(): - yield 'data: {"choices":[{"delta":{"content":"Hello"}}]}' - yield 'data: {"choices":[{"delta":{"content":" world"}}]}' - yield 'data: [DONE]' - - mock_response.aiter_lines = mock_aiter_lines - - mock_client = AsyncMock() - mock_client.post.return_value = mock_response - - mock_codealive_context = MagicMock() - mock_codealive_context.client = mock_client - mock_codealive_context.base_url = "https://app.codealive.ai" - - ctx.request_context.lifespan_context = mock_codealive_context - - # Test with simple string names - result = await chat( - ctx=ctx, - question="Test question", - data_sources=["repo123", "repo456"] - ) - - # Verify the API was called with correct format - call_args = mock_client.post.call_args - request_data = call_args.kwargs["json"] - - # Should convert simple names to the backend names array - assert request_data["names"] == [ - "repo123", - "repo456" - ] - - assert result == "Hello world" - assert call_args.kwargs["headers"]["Accept"] == "text/event-stream, application/problem+json" - assert call_args.kwargs["headers"]["X-CodeAlive-Tool"] == "chat" - - -@pytest.mark.asyncio -@patch('tools.chat.get_api_key_from_context') -async def test_consultant_alias_preserves_string_names(mock_get_api_key): - """Test deprecated consultant alias preserves behavior.""" - mock_get_api_key.return_value = "test_key" - - ctx = MagicMock(spec=Context) - ctx.info = AsyncMock() - ctx.warning = AsyncMock() - ctx.error = AsyncMock() - - mock_response = MagicMock() - mock_response.raise_for_status = MagicMock() - - async def mock_aiter_lines(): - yield 'data: {"choices":[{"delta":{"content":"Response"}}]}' - yield 'data: [DONE]' - - mock_response.aiter_lines = mock_aiter_lines - - mock_client = AsyncMock() - mock_client.post.return_value = mock_response - - mock_codealive_context = MagicMock() - mock_codealive_context.client = mock_client - mock_codealive_context.base_url = "https://app.codealive.ai" - - ctx.request_context.lifespan_context = mock_codealive_context - - # Test with string names - result = await codebase_consultant( - ctx=ctx, - question="Test", - data_sources=["repo123", "repo456"] - ) - - call_args = mock_client.post.call_args - request_data = call_args.kwargs["json"] - - # Should extract just the normalized names - assert request_data["names"] == [ - "repo123", - "repo456" - ] - - assert result == "Response" - - -@pytest.mark.asyncio -@patch('tools.chat.get_api_key_from_context') -async def test_chat_with_conversation_id(mock_get_api_key): - """Test chat with existing conversation ID.""" - mock_get_api_key.return_value = "test_key" - - ctx = MagicMock(spec=Context) - ctx.info = AsyncMock() - - mock_response = MagicMock() - mock_response.raise_for_status = MagicMock() - - async def mock_aiter_lines(): - yield 'data: {"choices":[{"delta":{"content":"Continued"}}]}' - yield 'data: [DONE]' - - mock_response.aiter_lines = mock_aiter_lines - - mock_client = AsyncMock() - mock_client.post.return_value = mock_response - - mock_codealive_context = MagicMock() - mock_codealive_context.client = mock_client - mock_codealive_context.base_url = "https://app.codealive.ai" - - ctx.request_context.lifespan_context = mock_codealive_context - - result = await chat( - ctx=ctx, - question="Follow up", - conversation_id="69fceb3e7b2a6a7efdd18180" - ) - - call_args = mock_client.post.call_args - request_data = call_args.kwargs["json"] - - # Should include conversation ID - assert request_data["conversationId"] == "69fceb3e7b2a6a7efdd18180" - # Should not have explicit names when continuing conversation - assert "names" not in request_data - assert result == "Continued" - - -@pytest.mark.asyncio -@patch('tools.chat.get_api_key_from_context') -async def test_chat_rejects_non_objectid_conversation_id(mock_get_api_key): - """Invalid continuation IDs fail locally with an actionable ToolError.""" - mock_get_api_key.return_value = "test_key" - - ctx = MagicMock(spec=Context) - ctx.info = AsyncMock() - ctx.warning = AsyncMock() - ctx.error = AsyncMock() - - with pytest.raises(ToolError) as exc: - await chat( - ctx=ctx, - question="Follow up", - conversation_id="conv_123", - ) - - msg = str(exc.value) - assert "24-character hex Mongo ObjectId" in msg - assert "Retry: no" in msg - - -@pytest.mark.asyncio -@patch('tools.chat.get_api_key_from_context') -async def test_chat_named_sse_error_raises_tool_error(mock_get_api_key): - """RFC 9457 `event: error` frames must not collapse to an empty answer.""" - mock_get_api_key.return_value = "test_key" - - ctx = MagicMock(spec=Context) - ctx.info = AsyncMock() - ctx.warning = AsyncMock() - ctx.error = AsyncMock() - - mock_response = MagicMock() - mock_response.raise_for_status = MagicMock() - - async def mock_aiter_lines(): - yield 'event: error' - yield 'data: {"title":"Bad request","status":400,"detail":"Message content violates our content policy","requestId":"req-1"}' - yield '' - - mock_response.aiter_lines = mock_aiter_lines - - mock_client = AsyncMock() - mock_client.post.return_value = mock_response - - mock_codealive_context = MagicMock() - mock_codealive_context.client = mock_client - mock_codealive_context.base_url = "https://app.codealive.ai" - - ctx.request_context.lifespan_context = mock_codealive_context - - with pytest.raises(ToolError) as exc: - await chat(ctx=ctx, question="Test question", data_sources=["repo123"]) - - msg = str(exc.value) - assert "Message content violates our content policy" in msg - assert "Code: 400" in msg - assert "Retry: no" in msg - assert "requestId=req-1" in msg - - -@pytest.mark.asyncio -@patch('tools.chat.get_api_key_from_context') -async def test_chat_named_sse_rate_limit_error_is_retryable(mock_get_api_key): - """429 ProblemDetails frames should tell agents to back off, not fix input.""" - mock_get_api_key.return_value = "test_key" - - ctx = MagicMock(spec=Context) - ctx.info = AsyncMock() - ctx.warning = AsyncMock() - ctx.error = AsyncMock() - - mock_response = MagicMock() - mock_response.raise_for_status = MagicMock() - - async def mock_aiter_lines(): - yield 'event: error' - yield 'data: {"title":"Plan limit","status":429,"detail":"Chat completion rate limit exceeded","requestId":"req-429"}' - yield '' - - mock_response.aiter_lines = mock_aiter_lines - - mock_client = AsyncMock() - mock_client.post.return_value = mock_response - - mock_codealive_context = MagicMock() - mock_codealive_context.client = mock_client - mock_codealive_context.base_url = "https://app.codealive.ai" - - ctx.request_context.lifespan_context = mock_codealive_context - - with pytest.raises(ToolError) as exc: - await chat(ctx=ctx, question="Test question", data_sources=["repo123"]) - - msg = str(exc.value) - assert "Chat completion rate limit exceeded" in msg - assert "Retry: yes" in msg - assert "back off" in msg - assert "requestId=req-429" in msg - - -@pytest.mark.asyncio -@patch('tools.chat.get_api_key_from_context') -async def test_chat_empty_question_validation(mock_get_api_key): - """Test validation of empty question.""" - mock_get_api_key.return_value = "test_key" - - ctx = MagicMock(spec=Context) - ctx.request_context.lifespan_context = MagicMock() - - # Test with empty question - with pytest.raises(ToolError, match="No question provided"): - await chat(ctx=ctx, question="") - - # Test with whitespace only - with pytest.raises(ToolError, match="No question provided"): - await chat(ctx=ctx, question=" ") - - - - -@pytest.mark.asyncio -@patch('tools.chat.get_api_key_from_context') -@patch('tools.chat.handle_api_error') -async def test_chat_error_handling(mock_handle_error, mock_get_api_key): - """Test error handling in chat — handle_api_error raises ToolError.""" - mock_get_api_key.return_value = "test_key" - mock_handle_error.side_effect = ToolError("Error: Authentication failed") - - ctx = MagicMock(spec=Context) - ctx.info = AsyncMock() - - mock_client = AsyncMock() - mock_client.post.side_effect = Exception("Network error") - - mock_codealive_context = MagicMock() - mock_codealive_context.client = mock_client - mock_codealive_context.base_url = "https://app.codealive.ai" - - ctx.request_context.lifespan_context = mock_codealive_context - - with pytest.raises(ToolError, match="Authentication failed"): - await chat( - ctx=ctx, - question="Test", - data_sources=["repo123"] - ) - - mock_handle_error.assert_called_once() diff --git a/src/tests/test_datasources.py b/src/tests/test_datasources.py deleted file mode 100644 index c79b640..0000000 --- a/src/tests/test_datasources.py +++ /dev/null @@ -1,356 +0,0 @@ -"""Tests for data sources tool.""" - -from unittest.mock import AsyncMock, MagicMock, patch - -import pytest -from fastmcp import Context - -from tools.datasources import get_data_sources - - -@pytest.mark.asyncio -@patch('tools.datasources.get_api_key_from_context') -async def test_get_data_sources_removes_repository_ids_from_workspaces(mock_get_api_key): - """Test that repositoryIds are removed from workspace data sources.""" - mock_get_api_key.return_value = "test-key" - - # Mock context - mock_ctx = MagicMock(spec=Context) - mock_ctx.info = AsyncMock() - mock_ctx.warning = AsyncMock() - mock_ctx.error = AsyncMock() - - mock_lifespan_context = MagicMock() - mock_lifespan_context.base_url = "https://api.example.com" - - # Mock client with response containing workspaces with repositoryIds - mock_response = MagicMock() - mock_response.json.return_value = [ - { - "id": "repo-1", - "name": "Test Repository", - "type": "Repository", - "url": "https://github.com/example/repo", - "state": "Alive" - }, - { - "id": "workspace-1", - "name": "Test Workspace", - "type": "Workspace", - "repositoryIds": ["repo-1", "repo-2", "repo-3"], - "state": "Alive" - } - ] - mock_response.raise_for_status = MagicMock() - - mock_client = AsyncMock() - mock_client.get = AsyncMock(return_value=mock_response) - mock_lifespan_context.client = mock_client - - mock_ctx.request_context.lifespan_context = mock_lifespan_context - - # Tool returns a dict {"dataSources":[...], "hint":"..."}. - result = await get_data_sources(mock_ctx, alive_only=True) - data_sources = result["dataSources"] - assert "hint" in result - - # Verify repository still has all fields - repo = next(ds for ds in data_sources if ds["type"] == "Repository") - assert repo["id"] == "repo-1" - assert repo["name"] == "Test Repository" - assert repo["url"] == "https://github.com/example/repo" - assert "repositoryIds" not in repo - - # Verify workspace has repositoryIds removed - workspace = next(ds for ds in data_sources if ds["type"] == "Workspace") - assert workspace["id"] == "workspace-1" - assert workspace["name"] == "Test Workspace" - assert "repositoryIds" not in workspace, "repositoryIds should be removed from workspace" - - # Verify API was called correctly. Headers include CodeAlive integration - # markers added on every request, so assert on the relevant subset. - mock_client.get.assert_called_once() - call_args = mock_client.get.call_args - assert call_args.args[0] == "/api/datasources/ready" - headers = call_args.kwargs["headers"] - assert headers["Authorization"] == "Bearer test-key" - assert headers["X-CodeAlive-Tool"] == "get_data_sources" - assert headers["X-CodeAlive-Integration"] == "mcp" - - -@pytest.mark.asyncio -@patch('tools.datasources.get_api_key_from_context') -async def test_get_data_sources_preserves_other_workspace_fields(mock_get_api_key): - """Test that other workspace fields are preserved when removing repositoryIds.""" - mock_get_api_key.return_value = "test-key" - - # Mock context - mock_ctx = MagicMock(spec=Context) - mock_ctx.info = AsyncMock() - mock_ctx.warning = AsyncMock() - mock_ctx.error = AsyncMock() - - mock_lifespan_context = MagicMock() - mock_lifespan_context.base_url = "https://api.example.com" - - # Mock client with workspace containing various fields - mock_response = MagicMock() - mock_response.json.return_value = [ - { - "id": "workspace-1", - "name": "Test Workspace", - "type": "Workspace", - "state": "Alive", - "repositoryIds": ["repo-1", "repo-2"], - "customField": "custom-value", - "createdAt": "2025-01-01T00:00:00Z" - } - ] - mock_response.raise_for_status = MagicMock() - - mock_client = AsyncMock() - mock_client.get = AsyncMock(return_value=mock_response) - mock_lifespan_context.client = mock_client - - mock_ctx.request_context.lifespan_context = mock_lifespan_context - - result = await get_data_sources(mock_ctx, alive_only=True) - data_sources = result["dataSources"] - - workspace = data_sources[0] - - # Verify repositoryIds removed but other fields preserved - assert "repositoryIds" not in workspace - assert workspace["id"] == "workspace-1" - assert workspace["name"] == "Test Workspace" - assert workspace["type"] == "Workspace" - assert workspace["state"] == "Alive" - assert workspace["customField"] == "custom-value" - assert workspace["createdAt"] == "2025-01-01T00:00:00Z" - - -@pytest.mark.asyncio -@patch('tools.datasources.get_api_key_from_context') -async def test_get_data_sources_handles_missing_repository_ids(mock_get_api_key): - """Test that function handles workspaces without repositoryIds field.""" - mock_get_api_key.return_value = "test-key" - - # Mock context - mock_ctx = MagicMock(spec=Context) - mock_ctx.info = AsyncMock() - mock_ctx.warning = AsyncMock() - mock_ctx.error = AsyncMock() - - mock_lifespan_context = MagicMock() - mock_lifespan_context.base_url = "https://api.example.com" - - # Mock client with workspace without repositoryIds - mock_response = MagicMock() - mock_response.json.return_value = [ - { - "id": "workspace-1", - "name": "Test Workspace", - "type": "Workspace", - "state": "Alive" - } - ] - mock_response.raise_for_status = MagicMock() - - mock_client = AsyncMock() - mock_client.get = AsyncMock(return_value=mock_response) - mock_lifespan_context.client = mock_client - - mock_ctx.request_context.lifespan_context = mock_lifespan_context - - # Should not raise an error - result = await get_data_sources(mock_ctx, alive_only=True) - data_sources = result["dataSources"] - - # Verify workspace is intact - workspace = data_sources[0] - assert workspace["id"] == "workspace-1" - assert workspace["name"] == "Test Workspace" - assert "repositoryIds" not in workspace - - -def _ctx_with_response(json_return, headers=None): - """Builds a mocked Context whose client.get returns a response with the given JSON body.""" - mock_ctx = MagicMock(spec=Context) - mock_ctx.info = AsyncMock() - mock_ctx.warning = AsyncMock() - mock_ctx.error = AsyncMock() - - mock_response = MagicMock() - mock_response.json.return_value = json_return - mock_response.headers = headers or {} - mock_response.raise_for_status = MagicMock() - - mock_client = AsyncMock() - mock_client.get = AsyncMock(return_value=mock_response) - - mock_lifespan_context = MagicMock() - mock_lifespan_context.base_url = "https://api.example.com" - mock_lifespan_context.client = mock_client - mock_ctx.request_context.lifespan_context = mock_lifespan_context - return mock_ctx, mock_client - - -@pytest.mark.asyncio -@patch('tools.datasources.get_api_key_from_context') -async def test_get_data_sources_with_query_passes_query_param(mock_get_api_key): - """When a query is supplied, it is forwarded to the listing endpoint as the `query` param.""" - mock_get_api_key.return_value = "test-key" - mock_ctx, mock_client = _ctx_with_response([ - {"id": "repo-1", "name": "Repo", "type": "Repository", "relevanceReason": "handles OAuth"}, - ]) - - await get_data_sources(mock_ctx, alive_only=True, query="add OAuth to checkout") - - call_args = mock_client.get.call_args - assert call_args.args[0] == "/api/datasources/ready" - assert call_args.kwargs["params"] == {"query": "add OAuth to checkout"} - - -@pytest.mark.asyncio -@patch('tools.datasources.get_api_key_from_context') -async def test_get_data_sources_without_query_sends_no_query_param(mock_get_api_key): - """Without a query, no `query` param is sent (legacy behavior unchanged).""" - mock_get_api_key.return_value = "test-key" - mock_ctx, mock_client = _ctx_with_response([ - {"id": "repo-1", "name": "Repo", "type": "Repository"}, - ]) - - await get_data_sources(mock_ctx, alive_only=True) - - call_args = mock_client.get.call_args - assert call_args.kwargs.get("params") is None - - -@pytest.mark.asyncio -@patch('tools.datasources.get_api_key_from_context') -async def test_get_data_sources_surfaces_relevance_reason(mock_get_api_key): - """relevanceReason is preserved per item for the client (wrapped shape when query is set).""" - mock_get_api_key.return_value = "test-key" - mock_ctx, _ = _ctx_with_response([ - {"id": "repo-1", "name": "Repo", "type": "Repository", "relevanceReason": "implements the checkout flow"}, - ]) - - result = await get_data_sources(mock_ctx, alive_only=True, query="checkout") - - payload = result - assert payload["dataSources"][0]["relevanceReason"] == "implements the checkout flow" - - -@pytest.mark.asyncio -@patch('tools.datasources.get_api_key_from_context') -async def test_get_data_sources_filtered_hint_reports_total_and_omitted(mock_get_api_key): - """Filtered success surfaces how many sources exist beyond the shown subset and how to get them.""" - mock_get_api_key.return_value = "test-key" - mock_ctx, _ = _ctx_with_response( - [{"id": "repo-1", "name": "Repo", "type": "Repository", "relevanceReason": "checkout flow"}], - headers={"X-CodeAlive-Total-Data-Sources": "25"}, - ) - - result = await get_data_sources(mock_ctx, alive_only=True, query="checkout") - - payload = result - assert len(payload["dataSources"]) == 1 - assert "1 of 25" in payload["message"] - assert "omitted" in payload["message"].lower() - assert "without a query" in payload["message"].lower() - - -@pytest.mark.asyncio -@patch('tools.datasources.get_api_key_from_context') -async def test_get_data_sources_filtered_hint_without_total_header(mock_get_api_key): - """Filtered success without the total header still hints that sources were omitted.""" - mock_get_api_key.return_value = "test-key" - mock_ctx, _ = _ctx_with_response( - [{"id": "repo-1", "name": "Repo", "type": "Repository", "relevanceReason": "checkout flow"}], - ) - - result = await get_data_sources(mock_ctx, alive_only=True, query="checkout") - - payload = result - assert "omitted" in payload["message"].lower() - assert "without a query" in payload["message"].lower() - - -@pytest.mark.asyncio -@patch('tools.datasources.get_api_key_from_context') -async def test_get_data_sources_filtered_hint_with_malformed_total_header(mock_get_api_key): - """A malformed total header is treated as absent rather than raising.""" - mock_get_api_key.return_value = "test-key" - mock_ctx, _ = _ctx_with_response( - [{"id": "repo-1", "name": "Repo", "type": "Repository", "relevanceReason": "checkout flow"}], - headers={"X-CodeAlive-Total-Data-Sources": "not-a-number"}, - ) - - result = await get_data_sources(mock_ctx, alive_only=True, query="checkout") - - payload = result - assert "omitted" in payload["message"].lower() - assert "without a query" in payload["message"].lower() - - -@pytest.mark.asyncio -@patch('tools.datasources.get_api_key_from_context') -async def test_get_data_sources_all_relevant_hint_reports_no_omission(mock_get_api_key): - """When every available source is relevant, the hint says so instead of claiming omissions.""" - mock_get_api_key.return_value = "test-key" - mock_ctx, _ = _ctx_with_response( - [{"id": "repo-1", "name": "Repo", "type": "Repository", "relevanceReason": "checkout flow"}], - headers={"X-CodeAlive-Total-Data-Sources": "1"}, - ) - - result = await get_data_sources(mock_ctx, alive_only=True, query="checkout") - - payload = result - assert "all 1" in payload["message"].lower() - assert "omitted" not in payload["message"].lower() - - -@pytest.mark.asyncio -@patch('tools.datasources.get_api_key_from_context') -async def test_get_data_sources_failopen_hint_when_no_reasons_present(mock_get_api_key): - """Query supplied but no item carries relevanceReason → the filter did not run (fail-open, - disabled, or an older backend); the hint must say the FULL list is returned.""" - mock_get_api_key.return_value = "test-key" - mock_ctx, _ = _ctx_with_response([ - {"id": "repo-1", "name": "Repo", "type": "Repository"}, - {"id": "repo-2", "name": "Other", "type": "Repository"}, - ]) - - result = await get_data_sources(mock_ctx, alive_only=True, query="checkout") - - payload = result - assert len(payload["dataSources"]) == 2 - assert "unavailable" in payload["message"].lower() - assert "full" in payload["message"].lower() - - -@pytest.mark.asyncio -@patch('tools.datasources.get_api_key_from_context') -async def test_get_data_sources_empty_with_query_returns_no_relevant_hint(mock_get_api_key): - """Empty result WITH a query returns a 'no relevant' hint, not 'add a repository'.""" - mock_get_api_key.return_value = "test-key" - mock_ctx, _ = _ctx_with_response([]) - - result = await get_data_sources(mock_ctx, alive_only=True, query="something unrelated") - - assert result["dataSources"] == [] - assert "relevant" in result["hint"].lower() - assert "add a repository" not in result["hint"].lower() - - -@pytest.mark.asyncio -@patch('tools.datasources.get_api_key_from_context') -async def test_get_data_sources_empty_without_query_keeps_add_repository_hint(mock_get_api_key): - """Empty result WITHOUT a query keeps the existing 'add a repository' hint.""" - mock_get_api_key.return_value = "test-key" - mock_ctx, _ = _ctx_with_response([]) - - result = await get_data_sources(mock_ctx, alive_only=True) - - assert result["dataSources"] == [] - assert "add a repository" in result["hint"].lower() \ No newline at end of file diff --git a/src/tests/test_e2e_tools.py b/src/tests/test_e2e_tools.py deleted file mode 100644 index 6f1cb6a..0000000 --- a/src/tests/test_e2e_tools.py +++ /dev/null @@ -1,1604 +0,0 @@ -"""End-to-end tests for MCP tools using FastMCP's built-in Client. - -Each test builds a FastMCP server with the real tool functions, a custom -lifespan backed by httpx.MockTransport, and exercises the tool through -the in-memory MCP transport — covering argument validation, HTTP -call dispatch, response parsing, and XML/text formatting in a single pass. -""" - -import json -import sys -from contextlib import asynccontextmanager -from pathlib import Path -from typing import AsyncIterator - -import httpx -import pytest -from fastmcp import Client, FastMCP -from loguru import logger - -sys.path.insert(0, str(Path(__file__).parent.parent)) - -from core import CodeAliveContext -from middleware.observability_middleware import ObservabilityMiddleware -from tools import ( - chat, - codebase_consultant, - codebase_search, - fetch_artifacts, - grep_search, - get_artifact_relationships, - get_data_sources, - semantic_search, -) - - -# --------------------------------------------------------------------------- -# Helpers -# --------------------------------------------------------------------------- - -def _mock_transport(routes: dict) -> httpx.MockTransport: - """httpx MockTransport dispatching by URL path. - - ``routes`` maps a URL path (e.g. "/api/search") to a callable - ``(httpx.Request) -> httpx.Response``. - """ - def handler(request: httpx.Request) -> httpx.Response: - for path, responder in routes.items(): - if request.url.path == path: - return responder(request) - return httpx.Response(404, json={"error": f"no mock for {request.url.path}"}) - return httpx.MockTransport(handler) - - -def _server(routes: dict) -> FastMCP: - """Build a FastMCP instance wired to mock HTTP routes.""" - - @asynccontextmanager - async def lifespan(server: FastMCP) -> AsyncIterator[CodeAliveContext]: - transport = _mock_transport(routes) - async with httpx.AsyncClient( - transport=transport, base_url="https://test.codealive.ai" - ) as client: - yield CodeAliveContext( - client=client, - api_key="", - base_url="https://test.codealive.ai", - ) - - mcp = FastMCP("E2E Test Server", lifespan=lifespan) - mcp.tool()(get_data_sources) - mcp.tool()(codebase_search) - mcp.tool()(semantic_search) - mcp.tool()(grep_search) - mcp.tool()(fetch_artifacts) - mcp.tool()(chat) - mcp.tool()(codebase_consultant) - mcp.tool()(get_artifact_relationships) - return mcp - - -def _text(result) -> str: - """Extract first text block from a CallToolResult.""" - return result.content[0].text - - -# --------------------------------------------------------------------------- -# Fixtures -# --------------------------------------------------------------------------- - -@pytest.fixture(autouse=True) -def _api_key_env(monkeypatch): - """Provide CODEALIVE_API_KEY so get_api_key_from_context falls back to it.""" - monkeypatch.setenv("CODEALIVE_API_KEY", "test-e2e-key") - - -# --------------------------------------------------------------------------- -# get_data_sources -# --------------------------------------------------------------------------- - -class TestGetDataSourcesE2E: - @pytest.mark.asyncio - async def test_returns_compact_json(self): - payload = [ - {"id": "r1", "name": "backend", "type": "Repository", "url": "https://github.com/org/backend"}, - {"id": "w1", "name": "platform", "type": "Workspace", "repositoryIds": ["r1", "r2"]}, - ] - - def handler(req): - assert req.headers["authorization"] == "Bearer test-e2e-key" - return httpx.Response(200, json=payload) - - mcp = _server({"/api/datasources/ready": handler}) - async with Client(mcp) as client: - result = await client.call_tool("get_data_sources", {}) - - text = _text(result) - data = json.loads(text) - # Compact JSON, UTF-8 preserved (FastMCP uses pydantic_core.to_json). - assert text == json.dumps(data, separators=(",", ":"), ensure_ascii=False) - names = [ds["name"] for ds in data["dataSources"]] - assert "backend" in names - assert "platform" in names - # repositoryIds must be stripped from workspaces - for ds in data["dataSources"]: - assert "repositoryIds" not in ds - # Always emit a follow-up hint pointing at search/chat tools. - assert "semantic_search" in data["hint"] - - @pytest.mark.asyncio - async def test_empty_list_returns_recovery_hint(self): - mcp = _server({"/api/datasources/ready": lambda r: httpx.Response(200, json=[])}) - async with Client(mcp) as client: - result = await client.call_tool("get_data_sources", {}) - - text = _text(result) - data = json.loads(text) - assert data["dataSources"] == [] - assert "No data sources found" in data["hint"] - - @pytest.mark.asyncio - async def test_unicode_preserved_in_response(self): - """Cyrillic in name/description must survive as UTF-8, not \\uXXXX.""" - payload = [ - {"id": "r1", "name": "кирилл-репо", "type": "Repository", - "description": "Описание про принтеры HPRT"}, - ] - - mcp = _server({"/api/datasources/ready": lambda r: httpx.Response(200, json=payload)}) - async with Client(mcp) as client: - result = await client.call_tool("get_data_sources", {}) - - text = _text(result) - # Round-trip via ensure_ascii=False — ASCII-escaped output would mismatch. - assert text == json.dumps(json.loads(text), separators=(",", ":"), ensure_ascii=False) - data = json.loads(text) - assert data["dataSources"][0]["name"] == "кирилл-репо" - assert data["dataSources"][0]["description"] == "Описание про принтеры HPRT" - assert "\\u04" not in text - - @pytest.mark.asyncio - async def test_alive_only_false_hits_all_endpoint(self): - hit = [] - - def handler_all(req): - hit.append("all") - return httpx.Response(200, json=[{"id": "1", "name": "r", "type": "Repository"}]) - - mcp = _server({ - "/api/datasources/all": handler_all, - "/api/datasources/ready": lambda r: httpx.Response(200, json=[]), - }) - async with Client(mcp) as client: - await client.call_tool("get_data_sources", {"alive_only": False}) - - assert "all" in hit - - @pytest.mark.asyncio - async def test_backend_500_returns_error(self): - mcp = _server({ - "/api/datasources/ready": lambda r: httpx.Response(500, text="boom"), - }) - async with Client(mcp) as client: - result = await client.call_tool("get_data_sources", {}, raise_on_error=False) - - text = _text(result) - assert result.is_error - assert "500" in text or "Server error" in text - - -# --------------------------------------------------------------------------- -# codebase_search -# --------------------------------------------------------------------------- - -class TestCodebaseSearchE2E: - _SEARCH_RESPONSE = { - "results": [ - { - "identifier": "org/repo::src/auth.py::AuthService", - "kind": "Class", - "description": "Handles authentication", - "contentByteSize": 4200, - "location": { - "path": "src/auth.py", - "range": {"start": {"line": 10}, "end": {"line": 85}}, - }, - } - ] - } - - @pytest.mark.asyncio - async def test_success_returns_compact_json(self): - def handler(req): - assert req.url.params.get("Query") == "auth service" - assert req.url.params.get("Mode") == "auto" - assert "X-CodeAlive-Tool" in req.headers - return httpx.Response(200, json=self._SEARCH_RESPONSE) - - mcp = _server({"/api/search": handler}) - async with Client(mcp) as client: - result = await client.call_tool( - "codebase_search", - {"query": "auth service", "data_sources": ["backend"]}, - ) - - text = _text(result) - data = json.loads(text) - # Compact JSON with Unicode preserved (pydantic_core.to_json keeps UTF-8) - assert text == json.dumps(data, separators=(",", ":"), ensure_ascii=False) - assert data["results"][0]["path"] == "src/auth.py" - assert "AuthService" in data["results"][0]["identifier"] - # Hint must always be present and instruct the agent to fetch real content - assert "fetch_artifacts" in data["hint"] - - @pytest.mark.asyncio - async def test_empty_query_returns_error(self): - mcp = _server({}) - async with Client(mcp) as client: - result = await client.call_tool( - "codebase_search", {"query": ""}, - raise_on_error=False, - ) - - text = _text(result) - assert result.is_error - assert "empty" in text.lower() or "Query cannot be empty" in text - - @pytest.mark.asyncio - async def test_no_results_returns_empty_json(self): - mcp = _server({ - "/api/search": lambda r: httpx.Response(200, json={"results": []}), - }) - async with Client(mcp) as client: - result = await client.call_tool( - "codebase_search", {"query": "nonexistent"}, - ) - - text = _text(result) - data = json.loads(text) - assert data["results"] == [] - assert "grep_search" in data["hint"] - assert "get_data_sources" in data["hint"] - assert "fetch_artifacts" not in data["hint"] - - @pytest.mark.asyncio - async def test_deep_mode_forwarded(self): - received_mode = [] - - def handler(req): - received_mode.append(req.url.params.get("Mode")) - return httpx.Response(200, json={"results": []}) - - mcp = _server({"/api/search": handler}) - async with Client(mcp) as client: - await client.call_tool( - "codebase_search", {"query": "x", "mode": "deep"}, - ) - - assert received_mode == ["deep"] - - @pytest.mark.asyncio - async def test_404_returns_not_found_error(self): - mcp = _server({ - "/api/search": lambda r: httpx.Response(404, text="not found"), - }) - async with Client(mcp) as client: - result = await client.call_tool( - "codebase_search", {"query": "x"}, - raise_on_error=False, - ) - - text = _text(result) - assert result.is_error - assert "404" in text or "not found" in text.lower() - - -# --------------------------------------------------------------------------- -# semantic_search -# --------------------------------------------------------------------------- - -class TestSemanticSearchE2E: - @pytest.mark.asyncio - async def test_success_hits_canonical_endpoint(self): - def handler(req): - assert req.url.params.get("Query") == "auth service" - assert req.url.params.get("MaxResults") == "7" - assert req.url.params.get_list("Names") == ["backend"] - assert req.url.params.get_list("Paths") == ["src/auth.py"] - assert req.url.params.get_list("Extensions") == [".py"] - assert req.headers["X-CodeAlive-Tool"] == "semantic_search" - return httpx.Response( - 200, - json={ - "results": [ - { - "identifier": "org/repo::src/auth.py::AuthService", - "kind": "Class", - "description": "Handles authentication", - "contentByteSize": 4200, - "location": { - "path": "src/auth.py", - "range": {"start": {"line": 10}, "end": {"line": 85}}, - }, - } - ] - }, - ) - - mcp = _server({"/api/search/semantic": handler}) - async with Client(mcp) as client: - result = await client.call_tool( - "semantic_search", - { - "query": "auth service", - "data_sources": ["backend"], - "paths": ["src/auth.py"], - "extensions": [".py"], - "max_results": 7, - }, - ) - - data = json.loads(_text(result)) - assert data["results"][0]["path"] == "src/auth.py" - assert "fetch_artifacts" in data["hint"] - - @pytest.mark.asyncio - async def test_max_results_forwarded(self): - def handler(req): - assert req.url.params.get("MaxResults") == "3" - return httpx.Response(200, json={"results": []}) - - mcp = _server({"/api/search/semantic": handler}) - async with Client(mcp) as client: - await client.call_tool( - "semantic_search", - {"query": "test", "data_sources": ["repo"], "max_results": 3}, - ) - - @pytest.mark.asyncio - async def test_max_results_not_sent_when_omitted(self): - def handler(req): - assert "MaxResults" not in dict(req.url.params) - return httpx.Response(200, json={"results": []}) - - mcp = _server({"/api/search/semantic": handler}) - async with Client(mcp) as client: - await client.call_tool( - "semantic_search", - {"query": "test", "data_sources": ["repo"]}, - ) - - @pytest.mark.asyncio - async def test_max_results_boundary_0_rejected(self): - mcp = _server({}) - async with Client(mcp) as client: - result = await client.call_tool( - "semantic_search", - {"query": "test", "data_sources": ["repo"], "max_results": 0}, - raise_on_error=False, - ) - assert result.is_error - assert "max_results" in _text(result) - - @pytest.mark.asyncio - async def test_max_results_boundary_501_rejected(self): - mcp = _server({}) - async with Client(mcp) as client: - result = await client.call_tool( - "semantic_search", - {"query": "test", "data_sources": ["repo"], "max_results": 501}, - raise_on_error=False, - ) - assert result.is_error - assert "max_results" in _text(result) - - @pytest.mark.asyncio - async def test_max_results_boundary_500_accepted(self): - def handler(req): - assert req.url.params.get("MaxResults") == "500" - return httpx.Response(200, json={"results": []}) - - mcp = _server({"/api/search/semantic": handler}) - async with Client(mcp) as client: - await client.call_tool( - "semantic_search", - {"query": "test", "data_sources": ["repo"], "max_results": 500}, - ) - - @pytest.mark.asyncio - async def test_max_results_boundary_1_accepted(self): - def handler(req): - assert req.url.params.get("MaxResults") == "1" - return httpx.Response(200, json={"results": []}) - - mcp = _server({"/api/search/semantic": handler}) - async with Client(mcp) as client: - await client.call_tool( - "semantic_search", - {"query": "test", "data_sources": ["repo"], "max_results": 1}, - ) - - @pytest.mark.asyncio - async def test_extensions_forwarded(self): - def handler(req): - assert req.url.params.get_list("Extensions") == [".cs", ".py"] - return httpx.Response(200, json={"results": []}) - - mcp = _server({"/api/search/semantic": handler}) - async with Client(mcp) as client: - await client.call_tool( - "semantic_search", - {"query": "test", "data_sources": ["repo"], "extensions": [".cs", ".py"]}, - ) - - @pytest.mark.asyncio - async def test_paths_forwarded(self): - def handler(req): - assert req.url.params.get_list("Paths") == ["src/services", "src/domain"] - return httpx.Response(200, json={"results": []}) - - mcp = _server({"/api/search/semantic": handler}) - async with Client(mcp) as client: - await client.call_tool( - "semantic_search", - {"query": "test", "data_sources": ["repo"], "paths": ["src/services", "src/domain"]}, - ) - - @pytest.mark.asyncio - async def test_multiple_data_sources_forwarded(self): - def handler(req): - assert req.url.params.get_list("Names") == ["repo-a", "repo-b"] - return httpx.Response(200, json={"results": []}) - - mcp = _server({"/api/search/semantic": handler}) - async with Client(mcp) as client: - await client.call_tool( - "semantic_search", - {"query": "test", "data_sources": ["repo-a", "repo-b"]}, - ) - - @pytest.mark.asyncio - async def test_all_filters_combined(self): - def handler(req): - assert req.url.params.get("Query") == "pattern" - assert req.url.params.get("MaxResults") == "10" - assert req.url.params.get_list("Names") == ["backend"] - assert req.url.params.get_list("Paths") == ["src/domain"] - assert req.url.params.get_list("Extensions") == [".cs"] - return httpx.Response(200, json={"results": []}) - - mcp = _server({"/api/search/semantic": handler}) - async with Client(mcp) as client: - await client.call_tool( - "semantic_search", - { - "query": "pattern", - "data_sources": ["backend"], - "paths": ["src/domain"], - "extensions": [".cs"], - "max_results": 10, - }, - ) - - @pytest.mark.asyncio - async def test_empty_data_sources_omits_names(self): - def handler(req): - assert "Names" not in dict(req.url.params) - return httpx.Response(200, json={"results": []}) - - mcp = _server({"/api/search/semantic": handler}) - async with Client(mcp) as client: - await client.call_tool( - "semantic_search", - {"query": "test", "data_sources": []}, - ) - - @pytest.mark.asyncio - async def test_data_sources_as_string_normalized(self): - def handler(req): - assert req.url.params.get_list("Names") == ["my-repo"] - return httpx.Response(200, json={"results": []}) - - mcp = _server({"/api/search/semantic": handler}) - async with Client(mcp) as client: - await client.call_tool( - "semantic_search", - {"query": "test", "data_sources": "my-repo"}, - ) - - @pytest.mark.asyncio - async def test_404_includes_recovery_hint(self): - mcp = _server({ - "/api/search/semantic": lambda r: httpx.Response(404, text="not found"), - }) - async with Client(mcp) as client: - result = await client.call_tool( - "semantic_search", - {"query": "test", "data_sources": ["bad-repo"]}, - raise_on_error=False, - ) - text = _text(result) - assert result.is_error - assert "get_data_sources" in text - - @pytest.mark.asyncio - async def test_unicode_preserved_in_response(self): - """Cyrillic in path/description must survive as UTF-8, not \\uXXXX.""" - payload = { - "results": [ - { - "kind": "File", - "identifier": "org/repo::база/file.md::", - "description": "Описание про принтеры HPRT", - "location": {"path": "база/file.md"}, - "contentByteSize": 100, - } - ] - } - - mcp = _server({"/api/search/semantic": lambda r: httpx.Response(200, json=payload)}) - async with Client(mcp) as client: - result = await client.call_tool( - "semantic_search", {"query": "кир", "data_sources": ["repo"]}, - ) - - text = _text(result) - assert text == json.dumps(json.loads(text), separators=(",", ":"), ensure_ascii=False) - assert "база/file.md" in text - assert "\\u04" not in text - - -# --------------------------------------------------------------------------- -# grep_search -# --------------------------------------------------------------------------- - -class TestGrepSearchE2E: - @pytest.mark.asyncio - async def test_success_hits_canonical_endpoint(self): - def handler(req): - assert req.url.params.get("Query") == "auth\\(" - assert req.url.params.get("Regex") == "true" - assert req.headers["X-CodeAlive-Tool"] == "grep_search" - return httpx.Response( - 200, - json={ - "results": [ - { - "identifier": "org/repo::src/auth.py", - "kind": "File", - "matchCount": 2, - "matches": [ - { - "lineNumber": 15, - "startColumn": 5, - "endColumn": 10, - "lineText": "auth(token)", - } - ], - "location": { - "path": "src/auth.py", - "range": {"start": {"line": 15}, "end": {"line": 15}}, - }, - } - ] - }, - ) - - mcp = _server({"/api/search/grep": handler}) - async with Client(mcp) as client: - result = await client.call_tool( - "grep_search", - {"query": "auth\\(", "data_sources": ["backend"], "regex": True}, - ) - - data = json.loads(_text(result)) - assert data["results"][0]["matchCount"] == 2 - assert data["results"][0]["matches"][0]["lineNumber"] == 15 - assert "fetch_artifacts" in data["hint"] or "Read()" in data["hint"] - - @pytest.mark.asyncio - async def test_regex_false_forwarded(self): - def handler(req): - assert req.url.params.get("Regex") == "false" - return httpx.Response(200, json={"results": []}) - - mcp = _server({"/api/search/grep": handler}) - async with Client(mcp) as client: - await client.call_tool( - "grep_search", - {"query": "literal string", "data_sources": ["repo"], "regex": False}, - ) - - @pytest.mark.asyncio - async def test_regex_default_is_false(self): - def handler(req): - assert req.url.params.get("Regex") == "false" - return httpx.Response(200, json={"results": []}) - - mcp = _server({"/api/search/grep": handler}) - async with Client(mcp) as client: - await client.call_tool( - "grep_search", - {"query": "literal string", "data_sources": ["repo"]}, - ) - - @pytest.mark.asyncio - async def test_max_results_forwarded(self): - def handler(req): - assert req.url.params.get("MaxResults") == "10" - return httpx.Response(200, json={"results": []}) - - mcp = _server({"/api/search/grep": handler}) - async with Client(mcp) as client: - await client.call_tool( - "grep_search", - {"query": "test", "data_sources": ["repo"], "max_results": 10}, - ) - - @pytest.mark.asyncio - async def test_max_results_boundary_0_rejected(self): - mcp = _server({}) - async with Client(mcp) as client: - result = await client.call_tool( - "grep_search", - {"query": "test", "data_sources": ["repo"], "max_results": 0}, - raise_on_error=False, - ) - assert result.is_error - assert "max_results" in _text(result) - - @pytest.mark.asyncio - async def test_max_results_boundary_501_rejected(self): - mcp = _server({}) - async with Client(mcp) as client: - result = await client.call_tool( - "grep_search", - {"query": "test", "data_sources": ["repo"], "max_results": 501}, - raise_on_error=False, - ) - assert result.is_error - assert "max_results" in _text(result) - - @pytest.mark.asyncio - async def test_extensions_forwarded(self): - def handler(req): - assert req.url.params.get_list("Extensions") == [".ts", ".vue"] - return httpx.Response(200, json={"results": []}) - - mcp = _server({"/api/search/grep": handler}) - async with Client(mcp) as client: - await client.call_tool( - "grep_search", - {"query": "test", "data_sources": ["repo"], "extensions": [".ts", ".vue"]}, - ) - - @pytest.mark.asyncio - async def test_paths_forwarded(self): - def handler(req): - assert req.url.params.get_list("Paths") == ["src/controllers"] - return httpx.Response(200, json={"results": []}) - - mcp = _server({"/api/search/grep": handler}) - async with Client(mcp) as client: - await client.call_tool( - "grep_search", - {"query": "test", "data_sources": ["repo"], "paths": ["src/controllers"]}, - ) - - @pytest.mark.asyncio - async def test_all_filters_combined(self): - def handler(req): - assert req.url.params.get("Query") == "Status\\.Alive" - assert req.url.params.get("Regex") == "true" - assert req.url.params.get("MaxResults") == "5" - assert req.url.params.get_list("Names") == ["backend"] - assert req.url.params.get_list("Paths") == ["src/services"] - assert req.url.params.get_list("Extensions") == [".cs"] - return httpx.Response(200, json={"results": []}) - - mcp = _server({"/api/search/grep": handler}) - async with Client(mcp) as client: - await client.call_tool( - "grep_search", - { - "query": "Status\\.Alive", - "data_sources": ["backend"], - "paths": ["src/services"], - "extensions": [".cs"], - "max_results": 5, - "regex": True, - }, - ) - - @pytest.mark.asyncio - async def test_empty_query_returns_error(self): - mcp = _server({}) - async with Client(mcp) as client: - result = await client.call_tool( - "grep_search", - {"query": "", "data_sources": ["repo"]}, - raise_on_error=False, - ) - assert result.is_error - assert "empty" in _text(result).lower() or "Query cannot be empty" in _text(result) - - @pytest.mark.asyncio - async def test_data_sources_as_string_normalized(self): - def handler(req): - assert req.url.params.get_list("Names") == ["my-repo"] - return httpx.Response(200, json={"results": []}) - - mcp = _server({"/api/search/grep": handler}) - async with Client(mcp) as client: - await client.call_tool( - "grep_search", - {"query": "test", "data_sources": "my-repo"}, - ) - - @pytest.mark.asyncio - async def test_404_includes_recovery_hint(self): - mcp = _server({ - "/api/search/grep": lambda r: httpx.Response(404, text="not found"), - }) - async with Client(mcp) as client: - result = await client.call_tool( - "grep_search", - {"query": "test", "data_sources": ["bad-repo"]}, - raise_on_error=False, - ) - assert result.is_error - assert "get_data_sources" in _text(result) - - @pytest.mark.asyncio - async def test_unicode_preserved_in_response(self): - """Cyrillic in path/lineText must survive as UTF-8, not \\uXXXX.""" - payload = { - "results": [ - { - "kind": "File", - "identifier": "org/repo::база/file.md::", - "location": {"path": "база/file.md"}, - "matchCount": 1, - "matches": [{"lineNumber": 3, "startColumn": 1, "endColumn": 5, - "lineText": "тест кириллица"}], - } - ] - } - mcp = _server({"/api/search/grep": lambda r: httpx.Response(200, json=payload)}) - async with Client(mcp) as client: - result = await client.call_tool( - "grep_search", {"query": "кир", "data_sources": ["repo"]}, - ) - - text = _text(result) - assert text == json.dumps(json.loads(text), separators=(",", ":"), ensure_ascii=False) - assert "тест кириллица" in text - assert "\\u04" not in text - - -# --------------------------------------------------------------------------- -# fetch_artifacts -# --------------------------------------------------------------------------- - -class TestFetchArtifactsE2E: - _ARTIFACTS_RESPONSE = { - "artifacts": [ - { - "identifier": "org/repo::src/auth.py::AuthService", - "content": "class AuthService:\n pass\n", - "contentByteSize": 28, - "startLine": 10, - } - ] - } - - @pytest.mark.asyncio - async def test_success_returns_xml_with_content(self): - def handler(req): - body = json.loads(req.content) - assert body["identifiers"] == ["org/repo::src/auth.py::AuthService"] - return httpx.Response(200, json=self._ARTIFACTS_RESPONSE) - - mcp = _server({"/api/search/artifacts": handler}) - async with Client(mcp) as client: - result = await client.call_tool( - "fetch_artifacts", - {"identifiers": ["org/repo::src/auth.py::AuthService"]}, - ) - - xml = _text(result) - assert "" in xml - assert "AuthService" in xml - assert "class AuthService" in xml - # Content body sits between newlines inside - assert "\n" in xml - assert "\n " in xml - - @pytest.mark.asyncio - async def test_not_found_surfaced_with_found_flag(self): - payload = { - "artifacts": [ - {"identifier": "org/repo::src/auth.py::AuthService", "found": True, - "content": "class AuthService:\n pass\n", "contentByteSize": 28, "startLine": 10}, - {"identifier": "org/repo::src/missing.py::Gone", "found": False, "content": None}, - ] - } - mcp = _server({"/api/search/artifacts": lambda r: httpx.Response(200, json=payload)}) - async with Client(mcp) as client: - result = await client.call_tool( - "fetch_artifacts", - {"identifiers": ["org/repo::src/auth.py::AuthService", "org/repo::src/missing.py::Gone"]}, - ) - - xml = _text(result) - assert "" in xml - assert "AuthService" in xml - - @pytest.mark.asyncio - async def test_single_string_identifier_coerced(self): - """A bare string identifier should be wrapped into a list.""" - def handler(req): - body = json.loads(req.content) - assert body["identifiers"] == ["org/repo::src/auth.py"] - return httpx.Response(200, json=self._ARTIFACTS_RESPONSE) - - mcp = _server({"/api/search/artifacts": handler}) - async with Client(mcp) as client: - result = await client.call_tool( - "fetch_artifacts", - {"identifiers": "org/repo::src/auth.py"}, - ) - - xml = _text(result) - assert "" in xml - - @pytest.mark.asyncio - async def test_unicode_preserved_in_xml(self): - """Cyrillic in identifier and content must survive into the XML output.""" - payload = { - "artifacts": [ - { - "identifier": "org/repo::файл.cs::Класс.Метод", - "content": "класс Привет {\n метод() => 42\n}\n", - "contentByteSize": 100, - "startLine": 1, - } - ] - } - mcp = _server({"/api/search/artifacts": lambda r: httpx.Response(200, json=payload)}) - async with Client(mcp) as client: - result = await client.call_tool( - "fetch_artifacts", - {"identifiers": ["org/repo::файл.cs::Класс.Метод"]}, - ) - - xml = _text(result) - assert "Класс.Метод" in xml - assert "класс Привет" in xml - assert "\\u04" not in xml - - -# --------------------------------------------------------------------------- -# Stringified parameter coercion for search tools -# --------------------------------------------------------------------------- - -class TestSearchStringifiedParamsE2E: - """Verify that search tools accept stringified JSON arrays for list params.""" - - @pytest.mark.asyncio - async def test_semantic_search_stringified_extensions(self): - def handler(req): - assert req.url.params.get_list("Extensions") == [".cs", ".py"] - return httpx.Response(200, json={"results": []}) - - mcp = _server({"/api/search/semantic": handler}) - async with Client(mcp) as client: - await client.call_tool( - "semantic_search", - {"query": "test", "data_sources": ["repo"], "extensions": '[".cs", ".py"]'}, - ) - - @pytest.mark.asyncio - async def test_semantic_search_stringified_paths(self): - def handler(req): - assert req.url.params.get_list("Paths") == ["src/services", "src/domain"] - return httpx.Response(200, json={"results": []}) - - mcp = _server({"/api/search/semantic": handler}) - async with Client(mcp) as client: - await client.call_tool( - "semantic_search", - {"query": "test", "data_sources": ["repo"], "paths": '["src/services", "src/domain"]'}, - ) - - @pytest.mark.asyncio - async def test_grep_search_stringified_extensions(self): - def handler(req): - assert req.url.params.get_list("Extensions") == [".ts"] - return httpx.Response(200, json={"results": []}) - - mcp = _server({"/api/search/grep": handler}) - async with Client(mcp) as client: - await client.call_tool( - "grep_search", - {"query": "test", "data_sources": ["repo"], "extensions": '[".ts"]'}, - ) - - -# --------------------------------------------------------------------------- -# chat / codebase_consultant (streaming SSE) -# --------------------------------------------------------------------------- - -class TestChatE2E: - @staticmethod - def _sse_body( - chunks: list[str], - conv_id: str = "69fceb3e7b2a6a7efdd18180", - msg_id: str = "69fceb3e7b2a6a7efdd18181", - ) -> str: - """Build an SSE response body with metadata + content chunks + DONE.""" - lines = [ - "event: message", - f'data: {{"event":"metadata","conversationId":"{conv_id}","messageId":"{msg_id}"}}', - "", - ] - for chunk in chunks: - payload = json.dumps({"choices": [{"delta": {"content": chunk}}]}) - lines.append(f"data: {payload}") - lines.append("") - lines.append("data: [DONE]") - lines.append("") - return "\n".join(lines) - - @pytest.mark.asyncio - async def test_streaming_success(self): - body = self._sse_body(["Hello ", "world!"]) - - def handler(req): - data = json.loads(req.content) - assert data["stream"] is True - assert data["messages"][0]["content"] == "How does auth work?" - assert req.headers["accept"] == "text/event-stream, application/problem+json" - return httpx.Response(200, text=body, headers={"content-type": "text/event-stream"}) - - mcp = _server({"/api/chat/completions": handler}) - async with Client(mcp) as client: - result = await client.call_tool( - "chat", - {"question": "How does auth work?", "data_sources": ["backend"]}, - ) - - text = _text(result) - assert "Hello world!" in text - # New conversation gets ID appended - assert "69fceb3e7b2a6a7efdd18180" in text - - @pytest.mark.asyncio - async def test_continuing_conversation(self): - conversation_id = "69fceb3e7b2a6a7efdd18180" - body = self._sse_body(["Follow-up answer"], conv_id=conversation_id) - - def handler(req): - data = json.loads(req.content) - assert data["conversationId"] == conversation_id - return httpx.Response(200, text=body, headers={"content-type": "text/event-stream"}) - - mcp = _server({"/api/chat/completions": handler}) - async with Client(mcp) as client: - result = await client.call_tool( - "chat", - {"question": "And the error handling?", "conversation_id": conversation_id}, - ) - - text = _text(result) - assert "Follow-up answer" in text - - @pytest.mark.asyncio - async def test_invalid_conversation_id_returns_actionable_tool_error(self): - mcp = _server({}) - async with Client(mcp) as client: - result = await client.call_tool( - "chat", - {"question": "And the error handling?", "conversation_id": "conv-existing"}, - raise_on_error=False, - ) - - text = _text(result) - assert "24-character hex Mongo ObjectId" in text - assert "Retry: no" in text - - @pytest.mark.asyncio - async def test_empty_question_returns_error(self): - mcp = _server({}) - async with Client(mcp) as client: - result = await client.call_tool( - "chat", {"question": ""}, - raise_on_error=False, - ) - - text = _text(result) - assert "error" in text.lower() or "question" in text.lower() - - @pytest.mark.asyncio - async def test_backend_error_handled(self): - mcp = _server({ - "/api/chat/completions": lambda r: httpx.Response(401, text="unauthorized"), - }) - async with Client(mcp) as client: - result = await client.call_tool( - "chat", - {"question": "hello"}, - raise_on_error=False, - ) - - text = _text(result) - assert "401" in text or "auth" in text.lower() - - @pytest.mark.asyncio - async def test_problem_details_backend_error_keeps_detail_and_request_id(self): - problem = { - "type": "https://app.codealive.ai/errors/bad-request", - "title": "Bad request", - "status": 400, - "detail": "Message content violates our content policy", - "requestId": "req-rest", - } - - mcp = _server({ - "/api/chat/completions": lambda r: httpx.Response( - 400, - json=problem, - headers={"content-type": "application/problem+json"}, - ), - }) - async with Client(mcp) as client: - result = await client.call_tool( - "chat", - {"question": "hello"}, - raise_on_error=False, - ) - - text = _text(result) - assert "Message content violates our content policy" in text - assert "requestId=req-rest" in text - assert "Retry: no" in text - - @pytest.mark.asyncio - async def test_named_sse_problem_details_error_returns_tool_error(self): - problem = json.dumps({ - "type": "https://app.codealive.ai/errors/bad-request", - "title": "Bad request", - "status": 400, - "detail": "Message content violates our content policy", - "requestId": "req-sse", - }) - body = f"event: error\ndata: {problem}\n\n" - - mcp = _server({ - "/api/chat/completions": lambda r: httpx.Response( - 200, - text=body, - headers={"content-type": "text/event-stream"}, - ), - }) - async with Client(mcp) as client: - result = await client.call_tool( - "chat", - {"question": "hello", "data_sources": ["backend"]}, - raise_on_error=False, - ) - - text = _text(result) - assert "Message content violates our content policy" in text - assert "Code: 400" in text - assert "requestId=req-sse" in text - assert "Retry: no" in text - - @pytest.mark.asyncio - async def test_named_sse_rate_limit_error_is_retryable(self): - problem = json.dumps({ - "type": "https://app.codealive.ai/errors/plan-limit", - "title": "Plan limit", - "status": 429, - "detail": "Chat completion rate limit exceeded", - "requestId": "req-sse-429", - }) - body = f"event: error\ndata: {problem}\n\n" - - mcp = _server({ - "/api/chat/completions": lambda r: httpx.Response( - 200, - text=body, - headers={"content-type": "text/event-stream"}, - ), - }) - async with Client(mcp) as client: - result = await client.call_tool( - "chat", - {"question": "hello", "data_sources": ["backend"]}, - raise_on_error=False, - ) - - text = _text(result) - assert "Chat completion rate limit exceeded" in text - assert "Retry: yes" in text - assert "back off" in text - assert "requestId=req-sse-429" in text - - @pytest.mark.asyncio - async def test_unicode_preserved_in_streamed_response(self): - """Cyrillic chunks streamed via SSE must survive as UTF-8 in the final text.""" - body = self._sse_body(["Привет, ", "мир!"]) - - mcp = _server({ - "/api/chat/completions": lambda r: httpx.Response( - 200, text=body, headers={"content-type": "text/event-stream"}, - ), - }) - async with Client(mcp) as client: - result = await client.call_tool( - "chat", - {"question": "Как работает аутентификация?", "data_sources": ["backend"]}, - ) - - text = _text(result) - assert "Привет, мир!" in text - assert "\\u04" not in text - - @pytest.mark.asyncio - async def test_legacy_alias_still_works(self): - body = self._sse_body(["Legacy alias"]) - - def handler(req): - assert req.headers["X-CodeAlive-Tool"] == "codebase_consultant" - return httpx.Response(200, text=body, headers={"content-type": "text/event-stream"}) - - mcp = _server({"/api/chat/completions": handler}) - async with Client(mcp) as client: - result = await client.call_tool( - "codebase_consultant", - {"question": "How does auth work?", "data_sources": ["backend"]}, - ) - - assert "Legacy alias" in _text(result) - - -# --------------------------------------------------------------------------- -# get_artifact_relationships -# --------------------------------------------------------------------------- - -class TestGetArtifactRelationshipsE2E: - _RELATIONSHIPS_RESPONSE = { - "sourceIdentifier": "org/repo::src/svc.py::Service", - "profile": "CallsOnly", - "found": True, - "availableRelationshipCounts": { - "outgoingCalls": 3, - "incomingCalls": 1, - "ancestors": 0, - "descendants": 0, - "references": 2, - }, - "relationships": [ - { - "relationType": "OutgoingCalls", - "totalCount": 3, - "returnedCount": 3, - "truncated": False, - "items": [ - {"identifier": "org/repo::src/db.py::query", "filePath": "src/db.py", "startLine": 42}, - {"identifier": "org/repo::src/cache.py::get", "filePath": "src/cache.py", "startLine": 10, - "shortSummary": "Cache lookup"}, - ], - }, - { - "relationType": "IncomingCalls", - "totalCount": 1, - "returnedCount": 1, - "truncated": False, - "items": [ - {"identifier": "org/repo::src/main.py::run", "filePath": "src/main.py", "startLine": 5}, - ], - }, - ], - } - - @pytest.mark.asyncio - async def test_success_returns_compact_json(self): - def handler(req): - body = json.loads(req.content) - assert body["identifier"] == "org/repo::src/svc.py::Service" - assert body["profile"] == "CallsOnly" - return httpx.Response(200, json=self._RELATIONSHIPS_RESPONSE) - - mcp = _server({"/api/search/artifact-relationships": handler}) - async with Client(mcp) as client: - result = await client.call_tool( - "get_artifact_relationships", - {"identifier": "org/repo::src/svc.py::Service", "profile": "callsOnly"}, - ) - - text = _text(result) - data = json.loads(text) - # FastMCP serializes via pydantic_core.to_json — compact, UTF-8. - assert text == json.dumps(data, separators=(",", ":"), ensure_ascii=False) - assert data["found"] is True - assert data["availableRelationshipCounts"]["references"] == 2 - assert "Fetch promising related artifacts" in data["hint"] - types = [g["type"] for g in data["relationships"]] - assert "outgoing_calls" in types - assert "incoming_calls" in types - outgoing_items = data["relationships"][0]["items"] - assert any(item.get("shortSummary") == "Cache lookup" for item in outgoing_items) - assert any(item.get("filePath") == "src/db.py" for item in outgoing_items) - - @pytest.mark.asyncio - async def test_not_found(self): - response_data = { - "sourceIdentifier": "org/repo::missing", - "profile": "CallsOnly", - "found": False, - } - mcp = _server({ - "/api/search/artifact-relationships": lambda r: httpx.Response(200, json=response_data), - }) - async with Client(mcp) as client: - result = await client.call_tool( - "get_artifact_relationships", - {"identifier": "org/repo::missing"}, - ) - - data = json.loads(_text(result)) - assert data["found"] is False - assert "relationships" not in data - - @pytest.mark.asyncio - async def test_invalid_profile_returns_error(self): - """Pydantic rejects invalid Literal values before the function body runs.""" - mcp = _server({}) - async with Client(mcp) as client: - result = await client.call_tool( - "get_artifact_relationships", - {"identifier": "org/repo::x", "profile": "bogus"}, - raise_on_error=False, - ) - - text = _text(result) - # Pydantic Literal validation fires before the function body, producing - # a human-readable validation error (not our custom JSON). - assert "callsOnly" in text - assert "literal_error" in text or "Input should be" in text - - @pytest.mark.asyncio - async def test_invalid_profile_is_logged_with_arguments_by_middleware(self): - """FastMCP validation fails before the tool body, so middleware must capture args.""" - mcp = _server({}) - mcp.add_middleware(ObservabilityMiddleware()) - records = [] - handler_id = logger.add(lambda message: records.append(message.record), level="DEBUG") - - try: - async with Client(mcp) as client: - result = await client.call_tool( - "get_artifact_relationships", - {"identifier": "org/repo::x", "profile": "bogus"}, - raise_on_error=False, - ) - finally: - logger.remove(handler_id) - - assert result.is_error - failures = [ - record for record in records - if record["message"] == "Tool call failed: get_artifact_relationships" - ] - assert len(failures) == 1 - failure = failures[0] - assert failure["level"].name == "WARNING" - assert failure["extra"]["tool_arguments"] == { - "identifier": "org/repo::x", - "profile": "bogus", - } - assert failure["extra"]["error_type"] == "ValidationError" - - @pytest.mark.asyncio - async def test_empty_identifier_returns_error(self): - mcp = _server({}) - async with Client(mcp) as client: - result = await client.call_tool( - "get_artifact_relationships", - {"identifier": ""}, - raise_on_error=False, - ) - - assert result.is_error - assert "required" in _text(result).lower() - - @pytest.mark.asyncio - async def test_max_count_per_type_0_rejected(self): - mcp = _server({}) - async with Client(mcp) as client: - result = await client.call_tool( - "get_artifact_relationships", - {"identifier": "org/repo::x", "max_count_per_type": 0}, - raise_on_error=False, - ) - assert result.is_error - assert "max_count_per_type" in _text(result) - - @pytest.mark.asyncio - async def test_max_count_per_type_1001_rejected(self): - mcp = _server({}) - async with Client(mcp) as client: - result = await client.call_tool( - "get_artifact_relationships", - {"identifier": "org/repo::x", "max_count_per_type": 1001}, - raise_on_error=False, - ) - assert result.is_error - assert "max_count_per_type" in _text(result) - - @pytest.mark.asyncio - async def test_max_count_per_type_negative_rejected(self): - mcp = _server({}) - async with Client(mcp) as client: - result = await client.call_tool( - "get_artifact_relationships", - {"identifier": "org/repo::x", "max_count_per_type": -1}, - raise_on_error=False, - ) - assert result.is_error - assert "max_count_per_type" in _text(result) - - @pytest.mark.asyncio - async def test_max_count_per_type_forwarded(self): - response_data = { - "sourceIdentifier": "org/repo::src/svc.py::run", - "profile": "CallsOnly", - "found": True, - "relationships": [], - } - - def handler(req): - body = json.loads(req.content) - assert body["maxCountPerType"] == 3 - return httpx.Response(200, json=response_data) - - mcp = _server({"/api/search/artifact-relationships": handler}) - async with Client(mcp) as client: - result = await client.call_tool( - "get_artifact_relationships", - {"identifier": "org/repo::src/svc.py::run", "max_count_per_type": 3}, - ) - - data = json.loads(_text(result)) - assert data["found"] is True - - @pytest.mark.asyncio - async def test_all_relevant_profile(self): - response_data = { - "sourceIdentifier": "org/repo::cls", - "profile": "AllRelevant", - "found": True, - "relationships": [ - {"relationType": "OutgoingCalls", "totalCount": 0, "returnedCount": 0, "truncated": False, "items": []}, - {"relationType": "IncomingCalls", "totalCount": 0, "returnedCount": 0, "truncated": False, "items": []}, - {"relationType": "Ancestors", "totalCount": 1, "returnedCount": 1, "truncated": False, "items": [{"identifier": "org/repo::Base"}]}, - {"relationType": "Descendants", "totalCount": 0, "returnedCount": 0, "truncated": False, "items": []}, - ], - } - - def handler(req): - body = json.loads(req.content) - assert body["profile"] == "AllRelevant" - return httpx.Response(200, json=response_data) - - mcp = _server({"/api/search/artifact-relationships": handler}) - async with Client(mcp) as client: - result = await client.call_tool( - "get_artifact_relationships", - {"identifier": "org/repo::cls", "profile": "allRelevant"}, - ) - - data = json.loads(_text(result)) - assert data["profile"] == "allRelevant" - types = [g["type"] for g in data["relationships"]] - assert "outgoing_calls" in types - assert "incoming_calls" in types - assert "ancestors" in types - assert "descendants" in types - - @pytest.mark.asyncio - async def test_references_profile(self): - response_data = { - "sourceIdentifier": "org/repo::var", - "profile": "ReferencesOnly", - "found": True, - "relationships": [ - {"relationType": "References", "totalCount": 5, "returnedCount": 5, "truncated": False, "items": [ - {"identifier": "org/repo::src/a.py::func_a", "filePath": "src/a.py", "startLine": 10} - ]}, - ], - } - - def handler(req): - body = json.loads(req.content) - assert body["profile"] == "ReferencesOnly" - return httpx.Response(200, json=response_data) - - mcp = _server({"/api/search/artifact-relationships": handler}) - async with Client(mcp) as client: - result = await client.call_tool( - "get_artifact_relationships", - {"identifier": "org/repo::var", "profile": "referencesOnly"}, - ) - - data = json.loads(_text(result)) - assert data["profile"] == "referencesOnly" - assert data["relationships"][0]["type"] == "references" - assert data["relationships"][0]["totalCount"] == 5 - - @pytest.mark.asyncio - async def test_unicode_preserved_in_response(self): - """Cyrillic in identifiers/summaries must survive as UTF-8, not \\uXXXX.""" - response_data = { - "sourceIdentifier": "org/repo::файл.cs::Класс.Метод", - "profile": "CallsOnly", - "found": True, - "relationships": [ - { - "relationType": "OutgoingCalls", - "totalCount": 1, - "returnedCount": 1, - "truncated": False, - "items": [{"identifier": "org/repo::другой.cs::Метод2", - "filePath": "другой.cs", - "shortSummary": "Описание метода"}], - } - ], - } - mcp = _server({ - "/api/search/artifact-relationships": lambda r: httpx.Response(200, json=response_data), - }) - async with Client(mcp) as client: - result = await client.call_tool( - "get_artifact_relationships", - {"identifier": "org/repo::файл.cs::Класс.Метод"}, - ) - - text = _text(result) - assert text == json.dumps(json.loads(text), separators=(",", ":"), ensure_ascii=False) - assert "Класс.Метод" in text - assert "Описание метода" in text - assert "\\u04" not in text - - @pytest.mark.asyncio - async def test_inheritance_profile_maps_correctly(self): - response_data = { - "sourceIdentifier": "org/repo::cls", - "profile": "InheritanceOnly", - "found": True, - "relationships": [ - { - "relationType": "Ancestors", - "totalCount": 1, - "returnedCount": 1, - "truncated": False, - "items": [{"identifier": "org/repo::Base", "filePath": "base.py", "startLine": 1}], - } - ], - } - - def handler(req): - body = json.loads(req.content) - assert body["profile"] == "InheritanceOnly" - return httpx.Response(200, json=response_data) - - mcp = _server({"/api/search/artifact-relationships": handler}) - async with Client(mcp) as client: - result = await client.call_tool( - "get_artifact_relationships", - {"identifier": "org/repo::cls", "profile": "inheritanceOnly"}, - ) - - data = json.loads(_text(result)) - assert data["profile"] == "inheritanceOnly" - assert data["relationships"][0]["type"] == "ancestors" diff --git a/src/tests/test_error_handling.py b/src/tests/test_error_handling.py index 471e849..7a1b7b4 100644 --- a/src/tests/test_error_handling.py +++ b/src/tests/test_error_handling.py @@ -79,7 +79,7 @@ async def test_handle_500_server_error(): @pytest.mark.asyncio async def test_handle_422_data_source_not_ready(): - """422 errors must point at get_data_sources(alive_only=false).""" + """422 errors must point at get_data_sources(ready_only=false).""" ctx = MagicMock() ctx.error = AsyncMock() @@ -88,7 +88,7 @@ async def test_handle_422_data_source_not_ready(): error_msg = ctx.error.call_args[0][0] assert "Retry: yes" in error_msg - assert "alive_only=false" in error_msg + assert "ready_only=false" in error_msg @pytest.mark.asyncio @@ -170,7 +170,7 @@ async def test_recovery_hints_override_default_404(): ctx = MagicMock() ctx.error = AsyncMock() - custom = "(1) check conversation_id, (2) drop conversation_id and retry" + custom = "(1) include prior context in the stateless chat question, (2) retry without legacy conversation fields" with pytest.raises(ToolError) as exc_info: await handle_api_error( ctx, _make_http_error(404), "chat", diff --git a/src/tests/test_fetch_artifacts.py b/src/tests/test_fetch_artifacts.py deleted file mode 100644 index 209c154..0000000 --- a/src/tests/test_fetch_artifacts.py +++ /dev/null @@ -1,794 +0,0 @@ -"""Test suite for fetch_artifacts tool.""" - -import pytest -from unittest.mock import AsyncMock, MagicMock, patch -from fastmcp import Context -from fastmcp.exceptions import ToolError -from tools.fetch_artifacts import ( - _add_line_numbers, - _build_artifacts_xml, - _has_any_calls, - fetch_artifacts, -) - - -class TestAddLineNumbers: - """Test cases for _add_line_numbers helper.""" - - def test_multi_line_content(self): - content = "line1\nline2\nline3" - result = _add_line_numbers(content) - assert result == "1 | line1\n2 | line2\n3 | line3" - - def test_single_line_content(self): - content = "only one line" - result = _add_line_numbers(content) - assert result == "1 | only one line" - - def test_empty_content(self): - assert _add_line_numbers("") == "" - - def test_right_aligned_padding(self): - lines = "\n".join(f"line{i}" for i in range(100)) - result = _add_line_numbers(lines) - first_line = result.split("\n")[0] - assert first_line == " 1 | line0" - last_line = result.split("\n")[99] - assert last_line == "100 | line99" - - def test_start_line_offset(self): - result = _add_line_numbers("a\nb", start_line=50) - assert result == "50 | a\n51 | b" - - def test_start_line_default(self): - result = _add_line_numbers("x", start_line=1) - assert result == "1 | x" - - def test_start_line_right_aligned_padding(self): - result = _add_line_numbers("a\nb\nc", start_line=98) - assert result == " 98 | a\n 99 | b\n100 | c" - - def test_start_line_empty_content(self): - assert _add_line_numbers("", start_line=50) == "" - - -class TestBuildArtifactsXmlStartLine: - """Test _build_artifacts_xml uses startLine from API response.""" - - def test_artifact_with_start_line(self): - data = {"artifacts": [ - {"identifier": "repo::file.py::func", "content": "line1\nline2", "contentByteSize": 10, "startLine": 50} - ]} - result = _build_artifacts_xml(data) - assert "50 | line1" in result - assert "51 | line2" in result - - def test_artifact_without_start_line_defaults_to_1(self): - data = {"artifacts": [ - {"identifier": "repo::file.py::func", "content": "line1\nline2", "contentByteSize": 10} - ]} - result = _build_artifacts_xml(data) - assert "1 | line1" in result - assert "2 | line2" in result - - def test_artifact_with_null_start_line_defaults_to_1(self): - data = {"artifacts": [ - {"identifier": "repo::file.py", "content": "hello", "contentByteSize": 5, "startLine": None} - ]} - result = _build_artifacts_xml(data) - assert "1 | hello" in result - - -class TestBuildArtifactsXmlContentWrapper: - """Test that content is wrapped in element with newlines around it.""" - - def test_content_wrapped_in_element_with_newlines(self): - data = {"artifacts": [ - {"identifier": "repo::file.py::func", "content": "code here", "contentByteSize": 9} - ]} - result = _build_artifacts_xml(data) - assert "" in result - assert "" in result - # Content lives on its own line(s) between the open and close tags - assert "\n1 | code here\n " in result - - def test_artifact_structure_has_content_child(self): - data = {"artifacts": [ - {"identifier": "repo::f.py::fn", "content": "x = 1", "contentByteSize": 5} - ]} - result = _build_artifacts_xml(data) - assert "" in result - assert "" in result - - def test_content_is_not_html_escaped(self): - """Quotes, ampersands, and angle brackets are kept as-is inside .""" - data = {"artifacts": [ - {"identifier": "repo::f.py::fn", - "content": 'if x < 10 && y > 5: return ""', - "contentByteSize": 32} - ]} - result = _build_artifacts_xml(data) - # Raw characters preserved - assert 'if x < 10 && y > 5: return ""' in result - # No HTML escaping - assert "<" not in result - assert "&" not in result - assert """ not in result - - -class TestBuildArtifactsXmlRelationships: - """Test relationships rendering in _build_artifacts_xml.""" - - def test_relationships_with_outgoing_and_incoming(self): - data = {"artifacts": [{ - "identifier": "repo::src/a.ts::FuncA", - "content": "code", - "contentByteSize": 4, - "relationships": { - "outgoingCallsCount": 12, - "outgoingCalls": [ - {"identifier": "repo::src/b.ts::FuncB", "summary": "Validates token"}, - {"identifier": "repo::src/c.ts::FuncC", "summary": "Logs event"}, - ], - "incomingCallsCount": 3, - "incomingCalls": [ - {"identifier": "repo::src/d.ts::FuncD", "summary": "Entry point"}, - ], - } - }]} - result = _build_artifacts_xml(data) - assert "" in result - assert '' in result - assert '' in result - assert '' in result - assert '' in result - assert '' in result - assert 'identifier="repo::src/b.ts::FuncB" summary="Validates token"' in result - assert 'identifier="repo::src/d.ts::FuncD" summary="Entry point"' in result - - def test_relationships_with_only_outgoing(self): - data = {"artifacts": [{ - "identifier": "repo::src/a.ts::FuncA", - "content": "code", - "contentByteSize": 4, - "relationships": { - "outgoingCallsCount": 2, - "outgoingCalls": [ - {"identifier": "repo::src/b.ts::FuncB", "summary": "Does stuff"}, - ], - "incomingCallsCount": None, - "incomingCalls": None, - } - }]} - result = _build_artifacts_xml(data) - assert "" in result - assert "" not in result - assert "" in result - - def test_relationships_absent_omits_relationships_element(self): - data = {"artifacts": [{ - "identifier": "repo::src/a.ts", - "content": "code", - "contentByteSize": 4, - }]} - result = _build_artifacts_xml(data) - assert "" not in result - - def test_relationships_call_without_summary_omits_summary_attr(self): - data = {"artifacts": [{ - "identifier": "repo::src/a.ts::FuncA", - "content": "code", - "contentByteSize": 4, - "relationships": { - "outgoingCallsCount": 1, - "outgoingCalls": [ - {"identifier": "repo::src/b.ts::FuncB", "summary": None}, - ], - "incomingCallsCount": None, - "incomingCalls": None, - } - }]} - result = _build_artifacts_xml(data) - assert 'identifier="repo::src/b.ts::FuncB"/>' in result - assert 'summary' not in result.split('FuncB')[1].split('/>')[0] - - def test_relationships_summary_xml_escaped(self): - # Summaries are AI-generated text placed in an XML *attribute*; like identifiers they must - # be escaped so a crafted summary cannot break out of the attribute and inject pseudo-XML - # into the model context. (Source-code *content* stays raw — that is the body, - # not an attribute; see test_content_is_not_html_escaped.) - data = {"artifacts": [{ - "identifier": "repo::src/a.ts::FuncA", - "content": "code", - "contentByteSize": 4, - "relationships": { - "outgoingCallsCount": 1, - "outgoingCalls": [ - {"identifier": "repo::src/b.ts::FuncB", "summary": 'Checks if x < 10 & y > 5'}, - ], - "incomingCallsCount": None, - "incomingCalls": None, - } - }]} - result = _build_artifacts_xml(data) - # Special chars in the attribute are escaped, and the raw unescaped form is gone. - assert "<" in result - assert "&" in result - assert ">" in result - assert "x < 10 & y > 5" not in result - - def test_relationships_summary_injection_is_neutralized(self): - # A crafted summary must not break out of the attribute and inject pseudo-XML. - data = {"artifacts": [{ - "identifier": "repo::src/a.ts::FuncA", - "content": "code", - "contentByteSize": 4, - "relationships": { - "outgoingCallsCount": 1, - "outgoingCalls": [ - {"identifier": "repo::src/b.ts::FuncB", "summary": '"/>x" not in result - - -class TestHasAnyCalls: - """Test cases for _has_any_calls helper.""" - - def test_outgoing_calls_present(self): - assert _has_any_calls({"outgoingCallsCount": 5, "incomingCallsCount": 0}) is True - - def test_incoming_calls_present(self): - assert _has_any_calls({"outgoingCallsCount": 0, "incomingCallsCount": 2}) is True - - def test_both_zero(self): - assert _has_any_calls({"outgoingCallsCount": 0, "incomingCallsCount": 0}) is False - - def test_both_none(self): - assert _has_any_calls({"outgoingCallsCount": None, "incomingCallsCount": None}) is False - - def test_empty_dict(self): - assert _has_any_calls({}) is False - - -class TestBuildArtifactsXmlHint: - """Test the trailing hint that points to get_artifact_relationships.""" - - HINT_MARKER = "get_artifact_relationships" - - def test_hint_present_when_outgoing_calls_exist(self): - data = {"artifacts": [{ - "identifier": "repo::src/a.ts::FuncA", - "content": "code", - "contentByteSize": 4, - "relationships": { - "outgoingCallsCount": 12, - "outgoingCalls": [ - {"identifier": "repo::src/b.ts::FuncB", "summary": "Validates"}, - ], - "incomingCallsCount": 0, - "incomingCalls": None, - } - }]} - result = _build_artifacts_xml(data) - assert "" in result - assert self.HINT_MARKER in result - assert "" in result - # Hint must appear after relationships and before closing - assert result.index("") > result.index("") - assert result.index("") < result.index("") - - def test_hint_present_when_only_incoming_calls_exist(self): - data = {"artifacts": [{ - "identifier": "repo::src/a.ts::FuncA", - "content": "code", - "contentByteSize": 4, - "relationships": { - "outgoingCallsCount": 0, - "outgoingCalls": None, - "incomingCallsCount": 1, - "incomingCalls": [ - {"identifier": "repo::src/d.ts::FuncD", "summary": "Calls A"}, - ], - } - }]} - result = _build_artifacts_xml(data) - assert "" in result - assert self.HINT_MARKER in result - - def test_hint_absent_when_relationships_missing(self): - data = {"artifacts": [{ - "identifier": "repo::src/a.ts", - "content": "code", - "contentByteSize": 4, - }]} - result = _build_artifacts_xml(data) - assert "" not in result - assert self.HINT_MARKER not in result - - def test_hint_absent_when_relationships_null(self): - data = {"artifacts": [{ - "identifier": "repo::src/a.ts", - "content": "code", - "contentByteSize": 4, - "relationships": None, - }]} - result = _build_artifacts_xml(data) - assert "" not in result - - def test_hint_absent_when_all_call_counts_are_zero(self): - data = {"artifacts": [{ - "identifier": "repo::src/a.ts::FuncA", - "content": "code", - "contentByteSize": 4, - "relationships": { - "outgoingCallsCount": 0, - "outgoingCalls": None, - "incomingCallsCount": 0, - "incomingCalls": None, - } - }]} - result = _build_artifacts_xml(data) - assert "" not in result - - def test_hint_appears_once_with_multiple_artifacts(self): - data = {"artifacts": [ - { - "identifier": "repo::src/a.ts::FuncA", - "content": "code", - "contentByteSize": 4, - "relationships": { - "outgoingCallsCount": 2, - "outgoingCalls": [ - {"identifier": "repo::src/b.ts::FuncB", "summary": "X"}, - ], - "incomingCallsCount": 0, - "incomingCalls": None, - } - }, - { - "identifier": "repo::src/c.ts::FuncC", - "content": "code", - "contentByteSize": 4, - "relationships": { - "outgoingCallsCount": 0, - "outgoingCalls": None, - "incomingCallsCount": 3, - "incomingCalls": [ - {"identifier": "repo::src/d.ts::FuncD", "summary": "Y"}, - ], - } - }, - ]} - result = _build_artifacts_xml(data) - assert result.count("") == 1 - assert result.count(self.HINT_MARKER) == 1 - - def test_relationships_hint_absent_but_missing_surfaced_when_no_content(self): - # A not-found artifact produces no relationships preview hint (nothing to drill - # into), but it must still be surfaced — not silently dropped. - data = {"artifacts": [ - {"identifier": "repo::missing.ts::Func", "content": None, "contentByteSize": None}, - ]} - result = _build_artifacts_xml(data) - assert self.HINT_MARKER not in result - assert "" in result - assert "backend" in result - # Guides toward the two recovery moves. - assert "data_source" in result - assert "omit" in result.lower() - - def test_hint_when_empty_artifacts_and_data_source(self): - result = _build_artifacts_xml({"artifacts": []}, data_source="ds-main") - assert "ds-main" in result and "" in result - - def test_no_miss_hint_when_data_source_resolved_content(self): - data = {"artifacts": [ - {"identifier": "repo::a.ts::F", "content": "code", "contentByteSize": 4}, - ]} - result = _build_artifacts_xml(data, data_source="backend") - assert "omit data_source" not in result - - def test_no_data_source_miss_hint_without_data_source(self): - # Without a data_source selector there is no data-source-specific recovery hint, - # but the missing artifact is still surfaced in a block. - data = {"artifacts": [ - {"identifier": "repo::a.ts::F", "content": None, "contentByteSize": None}, - ]} - result = _build_artifacts_xml(data) - assert "omit data_source" not in result - assert "' in result - assert 'identifier="repo::missing.ts::G"' in result - # the found sibling is still rendered as a normal artifact - assert '' in result - assert 'identifier="repo::ghost.ts::Z"' in result - - def test_not_found_count_matches_rows(self): - data = {"artifacts": [ - {"identifier": "repo::m1::A", "found": False, "content": None}, - {"identifier": "repo::m2::B", "found": False, "content": None}, - ]} - result = _build_artifacts_xml(data) - assert '' in result - assert 'identifier="repo::m1::A"' in result - assert 'identifier="repo::m2::B"' in result - - def test_identifiers_are_xml_escaped(self): - # Crafted identifiers (caller/LLM-supplied, and any unmatched requested string lands in - # via the backstop) must not break out of the XML attribute and inject - # pseudo-XML into the model context — neither in nor . - data = {"artifacts": [ - {"identifier": 'r::ok">::F', "found": True, "content": "code", "contentByteSize": 4}, - {"identifier": 'r::bad">::G', "found": False, "content": None}, - ]} - result = _build_artifacts_xml(data) - assert "" not in result - assert "" not in result - assert ""><injected>" in result - assert ""><x>" in result - - -@pytest.mark.asyncio -@patch('tools.fetch_artifacts.get_api_key_from_context') -async def test_fetch_artifacts_returns_xml(mock_get_api_key): - """Test that fetch_artifacts returns properly formatted XML.""" - mock_get_api_key.return_value = "test_key" - - ctx = MagicMock(spec=Context) - ctx.info = AsyncMock() - ctx.warning = AsyncMock() - ctx.error = AsyncMock() - - mock_response = MagicMock() - mock_response.json.return_value = { - "artifacts": [ - { - "identifier": "owner/repo::src/auth.py::login", - "content": "def login(user, pwd):\n return True", - "contentByteSize": 38 - }, - { - "identifier": "owner/repo::src/missing.py::func", - "content": None, - "contentByteSize": None - } - ] - } - mock_response.raise_for_status = MagicMock() - - mock_client = AsyncMock() - mock_client.post.return_value = mock_response - - mock_codealive_context = MagicMock() - mock_codealive_context.client = mock_client - mock_codealive_context.base_url = "https://app.codealive.ai" - - ctx.request_context.lifespan_context = mock_codealive_context - ctx.request_context.headers = {"authorization": "Bearer test_key"} - - result = await fetch_artifacts( - ctx=ctx, - identifiers=["owner/repo::src/auth.py::login", "owner/repo::src/missing.py::func"], - ) - - assert isinstance(result, str) - assert "" in result - assert "" in result - # Found artifact has line-numbered content wrapped in - assert "" in result - assert "1 | def login(user, pwd):" in result - assert "2 | return True" in result - assert 'contentByteSize="38"' in result - assert 'identifier="owner/repo::src/auth.py::login"' in result - # Not-found artifact is surfaced in a block, not silently dropped. - assert " the field is omitted (preserves the 409-on-ambiguity fallback). - assert "dataSource" not in body - - -@pytest.mark.asyncio -@patch('tools.fetch_artifacts.get_api_key_from_context') -async def test_fetch_artifacts_forwards_data_source(mock_get_api_key): - """data_source (Name or Id) is forwarded as the DataSource body field when provided.""" - mock_get_api_key.return_value = "test_key" - - ctx = MagicMock(spec=Context) - ctx.info = AsyncMock() - ctx.warning = AsyncMock() - ctx.error = AsyncMock() - - mock_response = MagicMock() - mock_response.json.return_value = {"artifacts": []} - mock_response.raise_for_status = MagicMock() - - mock_client = AsyncMock() - mock_client.post.return_value = mock_response - - mock_codealive_context = MagicMock() - mock_codealive_context.client = mock_client - mock_codealive_context.base_url = "https://app.codealive.ai" - - ctx.request_context.lifespan_context = mock_codealive_context - ctx.request_context.headers = {"authorization": "Bearer test_key"} - - await fetch_artifacts( - ctx=ctx, - identifiers=["id1"], - data_source="backend", - ) - - body = mock_client.post.call_args.kwargs["json"] - assert body["dataSource"] == "backend" - - -@pytest.mark.asyncio -@patch('tools.fetch_artifacts.get_api_key_from_context') -async def test_fetch_artifacts_whitespace_data_source_omitted(mock_get_api_key): - """A whitespace-only data_source normalizes to None: not sent to the backend - and not echoed in the not-found hint (preserves the 409-on-ambiguity fallback).""" - mock_get_api_key.return_value = "test_key" - - ctx = MagicMock(spec=Context) - ctx.info = AsyncMock() - ctx.warning = AsyncMock() - ctx.error = AsyncMock() - - mock_response = MagicMock() - mock_response.json.return_value = {"artifacts": []} - mock_response.raise_for_status = MagicMock() - - mock_client = AsyncMock() - mock_client.post.return_value = mock_response - - mock_codealive_context = MagicMock() - mock_codealive_context.client = mock_client - mock_codealive_context.base_url = "https://app.codealive.ai" - - ctx.request_context.lifespan_context = mock_codealive_context - ctx.request_context.headers = {"authorization": "Bearer test_key"} - - result = await fetch_artifacts( - ctx=ctx, - identifiers=["id1"], - data_source=" ", - ) - - body = mock_client.post.call_args.kwargs["json"] - assert "dataSource" not in body - # The confusing `... data source " "` hint must not appear. - assert '" "' not in result - - -@pytest.mark.asyncio -@patch('tools.fetch_artifacts.get_api_key_from_context') -async def test_fetch_artifacts_api_error(mock_get_api_key): - """Test that API errors are handled gracefully.""" - import httpx - - mock_get_api_key.return_value = "test_key" - - ctx = MagicMock(spec=Context) - ctx.info = AsyncMock() - ctx.warning = AsyncMock() - ctx.error = AsyncMock() - - mock_response = MagicMock() - mock_response.status_code = 500 - mock_response.text = "Internal server error" - - def raise_500(): - raise httpx.HTTPStatusError( - "Server error", - request=MagicMock(), - response=mock_response - ) - - mock_response.raise_for_status = raise_500 - - mock_client = AsyncMock() - mock_client.post.return_value = mock_response - - mock_codealive_context = MagicMock() - mock_codealive_context.client = mock_client - mock_codealive_context.base_url = "https://app.codealive.ai" - - ctx.request_context.lifespan_context = mock_codealive_context - ctx.request_context.headers = {"authorization": "Bearer test_key"} - - with pytest.raises(ToolError, match="Server error \\(500\\)"): - await fetch_artifacts( - ctx=ctx, - identifiers=["some-id"], - ) - - -@pytest.mark.asyncio -@patch('tools.fetch_artifacts.get_api_key_from_context') -async def test_fetch_artifacts_keeps_content_raw(mock_get_api_key): - """Test that XML special chars in content are emitted as-is (no HTML escaping).""" - mock_get_api_key.return_value = "test_key" - - ctx = MagicMock(spec=Context) - ctx.info = AsyncMock() - ctx.warning = AsyncMock() - ctx.error = AsyncMock() - - mock_response = MagicMock() - mock_response.json.return_value = { - "artifacts": [ - { - "identifier": "owner/repo::file.py::func", - "content": 'if x < 10 && y > 5:\n return ""', - "contentByteSize": 40 - } - ] - } - mock_response.raise_for_status = MagicMock() - - mock_client = AsyncMock() - mock_client.post.return_value = mock_response - - mock_codealive_context = MagicMock() - mock_codealive_context.client = mock_client - mock_codealive_context.base_url = "https://app.codealive.ai" - - ctx.request_context.lifespan_context = mock_codealive_context - ctx.request_context.headers = {"authorization": "Bearer test_key"} - - result = await fetch_artifacts( - ctx=ctx, - identifiers=["owner/repo::file.py::func"], - ) - - # Line numbers are added but no escaping - assert '1 | if x < 10 && y > 5:' in result - assert '2 | return ""' in result - # No HTML escaping - assert "<" not in result - assert "&" not in result - assert """ not in result - # Structure is preserved with newline-bracketed content body - assert "" in result - assert "" in result - assert "\n" in result - assert "\n " in result diff --git a/src/tests/test_http_transport_security.py b/src/tests/test_http_transport_security.py new file mode 100644 index 0000000..7e9d89b --- /dev/null +++ b/src/tests/test_http_transport_security.py @@ -0,0 +1,99 @@ +"""HTTP Host/Origin protection tests for the FastMCP transport.""" + +import sys +from pathlib import Path +from unittest.mock import MagicMock + +import httpx +import pytest + +sys.path.insert(0, str(Path(__file__).parent.parent)) + +import codealive_mcp_server as server + +mcp = server.mcp + + +def _protected_app(): + return mcp.http_app( + path="/api", + stateless_http=True, + host_origin_protection=True, + allowed_hosts=["mcp.codealive.ai"], + allowed_origins=["https://mcp.codealive.ai"], + ) + + +async def _request(path: str, headers: dict[str, str]): + app = _protected_app() + async with app.router.lifespan_context(app): + transport = httpx.ASGITransport(app=app) + async with httpx.AsyncClient( + transport=transport, + base_url="http://mcp.codealive.ai", + ) as client: + return await client.get(path, headers=headers) + + +@pytest.mark.asyncio +async def test_http_transport_rejects_untrusted_host(monkeypatch): + monkeypatch.setenv("TRANSPORT_MODE", "http") + + response = await _request("/api", {"Host": "attacker.example"}) + + assert response.status_code == 421 + + +@pytest.mark.asyncio +async def test_http_transport_rejects_untrusted_origin(monkeypatch): + monkeypatch.setenv("TRANSPORT_MODE", "http") + + response = await _request("/api", { + "Host": "mcp.codealive.ai", + "Origin": "https://attacker.example", + }) + + assert response.status_code == 403 + + +@pytest.mark.asyncio +async def test_http_transport_allows_configured_host_and_origin(monkeypatch): + monkeypatch.setenv("TRANSPORT_MODE", "http") + + response = await _request("/api", { + "Host": "mcp.codealive.ai", + "Origin": "https://mcp.codealive.ai", + }) + + assert response.status_code not in {403, 421} + + +@pytest.mark.asyncio +async def test_health_route_accepts_configured_probe_host(monkeypatch): + monkeypatch.setenv("TRANSPORT_MODE", "http") + + response = await _request("/health", {"Host": "mcp.codealive.ai"}) + + assert response.status_code == 200 + + +def test_http_main_enables_guard_and_reads_environment_allowlists(monkeypatch): + run = MagicMock() + monkeypatch.setattr(server.mcp, "run", run) + monkeypatch.setattr(server, "setup_logging", MagicMock()) + monkeypatch.setattr(server, "init_tracing", MagicMock()) + monkeypatch.setenv( + "CODEALIVE_MCP_ALLOWED_HOSTS", + "mcp.codealive.ai,codealive-mcp-server", + ) + monkeypatch.setenv("CODEALIVE_MCP_ALLOWED_ORIGINS", "https://mcp.codealive.ai") + monkeypatch.setattr(sys, "argv", ["codealive-mcp", "--transport", "http"]) + + server.main() + + run.assert_called_once() + options = run.call_args.kwargs + assert options["host"] == "127.0.0.1" + assert options["host_origin_protection"] is True + assert options["allowed_hosts"] == ["mcp.codealive.ai", "codealive-mcp-server"] + assert options["allowed_origins"] == ["https://mcp.codealive.ai"] diff --git a/src/tests/test_logging.py b/src/tests/test_logging.py index 67c22c7..bbe063e 100644 --- a/src/tests/test_logging.py +++ b/src/tests/test_logging.py @@ -13,83 +13,35 @@ import core.logging as logging_module from core.logging import ( - _mask_pii, - _sanitize_body, + _mapping_shape, + _value_shape, _otel_patcher, _is_debug_enabled, setup_logging, setup_debug_logging, log_api_request, log_api_response, - _PII_MAX_LEN, - _RESPONSE_BODY_MAX_LEN, ) # --------------------------------------------------------------------------- -# _mask_pii +# value-shape descriptions # --------------------------------------------------------------------------- -class TestMaskPii: - def test_short_string_unchanged(self): - assert _mask_pii("hello") == "hello" +class TestValueShape: + def test_describes_string_without_retaining_content(self): + assert _value_shape("private question") == {"type": "string", "length": 16} - def test_exact_limit_unchanged(self): - value = "x" * _PII_MAX_LEN - assert _mask_pii(value) == value + def test_describes_collection_lengths(self): + assert _value_shape(["private", "values"]) == {"type": "array", "length": 2} - def test_over_limit_truncated(self): - value = "x" * (_PII_MAX_LEN + 20) - result = _mask_pii(value) - assert result.startswith("x" * _PII_MAX_LEN) - assert f"[{len(value)} chars]" in result - assert len(result) < len(value) - - def test_custom_max_len(self): - result = _mask_pii("abcdefgh", max_len=4) - assert result == "abcd...[8 chars]" - - def test_empty_string(self): - assert _mask_pii("") == "" - - -# --------------------------------------------------------------------------- -# _sanitize_body -# --------------------------------------------------------------------------- - -class TestSanitizeBody: - def test_pii_string_field_masked(self): - body = {"query": "x" * 200, "mode": "auto"} - result = _sanitize_body(body) - assert len(result["query"]) < 200 - assert result["mode"] == "auto" - - def test_pii_list_field_masked(self): - body = {"messages": [{"role": "user", "content": "secret"}]} - result = _sanitize_body(body) - assert result["messages"] == "[1 items]" - - def test_pii_non_string_non_list_masked(self): - body = {"question": 42} - result = _sanitize_body(body) - assert result["question"] == "" - - def test_non_pii_fields_preserved(self): - body = {"mode": "deep", "Names": ["repo1"], "IncludeContent": False} - result = _sanitize_body(body) - assert result == body - - def test_all_pii_fields_recognized(self): - body = {"query": "a" * 200, "question": "b" * 200, "message": "c" * 200, "messages": ["d"]} - result = _sanitize_body(body) - for key in ("query", "question", "message"): - assert len(result[key]) < 200 - assert result["messages"] == "[1 items]" - - def test_original_body_not_mutated(self): - body = {"query": "x" * 200} - _sanitize_body(body) - assert len(body["query"]) == 200 + def test_describes_mapping_keys_without_values(self): + result = _mapping_shape({"question": "secret", "max_results": 10}) + assert result == { + "question": {"type": "string", "length": 6}, + "max_results": {"type": "number"}, + } + assert "secret" not in json.dumps(result) # --------------------------------------------------------------------------- @@ -224,7 +176,8 @@ def test_masks_authorization_header(self): output = sink.getvalue() assert "sk-secret-key-12345" not in output - assert "Bearer ***" in output + parsed = json.loads(output.strip()) + assert parsed["record"]["extra"]["header_names"] == ["Authorization", "Content-Type"] logger.remove() def test_masks_pii_in_body(self): @@ -243,8 +196,12 @@ def test_masks_pii_in_body(self): output = sink.getvalue() # Full query should not appear assert long_query not in output - # Mode should be preserved - assert "auto" in output + parsed = json.loads(output.strip()) + assert parsed["record"]["extra"]["body_shape"] == { + "query": {"type": "string", "length": len(long_query)}, + "mode": {"type": "string", "length": 4}, + } + assert "auto" not in output logger.remove() def test_handles_params_as_dict(self): @@ -256,7 +213,9 @@ def test_handles_params_as_dict(self): output = sink.getvalue() parsed = json.loads(output.strip()) - assert parsed["record"]["extra"]["params"] == {"key": "val"} + assert parsed["record"]["extra"]["parameter_shape"] == { + "key": {"type": "string", "length": 3} + } logger.remove() def test_handles_params_as_list_of_tuples(self): @@ -268,8 +227,10 @@ def test_handles_params_as_list_of_tuples(self): output = sink.getvalue() parsed = json.loads(output.strip()) - assert parsed["record"]["extra"]["params"]["a"] == ["1", "2"] - assert parsed["record"]["extra"]["params"]["b"] == "3" + assert parsed["record"]["extra"]["parameter_shape"] == { + "a": {"type": "array", "length": 2}, + "b": {"type": "string", "length": 1}, + } logger.remove() @@ -284,43 +245,42 @@ def setup_method(self): def teardown_method(self): logging_module._current_level = "INFO" - def _make_response(self, text: str, status_code: int = 200, content_type: str = "application/json"): + def _make_response(self, status_code: int = 200, content_length: int | None = None): response = MagicMock(spec=httpx.Response) response.status_code = status_code response.url = httpx.URL("https://example.com/api") - response.text = text - response.headers = {"content-type": content_type} + response.headers = {} + if content_length is not None: + response.headers["content-length"] = str(content_length) return response - def test_short_body_not_truncated(self): + def test_logs_reported_response_size_without_body(self): sink = io.StringIO() logger.remove() logger.add(sink, level="DEBUG", serialize=True) - response = self._make_response('{"ok": true}') + response = self._make_response(content_length=12) log_api_response(response, request_id="test123") output = sink.getvalue() parsed = json.loads(output.strip()) - assert parsed["record"]["extra"]["response_body"] == '{"ok": true}' + assert parsed["record"]["extra"]["response_size_bytes"] == 12 + assert "response_body" not in parsed["record"]["extra"] assert parsed["record"]["extra"]["request_id"] == "test123" logger.remove() - def test_long_body_truncated(self): + def test_does_not_read_response_body(self): sink = io.StringIO() logger.remove() logger.add(sink, level="DEBUG", serialize=True) - long_body = "x" * (_RESPONSE_BODY_MAX_LEN + 200) - response = self._make_response(long_body) + response = self._make_response() + type(response).text = property(lambda self: (_ for _ in ()).throw(AssertionError("body read"))) log_api_response(response) output = sink.getvalue() parsed = json.loads(output.strip()) - body = parsed["record"]["extra"]["response_body"] - assert body.startswith("x" * _RESPONSE_BODY_MAX_LEN) - assert "chars total" in body - assert len(body) < len(long_body) + assert "response_body" not in parsed["record"]["extra"] logger.remove() def test_default_request_id(self): @@ -328,28 +288,10 @@ def test_default_request_id(self): logger.remove() logger.add(sink, level="DEBUG", serialize=True) - response = self._make_response('{}') + response = self._make_response() log_api_response(response) output = sink.getvalue() parsed = json.loads(output.strip()) assert parsed["record"]["extra"]["request_id"] == "unknown" logger.remove() - - def test_unreadable_response(self): - sink = io.StringIO() - logger.remove() - logger.add(sink, level="DEBUG", serialize=True) - - response = MagicMock(spec=httpx.Response) - response.status_code = 200 - response.url = httpx.URL("https://example.com/api") - response.headers = {"content-type": "application/json"} - type(response).text = property(lambda self: (_ for _ in ()).throw(RuntimeError("stream consumed"))) - - log_api_response(response) - - output = sink.getvalue() - parsed = json.loads(output.strip()) - assert parsed["record"]["extra"]["response_body"] == "" - logger.remove() diff --git a/src/tests/test_observability_middleware.py b/src/tests/test_observability_middleware.py index 2bb4865..890b697 100644 --- a/src/tests/test_observability_middleware.py +++ b/src/tests/test_observability_middleware.py @@ -50,7 +50,7 @@ def otel_setup(): provider.shutdown() -def _make_context(tool_name: str = "codebase_search", arguments: dict | None = None): +def _make_context(tool_name: str = "semantic_search", arguments: dict | None = None): ctx = MagicMock() ctx.message.name = tool_name ctx.message.arguments = arguments or {} @@ -65,7 +65,7 @@ class TestSuccessfulToolCall: @pytest.mark.asyncio async def test_returns_result_from_call_next(self, otel_setup): middleware = ObservabilityMiddleware() - context = _make_context("codebase_search") + context = _make_context("semantic_search") call_next = AsyncMock(return_value="xml") result = await middleware.on_call_tool(context, call_next) @@ -116,7 +116,7 @@ async def test_handles_missing_tool_name(self, otel_setup): assert span.attributes["mcp.tool.name"] == "unknown" @pytest.mark.asyncio - async def test_lifecycle_logs_are_debug_with_tool_arguments(self, otel_setup): + async def test_lifecycle_logs_only_include_tool_argument_shape(self, otel_setup): middleware = ObservabilityMiddleware() tool_arguments = {"identifier": "org/repo::src/svc.py::run", "profile": "callsOnly"} context = _make_context("get_artifact_relationships", tool_arguments) @@ -134,8 +134,13 @@ async def test_lifecycle_logs_are_debug_with_tool_arguments(self, otel_setup): if record["message"].startswith("Tool call ") ] assert [record["level"].name for record in lifecycle] == ["DEBUG", "DEBUG"] - assert lifecycle[0]["extra"]["tool_arguments"] == tool_arguments - assert lifecycle[1]["extra"]["tool_arguments"] == tool_arguments + expected_shape = { + "identifier": {"type": "string", "length": len(tool_arguments["identifier"])}, + "profile": {"type": "string", "length": len(tool_arguments["profile"])}, + } + assert lifecycle[0]["extra"]["tool_argument_shape"] == expected_shape + assert lifecycle[1]["extra"]["tool_argument_shape"] == expected_shape + assert tool_arguments["identifier"] not in str(lifecycle) # --------------------------------------------------------------------------- @@ -163,7 +168,7 @@ async def test_span_status_error_on_failure(self, otel_setup): span = otel_setup.get_finished_spans()[0] assert span.status.status_code == trace.StatusCode.ERROR - assert "bad input" in span.status.description + assert span.status.description == "ValueError" @pytest.mark.asyncio async def test_span_records_exception_event(self, otel_setup): @@ -177,14 +182,10 @@ async def test_span_records_exception_event(self, otel_setup): span = otel_setup.get_finished_spans()[0] exception_events = [e for e in span.events if e.name == "exception"] assert len(exception_events) >= 1 - # At least one event must carry the exception details - types = [e.attributes["exception.type"] for e in exception_events] - messages = [e.attributes["exception.message"] for e in exception_events] - assert "RuntimeError" in types - assert "boom" in messages + assert exception_events[0].attributes == {"exception.type": "RuntimeError"} @pytest.mark.asyncio - async def test_failure_logs_warning_with_full_tool_arguments(self, otel_setup): + async def test_failure_logs_warning_without_tool_values_or_error_message(self, otel_setup): middleware = ObservabilityMiddleware() tool_arguments = { "identifier": "org/repo::src/svc.py::run", @@ -207,9 +208,15 @@ async def test_failure_logs_warning_with_full_tool_arguments(self, otel_setup): failure = failures[0] assert failure["level"].name == "WARNING" assert failure["extra"]["tool"] == "get_artifact_relationships" - assert failure["extra"]["tool_arguments"] == tool_arguments + assert failure["extra"]["tool_argument_shape"] == { + "identifier": {"type": "string", "length": len(tool_arguments["identifier"])}, + "profile": {"type": "string", "length": len(tool_arguments["profile"])}, + "max_count_per_type": {"type": "number"}, + } assert failure["extra"]["error_type"] == "ValueError" - assert failure["extra"]["error"] == "bad profile" + assert "error" not in failure["extra"] + assert tool_arguments["identifier"] not in str(failure) + assert "bad profile" not in str(failure) class TestExtractToolArguments: diff --git a/src/tests/test_protocol_tools.py b/src/tests/test_protocol_tools.py new file mode 100644 index 0000000..293300d --- /dev/null +++ b/src/tests/test_protocol_tools.py @@ -0,0 +1,93 @@ +"""In-memory MCP protocol tests for the complete public tool surface.""" + +import sys +from pathlib import Path + +import httpx +import pytest +from fastmcp import Client + +sys.path.insert(0, str(Path(__file__).parent.parent)) + +from codealive_mcp_server import mcp + + +@pytest.fixture(autouse=True) +def _stdio_environment(monkeypatch): + monkeypatch.setenv("TRANSPORT_MODE", "stdio") + monkeypatch.setenv("CODEALIVE_API_KEY", "test-key") + monkeypatch.setenv("CODEALIVE_BASE_URL", "https://app.codealive.ai") + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("tool_name", "arguments"), + [ + ("get_data_sources", {}), + ("semantic_search", {"question": "How does startup work?"}), + ("grep_search", {"query": "FastMCP"}), + ("get_repository_ontology", {"data_source": "backend"}), + ("get_file_tree", {"data_source": "backend"}), + ("read_file", {"path": "README.md", "data_source": "backend"}), + ("fetch_artifacts", {"identifiers": ["backend::README.md"]}), + ( + "get_artifact_relationships", + {"identifier": "backend::src/Foo.cs::Foo"}, + ), + ("get_artifact_query_schema", {}), + ("query_artifact_metadata", {"statement": "SELECT path FROM files LIMIT 1"}), + ("chat", {"question": "Summarize startup."}), + ], +) +async def test_each_public_tool_completes_through_mcp_protocol( + monkeypatch, + tool_name, + arguments, +): + async def post(_client, path, **_kwargs): + return httpx.Response( + 200, + json={"rendered": f"ok"}, + request=httpx.Request("POST", f"https://app.codealive.ai{path}"), + ) + + monkeypatch.setattr(httpx.AsyncClient, "post", post) + + async with Client(mcp) as client: + result = await client.call_tool(tool_name, arguments) + + assert result.is_error is False + assert result.content[0].text == f"ok" + + +@pytest.mark.asyncio +async def test_repairable_backend_error_sets_protocol_is_error(monkeypatch): + async def post(_client, path, **_kwargs): + return httpx.Response( + 200, + json={ + "obj": { + "error": { + "code": "invalid_tool_arguments", + "message": "question is required", + "retry": "yes - repair the tool arguments and call the tool again", + "try": "Provide question and retry.", + } + }, + "rendered": "invalid_tool_arguments", + }, + request=httpx.Request("POST", f"https://app.codealive.ai{path}"), + ) + + monkeypatch.setattr(httpx.AsyncClient, "post", post) + + async with Client(mcp) as client: + result = await client.call_tool( + "semantic_search", + {"question": "valid locally"}, + raise_on_error=False, + ) + + assert result.is_error is True + assert result.content[0].text.startswith("") + assert result.structured_content["error"]["code"] == "invalid_tool_arguments" diff --git a/src/tests/test_response_transformer.py b/src/tests/test_response_transformer.py index 52058ca..2a7538a 100644 --- a/src/tests/test_response_transformer.py +++ b/src/tests/test_response_transformer.py @@ -255,7 +255,7 @@ def test_data_preservation(self): "results": [ { "kind": "Symbol", - "identifier": "CodeAlive-AI/codealive-mcp::src/tools/search.py::codebase_search", + "identifier": "CodeAlive-AI/codealive-mcp::src/tools/search.py::semantic_search", "location": { "path": "src/tools/search.py", "range": {"start": {"line": 18}, "end": {"line": 168}} @@ -291,7 +291,7 @@ def test_data_preservation(self): assert first["startLine"] == 18 assert first["endLine"] == 168 assert first["kind"] == "Symbol" - assert first["identifier"] == "CodeAlive-AI/codealive-mcp::src/tools/search.py::codebase_search" + assert first["identifier"] == "CodeAlive-AI/codealive-mcp::src/tools/search.py::semantic_search" assert first["contentByteSize"] == 8500 assert first["description"] == "Main search function" # Data-source identity must be surfaced (not stripped) so the agent can feed it back diff --git a/src/tests/test_search_tool.py b/src/tests/test_search_tool.py deleted file mode 100644 index 42f930d..0000000 --- a/src/tests/test_search_tool.py +++ /dev/null @@ -1,197 +0,0 @@ -"""Test suite for semantic, grep, and legacy search tools.""" - -from unittest.mock import AsyncMock, MagicMock, patch - -import pytest -from fastmcp import Context -from fastmcp.exceptions import ToolError - -from tools.search import codebase_search, grep_search, semantic_search - - -def _build_context(mock_response): - ctx = MagicMock(spec=Context) - ctx.info = AsyncMock() - ctx.warning = AsyncMock() - ctx.error = AsyncMock() - - mock_client = AsyncMock() - mock_client.get.return_value = mock_response - - mock_codealive_context = MagicMock() - mock_codealive_context.client = mock_client - mock_codealive_context.base_url = "https://app.codealive.ai" - - ctx.request_context.lifespan_context = mock_codealive_context - ctx.request_context.headers = {"authorization": "Bearer test_key"} - return ctx, mock_client - - -@pytest.mark.asyncio -@patch("tools.search.get_api_key_from_context") -async def test_semantic_search_returns_compact_json(mock_get_api_key): - mock_get_api_key.return_value = "test_key" - - mock_response = MagicMock() - mock_response.json.return_value = { - "results": [ - { - "kind": "Symbol", - "identifier": "owner/repo::path/auth.py::authenticate_user", - "location": { - "path": "path/auth.py", - "range": {"start": {"line": 10}, "end": {"line": 25}}, - }, - "description": "Authenticates a user with credentials", - } - ] - } - mock_response.raise_for_status = MagicMock() - - ctx, mock_client = _build_context(mock_response) - - result = await semantic_search( - ctx=ctx, - query="authenticate_user", - data_sources=["test-name"], - paths=["src/auth.py"], - extensions=[".py"], - max_results=7, - ) - - assert isinstance(result, dict) - assert result["results"][0]["path"] == "path/auth.py" - assert result["results"][0]["identifier"] == "owner/repo::path/auth.py::authenticate_user" - - call_args = mock_client.get.call_args - assert call_args.args[0] == "/api/search/semantic" - params = call_args.kwargs["params"] - assert ("Query", "authenticate_user") in params - assert ("Names", "test-name") in params - assert ("Paths", "src/auth.py") in params - assert ("Extensions", ".py") in params - assert ("MaxResults", "7") in params - assert call_args.kwargs["headers"]["X-CodeAlive-Tool"] == "semantic_search" - - -@pytest.mark.asyncio -@patch("tools.search.get_api_key_from_context") -async def test_grep_search_returns_matches(mock_get_api_key): - mock_get_api_key.return_value = "test_key" - - mock_response = MagicMock() - mock_response.json.return_value = { - "results": [ - { - "kind": "File", - "identifier": "owner/repo::path/auth.py", - "location": { - "path": "path/auth.py", - "range": {"start": {"line": 15}}, - }, - "matchCount": 2, - "matches": [ - { - "lineNumber": 15, - "startColumn": 5, - "endColumn": 12, - "lineText": "token = auth()", - } - ], - } - ] - } - mock_response.raise_for_status = MagicMock() - - ctx, mock_client = _build_context(mock_response) - - result = await grep_search( - ctx=ctx, - query="auth\\(", - data_sources=["test-name"], - regex=True, - ) - - assert isinstance(result, dict) - assert result["results"][0]["matchCount"] == 2 - assert result["results"][0]["matches"][0]["lineNumber"] == 15 - - call_args = mock_client.get.call_args - assert call_args.args[0] == "/api/search/grep" - params = call_args.kwargs["params"] - assert ("Regex", "true") in params - assert call_args.kwargs["headers"]["X-CodeAlive-Tool"] == "grep_search" - - -@pytest.mark.asyncio -@patch("tools.search.get_api_key_from_context") -async def test_codebase_search_keeps_legacy_params(mock_get_api_key): - mock_get_api_key.return_value = "test_key" - - mock_response = MagicMock() - mock_response.json.return_value = {"results": []} - mock_response.raise_for_status = MagicMock() - - ctx, mock_client = _build_context(mock_response) - - await codebase_search( - ctx=ctx, - query="test", - data_sources=["test-name"], - mode="deep", - description_detail="full", - ) - - call_args = mock_client.get.call_args - assert call_args.args[0] == "/api/search" - params = call_args.kwargs["params"] - assert ("Mode", "deep") in params - assert ("DescriptionDetail", "Full") in params - assert ("IncludeContent", "false") in params - assert call_args.kwargs["headers"]["X-CodeAlive-Tool"] == "codebase_search" - - -@pytest.mark.asyncio -async def test_semantic_search_empty_query_raises_tool_error(): - ctx = MagicMock(spec=Context) - - with pytest.raises(ToolError, match="Query cannot be empty"): - await semantic_search(ctx=ctx, query="") - - -@pytest.mark.asyncio -async def test_grep_search_invalid_max_results_raises_tool_error(): - ctx = MagicMock(spec=Context) - - with pytest.raises(ToolError, match="max_results"): - await grep_search(ctx=ctx, query="foo", max_results=501) - - -@pytest.mark.asyncio -@patch("tools.search.get_api_key_from_context") -async def test_codebase_search_api_error_raises_tool_error(mock_get_api_key): - import httpx - - mock_get_api_key.return_value = "test_key" - - mock_response = MagicMock() - mock_response.status_code = 404 - mock_response.text = "Not found" - - def raise_404(): - raise httpx.HTTPStatusError( - "Not found", - request=MagicMock(), - response=mock_response, - ) - - mock_response.raise_for_status = raise_404 - ctx, mock_client = _build_context(mock_response) - mock_client.get.return_value = mock_response - - with pytest.raises(ToolError, match="404"): - await codebase_search( - ctx=ctx, - query="test query", - data_sources=["invalid-name"], - ) diff --git a/src/tests/test_stdio_smoke.py b/src/tests/test_stdio_smoke.py index d8c574f..65af9c3 100644 --- a/src/tests/test_stdio_smoke.py +++ b/src/tests/test_stdio_smoke.py @@ -19,7 +19,9 @@ def _mock_codealive_server(): requests = [] class Handler(BaseHTTPRequestHandler): - def do_GET(self): + def do_POST(self): + length = int(self.headers.get("Content-Length", "0")) + body = self.rfile.read(length).decode("utf-8") if length else "{}" requests.append( { "path": self.path, @@ -27,26 +29,32 @@ def do_GET(self): "tool": self.headers.get("X-CodeAlive-Tool"), "integration": self.headers.get("X-CodeAlive-Integration"), "client": self.headers.get("X-CodeAlive-Client"), + "body": json.loads(body), } ) - if self.path == "/api/datasources/ready": + if self.path == "/api/tools/get_data_sources": body = json.dumps( - [ - { - "id": "repo-1", - "name": "backend", - "type": "Repository", - "url": "https://github.com/CodeAlive-AI/backend", - "state": "Ready", + { + "rendered": "backend\ncore-workspace", + "obj": { + "data_sources": [ + { + "id": "repo-1", + "name": "backend", + "type": "Repository", + "url": "https://github.com/CodeAlive-AI/backend", + "state": "Ready", + }, + { + "id": "ws-1", + "name": "core-workspace", + "type": "Workspace", + "repositoryIds": ["repo-1"], + "state": "Alive", + }, + ] }, - { - "id": "ws-1", - "name": "core-workspace", - "type": "Workspace", - "repositoryIds": ["repo-1"], - "state": "Alive", - }, - ] + } ).encode("utf-8") self.send_response(200) self.send_header("Content-Type", "application/json") @@ -72,7 +80,7 @@ def log_message(self, format, *args): @pytest.mark.asyncio -async def test_stdio_server_lists_tools_and_uses_normalized_ready_endpoint(): +async def test_stdio_server_lists_tools_and_uses_tool_api_v3_endpoint(): server_script = Path(__file__).resolve().parents[1] / "codealive_mcp_server.py" with _mock_codealive_server() as (port, requests): @@ -95,12 +103,15 @@ async def test_stdio_server_lists_tools_and_uses_normalized_ready_endpoint(): tool_names = sorted(tool.name for tool in tools_result.tools) assert tool_names == [ "chat", - "codebase_consultant", - "codebase_search", "fetch_artifacts", + "get_artifact_query_schema", "get_artifact_relationships", "get_data_sources", + "get_file_tree", + "get_repository_ontology", "grep_search", + "query_artifact_metadata", + "read_file", "semantic_search", ] @@ -113,10 +124,11 @@ async def test_stdio_server_lists_tools_and_uses_normalized_ready_endpoint(): assert requests == [ { - "path": "/api/datasources/ready", + "path": "/api/tools/get_data_sources", "authorization": "Bearer stdio-smoke-test-key", "tool": "get_data_sources", "integration": "mcp", - "client": "fastmcp", + "client": "fastmcp-v3", + "body": {"ready_only": True, "output_format": "agentic"}, } ] diff --git a/src/tests/test_tool_api_v3.py b/src/tests/test_tool_api_v3.py new file mode 100644 index 0000000..e791cbc --- /dev/null +++ b/src/tests/test_tool_api_v3.py @@ -0,0 +1,205 @@ +"""MCP Tool API v3 contract tests.""" + +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +from fastmcp import Context +from fastmcp.exceptions import ToolError +from fastmcp.tools.tool import ToolResult + +from tools.artifact_query import get_artifact_query_schema, query_artifact_metadata +from tools.artifact_relationships import get_artifact_relationships +from tools.chat import chat +from tools.datasources import get_data_sources +from tools.fetch_artifacts import fetch_artifacts +from tools.repository import get_file_tree, get_repository_ontology, read_file +from tools.search import grep_search, semantic_search + + +def _context_with_response( + rendered: str = "ok", + obj: dict | None = None, +): + ctx = MagicMock(spec=Context) + ctx.info = AsyncMock() + ctx.warning = AsyncMock() + ctx.error = AsyncMock() + + response = MagicMock() + response.json.return_value = { + "rendered": rendered, + "obj": obj if obj is not None else {"ok": True}, + } + response.raise_for_status = MagicMock() + + client = AsyncMock() + client.post.return_value = response + + codealive_context = MagicMock() + codealive_context.client = client + codealive_context.base_url = "https://app.codealive.ai/api/" + + ctx.request_context.lifespan_context = codealive_context + return ctx, client + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("tool_call", "expected_path", "expected_payload"), + [ + ( + lambda ctx: get_data_sources(ctx, query="checkout", ready_only=False), + "/api/tools/get_data_sources", + {"query": "checkout", "ready_only": False}, + ), + ( + lambda ctx: semantic_search( + ctx, + question="How does checkout authorization work?", + data_sources=["backend"], + paths=["src"], + extensions=".cs", + max_results=7, + exclude_markdown=True, + ), + "/api/tools/semantic_search", + { + "question": "How does checkout authorization work?", + "data_sources": ["backend"], + "paths": ["src"], + "extensions": [".cs"], + "max_results": 7, + "exclude_markdown": True, + }, + ), + ( + lambda ctx: grep_search(ctx, query="Authorize", data_sources="backend", regex=True), + "/api/tools/grep_search", + { + "query": "Authorize", + "data_sources": ["backend"], + "exclude_markdown": False, + "regex": True, + }, + ), + ( + lambda ctx: get_repository_ontology(ctx, data_source="backend"), + "/api/tools/get_repository_ontology", + {"data_source": "backend"}, + ), + ( + lambda ctx: get_file_tree(ctx, data_source="backend", path="src", max_depth=2), + "/api/tools/get_file_tree", + {"data_source": "backend", "path": "src", "max_depth": 2}, + ), + ( + lambda ctx: read_file(ctx, path="README.md", data_source="backend", start_line=1, end_line=20), + "/api/tools/read_file", + {"data_source": "backend", "path": "README.md", "start_line": 1, "end_line": 20}, + ), + ( + lambda ctx: fetch_artifacts(ctx, identifiers=["repo::src/Foo.cs::Foo"], data_source="backend"), + "/api/tools/fetch_artifacts", + {"identifiers": ["repo::src/Foo.cs::Foo"], "data_source": "backend"}, + ), + ( + lambda ctx: get_artifact_relationships( + ctx, + identifier="repo::src/Foo.cs::Foo", + profile="all_relevant", + max_count_per_type=25, + data_source="backend", + ), + "/api/tools/get_artifact_relationships", + { + "identifier": "repo::src/Foo.cs::Foo", + "profile": "all_relevant", + "max_count_per_type": 25, + "data_source": "backend", + }, + ), + ( + lambda ctx: get_artifact_query_schema(ctx, entity="files", include_examples=False), + "/api/tools/get_artifact_query_schema", + {"entity": "files", "include_examples": False}, + ), + ( + lambda ctx: query_artifact_metadata(ctx, statement="SELECT path FROM files LIMIT 5", data_sources=["backend"]), + "/api/tools/query_artifact_metadata", + {"statement": "SELECT path FROM files LIMIT 5", "data_sources": ["backend"]}, + ), + ( + lambda ctx: chat(ctx, question="Summarize repository startup flow.", data_sources=["backend"]), + "/api/tools/chat", + {"question": "Summarize repository startup flow.", "data_sources": ["backend"]}, + ), + ], +) +@patch("tools.tool_api.get_api_key_from_context") +async def test_mcp_tools_post_canonical_v3_payloads(mock_get_api_key, tool_call, expected_path, expected_payload): + mock_get_api_key.return_value = "test_key" + ctx, client = _context_with_response("done") + + result = await tool_call(ctx) + + assert result == "done" + call_args = client.post.call_args + assert call_args.args[0] == expected_path + assert call_args.kwargs["json"] == {**expected_payload, "output_format": "agentic"} + assert call_args.kwargs["headers"]["Authorization"] == "Bearer test_key" + assert call_args.kwargs["headers"]["X-CodeAlive-Integration"] == "mcp" + assert call_args.kwargs["headers"]["X-CodeAlive-Tool"] == expected_path.rsplit("/", 1)[1] + assert call_args.kwargs["headers"]["X-CodeAlive-Client"] == "fastmcp-v3" + + +@pytest.mark.asyncio +async def test_required_arguments_fail_before_network_call(): + ctx, client = _context_with_response() + + with pytest.raises(ToolError, match="question is required"): + await semantic_search(ctx, question="") + + with pytest.raises(ToolError, match="path is required"): + await read_file(ctx, path="") + + with pytest.raises(ToolError, match="identifiers is required"): + await fetch_artifacts(ctx, identifiers=[]) + + client.post.assert_not_called() + + +@pytest.mark.asyncio +async def test_local_bounds_validation_fail_before_network_call(): + ctx, client = _context_with_response() + + with pytest.raises(ToolError, match="max_results"): + await grep_search(ctx, query="Foo", max_results=501) + + with pytest.raises(ToolError, match="max_count_per_type"): + await get_artifact_relationships(ctx, identifier="repo::Foo", max_count_per_type=0) + + client.post.assert_not_called() + + +@pytest.mark.asyncio +@patch("tools.tool_api.get_api_key_from_context") +async def test_repairable_backend_error_sets_native_mcp_error(mock_get_api_key): + mock_get_api_key.return_value = "test_key" + error = { + "code": "invalid_tool_arguments", + "message": "question is required", + "retry": "yes - repair the tool arguments and call the tool again", + "try": "Provide question and retry.", + } + ctx, _ = _context_with_response( + rendered="invalid_tool_arguments", + obj={"error": error}, + ) + + result = await semantic_search(ctx, question="missing upstream validation") + + assert isinstance(result, ToolResult) + assert result.is_error is True + assert len(result.content) == 1 + assert result.content[0].text == "invalid_tool_arguments" + assert result.structured_content == {"error": error} diff --git a/src/tests/test_tool_metadata.py b/src/tests/test_tool_metadata.py index 1ec6e13..3be8832 100644 --- a/src/tests/test_tool_metadata.py +++ b/src/tests/test_tool_metadata.py @@ -8,7 +8,7 @@ sys.path.insert(0, str(Path(__file__).parent.parent)) -from codealive_mcp_server import mcp +from codealive_mcp_server import _package_version, mcp @pytest.mark.asyncio @@ -18,13 +18,16 @@ async def test_all_tools_are_marked_read_only_with_titles(): expected_titles = { "chat": "Chat About Codebase", - "codebase_consultant": "Consult Codebase (Deprecated)", "get_data_sources": "List Data Sources", - "codebase_search": "Search Codebase (Deprecated)", "semantic_search": "Semantic Search", "grep_search": "Grep Search", + "get_repository_ontology": "Get Repository Ontology", + "get_file_tree": "Get File Tree", + "read_file": "Read File", "fetch_artifacts": "Fetch Artifacts", "get_artifact_relationships": "Inspect Artifact Relationships", + "get_artifact_query_schema": "Get ArtifactQuery Schema", + "query_artifact_metadata": "Query Artifact Metadata", } actual = {tool.name: tool for tool in tools} @@ -35,11 +38,44 @@ async def test_all_tools_are_marked_read_only_with_titles(): assert tool.title == title assert tool.annotations is not None assert tool.annotations.readOnlyHint is True + assert tool.annotations.destructiveHint is False + assert tool.annotations.idempotentHint is True + assert tool.annotations.openWorldHint is True relationships_description = actual["get_artifact_relationships"].description assert relationships_description is not None assert "exact artifact identifier" in relationships_description assert "not a search tool" in relationships_description assert "fetch_artifacts" in relationships_description - assert "excludes references" in relationships_description - assert "Mediated or dynamic frameworks" in relationships_description + + semantic_schema = actual["semantic_search"].inputSchema["properties"] + assert semantic_schema["question"]["minLength"] == 1 + max_results_schema = semantic_schema["max_results"]["anyOf"][0] + assert max_results_schema["minimum"] == 1 + assert max_results_schema["maximum"] == 500 + + tree_schema = actual["get_file_tree"].inputSchema["properties"] + assert tree_schema["max_depth"]["anyOf"][0]["maximum"] == 8 + assert tree_schema["max_nodes"]["anyOf"][0]["maximum"] == 300 + + fetch_schema = actual["fetch_artifacts"].inputSchema["properties"]["identifiers"] + identifier_array_schema = next(branch for branch in fetch_schema["anyOf"] if branch.get("type") == "array") + assert identifier_array_schema["minItems"] == 1 + assert identifier_array_schema["maxItems"] == 50 + + relationship_schema = actual["get_artifact_relationships"].inputSchema["properties"] + assert relationship_schema["profile"]["enum"] == [ + "calls_only", + "inheritance_only", + "all_relevant", + "references_only", + ] + assert relationship_schema["max_count_per_type"]["maximum"] == 1000 + + +def test_server_advertises_codealive_version_and_compact_instructions(): + assert mcp.version == _package_version() + assert mcp.instructions is not None + assert len(mcp.instructions.split()) <= 150 + assert "DISCOVER → SEARCH → READ → EXPAND" in mcp.instructions + assert "chat only when the user explicitly requests" in mcp.instructions.lower() diff --git a/src/tools/__init__.py b/src/tools/__init__.py index 4ec565a..93654c5 100644 --- a/src/tools/__init__.py +++ b/src/tools/__init__.py @@ -1,18 +1,23 @@ """Tool implementations for CodeAlive MCP server.""" -from .artifact_relationships import get_artifact_relationships -from .chat import chat, codebase_consultant from .datasources import get_data_sources +from .search import grep_search, semantic_search +from .repository import get_file_tree, get_repository_ontology, read_file from .fetch_artifacts import fetch_artifacts -from .search import codebase_search, grep_search, semantic_search +from .artifact_relationships import get_artifact_relationships +from .artifact_query import get_artifact_query_schema, query_artifact_metadata +from .chat import chat __all__ = [ - 'chat', - 'codebase_consultant', 'get_data_sources', - 'fetch_artifacts', - 'codebase_search', 'semantic_search', 'grep_search', + 'get_repository_ontology', + 'get_file_tree', + 'read_file', + 'fetch_artifacts', 'get_artifact_relationships', + 'get_artifact_query_schema', + 'query_artifact_metadata', + 'chat', ] diff --git a/src/tools/artifact_query.py b/src/tools/artifact_query.py new file mode 100644 index 0000000..3acbe4b --- /dev/null +++ b/src/tools/artifact_query.py @@ -0,0 +1,39 @@ +"""Tool API v3 ArtifactQuery tools.""" + +from typing import Annotated, Optional, Union + +from fastmcp import Context +from pydantic import Field + +from .tool_api import ToolApiResult, call_tool_api, normalize_optional_list, require_text + + +async def get_artifact_query_schema( + ctx: Context, + entity: Annotated[ + Optional[str], + Field(description="Optional ArtifactQuery entity such as files or symbols."), + ] = None, + include_examples: bool = True, +) -> ToolApiResult: + """Return the ArtifactQuery v1 schema, fields, operators, and examples.""" + return await call_tool_api(ctx, "get_artifact_query_schema", { + "entity": entity, + "include_examples": include_examples, + }, action_label="get artifact query schema") + + +async def query_artifact_metadata( + ctx: Context, + statement: Annotated[str, Field(min_length=1, description="One bounded ArtifactQuery statement.")], + data_sources: Annotated[ + Optional[Union[str, list[str]]], + Field(description="Repository or workspace names returned by get_data_sources."), + ] = None, +) -> ToolApiResult: + """Execute one bounded ArtifactQuery metadata statement.""" + require_text(statement, "query_artifact_metadata", "statement") + return await call_tool_api(ctx, "query_artifact_metadata", { + "statement": statement, + "data_sources": normalize_optional_list(data_sources), + }, action_label="query artifact metadata") diff --git a/src/tools/artifact_relationships.py b/src/tools/artifact_relationships.py index 54ab63f..545a2db 100644 --- a/src/tools/artifact_relationships.py +++ b/src/tools/artifact_relationships.py @@ -1,391 +1,47 @@ -"""Artifact relationships tool implementation.""" +"""Tool API v3 artifact relationship expansion.""" -from typing import Any, Dict, List, Literal, Optional -from urllib.parse import urljoin +from typing import Annotated, Literal, Optional -import httpx from fastmcp import Context from fastmcp.exceptions import ToolError -from loguru import logger +from pydantic import Field -from core import CodeAliveContext, get_api_key_from_context, log_api_request, log_api_response -from utils import handle_api_error - -# MCP tool/method name surfaced in every error/log message from this module. -_TOOL_NAME = "get_artifact_relationships" - -# Map MCP profile names to backend enum values -PROFILE_MAP = { - "callsOnly": "CallsOnly", - "inheritanceOnly": "InheritanceOnly", - "allRelevant": "AllRelevant", - "referencesOnly": "ReferencesOnly", -} - -# Backend relationship type to MCP-friendly snake_case -RELATIONSHIP_TYPE_MAP = { - "OutgoingCalls": "outgoing_calls", - "IncomingCalls": "incoming_calls", - "Ancestors": "ancestors", - "Descendants": "descendants", - "References": "references", -} +from .tool_api import ToolApiResult, call_tool_api, require_text async def get_artifact_relationships( ctx: Context, - identifier: str, - profile: Literal["callsOnly", "inheritanceOnly", "allRelevant", "referencesOnly"] = "callsOnly", - max_count_per_type: int = 50, - data_source: Optional[str] = None, -) -> Dict[str, Any]: + identifier: Annotated[ + str, + Field( + min_length=1, + description="Exact artifact identifier returned by a prior CodeAlive tool.", + ), + ], + profile: Literal["calls_only", "inheritance_only", "all_relevant", "references_only"] = "calls_only", + max_count_per_type: Annotated[ + int, + Field(ge=1, le=1000, description="Maximum relationships per type (1-1000)."), + ] = 50, + data_source: Annotated[ + Optional[str], + Field(description="Optional repository name or id used to disambiguate the identifier."), + ] = None, +) -> ToolApiResult: + """Expand relationships around one exact artifact identifier. + + This is a graph expansion tool, not a search tool. Use identifiers returned + by semantic_search, grep_search, fetch_artifacts, read_file, or prior + relationship results. """ - Retrieve relationship groups for a single artifact by profile. - - Use this tool to expand the relationship graph around one known artifact: - call graph edges, inheritance hierarchy, or references. - - Important usage rules: - - This is a graph expansion tool, not a search tool. The `identifier` - must be an exact artifact identifier returned by `semantic_search`, - `grep_search`, legacy `codebase_search`, or `fetch_artifacts`. - - Do not pass a repository name, file path, class name, method name, or - guessed symbol name unless it is the full identifier from a prior - tool result. - - If `found=false` or the backend returns a not-found/inaccessible - error, get a fresh identifier with `semantic_search`, `grep_search`, - `codebase_search`, or `fetch_artifacts` before retrying. Repeating - the same guessed identifier usually repeats the same failure. - - Relationships are primarily available for symbol artifacts such as - functions, methods, classes, and interfaces. Plain files and prose - documents can legitimately have no relationship graph. - - The response contains relationship metadata and short summaries, not - full source code. Use `fetch_artifacts` on returned identifiers when - exact source content is needed. - - Choose `profile` by artifact shape: `callsOnly` for function/method - callers and callees; `inheritanceOnly` for hierarchy; `allRelevant` - for calls plus inheritance only (it excludes references); - `referencesOnly` for where-used checks on types, containers, fields, - commands, events, interfaces, and other non-call usage. - - Mediated or dynamic frameworks such as command buses, event buses, - dependency injection, reflection, route binding, subscriptions, - schedulers, or generated dispatch may not expose a direct call edge. - When graph context is missing or insufficient, use targeted - `grep_search` for construction, registration, dispatch, route, - subscription, or scheduler text surfaced by source you've already read. - - If any relationship group has `truncated=true`, increase - `max_count_per_type` up to 1000 or narrow the investigation with a - more specific `profile`. - - Args: - identifier: Fully qualified artifact identifier from search or fetch results. - profile: Relationship profile to expand. One of: - - "callsOnly" (default): outgoing and incoming calls - - "inheritanceOnly": ancestors, descendants, implementations, and derived types - - "allRelevant": calls + inheritance only; references are excluded - - "referencesOnly": where-used LSP references for non-call usage - max_count_per_type: Maximum related artifacts per relationship type (1–1000, default 50). - data_source: Optional data-source Name or Id used to disambiguate an identifier that - exists in more than one data source. Copy the `dataSource.name` or - `dataSource.id` from a search result. Omit it for normal lookups; if the - source identifier is ambiguous and you omit it, the backend returns a 409 - listing the candidate data sources. - - Returns: - A dict with grouped relationships: - {"sourceIdentifier":"...","profile":"callsOnly","found":true, - "relationships":[ - {"type":"outgoing_calls","totalCount":57,"returnedCount":50,"truncated":true, - "items":[{"identifier":"...","filePath":"src/Data/Repo.cs","startLine":88, - "shortSummary":"Stores data"}]}, - {"type":"incoming_calls","totalCount":3,"returnedCount":3,"truncated":false, - "items":[{"identifier":"...","filePath":"src/Services/Worker.cs","startLine":142}]} - ]} + tool_name = "get_artifact_relationships" + require_text(identifier, tool_name, "identifier") + if not (1 <= max_count_per_type <= 1000): + raise ToolError(f"[{tool_name}] max_count_per_type must be between 1 and 1000.") - When the artifact is not found or inaccessible: - {"sourceIdentifier":"...","profile":"callsOnly","found":false} - """ - tool_arguments = { + return await call_tool_api(ctx, tool_name, { "identifier": identifier, "profile": profile, "max_count_per_type": max_count_per_type, "data_source": data_source, - } - - # Normalize the optional selector: treat empty/whitespace-only as "no selector" - # so we don't send a junk dataSource to the backend or echo it in the not-found hint. - # (tool_arguments above intentionally keeps the raw value for exact-invocation logging.) - if data_source is not None: - data_source = data_source.strip() or None - - if not identifier: - logger.bind(tool=_TOOL_NAME, tool_arguments=tool_arguments).warning( - "Tool validation failed: artifact identifier is required" - ) - raise ToolError(f"[{_TOOL_NAME}] Artifact identifier is required.") - - if not (1 <= max_count_per_type <= 1000): - logger.bind(tool=_TOOL_NAME, tool_arguments=tool_arguments).warning( - "Tool validation failed: max_count_per_type is out of range" - ) - raise ToolError(f"[{_TOOL_NAME}] max_count_per_type must be between 1 and 1000.") - - # Literal type handles most validation via Pydantic, but direct callers - # (e.g. unit tests) can still pass invalid values — keep as fallback. - api_profile = PROFILE_MAP.get(profile) - if api_profile is None: - supported = ", ".join(PROFILE_MAP.keys()) - logger.bind(tool=_TOOL_NAME, tool_arguments=tool_arguments).warning( - "Tool validation failed: unsupported relationship profile" - ) - raise ToolError(f'[{_TOOL_NAME}] Unsupported profile "{profile}". Use one of: {supported}') - - context: CodeAliveContext = ctx.request_context.lifespan_context - - try: - api_key = get_api_key_from_context(ctx) - headers = { - "Authorization": f"Bearer {api_key}", - "X-CodeAlive-Integration": "mcp", - "X-CodeAlive-Tool": "get_artifact_relationships", - "X-CodeAlive-Client": "fastmcp", - } - - body = { - "identifier": identifier, - "profile": api_profile, - "maxCountPerType": max_count_per_type, - } - if data_source: - body["dataSource"] = data_source - - await ctx.debug(f"Fetching {profile} relationships for artifact") - - full_url = urljoin(context.base_url, "/api/search/artifact-relationships") - request_id = log_api_request("POST", full_url, headers, body=body) - - response = await context.client.post( - "/api/search/artifact-relationships", json=body, headers=headers - ) - - log_api_response(response, request_id) - response.raise_for_status() - - return _build_relationships_dict(response.json(), data_source=data_source) - - except (httpx.HTTPStatusError, Exception) as e: - logger.bind( - tool=_TOOL_NAME, - tool_arguments=tool_arguments, - error_type=type(e).__name__, - error=str(e), - ).warning("Tool call failed while fetching artifact relationships") - await handle_api_error( - ctx, e, "get artifact relationships", method=_TOOL_NAME, - recovery_hints={ - 404: ( - "(1) verify the identifier came from a recent semantic_search, grep_search, codebase_search, or fetch_artifacts result, " - "(2) call semantic_search or grep_search again to get a fresh identifier — the index may have changed, " - "(3) check that the artifact is a function/class (relationships are not available for non-symbol artifacts)" - ), - 409: ( - "(1) the identifier exists in more than one data source — see the candidate data sources in the Detail above; each one will resolve, " - "(2) retry get_artifact_relationships with data_source set to one candidate's Name or Id; if that data source isn't the one you want, retry with the next candidate, " - "(3) do NOT invent relation results — pick from the listed data sources" - ), - }, - ) - - -def _build_relationships_dict(data: dict, data_source: Optional[str] = None) -> Dict[str, Any]: - """Build a dict representation of an artifact relationships response. - - FastMCP serializes the dict via pydantic_core.to_json, which preserves UTF-8 — - don't reintroduce json.dumps here, it would re-escape non-ASCII identifiers. - - ``data_source`` is the selector the caller passed (if any); when the source is not - found it shapes the recovery hint so the agent can retry with another data source - or drop the selector. - """ - raw_source_id = data.get("sourceIdentifier") or "" - raw_profile = data.get("profile") or "" - found = bool(data.get("found", False)) - - # Map profile back to MCP-friendly name - mcp_profile = raw_profile - for mcp_name, api_name in PROFILE_MAP.items(): - if api_name == raw_profile: - mcp_profile = mcp_name - break - - payload: Dict[str, Any] = { - "sourceIdentifier": raw_source_id, - "profile": mcp_profile, - "found": found, - } - - if found: - relationships = data.get("relationships") or [] - groups = [_build_group(group) for group in relationships] - payload["relationships"] = groups - - counts = _build_counts(data.get("availableRelationshipCounts")) - if counts is not None: - payload["availableRelationshipCounts"] = counts - payload["hint"] = _build_relationship_hint(found, mcp_profile, groups, counts, data_source) - else: - payload["hint"] = _build_relationship_hint(found, mcp_profile, [], None, data_source) - - return payload - - -def _build_group(group: dict) -> Dict[str, Any]: - """Build the JSON representation of a single relationship group.""" - relationship_type = group.get("relationType", "") - mcp_type = RELATIONSHIP_TYPE_MAP.get(relationship_type, relationship_type.lower()) - - items: List[Dict[str, Any]] = [] - for item in group.get("items", []) or []: - item_dict: Dict[str, Any] = {"identifier": item.get("identifier") or ""} - - file_path = item.get("filePath") - if file_path is not None: - item_dict["filePath"] = file_path - - start_line = item.get("startLine") - if start_line is not None: - item_dict["startLine"] = start_line - - short_summary = item.get("shortSummary") - if short_summary is not None: - item_dict["shortSummary"] = short_summary - - items.append(item_dict) - - return { - "type": mcp_type, - "totalCount": group.get("totalCount") or 0, - "returnedCount": group.get("returnedCount") or 0, - "truncated": bool(group.get("truncated")), - "items": items, - } - - -def _build_counts(counts: Any) -> Dict[str, int] | None: - """Preserve backend relationship counts that guide profile recovery.""" - if not isinstance(counts, dict): - return None - - return { - "outgoingCalls": int(counts.get("outgoingCalls") or counts.get("OutgoingCalls") or 0), - "incomingCalls": int(counts.get("incomingCalls") or counts.get("IncomingCalls") or 0), - "ancestors": int(counts.get("ancestors") or counts.get("Ancestors") or 0), - "descendants": int(counts.get("descendants") or counts.get("Descendants") or 0), - "references": int(counts.get("references") or counts.get("References") or 0), - } - - -def _build_relationship_hint( - found: bool, - profile: str, - groups: List[Dict[str, Any]], - counts: Dict[str, int] | None, - data_source: Optional[str] = None, -) -> str: - """Give model-facing next-step guidance for graph traversal results.""" - if not found: - if data_source: - return ( - f'No relationship data was found for this identifier in data source "{data_source}". ' - "The identifier may belong to a different data source, or the data_source value may be " - "wrong. Try: re-run with data_source set to a different candidate (use the `dataSource` " - "name or id from your search results, or call get_data_sources), or omit data_source " - "entirely — if the identifier is ambiguous you then get a 409 listing the candidate data " - "sources. Otherwise re-run semantic_search or grep_search to get a fresh identifier." - ) - return ( - "No relationship data was found for this identifier. Verify that the identifier came from " - "a recent search/fetch result and points to a symbol-level artifact; otherwise re-run " - "semantic_search or grep_search to get a fresh identifier." - ) - - if any(group["truncated"] for group in groups): - return ( - "Some relationship groups are truncated. If the user asked for all usages or full graph " - "scope, call get_artifact_relationships again with a higher max_count_per_type, then " - "fetch promising related artifacts before making broad claims." - ) - - if all(group["totalCount"] == 0 for group in groups): - return _build_empty_profile_hint(profile, counts) - - return ( - "Fetch promising related artifacts before making claims about behavior, concrete applications, " - "or how broadly this mechanism is used." - ) - - -def _build_empty_profile_hint(profile: str, counts: Dict[str, int] | None) -> str: - has_calls = (counts or {}).get("outgoingCalls", 0) > 0 or (counts or {}).get("incomingCalls", 0) > 0 - has_inheritance = (counts or {}).get("ancestors", 0) > 0 or (counts or {}).get("descendants", 0) > 0 - has_references = (counts or {}).get("references", 0) > 0 - - if profile == "referencesOnly" and has_calls and has_inheritance: - return ( - "No references were found for this profile, but call and inheritance relationships exist. " - "Use callsOnly for function/method callers or callees, or inheritanceOnly for base classes, " - "interfaces, overrides, implementations, or derived types." - ) - if profile == "referencesOnly" and has_calls: - return ( - "No references were found for this profile, but call relationships exist. Use callsOnly " - "for function/method callers or callees. Use referencesOnly for where-used checks on " - "types, containers, fields, commands, events, interfaces, and other non-call usage." - ) - if profile == "referencesOnly" and has_inheritance: - return ( - "No references were found for this profile, but inheritance relationships exist. Use " - "inheritanceOnly for base classes, interfaces, overrides, implementations, or derived types." - ) - if profile == "callsOnly" and has_references and has_inheritance: - return ( - "No call relationships were found for this profile, but references and inheritance " - "relationships exist. Try referencesOnly for where-used checks or inheritanceOnly for hierarchy." - ) - if profile == "callsOnly" and has_references: - return ( - "No call relationships were found for this profile, but references exist. Use referencesOnly " - "for where-used checks on types, containers, fields, commands, events, interfaces, or mediated dispatch symbols." - ) - if profile == "callsOnly" and has_inheritance: - return ( - "No call relationships were found for this profile, but inheritance relationships exist. " - "Use inheritanceOnly for base classes, interfaces, overrides, implementations, or derived types." - ) - if profile == "allRelevant" and has_references: - return ( - "No calls or inheritance relationships were found for allRelevant. allRelevant excludes " - "references by design; use referencesOnly for where-used checks." - ) - if profile == "inheritanceOnly" and has_calls and has_references: - return ( - "No inheritance relationships were found for this profile. Use callsOnly for function " - "callers/callees, or referencesOnly for where-used checks on types, commands, events, fields, containers, or interfaces." - ) - if profile == "inheritanceOnly" and has_calls: - return ( - "No inheritance relationships were found for this profile, but call relationships exist. " - "Use callsOnly for function/method callers or callees." - ) - if profile == "inheritanceOnly" and has_references: - return ( - "No inheritance relationships were found for this profile, but references exist. Use " - "referencesOnly for where-used checks on types, containers, fields, commands, events, interfaces, or mediated dispatch symbols." - ) - - return ( - "No relationships were found for this profile. Empty profile results do not mean the artifact " - "has no graph data. Use callsOnly for function/method callers and callees, inheritanceOnly for " - "hierarchy, allRelevant for calls plus inheritance, and referencesOnly for where-used checks on " - "types, containers, fields, commands, events, interfaces, and other non-call usage." - ) + }, action_label="get artifact relationships") diff --git a/src/tools/chat.py b/src/tools/chat.py index 804ab25..44087f5 100644 --- a/src/tools/chat.py +++ b/src/tools/chat.py @@ -1,338 +1,36 @@ -"""Chat completions tool implementation. +"""Tool API v3 stateless chat.""" -The canonical MCP tool name is ``chat``. ``codebase_consultant`` remains as a -deprecated alias for backward compatibility. -""" +from typing import Annotated, Optional, Union -import json -import re -from typing import Dict, List, Optional, Union -from urllib.parse import urljoin - -import httpx from fastmcp import Context -from fastmcp.exceptions import ToolError - -from core import CodeAliveContext, get_api_key_from_context, log_api_request, log_api_response -from utils import handle_api_error, format_validation_error, format_data_source_names, normalize_data_source_names +from pydantic import Field -_PRIMARY_TOOL_NAME = "chat" -_LEGACY_TOOL_NAME = "codebase_consultant" -_OBJECT_ID_RE = re.compile(r"^[0-9a-fA-F]{24}$") +from .tool_api import ToolApiResult, call_tool_api, normalize_optional_list, require_text async def chat( ctx: Context, - question: str, - data_sources: Optional[Union[str, List[str]]] = None, - conversation_id: Optional[str] = None, -) -> str: - """ - Ask CodeAlive for a synthesized answer about the indexed codebase. - - **IMPORTANT: Do NOT call this tool unless the user explicitly asks for it** - (e.g. "use chat", "use codebase_consultant", "call the chat tool"). - For all other tasks — finding code, understanding architecture, debugging — - use `semantic_search`, `grep_search`, `fetch_artifacts`, and - `get_artifact_relationships` instead. These tools are faster, return - primary evidence, and give you full control over the workflow. - - `chat` is a slow synthesis fallback (up to 30 seconds) with lower evidence - fidelity. It exists for cases where the user wants a single opinionated - answer from CodeAlive rather than raw search results. - - **PREREQUISITE**: You MUST call `get_data_sources` FIRST to discover available data source names, - UNLESS the user has explicitly provided specific data source names OR you are continuing an - existing conversation with a `conversation_id`. - - When invoked by the user, this tool can produce synthesized answers about - architecture, design decisions, code walkthroughs, debugging, etc. - These topics do NOT by themselves justify calling the tool — only an - explicit user request does. - - Args: - question: What you want to know about the codebase - Example: "How does the authentication system work?" - - data_sources: Repository or workspace names to analyze. These names are - resolved to IDs on the server side. - Example: ["enterprise-platform", "workspace:payments-team"] - - conversation_id: Continue a previous consultation session. Must be the - 24-character hex Mongo ObjectId returned by a previous - response. - Example: "69fceb3e7b2a6a7efdd18180" - - Returns: - Synthesized analysis and explanation addressing your question. - - Examples: - 1. Ask about architecture: - chat( - question="What's the best way to add caching to our API?", - data_sources=["repo123"] - ) - - 2. Understand implementation: - chat( - question="How do the microservices communicate?", - data_sources=["platform", "payments"] - ) - - 3. Continue a consultation: - chat( - question="What about error handling in that flow?", - conversation_id="69fceb3e7b2a6a7efdd18180" - ) - - Note: - - `chat` is disabled by default; see top of docstring for the single - condition that permits calling it. - - Either conversation_id OR data_sources is typically provided - - When creating a new conversation, data_sources is optional if your API key has exactly one assigned data source - - When continuing a conversation, conversation_id is required to maintain context - - The tool maintains full conversation history for follow-up questions - - Choose workspace names for broad architectural questions or repository names for specific implementation details - """ - return await _chat_impl( - ctx, - question=question, - data_sources=data_sources, - conversation_id=conversation_id, - method_name=_PRIMARY_TOOL_NAME, - ) - - -async def codebase_consultant( - ctx: Context, - question: str, - data_sources: Optional[Union[str, List[str]]] = None, - conversation_id: Optional[str] = None, -) -> str: - """Deprecated alias for `chat`. - - Keep this for backward compatibility with older prompts and MCP clients. - New integrations should prefer the canonical `chat` tool name. - - **Same invocation policy as `chat`**: do NOT call unless the user explicitly - named the tool (e.g. "use codebase_consultant", "use chat", "call the chat tool"). - For all other tasks use `semantic_search`, `grep_search`, `fetch_artifacts`, - and `get_artifact_relationships`. + question: Annotated[ + str, + Field( + min_length=1, + description="Self-contained stateless question including relevant prior context.", + ), + ], + data_sources: Annotated[ + Optional[Union[str, list[str]]], + Field(description="Repository or workspace names returned by get_data_sources."), + ] = None, +) -> ToolApiResult: + """Ask stateless CodeAlive chat through Tool API v3. + + Call only when the user explicitly asks for chat/synthesis. Tool API v3 + chat does not preserve public conversation context across calls; include all + important prior findings, artifact identifiers, assumptions, scope, and + constraints in each `question`. """ - return await _chat_impl( - ctx, - question=question, - data_sources=data_sources, - conversation_id=conversation_id, - method_name=_LEGACY_TOOL_NAME, - ) - - -async def _chat_impl( - ctx: Context, - *, - question: str, - data_sources: Optional[Union[str, List[str]]], - conversation_id: Optional[str], - method_name: str, -) -> str: - context: CodeAliveContext = ctx.request_context.lifespan_context - - # Normalize data source names (handles Claude Desktop serialization issues) - data_sources = normalize_data_source_names(data_sources) - - if not question or not question.strip(): - raise ToolError(format_validation_error( - method_name, - "No question provided. Please provide a question to ask the chat tool.", - )) - - if conversation_id and not _OBJECT_ID_RE.match(conversation_id): - raise ToolError(format_validation_error( - method_name, - f"conversation_id {conversation_id!r} is not a 24-character hex Mongo ObjectId. " - "Retry: no — fix the input. Try: pass the Conversation ID returned by an earlier " - "successful chat response, or omit conversation_id to start a new conversation.", - )) - - # Validate that either conversation_id or data_sources is provided - if not conversation_id and (not data_sources or len(data_sources) == 0): - await ctx.info("No data sources provided. If the API key has exactly one assigned data source, that will be used as default.") - await ctx.info( - f"[{method_name}] This synthesized call can take up to 30 seconds. " - "Prefer semantic_search and grep_search for default discovery." - ) - - # Transform simple question into message format internally - messages = [{"role": "user", "content": question}] - - # Prepare the request payload - request_data = { - "messages": messages, - "stream": True # Always stream internally for efficiency - } - - if conversation_id: - request_data["conversationId"] = conversation_id - - if data_sources: - request_data["names"] = format_data_source_names(data_sources) - - try: - api_key = get_api_key_from_context(ctx) - - # Log the attempt - await ctx.info(f"Consulting about: '{question[:100]}...'" if len(question) > 100 else f"Consulting about: '{question}'" + - (f" (continuing conversation {conversation_id})" if conversation_id else "")) - - headers = { - "Authorization": f"Bearer {api_key}", - "Accept": "text/event-stream, application/problem+json", - "X-CodeAlive-Integration": "mcp", - "X-CodeAlive-Tool": method_name, - "X-CodeAlive-Client": "fastmcp", - } - - # Log the request - full_url = urljoin(context.base_url, "/api/chat/completions") - request_id = log_api_request("POST", full_url, headers, body=request_data) - - # Make API request - response = await context.client.post( - "/api/chat/completions", - json=request_data, - headers=headers - ) - - # Log the response - log_api_response(response, request_id) - - response.raise_for_status() - - # Process streaming response - we always stream internally for efficiency - full_response = "" - conversation_metadata = {} - current_event_name = "message" - - try: - async for line in response.aiter_lines(): - line = line.rstrip("\r") - if not line: - current_event_name = "message" - continue - - # Handle metadata events - if line.startswith("event:"): - current_event_name = line[len("event:"):].strip() or "message" - continue - - if line.startswith("data:"): - data = line[len("data:"):].lstrip() - if data == "[DONE]": - break - try: - chunk = json.loads(data) - - if current_event_name == "error" and chunk.get("event") is None: - raise ToolError(_format_stream_error(method_name, chunk, conversation_metadata)) - - if chunk.get("event") == "error": - raise ToolError(_format_stream_error(method_name, chunk, conversation_metadata)) - - # Capture metadata with conversation ID and message ID - if chunk.get("event") == "metadata": - conv_id = chunk.get("conversationId") - msg_id = chunk.get("messageId") - if conv_id or msg_id: - conversation_metadata = chunk - await ctx.info(f"Conversation ID: {conv_id}, Message ID: {msg_id}") - continue - - # Process content chunks - if "choices" in chunk and len(chunk["choices"]) > 0: - delta = chunk["choices"][0].get("delta", {}) - if delta and "content" in delta and delta["content"] is not None: - full_response += delta["content"] - except json.JSONDecodeError: - pass - except ToolError: - raise - except Exception as streaming_error: - # Include conversation and message IDs in streaming error response - error_context = _format_metadata_context(conversation_metadata) - error_msg = ( - f"[{method_name}] Error during streaming: {str(streaming_error)}" - ) - await ctx.error(error_msg) - raise ToolError(f"{error_msg} {error_context}") - - # Append conversation ID info to the response if we got one and it's a new conversation - if conversation_metadata.get("conversationId") and not conversation_id: - conversation_id_note = f"\n\n---\n**Conversation ID:** `{conversation_metadata['conversationId']}`\n*Use this ID in the `conversation_id` parameter to continue this conversation.*" - full_response += conversation_id_note - - return full_response or "No content returned from the API. Please check that your data sources are accessible and try again." - - except ToolError: - raise - except (httpx.HTTPStatusError, Exception) as e: - await handle_api_error( - ctx, e, "chat completion", method=method_name, - recovery_hints={ - 404: ( - "(1) if continuing a conversation, verify conversation_id matches one returned by an earlier call, " - "(2) if starting a new conversation, call get_data_sources to list valid data source names, " - "(3) drop conversation_id and data_sources to fall back to the API key's default" - ), - }, - ) - - -def _format_metadata_context(metadata: Dict) -> str: - """Format conversation metadata for error messages.""" - if not metadata: - return "" - - parts = [] - if metadata.get("conversationId"): - parts.append(f"Conversation ID: {metadata['conversationId']}") - if metadata.get("messageId"): - parts.append(f"Message ID: {metadata['messageId']}") - - if parts: - return f"\n\n---\n**Debug Info:**\n" + "\n".join(f"- {p}" for p in parts) - return "" - - -def _format_stream_error(method_name: str, payload: Dict, metadata: Dict) -> str: - """Format an in-stream SSE error frame into an agent-actionable ToolError.""" - status = payload.get("status") - code = str(status or payload.get("code") or "STREAM_ERROR") - message = payload.get("message") or payload.get("detail") or payload.get("title") or "Streaming error" - detail = payload.get("detail") or payload.get("details") - request_id = payload.get("requestId") or payload.get("traceId") - - try: - status_int = int(status) if status is not None else None - except (TypeError, ValueError): - status_int = None - - if status_int in {408, 425, 429}: - retry = "Retry: yes (back off before retrying)" - hint = "Try: (1) wait 30-60 seconds before retrying, (2) reduce request frequency if this repeats" - elif status_int is not None and status_int >= 500: - retry = "Retry: yes (retry once after a few seconds)" - hint = "Try: (1) retry the call once, (2) if it fails again, stop retrying and report the requestId" - else: - retry = "Retry: no — fix the input or credentials, do not loop" - hint = "Try: inspect the detail/requestId below and adjust the chat request before retrying" - - extras = [] - if detail and detail != message: - extras.append(f"Detail: {detail}") - if request_id: - extras.append(f"requestId={request_id}") - metadata_context = _format_metadata_context(metadata) - suffix = f" ({' | '.join(extras)})" if extras else "" - - return f"[{method_name}] Error: {message}. Code: {code}. {retry}. {hint}{suffix}{metadata_context}" + require_text(question, "chat", "question") + return await call_tool_api(ctx, "chat", { + "question": question, + "data_sources": normalize_optional_list(data_sources), + }, action_label="chat") diff --git a/src/tools/datasources.py b/src/tools/datasources.py index 672bf7a..42864d1 100644 --- a/src/tools/datasources.py +++ b/src/tools/datasources.py @@ -1,235 +1,27 @@ -"""Data sources tool implementation.""" +"""Tool API v3 data-source discovery.""" -from typing import Any, Dict -from urllib.parse import urljoin +from typing import Annotated, Optional -import httpx from fastmcp import Context +from pydantic import Field -from core import ( - CodeAliveContext, - get_api_key_from_context, - log_api_request, - log_api_response, -) -from utils import handle_api_error +from .tool_api import ToolApiResult, call_tool_api -# MCP tool/method name surfaced in every error/log message from this module. -_TOOL_NAME = "get_data_sources" -# Pre-filter scoped candidate count, emitted by the backend only on relevance-filtered requests. -_TOTAL_HEADER = "X-CodeAlive-Total-Data-Sources" - - -def _relevance_message(data_sources: list, response) -> str: - """Builds the hint accompanying a query'd (relevance-filtered) result. - - The backend guarantees every relevance-selected item carries a non-empty `relevanceReason`, - so a query'd response where NO item has one means the filter did not run (fail-open on error, - disabled by config, or an older backend ignoring `query`) and the FULL list was returned — - the model must be told, instead of mistaking the full dump for a relevant shortlist. - """ - filtered = any(ds.get("relevanceReason") for ds in data_sources) - if not filtered: - return ( - "Relevance filtering was unavailable for this request (it may have failed or be " - "disabled), so the FULL unfiltered list of data sources is returned." - ) - - shown = len(data_sources) - try: - total = int(response.headers.get(_TOTAL_HEADER)) - except (TypeError, ValueError): - # Header absent (TypeError on int(None)) or malformed (ValueError). - total = None - if total is not None and total > shown: - return ( - f"{shown} of {total} available data sources are relevant to this query; the other " - f"{total - shown} were omitted. Call get_data_sources without a query to get the full list." - ) - if total is not None: - return f"All {total} available data sources are relevant to this query." - return ( - "Only the data sources relevant to this query are shown; non-relevant sources were " - "omitted. Call get_data_sources without a query to get the full list." - ) - - -# Hint embedded in every successful response. Mirrors the convention used by -# the search tools (see _SEARCH_HINT in utils/response_transformer.py): the -# response is always in front of the model when it picks the next step, so we -# repeat the most load-bearing usage rule here instead of relying on the -# tool's docstring being re-read mid-conversation. -_DATASOURCES_HINT = ( - "Use the `name` field as the `data_sources` parameter for `semantic_search`, " - "`grep_search`, or `chat`. To identify the CURRENT repository (vs external), " - "compare `name`/`description`/`url` against your working directory and the " - "code you've already observed." -) - -_DATASOURCES_EMPTY_HINT = ( - "No data sources found. Add a repository or workspace to CodeAlive at " - "https://app.codealive.ai before calling search or chat tools. If you " - "expected sources here, retry with alive_only=false to surface ones still " - "being indexed." -) - -# Empty result WITH a query means "nothing relevant to this intent" (sources DO exist) — -# a distinct hint from the no-sources-at-all case, so the model doesn't tell the user -# to add a repository. -_DATASOURCES_EMPTY_QUERY_HINT = ( - "No data sources are relevant to this query. Try a broader query, or call " - "get_data_sources without a query to see the full list." -) - - -# alive_only refers to ready_only. leaved as is for backward compatibility. async def get_data_sources( - ctx: Context, alive_only: bool = True, query: str | None = None -) -> Dict[str, Any]: + ctx: Context, + query: Annotated[ + Optional[str], + Field(description="Optional relevance question used to rank visible data sources."), + ] = None, + ready_only: bool = True, +) -> ToolApiResult: + """List visible repositories and workspaces. + + Use the returned `name` value for `data_sources` or `data_source` in other + v3 tools unless automation needs a stable `id`. """ - **CALL THIS FIRST**: Gets all available data sources (repositories and workspaces) for the user's account. - - This tool MUST be called BEFORE using `semantic_search`, `grep_search`, or - `chat` to discover available data source names, UNLESS the user - has explicitly provided data source names. - - A data source is a code repository or workspace that has been indexed by CodeAlive - and can be used for code search and chat completions. - - Args: - alive_only: If True (default), returns only data sources that are fully processed and ready for use. - If False, returns all data sources regardless of processing state. - query: Optional. The user's initial intent/task in natural language (e.g. "add OAuth to - checkout"). When provided, the backend runs an agentic relevance filter and returns - ONLY the data sources relevant to that intent, each with a `relevanceReason` - explaining why. This is the user's GOAL — distinct from `searchTerm` (a substring - name filter). Omit it to get the full list. Pass it whenever you - know what the user is trying to accomplish, to keep the returned list focused. - - Returns: - {"dataSources": [...], "hint": "..."} - - With `query`, the object also carries a `message` field telling you whether sources - were omitted as non-relevant (and how many of the total), that every available source - was relevant, or that relevance filtering was unavailable and the FULL list is returned. - - Each entry in `dataSources` carries: - - id: Unique identifier for the data source - - name: Human-readable name - CRITICAL for matching with current working directory name - - description: Summary of codebase contents - CRITICAL for identifying if this matches your - current working codebase (compare tech stack, architecture, features you've observed) - - type: The type of data source ("Repository" or "Workspace") - - url: Repository URL (for Repository type only) - useful for matching with git remote - - state: The processing state of the data source (if alive_only=false) - - relevanceReason: Why this source is relevant to `query` (present ONLY when `query` was supplied) - - The `hint` field reminds you how to use the result and how to distinguish - the CURRENT repository from EXTERNAL ones. - - Use name + description + url together to determine if a repository is the CURRENT one - you're working in versus an EXTERNAL repository. - - Examples: - 1. Get only ready-to-use data sources: - get_data_sources() - - 2. Get all data sources including those still processing: - get_data_sources(alive_only=false) - - Note: - Ready data sources are fully processed and available for search and chat. - Other states include "New" (just added), "Processing" (being indexed), - "Failed" (indexing failed), etc. - - CRITICAL - Use ALL available information to identify CURRENT vs EXTERNAL repositories: - - Heuristic signals to combine (in order of reliability): - 1. **Name matching**: Does repo name match your current working directory name? - Example: In "/Users/bob/my-app" and repo name is "my-app" → CURRENT - - 2. **Description matching**: Does description match what you've observed in the codebase? - - Tech stack (Python, JavaScript, FastAPI, React, etc.) - - Architecture patterns (microservices, monolith, MCP server, etc.) - - Key features mentioned - Example: Description says "FastAPI MCP server" and you see FastAPI + MCP code → CURRENT - - 3. **User context**: What is the user asking about? - - "this repo", "our code", "my project" → CURRENT - - "the payments service", "external API" → EXTERNAL - - 4. **URL matching** (when available): Compare with git remote URL - Note: May have format differences (SSH vs HTTPS), but hostname + path should match - - 5. **Working history**: Have you been reading/editing files that align with this repo? - - Use the returned data source names with `semantic_search`, `grep_search`, - `codebase_search` (legacy), `chat`, and `codebase_consultant` (legacy). - """ - context: CodeAliveContext = ctx.request_context.lifespan_context - - try: - api_key = get_api_key_from_context(ctx) - - # Determine the endpoint based on ready_only flag - endpoint = "/api/datasources/ready" if alive_only else "/api/datasources/all" - - headers = { - "Authorization": f"Bearer {api_key}", - "X-CodeAlive-Integration": "mcp", - "X-CodeAlive-Tool": "get_data_sources", - "X-CodeAlive-Client": "fastmcp", - } - - # Thread the user's intent as the `query` param when present so the backend relevance - # filter runs. Omitted entirely otherwise, so the request is unchanged for legacy callers - # (and an older backend that ignores `query` simply returns the full list). - params = {"query": query} if query else None - - # Log the request - full_url = urljoin(context.base_url, endpoint) - request_id = log_api_request("GET", full_url, headers) - - # Make API request - response = await context.client.get(endpoint, headers=headers, params=params) - - # Log the response - log_api_response(response, request_id) - - response.raise_for_status() - - # Parse and format the response - data_sources = response.json() - - if not data_sources or len(data_sources) == 0: - hint = _DATASOURCES_EMPTY_QUERY_HINT if query else _DATASOURCES_EMPTY_HINT - return {"dataSources": [], "hint": hint} - - # Remove repositoryIds from workspace data sources - for data_source in data_sources: - if ( - data_source.get("type") == "Workspace" - and "repositoryIds" in data_source - ): - del data_source["repositoryIds"] - - # FastMCP serializes via pydantic_core.to_json, which preserves UTF-8. - result: Dict[str, Any] = {"dataSources": data_sources, "hint": _DATASOURCES_HINT} - if query: - result["message"] = _relevance_message(data_sources, response) - return result - - except (httpx.HTTPStatusError, Exception) as e: - await handle_api_error( - ctx, - e, - "retrieving data sources", - method=_TOOL_NAME, - recovery_hints={ - # 422 means *some* sources are still indexing — surface alive_only=false as the next step - 422: ( - "(1) call get_data_sources(alive_only=false) to see which sources are still being processed, " - "(2) wait a few minutes for indexing to complete and retry" - ), - }, - ) + return await call_tool_api(ctx, "get_data_sources", { + "query": query, + "ready_only": ready_only, + }, action_label="list data sources") diff --git a/src/tools/fetch_artifacts.py b/src/tools/fetch_artifacts.py index ef8f28e..05289a1 100644 --- a/src/tools/fetch_artifacts.py +++ b/src/tools/fetch_artifacts.py @@ -1,358 +1,36 @@ -"""Fetch artifacts tool implementation.""" +"""Tool API v3 artifact fetch.""" -from typing import List, Optional, Union -from urllib.parse import urljoin +from typing import Annotated, Optional, Union -import httpx from fastmcp import Context from fastmcp.exceptions import ToolError +from pydantic import Field -from core import CodeAliveContext, get_api_key_from_context, log_api_request, log_api_response -from utils import coerce_stringified_list, handle_api_error - -# MCP tool/method name surfaced in every error/log message from this module. -_TOOL_NAME = "fetch_artifacts" - -# Emitted alongside a block so the agent never silently drops a requested -# artifact. Lists the concrete missing identifiers (in the block) and tells the agent to -# re-check those ids and retry the problematic ones. Parallel to search.py's _SEARCH_EMPTY_HINT. -_NOT_FOUND_HINT = ( - "{count} requested identifier(s) returned no accessible artifact and are listed under " - " above. Do NOT silently omit them from your answer. A entry means " - "the identifier did not resolve, or points outside the data sources this key can read — it " - "is NOT proof the code is absent. Required next steps: (1) re-check those exact identifiers " - "for typos or staleness; (2) re-run semantic_search or grep_search to obtain fresh, valid " - "identifiers, then call fetch_artifacts again for those problematic ids; (3) if they still " - "cannot be retrieved, explicitly tell the user which artifacts could not be fetched — do not " - "pretend they don't exist." -) +from .tool_api import ToolApiResult, call_tool_api, normalize_optional_list async def fetch_artifacts( ctx: Context, - identifiers: Union[str, List[str]], - data_source: Optional[str] = None, -) -> str: - """ - Retrieve the full content of code artifacts by their identifiers. - - Use this tool AFTER `semantic_search`, `grep_search`, or legacy `codebase_search` - to get the complete source code for results you need to inspect. The `identifier` - values come from the search results. - - This is the recommended way to retrieve content for **external repositories** that - you cannot access via local file reads. For repositories in your working directory, - prefer using `Read()` on the local files instead. - - Args: - identifiers: List of artifact identifiers from search results (max 20). - These are the `identifier` attribute values from `semantic_search`, - `grep_search`, or legacy `codebase_search` results. - - Identifier format examples: - Symbol: "my-org/backend::src/services/auth.py::AuthService.validate_token(token: str)" - File: "my-org/backend::src/services/auth.py" - Chunk: "my-org/backend::README.md::0042" - - data_source: Optional data-source Name or Id used to disambiguate an identifier that - exists in more than one data source. Copy the `dataSource.name` or - `dataSource.id` from the search result you want. Omit it for normal lookups; - if an identifier is ambiguous and you omit it, the backend returns a 409 - listing the candidate data sources. - - Returns: - XML with full content and call relationships for each found artifact: - - - numbered source code - - - - - - - - - - - - Requested identifiers the backend could not resolve (or that are outside your - access scope) are NOT dropped silently — they are listed in a - block with each concrete identifier, plus a hint to re-check - those ids and retry the problematic ones. - The `` element shows the artifact's call graph: - - **outgoing_calls**: functions this artifact calls (its dependencies) - - **incoming_calls**: functions that call this artifact (its blast radius) - Each shows up to 3 related artifacts with summaries. The `count` attribute - gives the total. Relationships are omitted for non-function artifacts. - - Note: - - Hard limit: 50 identifiers per request. Recommended: ≤20 to keep - context size manageable and avoid flooding the conversation with code. - - Identifiers must come from `semantic_search`, `grep_search`, or legacy `codebase_search` results. - - Relationships shown here are a **preview** (up to 3 call relationships per direction). - To retrieve the complete list, or to explore other relationship types - (inheritance, references), use `get_artifact_relationships`. - """ - # Coerce stringified JSON arrays sent by some MCP clients (Claude Code - # deferred tools, LiveKit agents, etc.) into a proper Python list. - identifiers = coerce_stringified_list(identifiers) - - # Normalize the optional selector: treat empty/whitespace-only as "no selector" - # so we don't send a junk dataSource to the backend or echo it in the not-found hint. - if data_source is not None: - data_source = data_source.strip() or None - - if not identifiers: - raise ToolError(f"[{_TOOL_NAME}] At least one identifier is required.") - - if len(identifiers) > 50: - raise ToolError(f"[{_TOOL_NAME}] Maximum 50 identifiers per request. Please reduce the number of identifiers.") - - context: CodeAliveContext = ctx.request_context.lifespan_context - - try: - api_key = get_api_key_from_context(ctx) - headers = { - "Authorization": f"Bearer {api_key}", - "X-CodeAlive-Integration": "mcp", - "X-CodeAlive-Tool": "fetch_artifacts", - "X-CodeAlive-Client": "fastmcp", - } - - body = {"identifiers": identifiers} - if data_source: - body["dataSource"] = data_source - - await ctx.info(f"Fetching {len(identifiers)} artifact(s)") - - # Log the request - full_url = urljoin(context.base_url, "/api/search/artifacts") - request_id = log_api_request("POST", full_url, headers, body=body) - - # Make API request - response = await context.client.post( - "/api/search/artifacts", json=body, headers=headers - ) - - # Log the response - log_api_response(response, request_id) - - response.raise_for_status() - - artifacts_data = response.json() - - # Build XML output - return _build_artifacts_xml(artifacts_data, data_source=data_source, requested=identifiers) - - except (httpx.HTTPStatusError, Exception) as e: - # handle_api_error raises ToolError → MCP response gets isError: true - await handle_api_error( - ctx, e, "fetch artifacts", method=_TOOL_NAME, - recovery_hints={ - 404: ( - "(1) verify the identifiers came from a recent semantic_search, grep_search, or codebase_search call (do not invent them), " - "(2) re-run semantic_search or grep_search to get fresh identifiers — the index may have changed, " - "(3) for local repos in your working directory, use Read() on the file path instead" - ), - 409: ( - "(1) the identifier exists in more than one data source — see the candidate data sources in the Detail above; each one will resolve, " - "(2) retry fetch_artifacts with data_source set to one candidate's Name or Id; if that data source isn't the one you want, retry with the next candidate, " - "(3) do NOT invent a result — pick from the listed data sources" - ), - }, - ) - - -def _add_line_numbers(content: str, start_line: int = 1) -> str: - """Add line numbers to content for easier navigation. - - Returns content with each line prefixed by its line number, - right-aligned and separated by ' | '. - - Args: - content: The text content to number. - start_line: 1-based line number for the first line (default 1). - """ - if not content: - return content - - lines = content.split("\n") - width = len(str(start_line + len(lines) - 1)) - numbered = [f"{start_line + i:>{width}} | {line}" for i, line in enumerate(lines)] - return "\n".join(numbered) - - -def _escape_attr(value: str) -> str: - """Escape a value for safe inclusion in an XML attribute (identifiers). - - Identifiers are caller-supplied — and in the MCP setting the "caller" is an - untrusted LLM/user — and they are reflected straight back into the model's context - (especially in the block, which echoes any unmatched requested string via - the backstop). An un-escaped quote or angle bracket would let a crafted identifier break - out of the attribute and inject pseudo-XML. Mirrors the C# wrapper's - XmlToolResultFormatter.EscapeAttr. Source-code *content* is intentionally NOT escaped - (see ); this helper is for attribute values only. - """ - return ( - value.replace("&", "&") - .replace("<", "<") - .replace(">", ">") - .replace('"', """) - ) - - -def _build_artifacts_xml( - data: dict, - data_source: str | None = None, - requested: list[str] | None = None, -) -> str: - """Build XML representation of fetched artifacts. - - Backend DTO: Identifier (string), Found (bool), Content (string?), - ContentByteSize (long?), Relationships (object?). - - A requested identifier that the backend could not resolve — or that points outside - the caller's access scope — comes back with ``found: false`` (older backends omit - the flag and return ``content: null``). Such identifiers are NOT dropped silently: - they are collected into a ```` block listing each concrete - identifier, followed by ``_NOT_FOUND_HINT`` telling the agent to re-check the ids and - retry the problematic ones — otherwise the user silently loses a requested artifact. - A ``found: true`` artifact with empty content is still rendered as a normal - ```` (it was located; it just has no extractable body). - - Content is emitted raw (no HTML escaping) and wrapped between newlines so the - LLM sees the source code exactly as-is. - - When ``data_source`` was supplied and nothing was found, an additional recovery hint - suggests the identifier may live in a different data source, or the selector is wrong. - ``requested`` is the original identifier list; it backstops the diff so an id the - backend never echoed back is still surfaced as not-found. - """ - xml_parts = [""] - - has_any_relationships = False - emitted = 0 - returned_identifiers: set[str] = set() - not_found: list[str] = [] - artifacts = data.get("artifacts", []) - for artifact in artifacts: - identifier = artifact.get("identifier", "") - if identifier: - returned_identifiers.add(identifier) - - content = artifact.get("content") - # Prefer the backend's explicit `found` flag; fall back to content-is-null for - # older backends that don't emit it yet. - found = artifact.get("found") - is_missing = (found is False) if found is not None else (content is None) - if is_missing: - if identifier: - not_found.append(identifier) - continue - - emitted += 1 - content_byte_size = artifact.get("contentByteSize") - - attrs = [f'identifier="{_escape_attr(identifier)}"'] - if content_byte_size is not None: - attrs.append(f'contentByteSize="{content_byte_size}"') - - start_line = artifact.get("startLine") or 1 - numbered_content = _add_line_numbers(content or "", start_line) - - xml_parts.append(f' ') - xml_parts.append(' ') - xml_parts.append(numbered_content) - xml_parts.append(' ') - - relationships = artifact.get("relationships") - if relationships is not None: - relationships_xml = _build_relationships_xml(relationships) - if relationships_xml: - xml_parts.append(relationships_xml) - if _has_any_calls(relationships): - has_any_relationships = True - - xml_parts.append(' ') - - # Backstop: any requested identifier the backend never echoed back is also missing. - if requested: - for identifier in requested: - if identifier not in returned_identifiers and identifier not in not_found: - not_found.append(identifier) - - if has_any_relationships: - xml_parts.append( - ' The above are a preview (up to 3 calls per ' - 'direction). To retrieve the full list, or to explore other relationship ' - 'types (inheritance, references), call `get_artifact_relationships` with ' - 'an artifact identifier.' - ) - - if not_found: - xml_parts.append(f' ') - for identifier in not_found: - xml_parts.append(f' ') - xml_parts.append(' ') - xml_parts.append(f' {_NOT_FOUND_HINT.format(count=len(not_found))}') - - if emitted == 0 and data_source: - xml_parts.append( - f' No artifacts were found in data source "{_escape_attr(data_source)}". The identifier may ' - 'belong to a different data source, or the data_source value may be wrong. Try: ' - '(1) re-run fetch_artifacts with data_source set to a different candidate (use the ' - '`dataSource` name or id from your search results, or call get_data_sources), or ' - '(2) omit data_source entirely — if the identifier is ambiguous you then get a 409 ' - 'that lists the candidate data sources to choose from.' - ) - - xml_parts.append("") - return "\n".join(xml_parts) - - -def _has_any_calls(relationships: dict) -> bool: - """Return True if relationships contain at least one outgoing or incoming call.""" - for rel_type in ("outgoingCalls", "incomingCalls"): - count = relationships.get(f"{rel_type}Count") - if count and count > 0: - return True - return False - - -def _build_relationships_xml(relationships: dict) -> str | None: - """Build XML for artifact call relationships. - - Returns None if no relationship types are present. - Identifiers and summaries are emitted raw (no HTML escaping). - """ - parts = [] - - for rel_type in ("outgoingCalls", "incomingCalls"): - tag = "outgoing_calls" if rel_type == "outgoingCalls" else "incoming_calls" - count = relationships.get(f"{rel_type}Count") - calls = relationships.get(rel_type) - - if count is None: - continue - - call_elements = [] - if calls: - for call in calls: - call_id = call.get("identifier") or "" - summary = call.get("summary") - if summary is not None: - call_elements.append( - f' ' - ) - else: - call_elements.append(f' ') - - parts.append(f' <{tag} count="{count}">') - parts.extend(call_elements) - parts.append(f' ') - - if not parts: - return None - - return " \n" + "\n".join(parts) + "\n " + identifiers: Annotated[ + Union[ + str, + Annotated[list[str], Field(min_length=1, max_length=50)], + ], + Field(description="Artifact identifiers returned by search, read, or relationship tools."), + ], + data_source: Annotated[ + Optional[str], + Field(description="Optional repository name or id used to disambiguate identifiers."), + ] = None, +) -> ToolApiResult: + """Fetch full artifact content for identifiers returned by search tools.""" + normalized = normalize_optional_list(identifiers) + if not normalized: + raise ToolError("[fetch_artifacts] identifiers is required.") + if len(normalized) > 50: + raise ToolError("[fetch_artifacts] Maximum 50 identifiers per request.") + + return await call_tool_api(ctx, "fetch_artifacts", { + "identifiers": normalized, + "data_source": data_source, + }, action_label="fetch artifacts") diff --git a/src/tools/repository.py b/src/tools/repository.py new file mode 100644 index 0000000..3266717 --- /dev/null +++ b/src/tools/repository.py @@ -0,0 +1,65 @@ +"""Tool API v3 repository context tools.""" + +from typing import Annotated, Optional + +from fastmcp import Context +from pydantic import Field + +from .tool_api import ToolApiResult, call_tool_api, require_text + + +async def get_repository_ontology( + ctx: Context, + data_source: Annotated[ + Optional[str], + Field(description="One repository name or id returned by get_data_sources."), + ] = None, +) -> ToolApiResult: + """Return ontology and orientation context for exactly one repository.""" + return await call_tool_api(ctx, "get_repository_ontology", { + "data_source": data_source, + }, action_label="get repository ontology") + + +async def get_file_tree( + ctx: Context, + data_source: Annotated[ + Optional[str], + Field(description="One repository name or id returned by get_data_sources."), + ] = None, + path: Annotated[Optional[str], Field(description="Repository-relative directory path; omit for root.")] = None, + max_depth: Annotated[Optional[int], Field(ge=1, le=8, description="Tree traversal depth (1-8).")] = None, + max_nodes: Annotated[Optional[int], Field(ge=1, le=300, description="Maximum tree nodes (1-300).")] = None, + output_depth: Annotated[Optional[int], Field(description="Optional render-only depth cap.")] = None, +) -> ToolApiResult: + """Return a bounded file tree for exactly one repository.""" + return await call_tool_api(ctx, "get_file_tree", { + "data_source": data_source, + "path": path, + "max_depth": max_depth, + "max_nodes": max_nodes, + "output_depth": output_depth, + }, action_label="get file tree") + + +async def read_file( + ctx: Context, + path: Annotated[str, Field(min_length=1, description="Safe repository-relative file path.")], + data_source: Annotated[ + Optional[str], + Field(description="One repository name or id returned by get_data_sources."), + ] = None, + start_line: Annotated[Optional[int], Field(ge=1, description="Optional one-based start line.")] = None, + end_line: Annotated[ + Optional[int], + Field(ge=1, description="Optional one-based end line; at most 1000 lines per call."), + ] = None, +) -> ToolApiResult: + """Read a safe repository-relative file path from exactly one repository.""" + require_text(path, "read_file", "path") + return await call_tool_api(ctx, "read_file", { + "data_source": data_source, + "path": path, + "start_line": start_line, + "end_line": end_line, + }, action_label="read file") diff --git a/src/tools/search.py b/src/tools/search.py index bdbef36..ed62599 100644 --- a/src/tools/search.py +++ b/src/tools/search.py @@ -1,434 +1,97 @@ -"""Search tool implementations.""" +"""Tool API v3 search tools.""" -import json -from typing import Any, Dict, List, Optional, Sequence, Union -from urllib.parse import urljoin +from typing import Annotated, Optional, Union -import httpx from fastmcp import Context from fastmcp.exceptions import ToolError +from pydantic import Field -from core import CodeAliveContext, get_api_key_from_context, log_api_request, log_api_response -from utils import ( - handle_api_error, - normalize_data_source_names, - transform_grep_response, - transform_search_response, -) - - -def _normalize_optional_list(value: Optional[Union[str, List[str]]]) -> List[str]: - """Normalize optional string-or-list inputs while preserving ordering. - - Handles stringified JSON arrays (e.g. ``'[".cs",".py"]'``) that some MCP - clients send instead of native arrays. - """ - if value is None: - return [] - if isinstance(value, str): - stripped = value.strip() - if not stripped: - return [] - if stripped.startswith("["): - try: - parsed = json.loads(stripped) - if isinstance(parsed, list): - return [str(item) for item in parsed if item] - except (json.JSONDecodeError, TypeError): - pass - return [stripped] - return [item for item in value if item] - - -def _validate_query(query: str, tool_name: str) -> None: - """Raise ToolError if query is empty.""" - if not query or not query.strip(): - raise ToolError( - f"[{tool_name}] Query cannot be empty. Please provide a search term, " - "pattern, function name, or description of the code you're looking for." - ) +from .tool_api import ToolApiResult, call_tool_api, normalize_optional_list, require_text def _validate_max_results(max_results: Optional[int], tool_name: str) -> None: - """Raise ToolError if max_results is out of range.""" if max_results is not None and not (1 <= max_results <= 500): raise ToolError(f"[{tool_name}] max_results must be between 1 and 500.") -async def _perform_search_request( - ctx: Context, - *, - tool_name: str, - endpoint: str, - params: List[tuple[str, str]], - transform_response, - action_label: str, -) -> Dict[str, Any]: - context: CodeAliveContext = ctx.request_context.lifespan_context - api_key = get_api_key_from_context(ctx) - - headers = { - "Authorization": f"Bearer {api_key}", - "X-CodeAlive-Integration": "mcp", - "X-CodeAlive-Tool": tool_name, - "X-CodeAlive-Client": "fastmcp", - } - - full_url = urljoin(context.base_url, endpoint) - request_id = log_api_request("GET", full_url, headers, params=params) - - try: - response = await context.client.get(endpoint, params=params, headers=headers) - log_api_response(response, request_id) - response.raise_for_status() - return transform_response(response.json()) - except (httpx.HTTPStatusError, Exception) as e: - # handle_api_error raises ToolError → MCP response gets isError: true - await handle_api_error( - ctx, - e, - action_label, - method=tool_name, - recovery_hints={ - 404: ( - "(1) call get_data_sources to list available data source names, " - "(2) check spelling and case of the names you passed in data_sources, " - "(3) drop data_sources entirely to fall back to the API key's default" - ), - }, - ) - - -def _build_scope_params( - *, - query: str, - data_sources: Sequence[str], - paths: Sequence[str], - extensions: Sequence[str], - max_results: Optional[int], -) -> List[tuple[str, str]]: - params: List[tuple[str, str]] = [("Query", query)] - - if max_results is not None: - params.append(("MaxResults", str(max_results))) - - for data_source in data_sources: - params.append(("Names", data_source)) - for path in paths: - params.append(("Paths", path)) - for extension in extensions: - params.append(("Extensions", extension)) - - return params - - async def semantic_search( ctx: Context, - query: str, - data_sources: Optional[Union[str, List[str]]] = None, - paths: Optional[Union[str, List[str]]] = None, - extensions: Optional[Union[str, List[str]]] = None, - max_results: Optional[int] = None, -) -> Dict[str, Any]: - """ - Search indexed code by meaning — the default discovery tool. - - Finds code by WHAT it does, not by exact text it contains. Start here - when you can describe the behavior or concept you're looking for but - don't know (or aren't sure of) the exact names in the codebase. - - **When to use semantic_search (default):** - - Exploring concepts: "authentication middleware", "retry logic" - - Describing behavior: "database connection pooling", "JWT validation" - - Architecture questions: "request handling pipeline", "event processing" - - You don't know the exact naming convention used in the codebase - - **When to use `grep_search` instead:** - - You know an exact identifier: class, function, or variable name - - Looking for a literal string: error message, URL, config key, file path - - Finding ALL usages of a known symbol: `RepositoryDeleted`, `handlePayment` - - Searching for import paths, TODO comments, or regex patterns - - Args: - query: Natural-language description of what you're looking for. - Example: "authentication middleware", "database connection pooling", - "JWT token validation" - - data_sources: Repository or workspace names to search. - Omit to use the API key's default data source. - Call `get_data_sources` first to discover available names. - Example: ["backend", "workspace:payments-team"] - - paths: Restrict results to specific directory paths. - Example: ["src/services", "src/domain"] - - extensions: Restrict results to specific file extensions. - Example: [".cs", ".py", ".ts"] - - max_results: Maximum number of results to return (1–500). - Omit for the server default. - - Returns: - {"results": [...], "hint": "..."} - - Each result contains: - - path: file path within the repository - - identifier: fully qualified artifact ID — pass this to `fetch_artifacts` - - kind: "File", "Symbol", or "Chunk" - - description: short triage summary (NOT the real source — see hint) - - startLine/endLine: line range (for symbols) - - contentByteSize: file size in bytes - - The `hint` field reminds you to load real source code via - `fetch_artifacts(identifier)` or local `Read(path)` before reasoning - about the code. - - Examples: - 1. Find authentication code: - semantic_search(query="authentication middleware", - data_sources=["backend"]) - - 2. Narrow to Python files in a specific directory: - semantic_search(query="database retry logic", - data_sources=["backend"], - paths=["src/services"], - extensions=[".py"]) + question: Annotated[ + str, + Field(min_length=1, description="Natural-language question about indexed code."), + ], + data_sources: Annotated[ + Optional[Union[str, list[str]]], + Field(description="Repository or workspace names returned by get_data_sources."), + ] = None, + paths: Annotated[ + Optional[Union[str, list[str]]], + Field(description="Repository-relative path prefixes to include."), + ] = None, + extensions: Annotated[ + Optional[Union[str, list[str]]], + Field(description="File extensions to include, such as cs, ts, or py."), + ] = None, + max_results: Annotated[ + Optional[int], + Field(ge=1, le=500, description="Maximum number of results (1-500)."), + ] = None, + exclude_markdown: bool = False, +) -> ToolApiResult: + """Search indexed code by meaning using Tool API v3. + + `question` must be a natural-language English sentence. Use `data_sources` + names returned by `get_data_sources`; use `id` only for automation or + disambiguation. Returns backend-rendered agentic output directly. """ tool_name = "semantic_search" - _validate_query(query, tool_name) + require_text(question, tool_name, "question") _validate_max_results(max_results, tool_name) - - data_source_names = normalize_data_source_names(data_sources) - normalized_paths = _normalize_optional_list(paths) - normalized_extensions = _normalize_optional_list(extensions) - - if data_source_names: - await ctx.info( - f"Semantic search for '{query}' across {len(data_source_names)} data source(s)" - ) - else: - await ctx.info( - f"Semantic search for '{query}' using the API key's default data source" - ) - - params = _build_scope_params( - query=query, - data_sources=data_source_names, - paths=normalized_paths, - extensions=normalized_extensions, - max_results=max_results, - ) - - return await _perform_search_request( - ctx, - tool_name=tool_name, - endpoint="/api/search/semantic", - params=params, - transform_response=transform_search_response, - action_label="semantic search", - ) + return await call_tool_api(ctx, tool_name, { + "question": question, + "data_sources": normalize_optional_list(data_sources), + "paths": normalize_optional_list(paths), + "extensions": normalize_optional_list(extensions), + "max_results": max_results, + "exclude_markdown": exclude_markdown, + }, action_label="semantic search") async def grep_search( ctx: Context, - query: str, - data_sources: Optional[Union[str, List[str]]] = None, - paths: Optional[Union[str, List[str]]] = None, - extensions: Optional[Union[str, List[str]]] = None, - max_results: Optional[int] = None, + query: Annotated[ + str, + Field(min_length=1, description="Exact literal text or regular expression to find."), + ], + data_sources: Annotated[ + Optional[Union[str, list[str]]], + Field(description="Repository or workspace names returned by get_data_sources."), + ] = None, + paths: Annotated[ + Optional[Union[str, list[str]]], + Field(description="Repository-relative path prefixes to include."), + ] = None, + extensions: Annotated[ + Optional[Union[str, list[str]]], + Field(description="File extensions to include, such as cs, ts, or py."), + ] = None, + max_results: Annotated[ + Optional[int], + Field(ge=1, le=500, description="Maximum number of results (1-500)."), + ] = None, + exclude_markdown: bool = False, regex: bool = False, -) -> Dict[str, Any]: - """ - Search indexed code by exact text or regex — matches file content - and, for literal queries, also file names/paths. - - Use this when you know WHAT TEXT to look for: an identifier, an error - message, a config key, or a file whose name you know (even if nothing - inside the file references that name — 1C `Form.xml`, `.mdo`, config - XML, media files, etc.). - - **When to use grep_search:** - - Specific identifiers: class/function/variable names, domain events - (e.g. `RepositoryDeleted`, `handlePayment`, `AUTH_PROVIDERS`) - - Literal strings: error messages, URLs, config keys, file paths - - File names whose content may never contain their own name - (e.g. `Form.xml`, `schema.graphql`, `appsettings.json`) - - Import paths, TODO/FIXME comments, annotations - - Regex patterns: `def test_.*async`, `Status\\.(Alive|Failed)` - - Finding ALL occurrences of a known symbol across the codebase - - **When to use `semantic_search` instead:** - - You're exploring a concept or behavior ("how does auth work?") - - You don't know the exact naming convention in the codebase - - You want code that DOES something, not code that CONTAINS a string - - Args: - query: Exact text or regex pattern to match. - Literal examples: "ConnectionString", "TODO: fix", "import numpy" - Regex examples: "def test_.*async", "Status\\.(Alive|Failed)" - - data_sources: Repository or workspace names to search. - Omit to use the API key's default data source. - Call `get_data_sources` first to discover available names. - - paths: Restrict results to specific directory paths. - Example: ["src/services"] - - extensions: Restrict results to specific file extensions. - Example: [".cs", ".py"] - - max_results: Maximum number of results to return (1–500). - - regex: If True, treat `query` as a regex pattern. Default: False (literal). - **Regex currently matches file content only** — file-name/path - matching is literal-substring only. This is a known limitation. - - Returns: - {"results": [...], "hint": "..."} - - Each result contains: - - path: file path - - identifier: pass to `fetch_artifacts` for full source - - matchCount: total matches in this file (0 for file-name-only hits) - - matches: array of line-level hits, each with: - - lineNumber, startColumn, endColumn, lineText - - matchedByName: present and `true` only when the artifact matched - by its file name/path and has no content match. In that case - `matches` is empty and `location.line` defaults to 1 as a - file-level reference — do NOT interpret `location.line` as an - actual line match. Content-match results omit this field. - - The `hint` reminds you that line previews are evidence only — load - full source via `fetch_artifacts` or local `Read()` before reasoning. - - Examples: - 1. Find exact string: - grep_search(query="ConnectionString", - data_sources=["backend"]) - - 2. Find a file by name (returns the file even if nothing inside - it references `Form.xml`): - grep_search(query="Form.xml", - data_sources=["biterp-bsl"]) - - 3. Regex search for test methods (content only): - grep_search(query="def test_.*auth", - data_sources=["backend"], - extensions=[".py"], - regex=True) - """ +) -> ToolApiResult: + """Search indexed code by exact literal text or regex using Tool API v3.""" tool_name = "grep_search" - _validate_query(query, tool_name) + require_text(query, tool_name, "query") _validate_max_results(max_results, tool_name) - - data_source_names = normalize_data_source_names(data_sources) - normalized_paths = _normalize_optional_list(paths) - normalized_extensions = _normalize_optional_list(extensions) - - search_kind = "regex grep" if regex else "literal grep" - if data_source_names: - await ctx.info( - f"{search_kind.capitalize()} for '{query}' across {len(data_source_names)} data source(s)" - ) - else: - await ctx.info( - f"{search_kind.capitalize()} for '{query}' using the API key's default data source" - ) - - params = _build_scope_params( - query=query, - data_sources=data_source_names, - paths=normalized_paths, - extensions=normalized_extensions, - max_results=max_results, - ) - params.append(("Regex", "true" if regex else "false")) - - return await _perform_search_request( - ctx, - tool_name=tool_name, - endpoint="/api/search/grep", - params=params, - transform_response=transform_grep_response, - action_label="grep search", - ) - - -async def codebase_search( - ctx: Context, - query: str, - data_sources: Optional[Union[str, List[str]]] = None, - mode: str = "auto", - description_detail: str = "short", -) -> Dict[str, Any]: - """ - Deprecated legacy semantic search tool. - - Prefer `semantic_search` for new integrations. This compatibility alias keeps the - previous MCP contract and forwards to the legacy backend endpoint unchanged. - """ - tool_name = "codebase_search" - _validate_query(query, tool_name) - - context: CodeAliveContext = ctx.request_context.lifespan_context - data_source_names = normalize_data_source_names(data_sources) - - normalized_mode = mode.lower() if mode else "auto" - if normalized_mode not in ["auto", "fast", "deep"]: - await ctx.warning( - f"[{tool_name}] Invalid search mode: {mode}. " - "Valid modes are 'auto', 'fast', and 'deep'. Using 'auto' instead." - ) - normalized_mode = "auto" - - detail_map = {"short": "Short", "full": "Full"} - normalized_detail = detail_map.get((description_detail or "short").lower(), "Short") - - if data_source_names: - await ctx.info( - f"Legacy codebase_search for '{query}' across {len(data_source_names)} data source(s)" - ) - else: - await ctx.info( - f"Legacy codebase_search for '{query}' using the API key's default data source" - ) - - params = [ - ("Query", query), - ("Mode", normalized_mode), - ("IncludeContent", "false"), - ("DescriptionDetail", normalized_detail), - ] - for data_source in data_source_names: - params.append(("Names", data_source)) - - api_key = get_api_key_from_context(ctx) - headers = { - "Authorization": f"Bearer {api_key}", - "X-CodeAlive-Integration": "mcp", - "X-CodeAlive-Tool": tool_name, - "X-CodeAlive-Client": "fastmcp", - } - - full_url = urljoin(context.base_url, "/api/search") - request_id = log_api_request("GET", full_url, headers, params=params) - - try: - response = await context.client.get("/api/search", params=params, headers=headers) - log_api_response(response, request_id) - response.raise_for_status() - return transform_search_response(response.json()) - except (httpx.HTTPStatusError, Exception) as e: - await handle_api_error( - ctx, - e, - "code search", - method=tool_name, - recovery_hints={ - 404: ( - "(1) call get_data_sources to list available data source names, " - "(2) check spelling and case of the names you passed in data_sources, " - "(3) drop data_sources entirely to fall back to the API key's default" - ), - }, - ) + return await call_tool_api(ctx, tool_name, { + "query": query, + "data_sources": normalize_optional_list(data_sources), + "paths": normalize_optional_list(paths), + "extensions": normalize_optional_list(extensions), + "max_results": max_results, + "exclude_markdown": exclude_markdown, + "regex": regex, + }, action_label="grep search") diff --git a/src/tools/tool_api.py b/src/tools/tool_api.py new file mode 100644 index 0000000..b9f747e --- /dev/null +++ b/src/tools/tool_api.py @@ -0,0 +1,105 @@ +"""Shared v3 Tool API caller for MCP tools.""" + +import json +from typing import Any, Iterable, Optional, Union +from urllib.parse import urljoin + +import httpx +from fastmcp import Context +from fastmcp.exceptions import ToolError +from fastmcp.tools.tool import ToolResult + +from core import CodeAliveContext, get_api_key_from_context, log_api_request, log_api_response +from utils import handle_api_error + +ToolApiResult = str | ToolResult + + +def normalize_optional_list(value: Optional[Union[str, list[str]]]) -> list[str]: + if value is None: + return [] + if isinstance(value, str): + stripped = value.strip() + if not stripped: + return [] + if stripped.startswith("["): + try: + parsed = json.loads(stripped) + if isinstance(parsed, list): + return [str(item) for item in parsed if str(item).strip()] + except (json.JSONDecodeError, TypeError): + pass + return [stripped] + return [str(item) for item in value if str(item).strip()] + + +def require_text(value: str, tool_name: str, field: str) -> None: + if not value or not value.strip(): + raise ToolError(f"[{tool_name}] {field} is required.") + + +def omit_empty(payload: dict[str, Any]) -> dict[str, Any]: + return { + key: value + for key, value in payload.items() + if value is not None and value != [] and value != "" + } + + +async def call_tool_api( + ctx: Context, + tool_name: str, + payload: dict[str, Any], + *, + action_label: Optional[str] = None, +) -> ToolApiResult: + context: CodeAliveContext = ctx.request_context.lifespan_context + api_key = get_api_key_from_context(ctx) + body = {**omit_empty(payload), "output_format": "agentic"} + headers = { + "Authorization": f"Bearer {api_key}", + "X-CodeAlive-Integration": "mcp", + "X-CodeAlive-Tool": tool_name, + "X-CodeAlive-Client": "fastmcp-v3", + } + + endpoint = f"/api/tools/{tool_name}" + full_url = urljoin(context.base_url, endpoint) + request_id = log_api_request("POST", full_url, headers, body=body) + + try: + response = await context.client.post(endpoint, json=body, headers=headers) + log_api_response(response, request_id) + response.raise_for_status() + data = response.json() + obj = data.get("obj") + rendered = data.get("rendered") + if isinstance(obj, dict) and isinstance(obj.get("error"), dict): + content = rendered if isinstance(rendered, str) else json.dumps( + obj, + ensure_ascii=False, + indent=2, + ) + return ToolResult( + content=content, + structured_content=obj, + is_error=True, + ) + if isinstance(rendered, str): + return rendered + return json.dumps(obj, ensure_ascii=False, indent=2) + except Exception as exc: + await handle_api_error( + ctx, + exc, + action_label or tool_name, + method=tool_name, + recovery_hints={ + 404: ( + "(1) verify the tool name is a Tool API v3 tool, " + "(2) call get_data_sources to choose visible data sources, " + "(3) retry with canonical snake_case arguments" + ), + }, + ) + raise AssertionError("handle_api_error always raises") diff --git a/src/utils/errors.py b/src/utils/errors.py index 22b662a..2241c18 100644 --- a/src/utils/errors.py +++ b/src/utils/errors.py @@ -11,8 +11,8 @@ Per-tool callers can override the default ``Try:`` text via ``recovery_hints`` when a generic hint isn't actionable enough — e.g. a 404 from ``semantic_search`` -should suggest ``get_data_sources``, while a 404 from ``chat`` (or legacy -``codebase_consultant``) should suggest checking ``conversation_id``. +should suggest ``get_data_sources``, while a 404 from artifact tools should +suggest reusing identifiers returned by v3 search/fetch/read tools. """ import json @@ -102,8 +102,7 @@ class _ErrorTemplate: retry_window=None, default_hint=( "(1) inspect the field-level errors below and fix the offending parameter, " - "(2) for conversation_id / message_id, ensure the value is a 24-character hex " - "Mongo ObjectId taken from a previous response, " + "(2) verify that the request uses canonical Tool API v3 snake_case fields, " "(3) if no field errors are surfaced, re-read the tool docstring and verify " "the request shape matches" ), @@ -135,7 +134,7 @@ class _ErrorTemplate: default_hint=( "(1) call get_data_sources to see available data source names, " "(2) check spelling and case, " - "(3) verify any identifiers were returned by a recent semantic_search, grep_search, or codebase_search" + "(3) verify any identifiers were returned by a recent semantic_search, grep_search, read_file, or fetch_artifacts" ), ), 409: _ErrorTemplate( @@ -155,7 +154,7 @@ class _ErrorTemplate: retry_window="wait 1–5 minutes and retry", default_hint=( "(1) wait for indexing to complete before retrying, " - "(2) call get_data_sources(alive_only=false) to check the processing state, " + "(2) call get_data_sources(ready_only=false) to check the processing state, " "(3) try a different data source if available" ), ), @@ -240,8 +239,7 @@ async def handle_api_error( are easy to attribute. recovery_hints: Optional per-tool overrides for the ``Try: ...`` text, keyed by HTTP status code. Use this when a generic hint isn't - enough — e.g. ``chat`` overrides 404 with - ``"check the conversation_id"``. + enough for a specific tool. Raises: ToolError: Always raised — sets ``isError: true`` in the MCP response diff --git a/uv.lock b/uv.lock index 43acc34..4f69b3e 100644 --- a/uv.lock +++ b/uv.lock @@ -6,6 +6,26 @@ requires-python = ">=3.11" exclude-newer = "0001-01-01T00:00:00Z" # This has no effect and is included for backwards compatibility when using relative exclude-newer values. exclude-newer-span = "P7D" +[options.exclude-newer-package] +idna = "2026-07-10T00:00:00Z" +pytest = "2026-07-10T00:00:00Z" +urllib3 = "2026-07-10T00:00:00Z" +pydantic-settings = "2026-07-10T00:00:00Z" +pyjwt = "2026-07-10T00:00:00Z" +python-dotenv = "2026-07-10T00:00:00Z" +fastmcp = "2026-07-10T00:00:00Z" +cryptography = "2026-07-10T00:00:00Z" +fastmcp-slim = "2026-07-10T00:00:00Z" + +[manifest] +constraints = [ + { name = "cryptography", specifier = ">=48.0.1" }, + { name = "idna", specifier = ">=3.15" }, + { name = "pydantic-settings", specifier = ">=2.14.2" }, + { name = "pyjwt", specifier = ">=2.13.0" }, + { name = "urllib3", specifier = ">=2.7.0" }, +] + [[package]] name = "aiofile" version = "3.9.0" @@ -51,14 +71,15 @@ wheels = [ [[package]] name = "authlib" -version = "1.6.12" +version = "1.7.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cryptography" }, + { name = "joserfc" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/d3/30/6691fdc63b35f54a5a65e04fa1e59d827f4d4e8f4a39678ba7d3088ce0c8/authlib-1.6.12.tar.gz", hash = "sha256:0656d8482f28fc8221929d5f35b2bde5d13e10555ebc06b4561b0d622e83b1bd", size = 165368, upload-time = "2026-05-04T08:11:31.826Z" } +sdist = { url = "https://files.pythonhosted.org/packages/36/98/7d93f30d029643c0275dbc0bd6d5a6f670661ee6c9a94d93af7ab4887600/authlib-1.7.2.tar.gz", hash = "sha256:2cea25fefcd4e7173bdf1372c0afc265c8034b23a8cd5dcb6a9164b826c64231", size = 176511, upload-time = "2026-05-06T08:10:23.116Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/cd/51/9b0b5cd4cf683a02db937a6f9bbebcdc9c56558a7bb3763ce7d3512103c3/authlib-1.6.12-py2.py3-none-any.whl", hash = "sha256:e9229ad7fde610b139dd12f5edbe97eab9ee78bfb85691247e767727850b99ab", size = 244473, upload-time = "2026-05-04T08:11:30.354Z" }, + { url = "https://files.pythonhosted.org/packages/fb/95/adcb68e20c34162e9135f370d6e31737719c2b6f94bc953fe7ed1f10fe21/authlib-1.7.2-py2.py3-none-any.whl", hash = "sha256:3e1faedc9d87e7d56a164eca3ccb6ace0d61b94abe83e92242f8dc8bba9b4a9f", size = 259548, upload-time = "2026-05-06T08:10:21.436Z" }, ] [[package]] @@ -319,7 +340,7 @@ test = [ [package.metadata] requires-dist = [ - { name = "fastmcp", specifier = "==3.2.0" }, + { name = "fastmcp", specifier = "==3.4.4" }, { name = "httpx", specifier = "==0.28.1" }, { name = "loguru", specifier = "==0.7.3" }, { name = "mcp", marker = "extra == 'test'", specifier = "==1.26.0" }, @@ -451,61 +472,58 @@ toml = [ [[package]] name = "cryptography" -version = "48.0.1" +version = "49.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/12/45/870e7f4bef50e5f53b9f51d4428aee5290eedf58ba443f16b1ebb7ab8e66/cryptography-48.0.1.tar.gz", hash = "sha256:266f4ee051abb2f725b74ef8072b521ce1feacf685a3364fa6a6b45548db791a", size = 832989, upload-time = "2026-06-09T22:32:31.8Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/1b/bc/ee4137cbbe105652c0ee4252792b78fc8e7afa4b8e61d9d5dc05a7f45731/cryptography-48.0.1-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:3e4a1a3232eef2e6c732827d5722db29a0cc8b27af2a4d865b094cf954be9ca1", size = 8008324, upload-time = "2026-06-09T22:31:00.702Z" }, - { url = "https://files.pythonhosted.org/packages/d5/85/6379d42181bfc713094f081360fc5784d6c816b599d45e7f082502d173ce/cryptography-48.0.1-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:32143b24adb918f078134e1e230f1eb8cc04886b92c28b5f0041aaf3e5699225", size = 4696243, upload-time = "2026-06-09T22:32:33.446Z" }, - { url = "https://files.pythonhosted.org/packages/9c/87/c85d147b53323c7eb4d850920c8901377323c2a0ff8d79c262d4fee89aa2/cryptography-48.0.1-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f0d27a5696721ef7a672b8c810f6aded391058e0b9486e63e6d93baf765da691", size = 4713235, upload-time = "2026-06-09T22:31:40.141Z" }, - { url = "https://files.pythonhosted.org/packages/79/58/67cbf8cf1ee7c54b439ca07bbecf8362c07afc11a3724fea70f745784add/cryptography-48.0.1-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:eb86ce1af36fe65041b6db9a8bb064ee621a7e5fded0f80d475ec243477cd242", size = 4702323, upload-time = "2026-06-09T22:31:42.191Z" }, - { url = "https://files.pythonhosted.org/packages/89/c6/24266ac10c47f6cd2a865f4446062b466da1d1f10b27189eac00e61bf0c9/cryptography-48.0.1-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:b024e784ad6c077ee0147b35ea9cbfc1e34e1fd4c1dcca214c2794d73a12df08", size = 5300085, upload-time = "2026-06-09T22:31:58.703Z" }, - { url = "https://files.pythonhosted.org/packages/d2/bb/cc4b78784f97efc8c5874c2a9743708d172be6663024b34a0467885ae0c8/cryptography-48.0.1-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:3752f2dbc8f07a30aad2932c986cea495b03bb554887828225da104f732852b6", size = 4746137, upload-time = "2026-06-09T22:31:31.01Z" }, - { url = "https://files.pythonhosted.org/packages/1f/52/0c44de3f5267f8fbe8e835138017522a333436166e406f0db9b9e6e3033f/cryptography-48.0.1-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:bd81490cd5801d755cf97bb68ac191f14b708470b1c7cf4580f669b9c9264cd8", size = 4333867, upload-time = "2026-06-09T22:32:28.096Z" }, - { url = "https://files.pythonhosted.org/packages/9a/2e/772d7adbfa931537bc401640b7cac9976bff689bda187833e5d63b428e49/cryptography-48.0.1-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:66fd0771e7b9c6dcd44cf1120690d2338d16d72795cf40cae2786a39eba65429", size = 4701805, upload-time = "2026-06-09T22:31:38.284Z" }, - { url = "https://files.pythonhosted.org/packages/f8/a3/b06844f303873493c963caf581c04df31c7035e0c1b0f02c4814d319ec80/cryptography-48.0.1-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:3fd2ca57062b241c856670b073487d2e86c4637937ca5601e48f97bf8e11fc8f", size = 5258461, upload-time = "2026-06-09T22:31:04.187Z" }, - { url = "https://files.pythonhosted.org/packages/9f/13/8b765e2e12b07c74941caadb9d1c8fdc006c4dfbf2b8f2d610519758954d/cryptography-48.0.1-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:0ee6ea481db1ab889cba043ec1eda17bb9c1ea79db6722f779c3667f9f70322f", size = 4745488, upload-time = "2026-06-09T22:32:30.07Z" }, - { url = "https://files.pythonhosted.org/packages/2e/aa/48972bce55049b32a94f4907eda4d75fa385aad8a39506cc2fc72196ecf0/cryptography-48.0.1-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:f2ceef93cb096aa3c4cc4b5c94ca6131f9196d28c64d6111533402a9b2054d41", size = 4830256, upload-time = "2026-06-09T22:31:43.868Z" }, - { url = "https://files.pythonhosted.org/packages/47/a2/e5079a032fb85cf6005046ca92bbd78b0c82dad2b5751ab8c311659da06f/cryptography-48.0.1-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:9bd3f92d76217892b15df84ca256c2c113d386fdda7a7d8691aeeced976507c6", size = 4979117, upload-time = "2026-06-09T22:31:05.845Z" }, - { url = "https://files.pythonhosted.org/packages/b7/a0/8f50cae9c74e718ed769d63ed5c74bd0ea830c9550a74629cebd1b9c7bc7/cryptography-48.0.1-cp311-abi3-win32.whl", hash = "sha256:b9a32b876490d66c8bcc9963ef220199569748434ab01a9d6aaeabf88e7f5158", size = 3304154, upload-time = "2026-06-09T22:32:16.845Z" }, - { url = "https://files.pythonhosted.org/packages/c5/69/0572c77dbace6fef72f33755bd52ea399c71367250d366237f8691826b9e/cryptography-48.0.1-cp311-abi3-win_amd64.whl", hash = "sha256:39489bfca54c7a1f6b297efcd8bc608ab92d16c4ca631b0cad4da46724588b24", size = 3817138, upload-time = "2026-06-09T22:32:00.388Z" }, - { url = "https://files.pythonhosted.org/packages/42/06/3e768b4c3bc78201583fa35a0e18f640dd782ff41afba88f8545481a8874/cryptography-48.0.1-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:f817adc181390bd54f2f700107a7419040fb7c1bdf2fc26f36551a06a68c3345", size = 7989830, upload-time = "2026-06-09T22:31:07.8Z" }, - { url = "https://files.pythonhosted.org/packages/8a/13/6476736484b94041110c8340a3eb63962fea4975baea8cb4a512adb44d4d/cryptography-48.0.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d5d30989c6917b478b5817902e85fddaea2261efa8648383d965381ccb9e1ac4", size = 4689201, upload-time = "2026-06-09T22:31:09.745Z" }, - { url = "https://files.pythonhosted.org/packages/79/62/65a87f34d2a431546e2509b85d55e8c90df86d668f6731da64d538512ac2/cryptography-48.0.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:df637c05205ea7c1d7fbcbe54bbfea648a52951155f997af13d895d0ecc96991", size = 4702822, upload-time = "2026-06-09T22:32:24.409Z" }, - { url = "https://files.pythonhosted.org/packages/7f/59/810b5204b0a9b10f4b6bc06bd551a8b609803cd931806bc3b71884b225e5/cryptography-48.0.1-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:869c3b8a53bfe27147832df48b32adadf558249d50e76cb3769d40e986b13265", size = 4694875, upload-time = "2026-06-09T22:32:08.737Z" }, - { url = "https://files.pythonhosted.org/packages/24/dc/d8ca05ffea724eec6d232ea6f18e74c269eb6bdfdcc9bfba689790d1325f/cryptography-48.0.1-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:e361afba8918070d376df76f408a4f67fec0ee9cff81a99e48fe9a233ef59e17", size = 5290385, upload-time = "2026-06-09T22:31:15.212Z" }, - { url = "https://files.pythonhosted.org/packages/03/8c/3be6cb4da181f5bb6c19cf560c2359d60644a6b5fc5b57854e528f47b296/cryptography-48.0.1-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:d069066deead00ac7f090be101be875a06855908f7ec004c27b8fefb4acfb411", size = 4737082, upload-time = "2026-06-09T22:32:22.66Z" }, - { url = "https://files.pythonhosted.org/packages/aa/f6/d5f60a5a1434dbfd949e227fd0065d194c7e6b6ac526b17f5c06152b8231/cryptography-48.0.1-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:09f73a725d582cef64b91281a322cd798d14a33b2b6f2b7ad9531dc336d84c02", size = 4325328, upload-time = "2026-06-09T22:32:10.777Z" }, - { url = "https://files.pythonhosted.org/packages/17/b7/ba75dd947a14b6ad907b01ae8f6b5b348cdd1b48142f0063dee9e20c1d9d/cryptography-48.0.1-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:15254441469dd6bf027039453288e2072124f8b6603563f5d759e1c9b69273fa", size = 4694530, upload-time = "2026-06-09T22:31:53.105Z" }, - { url = "https://files.pythonhosted.org/packages/62/29/50d6b9e8aff12d8b67afaeb3569335e32dc83a5723e3bbded24fdac9f809/cryptography-48.0.1-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:8ace4507d1e6533c125f4fac754f8bb8b6a74c08e92179dabd7e16571a3efbf3", size = 5245046, upload-time = "2026-06-09T22:31:25.774Z" }, - { url = "https://files.pythonhosted.org/packages/9f/04/618f4115cfc0add0838c82507aa18a346089428da8653ad38b3ff36f5cb3/cryptography-48.0.1-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:b4e391975f038e66432328639620a4aff2d307513b004f1ca06d6225bced815c", size = 4736660, upload-time = "2026-06-09T22:32:12.676Z" }, - { url = "https://files.pythonhosted.org/packages/24/9c/06e062462a0de28a3b3911322eded4c16deb9f441b1b7575d3dc59488ab5/cryptography-48.0.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:42fcd8e26fe555d9b3577a135f5091fefa0aa4e99129c23fb56787a1bd4ada72", size = 4822229, upload-time = "2026-06-09T22:31:17.062Z" }, - { url = "https://files.pythonhosted.org/packages/f4/be/0561971eaaee4b8a0e7d5113c536921063ab91aaf23278ac374eaf881e11/cryptography-48.0.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c1400da5e32a43253392277eac7490a60e497d810a63dd5608d71bbd7af507c9", size = 4966364, upload-time = "2026-06-09T22:31:32.842Z" }, - { url = "https://files.pythonhosted.org/packages/a4/27/728c77876f12b000820b69ae490f3c4083775e79e07827e9e60be07ad209/cryptography-48.0.1-cp314-cp314t-win32.whl", hash = "sha256:0df56b056bc17c1b7d6821dfa65216e62bd232d8ab05eb3db44e71d235651471", size = 3278498, upload-time = "2026-06-09T22:31:29.154Z" }, - { url = "https://files.pythonhosted.org/packages/06/e3/79a612c6d7b1e6ee0edd43633d53035bec2cfb78c82b76f7864f39e36f34/cryptography-48.0.1-cp314-cp314t-win_amd64.whl", hash = "sha256:9de21387aa95e2a895823d0745b430bed4f33503ba9ab5e0b5311f33e37d66d2", size = 3798790, upload-time = "2026-06-09T22:31:56.697Z" }, - { url = "https://files.pythonhosted.org/packages/ca/6c/00fa2a95997164c8b2072ce327c23d4ab20809ccc323ea5fab91e53a4bba/cryptography-48.0.1-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:4fdc69f8e4316bcf0c8c8ec1f26f285d12e8142d88d96c876a59a03be3f6ae67", size = 7987408, upload-time = "2026-06-09T22:32:20.777Z" }, - { url = "https://files.pythonhosted.org/packages/b0/d9/45f309a7e4e5f3f8f121d6d3be9e94024a7726ec598d6e08ae04edb2f04d/cryptography-48.0.1-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:48fe40804d4caa2288f24e70ca8c64c42dd826da0ad7e4f1b41b2128d679e6c8", size = 4690196, upload-time = "2026-06-09T22:31:54.74Z" }, - { url = "https://files.pythonhosted.org/packages/5f/9f/a1bc8bcc798811b8527eb374bbccf30a3f3e806829d967118222bf1125eb/cryptography-48.0.1-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:86be3b1b0b6bf09482fb50a979c508d2950ed95f5621ec77f4e385962006b83a", size = 4696782, upload-time = "2026-06-09T22:31:45.615Z" }, - { url = "https://files.pythonhosted.org/packages/66/c2/81a4fb4e4373c500bb526bc337ac5719dd31dd15b970b84a238168c6aa08/cryptography-48.0.1-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:4ab0a343c807bbcd90c971cd1ecf072937cd01847a9e002bef88fb47ac6be577", size = 4696618, upload-time = "2026-06-09T22:31:11.564Z" }, - { url = "https://files.pythonhosted.org/packages/e5/0b/aa68b221dde92d09cb29a024ede17550ee21e77a404e59fc093c82bb51e1/cryptography-48.0.1-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:9621de99d2da096006b629979efd8ae7eb2d8b822488d0c89ee4000c306c59b1", size = 5289970, upload-time = "2026-06-09T22:31:20.368Z" }, - { url = "https://files.pythonhosted.org/packages/78/13/fba657f958d2af66ea959a4ba01212632089249d34af1ae48054136344d7/cryptography-48.0.1-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:88c852a0ae366e262e5a1744b685e6a433dc8788dd2a277e418bf4904203609d", size = 4731873, upload-time = "2026-06-09T22:31:22.253Z" }, - { url = "https://files.pythonhosted.org/packages/4c/4c/9a964756d24a26b3e34dfcb16f961b89838786e6700b635b0d1e3adff4b6/cryptography-48.0.1-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:43c5835e2cb98c8733d86f57d6fc879b613f5c3478607281c3e36daffc6dd8a6", size = 4330804, upload-time = "2026-06-09T22:31:36.56Z" }, - { url = "https://files.pythonhosted.org/packages/4b/0f/a10f3a6eb12950a10e3a874070283aa2dd5875b2bfd15fad8a3e17b3f13e/cryptography-48.0.1-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:fe0180af5bf9236518a087e35bf2d9a347d5f5f51e63c579d683ddff424e3d46", size = 4696217, upload-time = "2026-06-09T22:31:13.351Z" }, - { url = "https://files.pythonhosted.org/packages/f3/6f/5cd12f951165ea73ef85266775d97e4c763b2474ccfd816dd69d3a18d6f8/cryptography-48.0.1-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:b7a2d1a937a738a881737cec135a38bb61470589b17515b9f73f571d0ae10401", size = 5245252, upload-time = "2026-06-09T22:32:02.193Z" }, - { url = "https://files.pythonhosted.org/packages/68/ab/8aaa12e4516ec4464033ab79b6f3b592bd5a92102467c4ace8a0d970203f/cryptography-48.0.1-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:b74ca3b8e5ecdd833bf6a002ca41b4793bb27fb8f1c06ffaf2643c9e9140e31b", size = 4731388, upload-time = "2026-06-09T22:32:04.019Z" }, - { url = "https://files.pythonhosted.org/packages/1b/24/50027ea4dca85ec1f40688f3c24fb32ccacd520583c9592c3cc95628e6fb/cryptography-48.0.1-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:2c37f2461406063b417837f5f3daab668652acd82423efcd7f0a9f04be972de1", size = 4824186, upload-time = "2026-06-09T22:32:18.707Z" }, - { url = "https://files.pythonhosted.org/packages/52/41/04cb5eb17085ade6f50cc611fb657df6a0f5885350de8764ece89c050197/cryptography-48.0.1-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:86fe77abb1bd87afb251d4d02ada7ecf53a32cee9b67d976abb2e45a13297475", size = 4964539, upload-time = "2026-06-09T22:31:18.793Z" }, - { url = "https://files.pythonhosted.org/packages/36/bf/ed70785c496e89d7e73b7cda2d21f2447fd6d4e821714b8d04ff217fed92/cryptography-48.0.1-cp39-abi3-win32.whl", hash = "sha256:6b2c0c3e6ccf3ade7750f836ef3ee36eea250cc467d45c256895573ac08cc6f1", size = 3282307, upload-time = "2026-06-09T22:30:53.162Z" }, - { url = "https://files.pythonhosted.org/packages/b3/ff/371ea7d252656ee1eb6d83eeeef3d1d0c6baf1d6497687d081ea03814670/cryptography-48.0.1-cp39-abi3-win_amd64.whl", hash = "sha256:9a49ca6c81417f6a5edb50375a60cccdd70fa0a91a5211829dbea74eba94d2ac", size = 3793408, upload-time = "2026-06-09T22:32:15.191Z" }, - { url = "https://files.pythonhosted.org/packages/a9/d3/eb4e394e587341fdad09a09101fa76478ead3a78b0ad63e55c22f0d75c02/cryptography-48.0.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:08a597acce1ff37f347400087776599e2348a3a8bc53b44120e463cd274efe4a", size = 3951747, upload-time = "2026-06-09T22:31:23.871Z" }, - { url = "https://files.pythonhosted.org/packages/e0/4a/3f43451b4f858bfceaaaffc649e6e787e8d4fb332a1d443af39ab02cc8f1/cryptography-48.0.1-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:735824ec41b7f74a7c45fb1591349333e4c696cb6c044e5f46356e560143e4cd", size = 4641226, upload-time = "2026-06-09T22:31:02.532Z" }, - { url = "https://files.pythonhosted.org/packages/73/4e/855584c2c23b09e4ce2d3b9c30e983e679cd60b068c513c6bbdb91e11782/cryptography-48.0.1-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:92a46e1d638daa264ba2971c0b0489c9409787943efae4d60ffda3d091ef832c", size = 4668958, upload-time = "2026-06-09T22:32:06.213Z" }, - { url = "https://files.pythonhosted.org/packages/42/3b/d35750e41d803d1e516fd6d6011f065424924da7af1748cef4cc9cb3ede1/cryptography-48.0.1-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:7e234ac052af99f2700826a5c29ea99d9c1b1f80341cde62d11c8154dc8e0bd9", size = 4640793, upload-time = "2026-06-09T22:32:26.331Z" }, - { url = "https://files.pythonhosted.org/packages/ca/aa/cdb7181fe865285e87e96825aaab239400f1de0c3bfba9bd9769b79f1a92/cryptography-48.0.1-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:33842cf0888951cef5bc7ac724ab844a42044c1727b967b7f8997289a0464f92", size = 4668505, upload-time = "2026-06-09T22:31:27.534Z" }, - { url = "https://files.pythonhosted.org/packages/5d/8c/ce3823c06c2804f194f9e64f0d67fa3f4094a39f2bb1a990cd03603af8fc/cryptography-48.0.1-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:6184ca7b174f28d7c703f1290d4b297217c45355f77a98f67e9b7f14549ac54a", size = 3742204, upload-time = "2026-06-09T22:31:34.773Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/1f/99/d1c90d6041656cc6ee229dc99cd67fd0cd5aec3c5f7d72fffc27cc750054/cryptography-49.0.0.tar.gz", hash = "sha256:f89660a348f4f78a92366240a61404e337586ef7f5909a2fef59ca88ef505493", size = 854345, upload-time = "2026-06-12T20:02:30.512Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9b/22/adf66990e63584a68dfb50c24f48a125c07b1699899381c8151e63ed458c/cryptography-49.0.0-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:966fe0e9c67490071f14c0d2b1cb2dfb3023c5ce39457343931415f08382f2db", size = 4032100, upload-time = "2026-06-12T20:02:32.143Z" }, + { url = "https://files.pythonhosted.org/packages/09/41/3797cfaf69cae04a13ee78ebd83f0678d9c02b4779d21ce24445326f1a69/cryptography-49.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:36d1709f992593689b45bda411498d62c6e365f2ca00b84657d4dadd24de16db", size = 4692978, upload-time = "2026-06-12T20:01:21.305Z" }, + { url = "https://files.pythonhosted.org/packages/e6/8b/43011f7ebe515a8aa20d61f290a326cd890c2e738e16e59eaff8d9c3a412/cryptography-49.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0e959b578856a3924bc0cbb710fc12c387b9412a951389f3ca61704a9e25f325", size = 4716422, upload-time = "2026-06-12T20:01:48.566Z" }, + { url = "https://files.pythonhosted.org/packages/4a/91/01ce7303a4579e6d3a6abef01bd322848e9ea7a219adcabc5048b9033571/cryptography-49.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:53ecee2e23f7169b6117e99fc8a944e5e50f79e69758a83b52a00cb98ab2b2d2", size = 4700503, upload-time = "2026-06-12T20:02:47.091Z" }, + { url = "https://files.pythonhosted.org/packages/62/99/a2c95cf8293f07491e9e27c20cc4dcd18176d944e674679adeb1d0173fd6/cryptography-49.0.0-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:2eda353d8a27bcbcaa4cbed18994a74ab4d19a2ca897db188ea269ab9b71419b", size = 5309779, upload-time = "2026-06-12T20:02:08.987Z" }, + { url = "https://files.pythonhosted.org/packages/20/2c/0622f20ff02b2ef32558733443805dc82fd4c275be01b2d19d14676f3a1b/cryptography-49.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:2afe9051da7ae7bd5905da5a949280c7d2bb75682e188f650a9d0f2756b834c6", size = 4749683, upload-time = "2026-06-12T20:02:03.335Z" }, + { url = "https://files.pythonhosted.org/packages/a3/5b/c5246635d5fd3b64e0d45ae10e99fd32fe9676a79915ccfe5a61ba9af1a5/cryptography-49.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:0b82e28ee398a386f0807bba7884d30f25218855690f45115831bcce5d90822c", size = 4337874, upload-time = "2026-06-12T20:02:54.323Z" }, + { url = "https://files.pythonhosted.org/packages/6d/88/05563c7fe2e914e87d1a536d06fe83e66b4e1d95cb593e05aea375531da8/cryptography-49.0.0-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:ccac2bfebc306b862133e3bb71f3f6ee8bb525240089b2d952e4144b3a6d5da7", size = 4700283, upload-time = "2026-06-12T20:01:34.822Z" }, + { url = "https://files.pythonhosted.org/packages/c4/b6/d7696e4e890d6ae1469935164c9e5215c557671cb78d6e3f458ccceaa632/cryptography-49.0.0-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:d0527ce944105f257f605a827d6ebead966c752038b6e8656abb9c5edee6fc68", size = 5265844, upload-time = "2026-06-12T20:01:24.09Z" }, + { url = "https://files.pythonhosted.org/packages/a9/3c/f3ad17eecc1a57b0ba236dc01f90e783c51f4a2f35f64777cc4f47a184b2/cryptography-49.0.0-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:cbc77da8c523d5abd028635ba850a6966fcee2c82e2bf65a41d1d8afe0f98be9", size = 4749290, upload-time = "2026-06-12T20:01:30.848Z" }, + { url = "https://files.pythonhosted.org/packages/4f/01/339573cf1023163a400b0b5d16f6d507de413b9f60be6fd1b77feeaf6737/cryptography-49.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:b87e65d263b3e5d3bb92a57e2a6638e2f31110fa7aa890c7b2dbba42248d0a3f", size = 4834612, upload-time = "2026-06-12T20:01:29.246Z" }, + { url = "https://files.pythonhosted.org/packages/71/fd/577302e213a1be9468f92d1afef66fcf1ef83d516819d9992ca547f592bd/cryptography-49.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:66ec79c3904820572d7e987abdf304281f141d37ad9a489b8e97066e7b9b6459", size = 4980804, upload-time = "2026-06-12T20:01:42.853Z" }, + { url = "https://files.pythonhosted.org/packages/1f/09/f42b1d190c5ba75f72062a387f8030d1d75f6ab035788f1d9c4b01de6525/cryptography-49.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:e5dfc1e64de5677cec922ffa8da89c546d0415bf6efdf081842e5d44c84e1f0e", size = 3810026, upload-time = "2026-06-12T20:02:39.262Z" }, + { url = "https://files.pythonhosted.org/packages/ec/9e/db72b3ae7fc9cfad53e630e56c6ae83b9b6ff0bf3718ffb8012d20b3aabf/cryptography-49.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:73a205dce83953d131a4aa1e0fd917a2fd1c5b1eef251e9d7152efefcbf5caf7", size = 4013892, upload-time = "2026-06-12T20:02:10.735Z" }, + { url = "https://files.pythonhosted.org/packages/86/12/c48a424f38db03027be9f7ed5c7dc5de9933dbee992865f98b13727a009d/cryptography-49.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:196ecd6a36e4e9aa10270393bb98d8df88fccee0bf1e5128b91ae4eb4375896d", size = 4678835, upload-time = "2026-06-12T20:02:48.743Z" }, + { url = "https://files.pythonhosted.org/packages/68/28/8a3ad4653662c93fc44dc4e5d8fd374c25c42e07b34bbfbadf49cf57a5a8/cryptography-49.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7abcee80084cda3f7691f3eb1ce480d8df49cec637b429aa35986c1de71738aa", size = 4697239, upload-time = "2026-06-12T20:02:56.03Z" }, + { url = "https://files.pythonhosted.org/packages/a8/b2/2193fc74f81aee4f9b62733133b73b5176718932ed8f2e4b03fa040480a6/cryptography-49.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:4ae387c9cb68ea569ca17e490d66d8142b81c3cc814bf179974b7d146e490bbb", size = 4685593, upload-time = "2026-06-12T20:02:50.666Z" }, + { url = "https://files.pythonhosted.org/packages/47/f1/1d3eaa243bfc5de4a187b22aa8c048b3e4980bfbe830ac46e6bac2e66947/cryptography-49.0.0-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:f37d847238971164fdbc68ade6f6574aecc9c0af714190e2083429ff68f4ce9d", size = 5289961, upload-time = "2026-06-12T20:01:46.468Z" }, + { url = "https://files.pythonhosted.org/packages/58/39/2d51306721330c486495853eda1c567880ff036de15a14c4b74f399934af/cryptography-49.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:c2bc30226390d60ea19d9f82b19db005fe0452154a23c1c410c12ea801e43561", size = 4731145, upload-time = "2026-06-12T20:02:16.832Z" }, + { url = "https://files.pythonhosted.org/packages/17/50/983e838c7fd0d87fd8c969bcdd328edaf5f756e38df5281637424c155873/cryptography-49.0.0-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:07cab27cc7b7e0fd28e5e26bb9eeedde5c135c868b46de4a27845abe94af6122", size = 4321719, upload-time = "2026-06-12T20:02:52.611Z" }, + { url = "https://files.pythonhosted.org/packages/a7/f5/8f571d7e27c55bce9f76f026143bcb1e040a4233149ecca0bea5fa5dd5f7/cryptography-49.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:b20133d204d2bb56ba047642199603876c872026ca53e79c35b83772ab2cc505", size = 4685209, upload-time = "2026-06-12T20:02:07.282Z" }, + { url = "https://files.pythonhosted.org/packages/e7/84/0e27016a6fc5a0886f797018b26aa42f40c09a82332bff77822a451deaaa/cryptography-49.0.0-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:b970c6da94d5bb18629db453d14f2a1300f6bf59b61e9b82377931ef95504866", size = 5246285, upload-time = "2026-06-12T20:01:32.439Z" }, + { url = "https://files.pythonhosted.org/packages/11/2d/5e1fb307cb5931881516b464c98774b3f2c36b5d4bb9a2830253cf553cad/cryptography-49.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:d8ecde755e2e91bf773fc94e8c9d730cd7f2007004cb492263a794ec3899a1c8", size = 4730441, upload-time = "2026-06-12T20:02:01.469Z" }, + { url = "https://files.pythonhosted.org/packages/e4/c0/bff5a02ee731d207d6a1ed51732549d8c53d2bc8da1d10ec6f2844201d68/cryptography-49.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e3fb64c420688e5319ae25113a354015abbd8dffbfbc41781a1ea66fc7622ac3", size = 4815869, upload-time = "2026-06-12T20:01:36.574Z" }, + { url = "https://files.pythonhosted.org/packages/b9/26/814681d14248d95d73d5c3eea0c39a94eb8302df966f670a2c60de90974b/cryptography-49.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:32703d93296f5c1f4b53349ad3a250c2cae0fdecd3a3dd5d47e616d8d616af27", size = 4960948, upload-time = "2026-06-12T20:02:18.688Z" }, + { url = "https://files.pythonhosted.org/packages/4c/fe/93ecac273d3738939d023612ad12cca9a3740a5345d69fda04134c43fd96/cryptography-49.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:33cd0565932807baddb67b96dbee92f2c374b5c89dee09fd74079aeb8c8dba61", size = 3799153, upload-time = "2026-06-12T20:01:39.059Z" }, + { url = "https://files.pythonhosted.org/packages/19/2a/5bb823f5bedcf80718cea7fbc95ec5515cca3769633c4b01a32be7f30e7c/cryptography-49.0.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:ec5e529fb80935c94fe7b729f9972b50e351a0e6b50aa294fd5cabb109fcc29a", size = 4025947, upload-time = "2026-06-12T20:01:25.745Z" }, + { url = "https://files.pythonhosted.org/packages/3d/df/40577043ca124e17012f408ddddaeb213b856336ac82ddb3bc915f39e29f/cryptography-49.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f78ff2c9ed8dc2d036b0f4d640e22522213d047c1b14e61205a7e55c80a494d4", size = 4692429, upload-time = "2026-06-12T20:01:53.628Z" }, + { url = "https://files.pythonhosted.org/packages/2c/99/2d13299eb3dd27b02dcfaafcc91d6b5cb3329f7cbd6d8f51921acd566c1a/cryptography-49.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:35b151772baff2c74cba7fa290ceaff4c3b11c0c881eb93eb5dbc05a7cfbba18", size = 4700968, upload-time = "2026-06-12T20:02:45.383Z" }, + { url = "https://files.pythonhosted.org/packages/a5/4d/9c0cd02f95e2602dd5e563da149ee0830abef3537be8b34dc56281ebe27a/cryptography-49.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:0f21641cf4b30fca7aee061ced0ec7ad7b073518088b7c9969a297c0ae796c69", size = 4697758, upload-time = "2026-06-12T20:01:41.13Z" }, + { url = "https://files.pythonhosted.org/packages/24/01/186c825898477d77e2324d5360fefe622ff1d8d1963ec0554e2cada8ec77/cryptography-49.0.0-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:9e82dcc8e56052715fb18b2429e3bca4823b1629136a2084fc45a9a5cecb9b64", size = 5298863, upload-time = "2026-06-12T20:02:24.579Z" }, + { url = "https://files.pythonhosted.org/packages/b8/7b/62cbbab75d0659865bf0273790031544a0b16c8072d258f9428dcd8190dc/cryptography-49.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:6f2debedf9ca60cf1d5bd466475638af5130f89965605cd818484d19987d3a21", size = 4735983, upload-time = "2026-06-12T20:01:50.14Z" }, + { url = "https://files.pythonhosted.org/packages/6c/72/3e798c064bc39e471008075d0f9bc9daf77a80879c092e4a8e170c585ed4/cryptography-49.0.0-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:8c25ceb16df5b9435f3f6a9829204985b0e0cbee3b48aacd432c7d2c850b44d9", size = 4334173, upload-time = "2026-06-12T20:01:44.743Z" }, + { url = "https://files.pythonhosted.org/packages/f0/ee/6fca21d1ac73e06f8bef71940abfd4d2f6472b4bca284d770f32bd4086f6/cryptography-49.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:28d8b15e6275f12c8a207dc309dfa957903c927d08d0cc937ee3f63f200693cc", size = 4697298, upload-time = "2026-06-12T20:02:20.918Z" }, + { url = "https://files.pythonhosted.org/packages/67/d0/a5fcd3515f0bae49a7b6d0413cc1bdccdcc1fc0047037a0d480642cdc5d6/cryptography-49.0.0-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:6fc361c34fb6aac015ce19435876635e5c6d21db31998b0920f675f131e043b8", size = 5254338, upload-time = "2026-06-12T20:02:22.737Z" }, + { url = "https://files.pythonhosted.org/packages/a0/84/84fe36f19caf857d61cb7fc9c63035a47ffabd84ea12d1d393148efa3615/cryptography-49.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:2400ef9c9e2299a25614eb1dea3db54a69b1349efd043bfac9c67630d136df36", size = 4735650, upload-time = "2026-06-12T20:02:41.389Z" }, + { url = "https://files.pythonhosted.org/packages/6c/a0/db537264e234f7273a73ec020873d6d6b39dfd8a53db78b550ca8320440e/cryptography-49.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:67e1d20ad9ef3a563c59ef22e7a8a0b8210bd26604369ea4a30a7c66aefe504e", size = 4834820, upload-time = "2026-06-12T20:01:51.847Z" }, + { url = "https://files.pythonhosted.org/packages/93/77/8df9eb486495979bccecd1062e2eaf435250e84437040295b57d09048b0b/cryptography-49.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:42b0684e0e40cf26122427802486f6d93aea593612603a94fbf260c7eb1e9c1b", size = 4967968, upload-time = "2026-06-12T20:02:12.524Z" }, + { url = "https://files.pythonhosted.org/packages/c2/e6/f60198ea8d9dfa15fff9ed4ca02ce362f6eadd9ba757dcc50634c4257b63/cryptography-49.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:026ac7423e6fa66872d3bf889be5974507da3944f866f704fa200eadacd00001", size = 3785547, upload-time = "2026-06-12T20:02:26.847Z" }, + { url = "https://files.pythonhosted.org/packages/63/d3/4a83af35d65e3fad632c926fad684c193ea4398569ccb0bbbc7fe8f5dc9a/cryptography-49.0.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:fc1e275c2f1d97b1a6450b8b0ea3ebfa6e087a611c2b26cb2404d48588abab7b", size = 3993685, upload-time = "2026-06-12T20:02:14.883Z" }, + { url = "https://files.pythonhosted.org/packages/d6/a7/f9dac0ab7f80368c56993a7bf638ef9935f825c91902798481fac0898138/cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:c83782480a4a9da4d0feb51950131ba32e12e70813848b3343f6e18c28a66838", size = 4676239, upload-time = "2026-06-12T20:02:28.793Z" }, + { url = "https://files.pythonhosted.org/packages/d7/70/2ba3769dd0ae167e2f33dfa9592d45db6ff9a61d62ca1a5b3d1bdd09068f/cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:b39efa323140595abd3ecca8529d321ae50f55f3aa3ba9cc81ea56a6011953d5", size = 4715584, upload-time = "2026-06-12T20:01:27.495Z" }, + { url = "https://files.pythonhosted.org/packages/94/64/2923570ac1c0bd3a737aa366ac3abbbbde273042308b8cde95e2364a6e6a/cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:b47db11c2c3525083296069b98ac5221907455e989ae0c2e3008bde851921615", size = 4675885, upload-time = "2026-06-12T20:01:55.49Z" }, + { url = "https://files.pythonhosted.org/packages/ab/f8/614dc7e051418cfe53d55173c1e24c6b0085e89996fe90508c2fdf769aef/cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:084ef1af862eb07ec46d25f68689f2102a9fc0e05ce7b80f14f5fe51e4eef0f6", size = 4715449, upload-time = "2026-06-12T20:02:05.469Z" }, + { url = "https://files.pythonhosted.org/packages/aa/50/a9caea39ad19c431c1a3f8a31114df65b260cdfe67786b6c7e7c040c4c44/cryptography-49.0.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:be9fcb48a55f023493482827d4f459bd263cc20efde64f204b97c123201850c6", size = 3783731, upload-time = "2026-06-12T20:02:43.319Z" }, ] [[package]] @@ -577,35 +595,66 @@ wheels = [ [[package]] name = "fastmcp" -version = "3.2.0" +version = "3.4.4" source = { registry = "https://pypi.org/simple" } dependencies = [ + { name = "fastmcp-slim", extra = ["client", "server"] }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9c/f7/5188565d1b93ad611cbd80bf473e7ad669d1f3b689c4bedcd304e1ec3472/fastmcp-3.4.4.tar.gz", hash = "sha256:378202e26ec15b23819d9a1c0d1b0ebda096bc712720532010a0b82a45c2b1df", size = 28796458, upload-time = "2026-07-09T00:32:41.352Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5f/67/3cef84ba38a23dca1e1e776bfda8a35ab3c7a6c94a8ca81d0715de6dd3c5/fastmcp-3.4.4-py3-none-any.whl", hash = "sha256:f86f208713212260068cf55c32936839eee856fefc7808e18a032f31eb0f718e", size = 8019, upload-time = "2026-07-09T00:32:39.411Z" }, +] + +[[package]] +name = "fastmcp-slim" +version = "3.4.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "platformdirs" }, + { name = "pydantic", extra = ["email"] }, + { name = "pydantic-settings" }, + { name = "python-dotenv" }, + { name = "rich" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/45/79/f35661c6a1d76dfbe17a079f912d96fffcfdd40fad5a9144bb9e7dfb1fdf/fastmcp_slim-3.4.4.tar.gz", hash = "sha256:dcaa3e0be2127d7eacdce592c2ef0039204923dc0ec396454615cb4a3275b078", size = 590203, upload-time = "2026-07-09T00:32:20.531Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/16/91/321e0b2e9ed70d0628b17ddaec76fc7b09f3e1d5d290f70bf101a2890142/fastmcp_slim-3.4.4-py3-none-any.whl", hash = "sha256:9d3a6327b9ee835188eb7323fc3b5d4cd061631b48da8ece56794bb538972505", size = 765158, upload-time = "2026-07-09T00:32:19.11Z" }, +] + +[package.optional-dependencies] +client = [ + { name = "authlib" }, + { name = "exceptiongroup" }, + { name = "httpx" }, + { name = "mcp" }, + { name = "opentelemetry-api" }, + { name = "py-key-value-aio", extra = ["filetree", "keyring", "memory"] }, + { name = "starlette" }, +] +server = [ { name = "authlib" }, { name = "cyclopts" }, { name = "exceptiongroup" }, + { name = "griffelib" }, { name = "httpx" }, + { name = "joserfc" }, { name = "jsonref" }, { name = "jsonschema-path" }, { name = "mcp" }, { name = "openapi-pydantic" }, { name = "opentelemetry-api" }, { name = "packaging" }, - { name = "platformdirs" }, { name = "py-key-value-aio", extra = ["filetree", "keyring", "memory"] }, - { name = "pydantic", extra = ["email"] }, { name = "pyperclip" }, - { name = "python-dotenv" }, + { name = "python-multipart" }, { name = "pyyaml" }, - { name = "rich" }, + { name = "starlette" }, { name = "uncalled-for" }, { name = "uvicorn" }, { name = "watchfiles" }, { name = "websockets" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/d0/32/4f1b2cfd7b50db89114949f90158b1dcc2c92a1917b9f57c0ff24e47a2f4/fastmcp-3.2.0.tar.gz", hash = "sha256:d4830b8ffc3592d3d9c76dc0f398904cf41f04910e41a0de38cc1004e0903bef", size = 26318581, upload-time = "2026-03-30T20:25:37.692Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/4f/67/684fa2d2de1e7504549d4ca457b4f854ccec3cd3be03bd86b33b599fbf58/fastmcp-3.2.0-py3-none-any.whl", hash = "sha256:e71aba3df16f86f546a4a9e513261d3233bcc92bef0dfa647bac3fa33623f681", size = 705550, upload-time = "2026-03-30T20:25:35.499Z" }, -] [[package]] name = "googleapis-common-protos" @@ -619,6 +668,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b6/b0/be5d3329badb9230b765de6eea66b73abd5944bdeb5afb3562ddcd80ae84/googleapis_common_protos-1.74.0-py3-none-any.whl", hash = "sha256:702216f78610bb510e3f12ac3cafd281b7ac45cc5d86e90ad87e4d301a3426b5", size = 300743, upload-time = "2026-04-02T21:22:49.108Z" }, ] +[[package]] +name = "griffelib" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/33/e4/8d187ea29c2e30b3a09505c567513077d6117861bde1fbd997a167f262ec/griffelib-2.1.0.tar.gz", hash = "sha256:762a186d2c6fd6794d4ea20d428d597ffb857cb56b66421651cbba15bdd5e813", size = 216234, upload-time = "2026-06-19T12:05:42.278Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e4/d3/5268aeabf2ad82658c4e2ff3a060648d0f02f3926cb53247c0e4d0dab49e/griffelib-2.1.0-py3-none-any.whl", hash = "sha256:cc7b3d2d2865ad0b909fcc38086e3f554b5ea7acbaa7bbb7ecaa3f5dfb7d9f00", size = 142560, upload-time = "2026-06-19T12:05:38.742Z" }, +] + [[package]] name = "h11" version = "0.16.0" @@ -667,11 +725,11 @@ wheels = [ [[package]] name = "idna" -version = "3.15" +version = "3.18" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/82/77/7b3966d0b9d1d31a36ddf1746926a11dface89a83409bf1483f0237aa758/idna-3.15.tar.gz", hash = "sha256:ca962446ea538f7092a95e057da437618e886f4d349216d2b1e294abfdb65fdc", size = 199245, upload-time = "2026-05-12T22:45:57.011Z" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/63/9496c57188a2ee585e0f1db071d75089a11e98aa86eb99d9d7618fc1edce/idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848", size = 196711, upload-time = "2026-06-02T14:34:07.794Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d2/23/408243171aa9aaba178d3e2559159c24c1171a641aa83b67bdd3394ead8e/idna-3.15-py3-none-any.whl", hash = "sha256:048adeaf8c2d788c40fee287673ccaa74c24ffd8dcf09ffa555a2fbb59f10ac8", size = 72340, upload-time = "2026-05-12T22:45:55.733Z" }, + { url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455, upload-time = "2026-06-02T14:34:06.319Z" }, ] [[package]] @@ -740,6 +798,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b2/a3/e137168c9c44d18eff0376253da9f1e9234d0239e0ee230d2fee6cea8e55/jeepney-0.9.0-py3-none-any.whl", hash = "sha256:97e5714520c16fc0a45695e5365a2e11b81ea79bba796e26f9f1d178cb182683", size = 49010, upload-time = "2025-02-27T18:51:00.104Z" }, ] +[[package]] +name = "joserfc" +version = "1.7.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cryptography" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f1/26/abe1ad855eb334b5ebc9c6495d4798e12bee70e5e8e815d54570710b8312/joserfc-1.7.2.tar.gz", hash = "sha256:537ffb8888b2df039cb5b6d017d7cff6f09d521ce65d89cc9b8ab752b1cff947", size = 233183, upload-time = "2026-06-29T09:03:10.868Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/13/80/d1b30336582cced4dce0dae776508a6011723e32f907bc7a702c0b25890a/joserfc-1.7.2-py3-none-any.whl", hash = "sha256:ddd818c0ca9b4f17bbc2d72cb3966e6ded7502be089316c62c3cc64ae86132b5", size = 70426, upload-time = "2026-06-29T09:03:09.393Z" }, +] + [[package]] name = "jsonref" version = "1.1.0" @@ -1214,16 +1284,16 @@ wheels = [ [[package]] name = "pydantic-settings" -version = "2.13.1" +version = "2.14.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pydantic" }, { name = "python-dotenv" }, { name = "typing-inspection" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/52/6d/fffca34caecc4a3f97bda81b2098da5e8ab7efc9a66e819074a11955d87e/pydantic_settings-2.13.1.tar.gz", hash = "sha256:b4c11847b15237fb0171e1462bf540e294affb9b86db4d9aa5c01730bdbe4025", size = 223826, upload-time = "2026-02-19T13:45:08.055Z" } +sdist = { url = "https://files.pythonhosted.org/packages/5c/b5/8f48e906c3e0205276e8bd8cb7512217a87b2685304d64be27cad5b3019f/pydantic_settings-2.14.2.tar.gz", hash = "sha256:c19dd64b19097f1de80184f0cc7b0272a13ae6e170cbf240a3e27e381ed14a5f", size = 237700, upload-time = "2026-06-19T13:44:56.324Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/00/4b/ccc026168948fec4f7555b9164c724cf4125eac006e176541483d2c959be/pydantic_settings-2.13.1-py3-none-any.whl", hash = "sha256:d56fd801823dbeae7f0975e1f8c8e25c258eb75d278ea7abb5d9cebb01b56237", size = 58929, upload-time = "2026-02-19T13:45:06.034Z" }, + { url = "https://files.pythonhosted.org/packages/77/c1/6e422f34e569cf8e18df68d1939c81c099d2b61e4f7d9621c8a77560799c/pydantic_settings-2.14.2-py3-none-any.whl", hash = "sha256:a20c97b37910b6550d5ea50fbcc2d4187defe58cd57070b73863d069419c9440", size = 61715, upload-time = "2026-06-19T13:44:55.02Z" }, ] [[package]] @@ -1324,11 +1394,11 @@ wheels = [ [[package]] name = "python-multipart" -version = "0.0.31" +version = "0.0.32" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/64/7e/9b35ad8f3d9ca680f7c87a88f19612fdd8da9796c4d3b46e560ac79dcc4a/python_multipart-0.0.31.tar.gz", hash = "sha256:fc631183bb13e56db3158a4909908dfb2e23565286744e798241e63750e5d680", size = 46689, upload-time = "2026-06-04T08:27:49.014Z" } +sdist = { url = "https://files.pythonhosted.org/packages/5b/42/55c32bb9b12693c092ad250a0e82edb5b31ddeda6eb772de5f308b3804ad/python_multipart-0.0.32.tar.gz", hash = "sha256:be54b7f3fa167bb83e4fcd936b887b708f4e57fe75911c02aebf53efaf8d938e", size = 46881, upload-time = "2026-06-04T16:18:58.647Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/5e/1e/7f7f299527a5a8ad90acd5f2f78dfa6c8495c6301a3205106ea68a84de96/python_multipart-0.0.31-py3-none-any.whl", hash = "sha256:8408153d68a9773291fc1da39a8b85a50044bddbabd2dd72e9229776b7b15e28", size = 29996, upload-time = "2026-06-04T08:27:47.804Z" }, + { url = "https://files.pythonhosted.org/packages/e1/04/e8135ebd1ad02c56ec633277529b2602ff99ff634be76cdba5744cf554fd/python_multipart-0.0.32-py3-none-any.whl", hash = "sha256:ff6d3f776f16878c894e52e107296ffc890e913c611b1a4ec6c44e2821fe2e23", size = 30042, upload-time = "2026-06-04T16:18:57.319Z" }, ] [[package]]