import asyncio import hashlib import json 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, Scope, SearchRequest, TypedEdge from gewu.domain.validation import evidence_digest from tests.domain.proposal_fixtures import CLAIM, EVIDENCE, NODE, SOURCE_REVISION def _canonical_path(prefix: str, stable_id: str) -> str: key = hashlib.sha256(stable_id.encode()).hexdigest() return f"{prefix}/{key}.yaml" def test_project_include_resolution_across_http_mcp_and_direct_reads(tmp_path: Path) -> None: async def exercise() -> None: repository = GitCanonicalRepository(tmp_path / "knowledge.git") dome_node = NODE.model_copy( update={ "node_id": "dome:scope-default-node", "summary": "scopeprobe dome project record", } ) video_source = SOURCE_REVISION.model_copy( update={ "source_id": "video:scope-source", "revision_id": "video:scope-source:r1", "title": "Video-only scope source", "ingested_for": ("interactive-video",), "artifact_sha256": None, "media_type": "application/octet-stream", "byte_length": 0, "original_url": "https://video.example/scope-source", "archive_status": "reference-only", } ) video_evidence = EVIDENCE.model_copy( update={"source_revision_id": video_source.revision_id} ) video_assessment = CLAIM.assessment.model_copy( update={"evidence_digests": (evidence_digest(video_evidence),)} ) video_claim = CLAIM.model_copy( update={"evidence": (video_evidence,), "assessment": video_assessment} ) video_node = NODE.model_copy( update={ "project_id": "interactive-video", "node_id": "video:scope-default-node", "title": "Video scope fixture", "summary": "scopeprobe interactive video project record", "claims": (video_claim,), } ) edge = TypedEdge( edge_id="scope:cross-project-edge", edge_type="compared_with", source=EdgeEndpoint(kind="node", id=dome_node.node_id), target=EdgeEndpoint(kind="node", id=video_node.node_id), ) files = { f"projects/{hashlib.sha256(dome_node.project_id.encode()).hexdigest()}/nodes/" f"{hashlib.sha256(dome_node.node_id.encode()).hexdigest()}.yaml": yaml.safe_dump( dome_node.model_dump(mode="json") ).encode(), f"projects/{hashlib.sha256(video_node.project_id.encode()).hexdigest()}/nodes/" f"{hashlib.sha256(video_node.node_id.encode()).hexdigest()}.yaml": yaml.safe_dump( video_node.model_dump(mode="json") ).encode(), ( f"sources/{hashlib.sha256(video_source.source_id.encode()).hexdigest()}/" f"{hashlib.sha256(video_source.revision_id.encode()).hexdigest()}.yaml" ): yaml.safe_dump(video_source.model_dump(mode="json")).encode(), _canonical_path("edges", edge.edge_id): yaml.safe_dump( edge.model_dump(mode="json") ).encode(), } repository.publish_files( expected_head=repository.head(), files=files, message="Add project-scope fixtures", index_directory=tmp_path / "publishing", ) identities = SQLiteIdentityStore(tmp_path / "identity.sqlite3") all_scope_token = identities.provision( credential_id="all-scope-reader", principal_id="all-scope-reader", roles=("viewer",), project_ids=("dome-performance", "interactive-video", "shared"), ) dome_only_token = identities.provision( credential_id="dome-only-reader", principal_id="dome-only-reader", roles=("viewer",), project_ids=("dome-performance", "shared"), ) search_index = SQLiteSearchIndex(tmp_path / "search.sqlite3", repository) indexed_nodes = search_index.canonical_nodes()[1] assert {node.node_id for node in indexed_nodes} == {dome_node.node_id, video_node.node_id} lexical_scope_results = search_index.search( query="scopeprobe", project_ids=("dome-performance", "interactive-video"), include_shared=False, limit=10, ) assert len(lexical_scope_results.nodes) == 2, [ item.node.summary for item in lexical_scope_results.nodes ] search = CanonicalSearchService( index=search_index, project_ids_provider=lambda: ("dome-performance", "interactive-video"), canonical_head=repository.head, default_includes_provider=lambda project_id: ( ("interactive-video", "shared") if project_id == "dome-performance" else () ), ) app = create_app( identity_provider=identities, node_reader=CanonicalNodeReader(repository), search_service=search, ) direct_service_result = search.search( SearchRequest( query="scopeprobe", scope=Scope(projects=("dome-performance",), include_shared=False), limit=10, budget_tokens=50000, ), principal=identities.authenticate(all_scope_token), ) assert len(direct_service_result.data) == 2 async with app.router.lifespan_context(app): async with httpx2.AsyncClient( transport=httpx2.ASGITransport(app=app), base_url="http://localhost" ) as http: default = await http.post( "/v1/search", json={ "query": "scopeprobe", "scope": {"projects": ["dome-performance"], "include_shared": False}, "limit": 10, "budget_tokens": 50000, }, headers={"Authorization": f"Bearer {all_scope_token}"}, ) assert default.status_code == 200, default.text assert {item["node"]["project_id"] for item in default.json()["data"]} == { "dome-performance", "interactive-video", }, default.json() assert video_source.revision_id in { item["source_revision_id"] for item in default.json()["citations"] } explicit_empty = await http.post( "/v1/search", json={ "query": "scopeprobe", "scope": { "projects": ["dome-performance"], "include_projects": [], "include_shared": False, }, "limit": 10, "budget_tokens": 50000, }, headers={"Authorization": f"Bearer {all_scope_token}"}, ) assert explicit_empty.status_code == 200, explicit_empty.text assert {item["node"]["project_id"] for item in explicit_empty.json()["data"]} == { "dome-performance" } assert video_source.revision_id not in { item["source_revision_id"] for item in explicit_empty.json()["citations"] } explicit_include = await http.post( "/v1/search", json={ "query": "scopeprobe", "scope": { "projects": ["dome-performance"], "include_projects": ["interactive-video"], "include_shared": False, }, "limit": 10, "budget_tokens": 50000, }, headers={"Authorization": f"Bearer {all_scope_token}"}, ) assert explicit_include.status_code == 200, explicit_include.text assert len(explicit_include.json()["data"]) == 2 denied_default = await http.post( "/v1/search", json={ "query": "scopeprobe", "scope": {"projects": ["dome-performance"], "include_shared": False}, "limit": 10, "budget_tokens": 50000, }, headers={"Authorization": f"Bearer {dome_only_token}"}, ) assert denied_default.status_code == 403 denied_explicit_include = await http.post( "/v1/search", json={ "query": "scopeprobe", "scope": { "projects": ["dome-performance"], "include_projects": ["interactive-video"], "include_shared": False, }, "limit": 10, "budget_tokens": 50000, }, headers={"Authorization": f"Bearer {dome_only_token}"}, ) assert denied_explicit_include.status_code == 403 allowed_empty = await http.post( "/v1/search", json={ "query": "scopeprobe", "scope": { "projects": ["dome-performance"], "include_projects": [], "include_shared": False, }, "limit": 10, "budget_tokens": 50000, }, headers={"Authorization": f"Bearer {dome_only_token}"}, ) assert allowed_empty.status_code == 200 default_neighbors = await http.post( "/v1/neighbors", json={ "node_id": dome_node.node_id, "scope": {"projects": ["dome-performance"], "include_shared": False}, "limit": 10, "budget_tokens": 50000, }, headers={"Authorization": f"Bearer {all_scope_token}"}, ) assert default_neighbors.status_code == 200, default_neighbors.text assert default_neighbors.json()["data"][0]["neighbor"]["id"] == video_node.node_id restricted_neighbors = await http.post( "/v1/neighbors", json={ "node_id": dome_node.node_id, "scope": { "projects": ["dome-performance"], "include_projects": [], "include_shared": False, }, "limit": 10, "budget_tokens": 50000, }, headers={"Authorization": f"Bearer {all_scope_token}"}, ) assert restricted_neighbors.status_code == 200 assert restricted_neighbors.json()["data"] == [] default_sources = await http.post( "/v1/sources", json={ "scope": {"projects": ["dome-performance"], "include_shared": False}, "limit": 10, "budget_tokens": 50000, }, headers={"Authorization": f"Bearer {all_scope_token}"}, ) assert default_sources.status_code == 200, default_sources.text assert video_source.revision_id in { item["source"]["revision_id"] for item in default_sources.json()["data"] } explicit_empty_sources = await http.post( "/v1/sources", json={ "scope": { "projects": ["dome-performance"], "include_projects": [], "include_shared": False, }, "limit": 10, "budget_tokens": 50000, }, headers={"Authorization": f"Bearer {all_scope_token}"}, ) assert explicit_empty_sources.status_code == 200 assert video_source.revision_id not in { item["source"]["revision_id"] for item in explicit_empty_sources.json()["data"] } direct_node_denied = await http.get( f"/v1/projects/interactive-video/nodes/{video_node.node_id}", headers={"Authorization": f"Bearer {dome_only_token}"}, ) assert direct_node_denied.status_code == 403 direct_source_denied = await http.get( f"/v1/projects/interactive-video/sources/{video_source.revision_id}", headers={"Authorization": f"Bearer {dome_only_token}"}, ) assert direct_source_denied.status_code == 403 direct_node_allowed = await http.get( f"/v1/projects/interactive-video/nodes/{video_node.node_id}", headers={"Authorization": f"Bearer {all_scope_token}"}, ) assert direct_node_allowed.status_code == 200 direct_source_allowed = await http.get( f"/v1/projects/interactive-video/sources/{video_source.revision_id}", headers={"Authorization": f"Bearer {all_scope_token}"}, ) assert direct_source_allowed.status_code == 200 http.headers["Authorization"] = f"Bearer {all_scope_token}" async with Client( streamable_http_client("http://localhost/mcp", http_client=http) ) as mcp: mcp_default = await mcp.call_tool( "search", { "query": "scopeprobe", "projects": ["dome-performance"], "include_shared": False, "all_projects": False, "limit": 10, "budget_tokens": 50000, }, ) assert not mcp_default.is_error assert isinstance(mcp_default.content[0], TextContent) default_payload = json.loads(mcp_default.content[0].text) assert len(default_payload["data"]) == 2 mcp_default_sources = await mcp.call_tool( "list_sources", { "projects": ["dome-performance"], "include_shared": False, "all_projects": False, "limit": 10, "budget_tokens": 50000, }, ) assert not mcp_default_sources.is_error assert isinstance(mcp_default_sources.content[0], TextContent) assert video_source.revision_id in { row["source"]["revision_id"] for row in json.loads(mcp_default_sources.content[0].text)["data"] } mcp_default_neighbors = await mcp.call_tool( "neighbors", { "node_id": dome_node.node_id, "projects": ["dome-performance"], "include_shared": False, "all_projects": False, "edge_types": [], "limit": 10, "budget_tokens": 50000, }, ) assert not mcp_default_neighbors.is_error assert isinstance(mcp_default_neighbors.content[0], TextContent) assert ( json.loads(mcp_default_neighbors.content[0].text)["data"][0]["neighbor"][ "id" ] == video_node.node_id ) mcp_explicit_empty = await mcp.call_tool( "search", { "query": "scopeprobe", "projects": ["dome-performance"], "include_projects": [], "include_shared": False, "all_projects": False, "limit": 10, "budget_tokens": 50000, }, ) assert not mcp_explicit_empty.is_error assert isinstance(mcp_explicit_empty.content[0], TextContent) assert len(json.loads(mcp_explicit_empty.content[0].text)["data"]) == 1 mcp_empty_sources = await mcp.call_tool( "list_sources", { "projects": ["dome-performance"], "include_projects": [], "include_shared": False, "all_projects": False, "limit": 10, "budget_tokens": 50000, }, ) assert not mcp_empty_sources.is_error assert isinstance(mcp_empty_sources.content[0], TextContent) assert video_source.revision_id not in { row["source"]["revision_id"] for row in json.loads(mcp_empty_sources.content[0].text)["data"] } mcp_empty_neighbors = await mcp.call_tool( "neighbors", { "node_id": dome_node.node_id, "projects": ["dome-performance"], "include_projects": [], "include_shared": False, "all_projects": False, "edge_types": [], "limit": 10, "budget_tokens": 50000, }, ) assert not mcp_empty_neighbors.is_error assert isinstance(mcp_empty_neighbors.content[0], TextContent) assert json.loads(mcp_empty_neighbors.content[0].text)["data"] == [] asyncio.run(exercise())