From dab978af6ac32d7a408fdf6e18756bea0b88f845 Mon Sep 17 00:00:00 2001 From: Rodion Mostovoi <36400912+rodion-m@users.noreply.github.com> Date: Wed, 1 Jul 2026 16:21:26 +0500 Subject: [PATCH 1/5] Switch MCP tools to Tool API v3 --- README.md | 25 +- manifest.json | 30 +- pyproject.toml | 2 +- server.json | 26 +- src/codealive_mcp_server.py | 65 +- src/tests/test_artifact_relationships.py | 577 ------- src/tests/test_chat_tool.py | 306 ---- src/tests/test_datasources.py | 356 ----- src/tests/test_e2e_tools.py | 1604 -------------------- src/tests/test_error_handling.py | 6 +- src/tests/test_fetch_artifacts.py | 794 ---------- src/tests/test_observability_middleware.py | 4 +- src/tests/test_response_transformer.py | 4 +- src/tests/test_search_tool.py | 197 --- src/tests/test_stdio_smoke.py | 56 +- src/tests/test_tool_api_v3.py | 174 +++ src/tests/test_tool_metadata.py | 9 +- src/tools/__init__.py | 19 +- src/tools/artifact_query.py | 32 + src/tools/artifact_relationships.py | 389 +---- src/tools/chat.py | 340 +---- src/tools/datasources.py | 242 +-- src/tools/fetch_artifacts.py | 362 +---- src/tools/repository.py | 49 + src/tools/search.py | 444 +----- src/tools/tool_api.py | 91 ++ src/utils/errors.py | 14 +- 27 files changed, 605 insertions(+), 5612 deletions(-) delete mode 100644 src/tests/test_artifact_relationships.py delete mode 100644 src/tests/test_chat_tool.py delete mode 100644 src/tests/test_datasources.py delete mode 100644 src/tests/test_e2e_tools.py delete mode 100644 src/tests/test_fetch_artifacts.py delete mode 100644 src/tests/test_search_tool.py create mode 100644 src/tests/test_tool_api_v3.py create mode 100644 src/tools/artifact_query.py create mode 100644 src/tools/repository.py create mode 100644 src/tools/tool_api.py diff --git a/README.md b/README.md index 304bf72..d0b3381 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, can take up to 30 seconds, 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:** ``` 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 3f15e35..bf1bdba 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -37,7 +37,7 @@ 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. 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/src/codealive_mcp_server.py b/src/codealive_mcp_server.py index 6c597f3..54f6e05 100644 --- a/src/codealive_mcp_server.py +++ b/src/codealive_mcp_server.py @@ -30,14 +30,17 @@ 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, ) # Initialize FastMCP server with lifespan and enhanced system instructions @@ -55,19 +58,18 @@ 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: + 2. Use `get_repository_ontology` for high-level orientation when exactly one repository is in scope + 3. Use `semantic_search` for natural-language retrieval by meaning + 4. Use `grep_search` for literal string or regex matching when the pattern matters + 5. 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 + - For external repos: use `fetch_artifacts` with identifiers from search results or `read_file` + 6. Use `get_artifact_relationships` for graph expansion and `query_artifact_metadata` + for aggregate metadata analytics after checking `get_artifact_query_schema` 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"). + named the tool (e.g. "use chat", "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, @@ -80,11 +82,12 @@ - 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 + - MCP v3 exposes only Tool API v3 tools; deprecated aliases are intentionally absent - 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 + - `chat` is stateless in v3. Include prior findings, artifact identifiers, + assumptions, scope, and constraints in each question. 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 @@ -160,10 +163,6 @@ async def readiness_check(request: Request) -> JSONResponse: 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 +172,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 +192,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, -)(codebase_consultant) +)(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, +)(chat) def main(): 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_observability_middleware.py b/src/tests/test_observability_middleware.py index 2bb4865..f53967e 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) 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..d3674a1 --- /dev/null +++ b/src/tests/test_tool_api_v3.py @@ -0,0 +1,174 @@ +"""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 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"): + ctx = MagicMock(spec=Context) + ctx.info = AsyncMock() + ctx.warning = AsyncMock() + ctx.error = AsyncMock() + + response = MagicMock() + response.json.return_value = {"rendered": rendered, "obj": {"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="AllRelevant", + max_count_per_type=25, + data_source="backend", + ), + "/api/tools/get_artifact_relationships", + { + "identifier": "repo::src/Foo.cs::Foo", + "profile": "AllRelevant", + "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() diff --git a/src/tests/test_tool_metadata.py b/src/tests/test_tool_metadata.py index 1ec6e13..bf43fd0 100644 --- a/src/tests/test_tool_metadata.py +++ b/src/tests/test_tool_metadata.py @@ -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} @@ -41,5 +44,3 @@ async def test_all_tools_are_marked_read_only_with_titles(): 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 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..3a1877b --- /dev/null +++ b/src/tools/artifact_query.py @@ -0,0 +1,32 @@ +"""Tool API v3 ArtifactQuery tools.""" + +from typing import Optional, Union + +from fastmcp import Context + +from .tool_api import call_tool_api, normalize_optional_list, require_text + + +async def get_artifact_query_schema( + ctx: Context, + entity: Optional[str] = None, + include_examples: bool = True, +) -> str: + """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: str, + data_sources: Optional[Union[str, list[str]]] = None, +) -> str: + """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..ab764dd 100644 --- a/src/tools/artifact_relationships.py +++ b/src/tools/artifact_relationships.py @@ -1,391 +1,34 @@ -"""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 Literal, Optional -import httpx from fastmcp import Context from fastmcp.exceptions import ToolError -from loguru import logger -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 call_tool_api, require_text async def get_artifact_relationships( ctx: Context, identifier: str, - profile: Literal["callsOnly", "inheritanceOnly", "allRelevant", "referencesOnly"] = "callsOnly", + profile: Literal["CallsOnly", "InheritanceOnly", "AllRelevant", "ReferencesOnly"] = "CallsOnly", max_count_per_type: int = 50, data_source: Optional[str] = None, -) -> Dict[str, Any]: - """ - 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}]} - ]} +) -> str: + """Expand relationships around one exact artifact identifier. - When the artifact is not found or inaccessible: - {"sourceIdentifier":"...","profile":"callsOnly","found":false} + 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. """ - tool_arguments = { + 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.") + + 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..ca42949 100644 --- a/src/tools/chat.py +++ b/src/tools/chat.py @@ -1,338 +1,26 @@ -"""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 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 -_PRIMARY_TOOL_NAME = "chat" -_LEGACY_TOOL_NAME = "codebase_consultant" -_OBJECT_ID_RE = re.compile(r"^[0-9a-fA-F]{24}$") +from .tool_api import 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, + data_sources: Optional[Union[str, list[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. + """Ask stateless CodeAlive chat through Tool API v3. - **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`. + 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..b21c9ae 100644 --- a/src/tools/datasources.py +++ b/src/tools/datasources.py @@ -1,235 +1,23 @@ -"""Data sources tool implementation.""" +"""Tool API v3 data-source discovery.""" -from typing import Any, Dict -from urllib.parse import urljoin +from typing import Optional -import httpx from fastmcp import Context -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 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: Optional[str] = None, + ready_only: bool = True, +) -> str: + """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..087fdf9 100644 --- a/src/tools/fetch_artifacts.py +++ b/src/tools/fetch_artifacts.py @@ -1,358 +1,26 @@ -"""Fetch artifacts tool implementation.""" +"""Tool API v3 artifact fetch.""" -from typing import List, Optional, Union -from urllib.parse import urljoin +from typing import Optional, Union -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 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 call_tool_api, normalize_optional_list async def fetch_artifacts( ctx: Context, - identifiers: Union[str, List[str]], + 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 " + """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..4ac707f --- /dev/null +++ b/src/tools/repository.py @@ -0,0 +1,49 @@ +"""Tool API v3 repository context tools.""" + +from typing import Optional + +from fastmcp import Context + +from .tool_api import call_tool_api, require_text + + +async def get_repository_ontology(ctx: Context, data_source: Optional[str] = None) -> str: + """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: Optional[str] = None, + path: Optional[str] = None, + max_depth: Optional[int] = None, + max_nodes: Optional[int] = None, + output_depth: Optional[int] = None, +) -> str: + """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: str, + data_source: Optional[str] = None, + start_line: Optional[int] = None, + end_line: Optional[int] = None, +) -> str: + """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..aa1786e 100644 --- a/src/tools/search.py +++ b/src/tools/search.py @@ -1,434 +1,66 @@ -"""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 Optional, Union -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, - 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 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, + question: 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"] + exclude_markdown: bool = False, +) -> str: + """Search indexed code by meaning using Tool API v3. - 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` 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, + 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, + 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) - """ +) -> str: + """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..23d629f --- /dev/null +++ b/src/tools/tool_api.py @@ -0,0 +1,91 @@ +"""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 core import CodeAliveContext, get_api_key_from_context, log_api_request, log_api_response +from utils import handle_api_error + + +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, +) -> str: + 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() + rendered = data.get("rendered") + if isinstance(rendered, str): + return rendered + obj = data.get("obj") + return json.dumps(obj, ensure_ascii=False, indent=2) + except (httpx.HTTPStatusError, 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 From f1c422a772cd5ecfbc0b43b14831aab235e6aef7 Mon Sep 17 00:00:00 2001 From: Rodion Mostovoi <36400912+rodion-m@users.noreply.github.com> Date: Wed, 1 Jul 2026 16:31:55 +0500 Subject: [PATCH 2/5] Remove legacy MCP alias references --- CLAUDE.md | 16 +++++++++------- integration_test.py | 17 ----------------- smoke_test.py | 37 +++++-------------------------------- 3 files changed, 14 insertions(+), 56 deletions(-) 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/integration_test.py b/integration_test.py index b2a5962..a0e1034 100644 --- a/integration_test.py +++ b/integration_test.py @@ -556,23 +556,6 @@ async def test_agent_workflow(s: ClientSession, target: str) -> None: len(text) > 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 ───────────────────────────────────────────────────────────────────── 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: From 13c2521f56dfdedf1abf372fb200d1ce63adb1c4 Mon Sep 17 00:00:00 2001 From: Rodion Mostovoi <36400912+rodion-m@users.noreply.github.com> Date: Wed, 1 Jul 2026 16:55:40 +0500 Subject: [PATCH 3/5] Use snake case relationship profiles --- src/tests/test_tool_api_v3.py | 4 ++-- src/tools/artifact_relationships.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/tests/test_tool_api_v3.py b/src/tests/test_tool_api_v3.py index d3674a1..676f417 100644 --- a/src/tests/test_tool_api_v3.py +++ b/src/tests/test_tool_api_v3.py @@ -99,14 +99,14 @@ def _context_with_response(rendered: str = "ok"): lambda ctx: get_artifact_relationships( ctx, identifier="repo::src/Foo.cs::Foo", - profile="AllRelevant", + profile="all_relevant", max_count_per_type=25, data_source="backend", ), "/api/tools/get_artifact_relationships", { "identifier": "repo::src/Foo.cs::Foo", - "profile": "AllRelevant", + "profile": "all_relevant", "max_count_per_type": 25, "data_source": "backend", }, diff --git a/src/tools/artifact_relationships.py b/src/tools/artifact_relationships.py index ab764dd..2f5e14f 100644 --- a/src/tools/artifact_relationships.py +++ b/src/tools/artifact_relationships.py @@ -11,7 +11,7 @@ async def get_artifact_relationships( ctx: Context, identifier: str, - profile: Literal["CallsOnly", "InheritanceOnly", "AllRelevant", "ReferencesOnly"] = "CallsOnly", + profile: Literal["calls_only", "inheritance_only", "all_relevant", "references_only"] = "calls_only", max_count_per_type: int = 50, data_source: Optional[str] = None, ) -> str: From a80f55845676faae8f6be26e483a8eb992635a2f Mon Sep 17 00:00:00 2001 From: Rodion Mostovoi <36400912+rodion-m@users.noreply.github.com> Date: Fri, 10 Jul 2026 23:50:12 +0500 Subject: [PATCH 4/5] Harden MCP v3 for FastMCP 3.4.4 --- .github/workflows/ci.yml | 9 +- Makefile | 19 +- README.md | 17 +- docker-compose.example.yml | 8 + integration_test.py | 843 ++++++--------------- pyproject.toml | 27 +- src/codealive_mcp_server.py | 117 ++- src/core/logging.py | 85 +-- src/middleware/observability_middleware.py | 32 +- src/tests/test_http_transport_security.py | 99 +++ src/tests/test_logging.py | 144 ++-- src/tests/test_observability_middleware.py | 31 +- src/tests/test_protocol_tools.py | 93 +++ src/tests/test_tool_api_v3.py | 35 +- src/tests/test_tool_metadata.py | 37 +- src/tools/artifact_query.py | 21 +- src/tools/artifact_relationships.py | 25 +- src/tools/chat.py | 20 +- src/tools/datasources.py | 12 +- src/tools/fetch_artifacts.py | 20 +- src/tools/repository.py | 44 +- src/tools/search.py | 59 +- src/tools/tool_api.py | 20 +- uv.lock | 252 +++--- 24 files changed, 1041 insertions(+), 1028 deletions(-) create mode 100644 src/tests/test_http_transport_security.py create mode 100644 src/tests/test_protocol_tools.py 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/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 d0b3381..95e99f5 100644 --- a/README.md +++ b/README.md @@ -46,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 stateless synthesis fallback, can take up to 30 seconds, 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. +`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 @@ -922,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: @@ -1023,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 a0e1034..cbafc9c 100644 --- a/integration_test.py +++ b/integration_test.py @@ -1,647 +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)}") - - -# ── 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/pyproject.toml b/pyproject.toml index bf1bdba..8608609 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,9 +5,9 @@ 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.1", + "python-dotenv==1.2.2", "loguru==0.7.3", "opentelemetry-api==1.39.1", "opentelemetry-sdk==1.39.1", @@ -18,7 +18,7 @@ dependencies = [ [project.optional-dependencies] test = [ - "pytest==9.0.2", + "pytest==9.0.3", "pytest-asyncio==1.3.0", "pytest-mock==3.15.1", "pytest-cov==7.1.0", @@ -44,3 +44,24 @@ fallback_version = "3.0.0" # 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-10" +fastmcp-slim = "2026-07-10" +cryptography = "2026-07-10" +idna = "2026-07-10" +pydantic-settings = "2026-07-10" +pyjwt = "2026-07-10" +pytest = "2026-07-10" +python-dotenv = "2026-07-10" +urllib3 = "2026-07-10" diff --git a/src/codealive_mcp_server.py b/src/codealive_mcp_server.py index 54f6e05..4f75a1b 100644 --- a/src/codealive_mcp_server.py +++ b/src/codealive_mcp_server.py @@ -43,68 +43,30 @@ 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 `get_repository_ontology` for high-level orientation when exactly one repository is in scope - 3. Use `semantic_search` for natural-language retrieval by meaning - 4. Use `grep_search` for literal string or regex matching when the pattern matters - 5. 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 or `read_file` - 6. Use `get_artifact_relationships` for graph expansion and `query_artifact_metadata` - for aggregate metadata analytics after checking `get_artifact_query_schema` - - User-invoked tool — `chat`: - - `chat` is disabled by default. Call it ONLY when the user has explicitly - named the tool (e.g. "use chat", "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 - - MCP v3 exposes only Tool API v3 tools; deprecated aliases are intentionally absent - - 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. - - `chat` is stateless in v3. Include prior findings, artifact identifiers, - assumptions, scope, and constraints in each question. - - 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 ) @@ -114,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(), @@ -157,7 +112,12 @@ 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", @@ -211,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") @@ -284,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", @@ -291,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_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 f53967e..890b697 100644 --- a/src/tests/test_observability_middleware.py +++ b/src/tests/test_observability_middleware.py @@ -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_tool_api_v3.py b/src/tests/test_tool_api_v3.py index 676f417..e791cbc 100644 --- a/src/tests/test_tool_api_v3.py +++ b/src/tests/test_tool_api_v3.py @@ -5,6 +5,7 @@ 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 @@ -15,14 +16,20 @@ from tools.search import grep_search, semantic_search -def _context_with_response(rendered: str = "ok"): +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": {"ok": True}} + response.json.return_value = { + "rendered": rendered, + "obj": obj if obj is not None else {"ok": True}, + } response.raise_for_status = MagicMock() client = AsyncMock() @@ -172,3 +179,27 @@ async def test_local_bounds_validation_fail_before_network_call(): 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 bf43fd0..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 @@ -38,9 +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 + + 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/artifact_query.py b/src/tools/artifact_query.py index 3a1877b..3acbe4b 100644 --- a/src/tools/artifact_query.py +++ b/src/tools/artifact_query.py @@ -1,17 +1,21 @@ """Tool API v3 ArtifactQuery tools.""" -from typing import Optional, Union +from typing import Annotated, Optional, Union from fastmcp import Context +from pydantic import Field -from .tool_api import call_tool_api, normalize_optional_list, require_text +from .tool_api import ToolApiResult, call_tool_api, normalize_optional_list, require_text async def get_artifact_query_schema( ctx: Context, - entity: Optional[str] = None, + entity: Annotated[ + Optional[str], + Field(description="Optional ArtifactQuery entity such as files or symbols."), + ] = None, include_examples: bool = True, -) -> str: +) -> ToolApiResult: """Return the ArtifactQuery v1 schema, fields, operators, and examples.""" return await call_tool_api(ctx, "get_artifact_query_schema", { "entity": entity, @@ -21,9 +25,12 @@ async def get_artifact_query_schema( async def query_artifact_metadata( ctx: Context, - statement: str, - data_sources: Optional[Union[str, list[str]]] = None, -) -> str: + 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", { diff --git a/src/tools/artifact_relationships.py b/src/tools/artifact_relationships.py index 2f5e14f..545a2db 100644 --- a/src/tools/artifact_relationships.py +++ b/src/tools/artifact_relationships.py @@ -1,20 +1,33 @@ """Tool API v3 artifact relationship expansion.""" -from typing import Literal, Optional +from typing import Annotated, Literal, Optional from fastmcp import Context from fastmcp.exceptions import ToolError +from pydantic import Field -from .tool_api import call_tool_api, require_text +from .tool_api import ToolApiResult, call_tool_api, require_text async def get_artifact_relationships( ctx: Context, - identifier: str, + 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: int = 50, - data_source: Optional[str] = None, -) -> str: + 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 diff --git a/src/tools/chat.py b/src/tools/chat.py index ca42949..44087f5 100644 --- a/src/tools/chat.py +++ b/src/tools/chat.py @@ -1,17 +1,27 @@ """Tool API v3 stateless chat.""" -from typing import Optional, Union +from typing import Annotated, Optional, Union from fastmcp import Context +from pydantic import Field -from .tool_api import call_tool_api, normalize_optional_list, require_text +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, -) -> str: + 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 diff --git a/src/tools/datasources.py b/src/tools/datasources.py index b21c9ae..42864d1 100644 --- a/src/tools/datasources.py +++ b/src/tools/datasources.py @@ -1,17 +1,21 @@ """Tool API v3 data-source discovery.""" -from typing import Optional +from typing import Annotated, Optional from fastmcp import Context +from pydantic import Field -from .tool_api import call_tool_api +from .tool_api import ToolApiResult, call_tool_api async def get_data_sources( ctx: Context, - query: Optional[str] = None, + query: Annotated[ + Optional[str], + Field(description="Optional relevance question used to rank visible data sources."), + ] = None, ready_only: bool = True, -) -> str: +) -> ToolApiResult: """List visible repositories and workspaces. Use the returned `name` value for `data_sources` or `data_source` in other diff --git a/src/tools/fetch_artifacts.py b/src/tools/fetch_artifacts.py index 087fdf9..05289a1 100644 --- a/src/tools/fetch_artifacts.py +++ b/src/tools/fetch_artifacts.py @@ -1,18 +1,28 @@ """Tool API v3 artifact fetch.""" -from typing import Optional, Union +from typing import Annotated, Optional, Union from fastmcp import Context from fastmcp.exceptions import ToolError +from pydantic import Field -from .tool_api import call_tool_api, normalize_optional_list +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: + 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: diff --git a/src/tools/repository.py b/src/tools/repository.py index 4ac707f..3266717 100644 --- a/src/tools/repository.py +++ b/src/tools/repository.py @@ -1,13 +1,20 @@ """Tool API v3 repository context tools.""" -from typing import Optional +from typing import Annotated, Optional from fastmcp import Context +from pydantic import Field -from .tool_api import call_tool_api, require_text +from .tool_api import ToolApiResult, call_tool_api, require_text -async def get_repository_ontology(ctx: Context, data_source: Optional[str] = None) -> str: +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, @@ -16,12 +23,15 @@ async def get_repository_ontology(ctx: Context, data_source: Optional[str] = Non async def get_file_tree( ctx: Context, - data_source: Optional[str] = None, - path: Optional[str] = None, - max_depth: Optional[int] = None, - max_nodes: Optional[int] = None, - output_depth: Optional[int] = None, -) -> str: + 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, @@ -34,11 +44,17 @@ async def get_file_tree( async def read_file( ctx: Context, - path: str, - data_source: Optional[str] = None, - start_line: Optional[int] = None, - end_line: Optional[int] = None, -) -> str: + 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", { diff --git a/src/tools/search.py b/src/tools/search.py index aa1786e..ed62599 100644 --- a/src/tools/search.py +++ b/src/tools/search.py @@ -1,11 +1,12 @@ """Tool API v3 search tools.""" -from typing import Optional, Union +from typing import Annotated, Optional, Union from fastmcp import Context from fastmcp.exceptions import ToolError +from pydantic import Field -from .tool_api import call_tool_api, normalize_optional_list, require_text +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: @@ -15,13 +16,28 @@ def _validate_max_results(max_results: Optional[int], tool_name: str) -> None: async def semantic_search( ctx: Context, - question: 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, + 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, -) -> str: +) -> ToolApiResult: """Search indexed code by meaning using Tool API v3. `question` must be a natural-language English sentence. Use `data_sources` @@ -43,14 +59,29 @@ async def 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, -) -> str: +) -> ToolApiResult: """Search indexed code by exact literal text or regex using Tool API v3.""" tool_name = "grep_search" require_text(query, tool_name, "query") diff --git a/src/tools/tool_api.py b/src/tools/tool_api.py index 23d629f..b9f747e 100644 --- a/src/tools/tool_api.py +++ b/src/tools/tool_api.py @@ -7,10 +7,13 @@ 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: @@ -49,7 +52,7 @@ async def call_tool_api( payload: dict[str, Any], *, action_label: Optional[str] = None, -) -> str: +) -> ToolApiResult: context: CodeAliveContext = ctx.request_context.lifespan_context api_key = get_api_key_from_context(ctx) body = {**omit_empty(payload), "output_format": "agentic"} @@ -69,12 +72,23 @@ async def call_tool_api( 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 - obj = data.get("obj") return json.dumps(obj, ensure_ascii=False, indent=2) - except (httpx.HTTPStatusError, Exception) as exc: + except Exception as exc: await handle_api_error( ctx, exc, diff --git a/uv.lock b/uv.lock index f28cc10..5eb34aa 100644 --- a/uv.lock +++ b/uv.lock @@ -3,9 +3,29 @@ revision = 3 requires-python = ">=3.11" [options] -exclude-newer = "2026-04-06T17:50:25.966153Z" +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-10T19:00:00Z" +pytest = "2026-07-10T19:00:00Z" +urllib3 = "2026-07-10T19:00:00Z" +pydantic-settings = "2026-07-10T19:00:00Z" +pyjwt = "2026-07-10T19:00:00Z" +python-dotenv = "2026-07-10T19:00:00Z" +fastmcp = "2026-07-10T19:00:00Z" +cryptography = "2026-07-10T19:00:00Z" +fastmcp-slim = "2026-07-10T19: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.9" +version = "1.7.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cryptography" }, + { name = "joserfc" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/af/98/00d3dd826d46959ad8e32af2dbb2398868fd9fd0683c26e56d0789bd0e68/authlib-1.6.9.tar.gz", hash = "sha256:d8f2421e7e5980cc1ddb4e32d3f5fa659cfaf60d8eaf3281ebed192e4ab74f04", size = 165134, upload-time = "2026-03-02T07:44:01.998Z" } +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/53/23/b65f568ed0c22f1efacb744d2db1a33c8068f384b8c9b482b52ebdbc3ef6/authlib-1.6.9-py2.py3-none-any.whl", hash = "sha256:f08b4c14e08f0861dc18a32357b33fbcfd2ea86cfe3fe149484b4d764c4a0ac3", size = 244197, upload-time = "2026-03-02T07:44:00.307Z" }, + { 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" }, @@ -328,11 +349,11 @@ requires-dist = [ { name = "opentelemetry-instrumentation-httpx", specifier = "==0.60b1" }, { name = "opentelemetry-sdk", specifier = "==1.39.1" }, { name = "opentelemetry-semantic-conventions", specifier = "==0.60b1" }, - { name = "pytest", marker = "extra == 'test'", specifier = "==9.0.2" }, + { name = "pytest", marker = "extra == 'test'", specifier = "==9.0.3" }, { name = "pytest-asyncio", marker = "extra == 'test'", specifier = "==1.3.0" }, { name = "pytest-cov", marker = "extra == 'test'", specifier = "==7.1.0" }, { name = "pytest-mock", marker = "extra == 'test'", specifier = "==3.15.1" }, - { name = "python-dotenv", specifier = "==1.2.1" }, + { name = "python-dotenv", specifier = "==1.2.2" }, ] provides-extras = ["test"] @@ -451,61 +472,58 @@ toml = [ [[package]] name = "cryptography" -version = "46.0.6" +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/a4/ba/04b1bd4218cbc58dc90ce967106d51582371b898690f3ae0402876cc4f34/cryptography-46.0.6.tar.gz", hash = "sha256:27550628a518c5c6c903d84f637fbecf287f6cb9ced3804838a1295dc1fd0759", size = 750542, upload-time = "2026-03-25T23:34:53.396Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/47/23/9285e15e3bc57325b0a72e592921983a701efc1ee8f91c06c5f0235d86d9/cryptography-46.0.6-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:64235194bad039a10bb6d2d930ab3323baaec67e2ce36215fd0952fad0930ca8", size = 7176401, upload-time = "2026-03-25T23:33:22.096Z" }, - { url = "https://files.pythonhosted.org/packages/60/f8/e61f8f13950ab6195b31913b42d39f0f9afc7d93f76710f299b5ec286ae6/cryptography-46.0.6-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:26031f1e5ca62fcb9d1fcb34b2b60b390d1aacaa15dc8b895a9ed00968b97b30", size = 4275275, upload-time = "2026-03-25T23:33:23.844Z" }, - { url = "https://files.pythonhosted.org/packages/19/69/732a736d12c2631e140be2348b4ad3d226302df63ef64d30dfdb8db7ad1c/cryptography-46.0.6-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:9a693028b9cbe51b5a1136232ee8f2bc242e4e19d456ded3fa7c86e43c713b4a", size = 4425320, upload-time = "2026-03-25T23:33:25.703Z" }, - { url = "https://files.pythonhosted.org/packages/d4/12/123be7292674abf76b21ac1fc0e1af50661f0e5b8f0ec8285faac18eb99e/cryptography-46.0.6-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:67177e8a9f421aa2d3a170c3e56eca4e0128883cf52a071a7cbf53297f18b175", size = 4278082, upload-time = "2026-03-25T23:33:27.423Z" }, - { url = "https://files.pythonhosted.org/packages/5b/ba/d5e27f8d68c24951b0a484924a84c7cdaed7502bac9f18601cd357f8b1d2/cryptography-46.0.6-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:d9528b535a6c4f8ff37847144b8986a9a143585f0540fbcb1a98115b543aa463", size = 4926514, upload-time = "2026-03-25T23:33:29.206Z" }, - { url = "https://files.pythonhosted.org/packages/34/71/1ea5a7352ae516d5512d17babe7e1b87d9db5150b21f794b1377eac1edc0/cryptography-46.0.6-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:22259338084d6ae497a19bae5d4c66b7ca1387d3264d1c2c0e72d9e9b6a77b97", size = 4457766, upload-time = "2026-03-25T23:33:30.834Z" }, - { url = "https://files.pythonhosted.org/packages/01/59/562be1e653accee4fdad92c7a2e88fced26b3fdfce144047519bbebc299e/cryptography-46.0.6-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:760997a4b950ff00d418398ad73fbc91aa2894b5c1db7ccb45b4f68b42a63b3c", size = 3986535, upload-time = "2026-03-25T23:33:33.02Z" }, - { url = "https://files.pythonhosted.org/packages/d6/8b/b1ebfeb788bf4624d36e45ed2662b8bd43a05ff62157093c1539c1288a18/cryptography-46.0.6-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:3dfa6567f2e9e4c5dceb8ccb5a708158a2a871052fa75c8b78cb0977063f1507", size = 4277618, upload-time = "2026-03-25T23:33:34.567Z" }, - { url = "https://files.pythonhosted.org/packages/dd/52/a005f8eabdb28df57c20f84c44d397a755782d6ff6d455f05baa2785bd91/cryptography-46.0.6-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:cdcd3edcbc5d55757e5f5f3d330dd00007ae463a7e7aa5bf132d1f22a4b62b19", size = 4890802, upload-time = "2026-03-25T23:33:37.034Z" }, - { url = "https://files.pythonhosted.org/packages/ec/4d/8e7d7245c79c617d08724e2efa397737715ca0ec830ecb3c91e547302555/cryptography-46.0.6-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:d4e4aadb7fc1f88687f47ca20bb7227981b03afaae69287029da08096853b738", size = 4457425, upload-time = "2026-03-25T23:33:38.904Z" }, - { url = "https://files.pythonhosted.org/packages/1d/5c/f6c3596a1430cec6f949085f0e1a970638d76f81c3ea56d93d564d04c340/cryptography-46.0.6-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:2b417edbe8877cda9022dde3a008e2deb50be9c407eef034aeeb3a8b11d9db3c", size = 4405530, upload-time = "2026-03-25T23:33:40.842Z" }, - { url = "https://files.pythonhosted.org/packages/7e/c9/9f9cea13ee2dbde070424e0c4f621c091a91ffcc504ffea5e74f0e1daeff/cryptography-46.0.6-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:380343e0653b1c9d7e1f55b52aaa2dbb2fdf2730088d48c43ca1c7c0abb7cc2f", size = 4667896, upload-time = "2026-03-25T23:33:42.781Z" }, - { url = "https://files.pythonhosted.org/packages/ad/b5/1895bc0821226f129bc74d00eccfc6a5969e2028f8617c09790bf89c185e/cryptography-46.0.6-cp311-abi3-win32.whl", hash = "sha256:bcb87663e1f7b075e48c3be3ecb5f0b46c8fc50b50a97cf264e7f60242dca3f2", size = 3026348, upload-time = "2026-03-25T23:33:45.021Z" }, - { url = "https://files.pythonhosted.org/packages/c3/f8/c9bcbf0d3e6ad288b9d9aa0b1dee04b063d19e8c4f871855a03ab3a297ab/cryptography-46.0.6-cp311-abi3-win_amd64.whl", hash = "sha256:6739d56300662c468fddb0e5e291f9b4d084bead381667b9e654c7dd81705124", size = 3483896, upload-time = "2026-03-25T23:33:46.649Z" }, - { url = "https://files.pythonhosted.org/packages/01/41/3a578f7fd5c70611c0aacba52cd13cb364a5dee895a5c1d467208a9380b0/cryptography-46.0.6-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:2ef9e69886cbb137c2aef9772c2e7138dc581fad4fcbcf13cc181eb5a3ab6275", size = 7117147, upload-time = "2026-03-25T23:33:48.249Z" }, - { url = "https://files.pythonhosted.org/packages/fa/87/887f35a6fca9dde90cad08e0de0c89263a8e59b2d2ff904fd9fcd8025b6f/cryptography-46.0.6-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7f417f034f91dcec1cb6c5c35b07cdbb2ef262557f701b4ecd803ee8cefed4f4", size = 4266221, upload-time = "2026-03-25T23:33:49.874Z" }, - { url = "https://files.pythonhosted.org/packages/aa/a8/0a90c4f0b0871e0e3d1ed126aed101328a8a57fd9fd17f00fb67e82a51ca/cryptography-46.0.6-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d24c13369e856b94892a89ddf70b332e0b70ad4a5c43cf3e9cb71d6d7ffa1f7b", size = 4408952, upload-time = "2026-03-25T23:33:52.128Z" }, - { url = "https://files.pythonhosted.org/packages/16/0b/b239701eb946523e4e9f329336e4ff32b1247e109cbab32d1a7b61da8ed7/cryptography-46.0.6-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:aad75154a7ac9039936d50cf431719a2f8d4ed3d3c277ac03f3339ded1a5e707", size = 4270141, upload-time = "2026-03-25T23:33:54.11Z" }, - { url = "https://files.pythonhosted.org/packages/0f/a8/976acdd4f0f30df7b25605f4b9d3d89295351665c2091d18224f7ad5cdbf/cryptography-46.0.6-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:3c21d92ed15e9cfc6eb64c1f5a0326db22ca9c2566ca46d845119b45b4400361", size = 4904178, upload-time = "2026-03-25T23:33:55.725Z" }, - { url = "https://files.pythonhosted.org/packages/b1/1b/bf0e01a88efd0e59679b69f42d4afd5bced8700bb5e80617b2d63a3741af/cryptography-46.0.6-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:4668298aef7cddeaf5c6ecc244c2302a2b8e40f384255505c22875eebb47888b", size = 4441812, upload-time = "2026-03-25T23:33:57.364Z" }, - { url = "https://files.pythonhosted.org/packages/bb/8b/11df86de2ea389c65aa1806f331cae145f2ed18011f30234cc10ca253de8/cryptography-46.0.6-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:8ce35b77aaf02f3b59c90b2c8a05c73bac12cea5b4e8f3fbece1f5fddea5f0ca", size = 3963923, upload-time = "2026-03-25T23:33:59.361Z" }, - { url = "https://files.pythonhosted.org/packages/91/e0/207fb177c3a9ef6a8108f234208c3e9e76a6aa8cf20d51932916bd43bda0/cryptography-46.0.6-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:c89eb37fae9216985d8734c1afd172ba4927f5a05cfd9bf0e4863c6d5465b013", size = 4269695, upload-time = "2026-03-25T23:34:00.909Z" }, - { url = "https://files.pythonhosted.org/packages/21/5e/19f3260ed1e95bced52ace7501fabcd266df67077eeb382b79c81729d2d3/cryptography-46.0.6-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:ed418c37d095aeddf5336898a132fba01091f0ac5844e3e8018506f014b6d2c4", size = 4869785, upload-time = "2026-03-25T23:34:02.796Z" }, - { url = "https://files.pythonhosted.org/packages/10/38/cd7864d79aa1d92ef6f1a584281433419b955ad5a5ba8d1eb6c872165bcb/cryptography-46.0.6-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:69cf0056d6947edc6e6760e5f17afe4bea06b56a9ac8a06de9d2bd6b532d4f3a", size = 4441404, upload-time = "2026-03-25T23:34:04.35Z" }, - { url = "https://files.pythonhosted.org/packages/09/0a/4fe7a8d25fed74419f91835cf5829ade6408fd1963c9eae9c4bce390ecbb/cryptography-46.0.6-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8e7304c4f4e9490e11efe56af6713983460ee0780f16c63f219984dab3af9d2d", size = 4397549, upload-time = "2026-03-25T23:34:06.342Z" }, - { url = "https://files.pythonhosted.org/packages/5f/a0/7d738944eac6513cd60a8da98b65951f4a3b279b93479a7e8926d9cd730b/cryptography-46.0.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:b928a3ca837c77a10e81a814a693f2295200adb3352395fad024559b7be7a736", size = 4651874, upload-time = "2026-03-25T23:34:07.916Z" }, - { url = "https://files.pythonhosted.org/packages/cb/f1/c2326781ca05208845efca38bf714f76939ae446cd492d7613808badedf1/cryptography-46.0.6-cp314-cp314t-win32.whl", hash = "sha256:97c8115b27e19e592a05c45d0dd89c57f81f841cc9880e353e0d3bf25b2139ed", size = 3001511, upload-time = "2026-03-25T23:34:09.892Z" }, - { url = "https://files.pythonhosted.org/packages/c9/57/fe4a23eb549ac9d903bd4698ffda13383808ef0876cc912bcb2838799ece/cryptography-46.0.6-cp314-cp314t-win_amd64.whl", hash = "sha256:c797e2517cb7880f8297e2c0f43bb910e91381339336f75d2c1c2cbf811b70b4", size = 3471692, upload-time = "2026-03-25T23:34:11.613Z" }, - { url = "https://files.pythonhosted.org/packages/c4/cc/f330e982852403da79008552de9906804568ae9230da8432f7496ce02b71/cryptography-46.0.6-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:12cae594e9473bca1a7aceb90536060643128bb274fcea0fc459ab90f7d1ae7a", size = 7162776, upload-time = "2026-03-25T23:34:13.308Z" }, - { url = "https://files.pythonhosted.org/packages/49/b3/dc27efd8dcc4bff583b3f01d4a3943cd8b5821777a58b3a6a5f054d61b79/cryptography-46.0.6-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:639301950939d844a9e1c4464d7e07f902fe9a7f6b215bb0d4f28584729935d8", size = 4270529, upload-time = "2026-03-25T23:34:15.019Z" }, - { url = "https://files.pythonhosted.org/packages/e6/05/e8d0e6eb4f0d83365b3cb0e00eb3c484f7348db0266652ccd84632a3d58d/cryptography-46.0.6-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ed3775295fb91f70b4027aeba878d79b3e55c0b3e97eaa4de71f8f23a9f2eb77", size = 4414827, upload-time = "2026-03-25T23:34:16.604Z" }, - { url = "https://files.pythonhosted.org/packages/2f/97/daba0f5d2dc6d855e2dcb70733c812558a7977a55dd4a6722756628c44d1/cryptography-46.0.6-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:8927ccfbe967c7df312ade694f987e7e9e22b2425976ddbf28271d7e58845290", size = 4271265, upload-time = "2026-03-25T23:34:18.586Z" }, - { url = "https://files.pythonhosted.org/packages/89/06/fe1fce39a37ac452e58d04b43b0855261dac320a2ebf8f5260dd55b201a9/cryptography-46.0.6-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:b12c6b1e1651e42ab5de8b1e00dc3b6354fdfd778e7fa60541ddacc27cd21410", size = 4916800, upload-time = "2026-03-25T23:34:20.561Z" }, - { url = "https://files.pythonhosted.org/packages/ff/8a/b14f3101fe9c3592603339eb5d94046c3ce5f7fc76d6512a2d40efd9724e/cryptography-46.0.6-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:063b67749f338ca9c5a0b7fe438a52c25f9526b851e24e6c9310e7195aad3b4d", size = 4448771, upload-time = "2026-03-25T23:34:22.406Z" }, - { url = "https://files.pythonhosted.org/packages/01/b3/0796998056a66d1973fd52ee89dc1bb3b6581960a91ad4ac705f182d398f/cryptography-46.0.6-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:02fad249cb0e090b574e30b276a3da6a149e04ee2f049725b1f69e7b8351ec70", size = 3978333, upload-time = "2026-03-25T23:34:24.281Z" }, - { url = "https://files.pythonhosted.org/packages/c5/3d/db200af5a4ffd08918cd55c08399dc6c9c50b0bc72c00a3246e099d3a849/cryptography-46.0.6-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:7e6142674f2a9291463e5e150090b95a8519b2fb6e6aaec8917dd8d094ce750d", size = 4271069, upload-time = "2026-03-25T23:34:25.895Z" }, - { url = "https://files.pythonhosted.org/packages/d7/18/61acfd5b414309d74ee838be321c636fe71815436f53c9f0334bf19064fa/cryptography-46.0.6-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:456b3215172aeefb9284550b162801d62f5f264a081049a3e94307fe20792cfa", size = 4878358, upload-time = "2026-03-25T23:34:27.67Z" }, - { url = "https://files.pythonhosted.org/packages/8b/65/5bf43286d566f8171917cae23ac6add941654ccf085d739195a4eacf1674/cryptography-46.0.6-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:341359d6c9e68834e204ceaf25936dffeafea3829ab80e9503860dcc4f4dac58", size = 4448061, upload-time = "2026-03-25T23:34:29.375Z" }, - { url = "https://files.pythonhosted.org/packages/e0/25/7e49c0fa7205cf3597e525d156a6bce5b5c9de1fd7e8cb01120e459f205a/cryptography-46.0.6-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:9a9c42a2723999a710445bc0d974e345c32adfd8d2fac6d8a251fa829ad31cfb", size = 4399103, upload-time = "2026-03-25T23:34:32.036Z" }, - { url = "https://files.pythonhosted.org/packages/44/46/466269e833f1c4718d6cd496ffe20c56c9c8d013486ff66b4f69c302a68d/cryptography-46.0.6-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:6617f67b1606dfd9fe4dbfa354a9508d4a6d37afe30306fe6c101b7ce3274b72", size = 4659255, upload-time = "2026-03-25T23:34:33.679Z" }, - { url = "https://files.pythonhosted.org/packages/0a/09/ddc5f630cc32287d2c953fc5d32705e63ec73e37308e5120955316f53827/cryptography-46.0.6-cp38-abi3-win32.whl", hash = "sha256:7f6690b6c55e9c5332c0b59b9c8a3fb232ebf059094c17f9019a51e9827df91c", size = 3010660, upload-time = "2026-03-25T23:34:35.418Z" }, - { url = "https://files.pythonhosted.org/packages/1b/82/ca4893968aeb2709aacfb57a30dec6fa2ab25b10fa9f064b8882ce33f599/cryptography-46.0.6-cp38-abi3-win_amd64.whl", hash = "sha256:79e865c642cfc5c0b3eb12af83c35c5aeff4fa5c672dc28c43721c2c9fdd2f0f", size = 3471160, upload-time = "2026-03-25T23:34:37.191Z" }, - { url = "https://files.pythonhosted.org/packages/2e/84/7ccff00ced5bac74b775ce0beb7d1be4e8637536b522b5df9b73ada42da2/cryptography-46.0.6-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:2ea0f37e9a9cf0df2952893ad145fd9627d326a59daec9b0802480fa3bcd2ead", size = 3475444, upload-time = "2026-03-25T23:34:38.944Z" }, - { url = "https://files.pythonhosted.org/packages/bc/1f/4c926f50df7749f000f20eede0c896769509895e2648db5da0ed55db711d/cryptography-46.0.6-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:a3e84d5ec9ba01f8fd03802b2147ba77f0c8f2617b2aff254cedd551844209c8", size = 4218227, upload-time = "2026-03-25T23:34:40.871Z" }, - { url = "https://files.pythonhosted.org/packages/c6/65/707be3ffbd5f786028665c3223e86e11c4cda86023adbc56bd72b1b6bab5/cryptography-46.0.6-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:12f0fa16cc247b13c43d56d7b35287ff1569b5b1f4c5e87e92cc4fcc00cd10c0", size = 4381399, upload-time = "2026-03-25T23:34:42.609Z" }, - { url = "https://files.pythonhosted.org/packages/f3/6d/73557ed0ef7d73d04d9aba745d2c8e95218213687ee5e76b7d236a5030fc/cryptography-46.0.6-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:50575a76e2951fe7dbd1f56d181f8c5ceeeb075e9ff88e7ad997d2f42af06e7b", size = 4217595, upload-time = "2026-03-25T23:34:44.205Z" }, - { url = "https://files.pythonhosted.org/packages/9e/c5/e1594c4eec66a567c3ac4400008108a415808be2ce13dcb9a9045c92f1a0/cryptography-46.0.6-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:90e5f0a7b3be5f40c3a0a0eafb32c681d8d2c181fc2a1bdabe9b3f611d9f6b1a", size = 4380912, upload-time = "2026-03-25T23:34:46.328Z" }, - { url = "https://files.pythonhosted.org/packages/1a/89/843b53614b47f97fe1abc13f9a86efa5ec9e275292c457af1d4a60dc80e0/cryptography-46.0.6-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:6728c49e3b2c180ef26f8e9f0a883a2c585638db64cf265b49c9ba10652d430e", size = 3409955, upload-time = "2026-03-25T23:34:48.465Z" }, +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.11" +version = "3.18" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" } +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/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" }, + { 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]] @@ -1237,11 +1307,11 @@ wheels = [ [[package]] name = "pyjwt" -version = "2.12.1" +version = "2.13.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/c2/27/a3b6e5bf6ff856d2509292e95c8f57f0df7017cf5394921fc4e4ef40308a/pyjwt-2.12.1.tar.gz", hash = "sha256:c74a7a2adf861c04d002db713dd85f84beb242228e671280bf709d765b03672b", size = 102564, upload-time = "2026-03-13T19:27:37.25Z" } +sdist = { url = "https://files.pythonhosted.org/packages/3b/81/58d0ac84e1ef3a3843791d6954d94c0b33d526c75eeb1efbce9d0a4c4077/pyjwt-2.13.0.tar.gz", hash = "sha256:41571c89ca91598c79e8ef18a2d07367d4810fbbd6f637794879baf1b7703423", size = 107515, upload-time = "2026-05-21T19:54:36.618Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e5/7a/8dd906bd22e79e47397a61742927f6747fe93242ef86645ee9092e610244/pyjwt-2.12.1-py3-none-any.whl", hash = "sha256:28ca37c070cad8ba8cd9790cd940535d40274d22f80ab87f3ac6a713e6e8454c", size = 29726, upload-time = "2026-03-13T19:27:35.677Z" }, + { url = "https://files.pythonhosted.org/packages/a3/5e/ecf12fdb62546d64385c158514e9b2b671f7832108ef2ecd2020ce0af2d1/pyjwt-2.13.0-py3-none-any.whl", hash = "sha256:66adcc2aff09b3f1bbd95fc1e1577df8ac8723c978552fd43304c8a290ac5728", size = 31274, upload-time = "2026-05-21T19:54:35.362Z" }, ] [package.optional-dependencies] @@ -1260,7 +1330,7 @@ wheels = [ [[package]] name = "pytest" -version = "9.0.2" +version = "9.0.3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, @@ -1269,9 +1339,9 @@ dependencies = [ { name = "pluggy" }, { name = "pygments" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload-time = "2026-04-07T17:16:18.027Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" }, + { url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" }, ] [[package]] @@ -1315,20 +1385,20 @@ wheels = [ [[package]] name = "python-dotenv" -version = "1.2.1" +version = "1.2.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f0/26/19cadc79a718c5edbec86fd4919a6b6d3f681039a2f6d66d14be94e75fb9/python_dotenv-1.2.1.tar.gz", hash = "sha256:42667e897e16ab0d66954af0e60a9caa94f0fd4ecf3aaf6d2d260eec1aa36ad6", size = 44221, upload-time = "2025-10-26T15:12:10.434Z" } +sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135, upload-time = "2026-03-01T16:00:26.196Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/14/1b/a298b06749107c305e1fe0f814c6c74aea7b2f1e10989cb30f544a1b3253/python_dotenv-1.2.1-py3-none-any.whl", hash = "sha256:b81ee9561e9ca4004139c6cbba3a238c32b03e4894671e181b671e8cb8425d61", size = 21230, upload-time = "2025-10-26T15:12:09.109Z" }, + { url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" }, ] [[package]] name = "python-multipart" -version = "0.0.24" +version = "0.0.32" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/8a/45/e23b5dc14ddb9918ae4a625379506b17b6f8fc56ca1d82db62462f59aea6/python_multipart-0.0.24.tar.gz", hash = "sha256:9574c97e1c026e00bc30340ef7c7d76739512ab4dfd428fec8c330fa6a5cc3c8", size = 37695, upload-time = "2026-04-05T20:49:13.829Z" } +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/a3/73/89930efabd4da63cea44a3f438aeb753d600123570e6d6264e763617a9ce/python_multipart-0.0.24-py3-none-any.whl", hash = "sha256:9b110a98db707df01a53c194f0af075e736a770dc5058089650d70b4a182f950", size = 24420, upload-time = "2026-04-05T20:49:12.555Z" }, + { 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]] @@ -1605,15 +1675,15 @@ wheels = [ [[package]] name = "starlette" -version = "1.0.0" +version = "1.3.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/81/69/17425771797c36cded50b7fe44e850315d039f28b15901ab44839e70b593/starlette-1.0.0.tar.gz", hash = "sha256:6a4beaf1f81bb472fd19ea9b918b50dc3a77a6f2e190a12954b25e6ed5eea149", size = 2655289, upload-time = "2026-03-22T18:29:46.779Z" } +sdist = { url = "https://files.pythonhosted.org/packages/eb/e3/7c1dc7381d9f8ab7d854328ebfa884e62cb3f3d8549ddfd37c7814f42afa/starlette-1.3.1.tar.gz", hash = "sha256:05d0213193f2fbaae60e2ecb593b4add4262ad4e46536b54abe36f11a71724e0", size = 2703240, upload-time = "2026-06-12T09:23:11.602Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/0b/c9/584bc9651441b4ba60cc4d557d8a547b5aff901af35bda3a4ee30c819b82/starlette-1.0.0-py3-none-any.whl", hash = "sha256:d3ec55e0bb321692d275455ddfd3df75fff145d009685eb40dc91fc66b03d38b", size = 72651, upload-time = "2026-03-22T18:29:45.111Z" }, + { url = "https://files.pythonhosted.org/packages/ec/bb/2799cc2ede3ed41131f8975621e7213dfc7ef4acbbaadfa440f32500c370/starlette-1.3.1-py3-none-any.whl", hash = "sha256:c7372aae11c3c3f26a42df7bd626cec2f47d03483d261d369516a615a53714c6", size = 73632, upload-time = "2026-06-12T09:23:10.017Z" }, ] [[package]] @@ -1702,11 +1772,11 @@ wheels = [ [[package]] name = "urllib3" -version = "2.6.3" +version = "2.7.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed", size = 435556, upload-time = "2026-01-07T16:24:43.925Z" } +sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" }, + { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" }, ] [[package]] From e30ae8a1f385086706449c3ef8d2ea16254e591a Mon Sep 17 00:00:00 2001 From: Rodion Mostovoi <36400912+rodion-m@users.noreply.github.com> Date: Fri, 10 Jul 2026 23:57:19 +0500 Subject: [PATCH 5/5] Make uv lock timestamps timezone-stable --- pyproject.toml | 18 +++++++++--------- uv.lock | 18 +++++++++--------- 2 files changed, 18 insertions(+), 18 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 8608609..1c5111e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -56,12 +56,12 @@ constraint-dependencies = [ ] [tool.uv.exclude-newer-package] -fastmcp = "2026-07-10" -fastmcp-slim = "2026-07-10" -cryptography = "2026-07-10" -idna = "2026-07-10" -pydantic-settings = "2026-07-10" -pyjwt = "2026-07-10" -pytest = "2026-07-10" -python-dotenv = "2026-07-10" -urllib3 = "2026-07-10" +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/uv.lock b/uv.lock index 5eb34aa..4f69b3e 100644 --- a/uv.lock +++ b/uv.lock @@ -7,15 +7,15 @@ exclude-newer = "0001-01-01T00:00:00Z" # This has no effect and is included for exclude-newer-span = "P7D" [options.exclude-newer-package] -idna = "2026-07-10T19:00:00Z" -pytest = "2026-07-10T19:00:00Z" -urllib3 = "2026-07-10T19:00:00Z" -pydantic-settings = "2026-07-10T19:00:00Z" -pyjwt = "2026-07-10T19:00:00Z" -python-dotenv = "2026-07-10T19:00:00Z" -fastmcp = "2026-07-10T19:00:00Z" -cryptography = "2026-07-10T19:00:00Z" -fastmcp-slim = "2026-07-10T19:00:00Z" +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 = [