import asyncio import hashlib import json from datetime import UTC, datetime from pathlib import Path import httpx2 import yaml from mcp import Client from mcp.client.streamable_http import streamable_http_client from mcp.types import TextContent from gewu.adapters.git_repository import GitCanonicalRepository from gewu.adapters.sqlite_identity import SQLiteIdentityStore from gewu.adapters.sqlite_search import SQLiteSearchIndex from gewu.application.reading import CanonicalNodeReader from gewu.application.search import CanonicalSearchService from gewu.bootstrap.app import create_app from gewu.domain.contracts import EdgeEndpoint, NodeType, TypedEdge from tests.domain.proposal_fixtures import NODE, PROJECTS, SOURCE_REVISION def test_authenticated_search_scope_through_http_and_mcp(tmp_path: Path) -> None: async def exercise() -> None: repository = GitCanonicalRepository(tmp_path / "knowledge.git") project_key = hashlib.sha256(NODE.project_id.encode()).hexdigest() node_key = hashlib.sha256(NODE.node_id.encode()).hexdigest() source_key = hashlib.sha256(SOURCE_REVISION.revision_id.encode()).hexdigest() stale_node = NODE.model_copy( update={ "as_of": datetime(2024, 1, 1, tzinfo=UTC), "stale_after": datetime(2025, 1, 1, tzinfo=UTC), } ) problem_node = NODE.model_copy( update={ "node_id": "dome:problem-browse-fixture", "node_type": NodeType.PROBLEM, "title": "Problem browse fixture", "value": None, "unit": None, } ) problem_key = hashlib.sha256(problem_node.node_id.encode()).hexdigest() older_source = SOURCE_REVISION.model_copy( update={ "source_id": "dome:older-pilot-report", "revision_id": "dome:older-pilot-report:r1", "title": "Older pilot installation report", "captured_at": datetime(2025, 1, 1, tzinfo=UTC), } ) related = NODE.model_copy(update={"node_id": "z:dome-related"}) related_key = hashlib.sha256(related.node_id.encode()).hexdigest() related_two = NODE.model_copy(update={"node_id": "zz:dome-related-two"}) related_two_key = hashlib.sha256(related_two.node_id.encode()).hexdigest() edge = TypedEdge( edge_id="dome:related-edge", edge_type="compared_with", source=EdgeEndpoint(kind="node", id=NODE.node_id), target=EdgeEndpoint(kind="node", id=related.node_id), ) edge_two = TypedEdge( edge_id="dome:related-edge-two", edge_type="compared_with", source=EdgeEndpoint(kind="node", id=NODE.node_id), target=EdgeEndpoint(kind="node", id=related_two.node_id), ) repository.publish_files( expected_head=repository.head(), files={ f"projects/{project_key}/nodes/{node_key}.yaml": yaml.safe_dump( stale_node.model_dump(mode="json") ).encode(), f"projects/{project_key}/nodes/{problem_key}.yaml": yaml.safe_dump( problem_node.model_dump(mode="json") ).encode(), f"projects/{project_key}/nodes/{related_key}.yaml": yaml.safe_dump( related.model_dump(mode="json") ).encode(), f"projects/{project_key}/nodes/{related_two_key}.yaml": yaml.safe_dump( related_two.model_dump(mode="json") ).encode(), ( f"sources/{hashlib.sha256(SOURCE_REVISION.source_id.encode()).hexdigest()}/" f"{source_key}.yaml" ): yaml.safe_dump(SOURCE_REVISION.model_dump(mode="json")).encode(), ( f"sources/{hashlib.sha256(older_source.source_id.encode()).hexdigest()}/" f"{hashlib.sha256(older_source.revision_id.encode()).hexdigest()}.yaml" ): yaml.safe_dump(older_source.model_dump(mode="json")).encode(), f"edges/{hashlib.sha256(edge.edge_id.encode()).hexdigest()}.yaml": yaml.safe_dump( edge.model_dump(mode="json") ).encode(), ( f"edges/{hashlib.sha256(edge_two.edge_id.encode()).hexdigest()}.yaml" ): yaml.safe_dump(edge_two.model_dump(mode="json")).encode(), }, message="Add searchable node", index_directory=tmp_path / "publishing", ) identities = SQLiteIdentityStore(tmp_path / "identity.sqlite3") token = identities.provision( credential_id="search-key", principal_id="search-reader", roles=("viewer",), project_ids=("dome-performance",), ) search = CanonicalSearchService( index=SQLiteSearchIndex(tmp_path / "search.sqlite3", repository), project_ids_provider=lambda: PROJECTS.keys(), canonical_head=repository.head, ) app = create_app( identity_provider=identities, node_reader=CanonicalNodeReader(repository), search_service=search, ) async with app.router.lifespan_context(app): async with httpx2.AsyncClient( transport=httpx2.ASGITransport(app=app), base_url="http://localhost", headers={"Authorization": f"Bearer {token}"}, ) as http: diagnostic_unbuilt = await http.get("/v1/diagnostics?projects=dome-performance") assert diagnostic_unbuilt.status_code == 200 unbuilt_state = diagnostic_unbuilt.json() assert unbuilt_state["index_state"] == "not_built" assert unbuilt_state["index_revision"] is None assert unbuilt_state["index_schema_version"] == 4 result = await http.post( "/v1/search", json={ "query": "400 visitors", "scope": { "projects": ["dome-performance"], "include_shared": False, }, "limit": 10, "budget_tokens": 5000, }, ) assert result.status_code == 200, result.text envelope = result.json() assert envelope["retrieval_mode"] == "keyword" assert envelope["index_revision"] == repository.head() assert envelope["data"][0]["node"]["node_id"] == NODE.node_id assert envelope["citations"][0]["quote"] == NODE.claims[0].evidence[0].quote listed_sources = await http.post( "/v1/sources", json={ "scope": {"projects": ["dome-performance"], "include_shared": False}, "limit": 10, "budget_tokens": 5000, }, ) assert listed_sources.status_code == 200, listed_sources.text assert listed_sources.json()["data"][0]["source"]["revision_id"] == ( SOURCE_REVISION.revision_id ) source_page_one = await http.post( "/v1/sources", json={ "scope": {"projects": ["dome-performance"], "include_shared": False}, "limit": 1, "budget_tokens": 5000, }, ) source_cursor = source_page_one.json()["next_cursor"] assert source_cursor source_page_two = await http.post( "/v1/sources", json={ "scope": {"projects": ["dome-performance"], "include_shared": False}, "limit": 1, "cursor": source_cursor, "budget_tokens": 5000, }, ) assert source_page_two.status_code == 200 assert source_page_two.json()["data"][0]["source"]["revision_id"] == ( older_source.revision_id ) denied_sources = await http.post( "/v1/sources", json={ "scope": {"projects": ["interactive-video"], "include_shared": False}, "limit": 10, "budget_tokens": 5000, }, ) assert denied_sources.status_code == 403 problem_filter = await http.post( "/v1/search", json={ "query": "unmatched phrase", "scope": {"projects": ["dome-performance"], "include_shared": False}, "node_types": ["problem"], "budget_tokens": 5000, }, ) assert problem_filter.status_code == 200, problem_filter.text assert problem_filter.json()["data"] == [] problem_browse = await http.post( "/v1/search", json={ "query": "", "scope": {"projects": ["dome-performance"], "include_shared": False}, "node_types": ["problem"], "budget_tokens": 5000, }, ) assert problem_browse.status_code == 200, problem_browse.text assert [row["node"]["node_id"] for row in problem_browse.json()["data"]] == [ problem_node.node_id ] unbounded_browse = await http.post( "/v1/search", json={ "query": "", "scope": {"projects": ["dome-performance"], "include_shared": False}, "budget_tokens": 5000, }, ) assert unbounded_browse.status_code == 422 stale_filter = await http.post( "/v1/search", json={ "query": "400 visitors", "scope": {"projects": ["dome-performance"], "include_shared": False}, "stale_only": True, "budget_tokens": 5000, }, ) assert stale_filter.status_code == 200, stale_filter.text assert stale_filter.json()["data"][0]["node"]["node_id"] == NODE.node_id facet_filter = await http.post( "/v1/search", json={ "query": "400 visitors", "scope": {"projects": ["dome-performance"], "include_shared": False}, "tags": ["attendance"], "audience": ["product"], "use_for": ["comparison"], "domain": ["production"], "budget_tokens": 5000, }, ) assert facet_filter.status_code == 200, facet_filter.text assert facet_filter.json()["data"][0]["node"]["node_id"] == NODE.node_id unmatched_facet = await http.post( "/v1/search", json={ "query": "400 visitors", "scope": {"projects": ["dome-performance"], "include_shared": False}, "domain": ["unrelated-domain"], "budget_tokens": 5000, }, ) assert unmatched_facet.status_code == 200, unmatched_facet.text assert unmatched_facet.json()["data"] == [] diagnostic_current = await http.get("/v1/diagnostics?projects=dome-performance") assert diagnostic_current.status_code == 200 current_state = diagnostic_current.json() assert current_state["index_state"] == "current" assert current_state["index_revision"] == repository.head() assert "proposal_submit" not in current_state["authorized_operations"] projects = await http.get("/v1/projects") assert projects.status_code == 200 assert projects.json() == ["dome-performance"] page_one = await http.post( "/v1/search", json={ "query": "visitors", "scope": {"projects": ["dome-performance"], "include_shared": False}, "limit": 1, "budget_tokens": 5000, }, ) assert page_one.status_code == 200 cursor = page_one.json()["next_cursor"] assert cursor page_two = await http.post( "/v1/search", json={ "query": "visitors", "scope": {"projects": ["dome-performance"], "include_shared": False}, "limit": 1, "cursor": cursor, "budget_tokens": 5000, }, ) assert page_two.status_code == 200 assert ( page_two.json()["data"][0]["node"]["node_id"] != page_one.json()["data"][0]["node"]["node_id"] ) reused_cursor = await http.post( "/v1/search", json={ "query": "400 visitors", "scope": {"projects": ["dome-performance"], "include_shared": False}, "limit": 1, "cursor": cursor, "budget_tokens": 5000, }, ) assert reused_cursor.status_code == 422 changed_facet_cursor = await http.post( "/v1/search", json={ "query": "visitors", "scope": {"projects": ["dome-performance"], "include_shared": False}, "node_types": ["problem"], "limit": 1, "cursor": cursor, "budget_tokens": 5000, }, ) assert changed_facet_cursor.status_code == 422 changed_tag_cursor = await http.post( "/v1/search", json={ "query": "visitors", "scope": {"projects": ["dome-performance"], "include_shared": False}, "tags": ["attendance"], "limit": 1, "cursor": cursor, "budget_tokens": 5000, }, ) assert changed_tag_cursor.status_code == 422 adjacent = await http.post( "/v1/neighbors", json={ "node_id": NODE.node_id, "scope": {"projects": ["dome-performance"], "include_shared": False}, "limit": 1, "budget_tokens": 5000, }, ) assert adjacent.status_code == 200, adjacent.text assert adjacent.json()["data"][0]["neighbor"]["id"] == related.node_id neighbor_cursor = adjacent.json()["next_cursor"] assert neighbor_cursor adjacent_two = await http.post( "/v1/neighbors", json={ "node_id": NODE.node_id, "scope": {"projects": ["dome-performance"], "include_shared": False}, "limit": 1, "cursor": neighbor_cursor, "budget_tokens": 5000, }, ) assert adjacent_two.status_code == 200 assert adjacent_two.json()["data"][0]["neighbor"]["id"] == related_two.node_id assert adjacent_two.json()["next_cursor"] is None assert {citation["quote"] for citation in adjacent.json()["citations"]} == { NODE.claims[0].evidence[0].quote } newer = NODE.model_copy(update={"node_id": "new:unrelated-revision-node"}) newer_key = hashlib.sha256(newer.node_id.encode()).hexdigest() repository.publish_files( expected_head=repository.head(), files={ f"projects/{project_key}/nodes/{newer_key}.yaml": yaml.safe_dump( newer.model_dump(mode="json") ).encode() }, message="Advance canonical index for cursor-expiry check", index_directory=tmp_path / "publishing", ) diagnostic_lag = await http.get("/v1/diagnostics?projects=dome-performance") assert diagnostic_lag.status_code == 200 lag_state = diagnostic_lag.json() assert lag_state["index_state"] == "lagging" assert lag_state["index_revision"] != repository.head() assert lag_state["canonical_revision"] == repository.head() expired_search = await http.post( "/v1/search", json={ "query": "visitors", "scope": {"projects": ["dome-performance"], "include_shared": False}, "limit": 1, "cursor": cursor, "budget_tokens": 5000, }, ) assert expired_search.status_code == 409 expired_neighbors = await http.post( "/v1/neighbors", json={ "node_id": NODE.node_id, "scope": {"projects": ["dome-performance"], "include_shared": False}, "limit": 1, "cursor": neighbor_cursor, "budget_tokens": 5000, }, ) assert expired_neighbors.status_code == 409 too_small = await http.post( "/v1/search", json={ "query": "visitors", "scope": { "projects": ["dome-performance"], "include_shared": False, }, "budget_tokens": 256, }, ) assert too_small.status_code == 413 dated_confidence_search = await http.post( "/v1/search", json={ "query": "visitors", "scope": { "projects": ["dome-performance"], "include_shared": False, }, "confidences": ["medium"], "as_of_from": "2024-01-01", "as_of_to": "2024-12-31", "budget_tokens": 5000, }, ) assert dated_confidence_search.status_code == 200, dated_confidence_search.text assert [ row["node"]["node_id"] for row in dated_confidence_search.json()["data"] ] == [NODE.node_id] filtered_page = await http.post( "/v1/search", json={ "query": "visitors", "scope": { "projects": ["dome-performance"], "include_shared": False, }, "confidences": ["medium"], "limit": 1, "budget_tokens": 5000, }, ) assert filtered_page.status_code == 200 filter_cursor = filtered_page.json()["next_cursor"] assert filter_cursor changed_filter_page = await http.post( "/v1/search", json={ "query": "visitors", "scope": { "projects": ["dome-performance"], "include_shared": False, }, "confidences": ["low"], "limit": 1, "cursor": filter_cursor, "budget_tokens": 5000, }, ) assert changed_filter_page.status_code == 422 forbidden = await http.post( "/v1/search", json={ "query": "visitors", "scope": { "projects": ["interactive-video"], "include_shared": False, }, "budget_tokens": 5000, }, ) assert forbidden.status_code == 403 async with Client( streamable_http_client("http://localhost/mcp", http_client=http) ) as mcp: tools = await mcp.list_tools() assert {tool.name for tool in tools.tools} == { "diagnostics", "get_node", "node_history", "get_source", "list_projects", "search", "neighbors", "list_sources", } listed = await mcp.call_tool("list_projects", {}) assert not listed.is_error assert isinstance(listed.content[0], TextContent) assert json.loads(listed.content[0].text) == ["dome-performance"] searched = await mcp.call_tool( "search", { "query": "400 visitors", "projects": ["dome-performance"], "include_shared": False, "all_projects": False, "limit": 10, "budget_tokens": 5000, }, ) assert not searched.is_error assert isinstance(searched.content[0], TextContent) assert ( json.loads(searched.content[0].text)["data"][0]["node"]["node_id"] == NODE.node_id ) dated_confidence_tool = await mcp.call_tool( "search", { "query": "visitors", "projects": ["dome-performance"], "include_shared": False, "all_projects": False, "limit": 10, "budget_tokens": 5000, "confidences": ["medium"], "as_of_from": "2024-01-01", "as_of_to": "2024-12-31", }, ) assert not dated_confidence_tool.is_error assert [ row["node"]["node_id"] for row in json.loads(dated_confidence_tool.content[0].text)["data"] ] == [NODE.node_id] filtered_search = await mcp.call_tool( "search", { "query": "unmatched phrase", "projects": ["dome-performance"], "include_shared": False, "all_projects": False, "limit": 10, "budget_tokens": 5000, "node_types": ["problem"], }, ) assert not filtered_search.is_error assert json.loads(filtered_search.content[0].text)["data"] == [] browsed_problems = await mcp.call_tool( "search", { "query": "", "projects": ["dome-performance"], "include_shared": False, "all_projects": False, "limit": 10, "budget_tokens": 5000, "node_types": ["problem"], }, ) assert not browsed_problems.is_error assert [ row["node"]["node_id"] for row in json.loads(browsed_problems.content[0].text)["data"] ] == [problem_node.node_id] facet_search = await mcp.call_tool( "search", { "query": "400 visitors", "projects": ["dome-performance"], "include_shared": False, "all_projects": False, "limit": 10, "budget_tokens": 5000, "tags": ["attendance"], "audience": ["product"], "use_for": ["comparison"], "domain": ["production"], }, ) assert not facet_search.is_error assert ( json.loads(facet_search.content[0].text)["data"][0]["node"]["node_id"] == NODE.node_id ) listed_sources_tool = await mcp.call_tool( "list_sources", { "projects": ["dome-performance"], "include_shared": False, "all_projects": False, "limit": 10, "budget_tokens": 5000, }, ) assert not listed_sources_tool.is_error assert ( json.loads(listed_sources_tool.content[0].text)["data"][0]["source"][ "revision_id" ] == SOURCE_REVISION.revision_id ) adjacent_tool = await mcp.call_tool( "neighbors", { "node_id": NODE.node_id, "projects": ["dome-performance"], "include_shared": False, "all_projects": False, "edge_types": [], "limit": 10, "budget_tokens": 5000, }, ) assert not adjacent_tool.is_error assert isinstance(adjacent_tool.content[0], TextContent) assert ( json.loads(adjacent_tool.content[0].text)["data"][0]["neighbor"]["id"] == related.node_id ) asyncio.run(exercise())