"""Fixtures for the MCP contract tests. Mirrors the same fixture-construction pattern as `tests/contract/api/**` and `tests/contract/sdk/python/**` (real `RetrievalAuthorizationService`, a fixture executor/generator standing in only for the retrieval backend/model provider) -- these tests exist to prove the MCP adapter's own auth/mapping/rate-limit layer, not to re-prove authorization/ retrieval business logic already covered there. """ from __future__ import annotations import contextlib from collections.abc import AsyncIterator from dataclasses import dataclass, replace from datetime import UTC, datetime from typing import Any from uuid import UUID, uuid4 import httpx2 from mcp.client.session import ClientSession from mcp.client.streamable_http import streamable_http_client from mcp.server.mcpserver import MCPServer from ragcore.api.v1.answers.service import AnswerService from ragcore.api.v1.context.service import ( ContextExecutionRequest, ContextExecutionResult, ContextService, ) from ragcore.api.v1.search.service import ( AuthorizationInputs, SearchHit, SearchService, ) from ragcore.application.identity.credentials import ServiceAccountCredentialService from ragcore.application.retrieval.authorization import RetrievalAuthorizationService from ragcore.domain.authorization import Principal from ragcore.domain.citations import Citation from ragcore.domain.control import Application, Role, Scope, ServiceAccount, Workspace from ragcore.infrastructure.database.identity import InMemoryCredentialRepository from ragcore.mcp.server import MCPDependencies, build_mcp_server from ragcore.retrieval.context import ContextCandidate from ragcore.retrieval.ranking import FusedCandidate from ragcore.retrieval.search.models import Candidate, CandidateBackendKind, CandidateProvenance EVIDENCE_EXCERPT = "The refund window is thirty days from the original purchase date." CREDENTIAL_PEPPER = "test-credential-pepper-that-is-long-enough" class InMemoryDocumentRepository: def __init__(self) -> None: self._documents: dict[UUID, Any] = {} self._versions: dict[UUID, Any] = {} def add_document(self, document: Any) -> None: self._documents[document.id] = document def add_version(self, version: Any) -> None: self._versions[version.id] = version async def get_document(self, document_id: UUID) -> Any: return self._documents.get(document_id) async def get_version(self, version_id: UUID) -> Any: return self._versions.get(version_id) async def list_searchable_versions(self, document_id: UUID) -> tuple[Any, ...]: from ragcore.application.documents.models import DocumentVersionState return tuple( v for v in self._versions.values() if v.document_id == document_id and v.state is DocumentVersionState.PUBLISHED ) class InMemoryCitationRepository: def __init__(self) -> None: self._citations: dict[UUID, Citation] = {} def add(self, citation: Citation) -> None: self._citations[citation.id] = citation async def get_authorized( self, citation_id: UUID, *, workspace_id: UUID, effective_space_ids: frozenset[UUID] ) -> Citation | None: citation = self._citations.get(citation_id) if citation is None or citation.workspace_id != workspace_id: return None if citation.space_id not in effective_space_ids: return None return citation class _FixtureInputs: """Loads the one space plus a real workspace-level `Grant` tying it to `application` -- `EffectiveSpaceResolver` requires an actual `Grant` row (not just `Application.default_knowledge_space_ids`, which is only consulted when the caller's own `requested_space_ids` is empty) to intersect an *explicit* request against for a SERVICE_ACCOUNT principal, exactly like a real workspace-level grant REST would use. """ def __init__(self, workspace: Workspace, space: Any, application: Application) -> None: self.workspace = workspace self.space = space self.application = application self.grant = workspace.grant( id=uuid4(), application=application, knowledge_space=space, role=Role.EDITOR ) async def load(self, principal: Principal) -> Any: return type( "Inputs", (), { "spaces": (self.space,), "grants": (self.grant,), "user_grants": (), "application": self.application, }, )() @dataclass class McpFixture: server: MCPServer deps: MCPDependencies workspace: Workspace application: Application space_id: UUID token: str document_repository: InMemoryDocumentRepository citation_repository: InMemoryCitationRepository def _citation(workspace_id: UUID, space_id: UUID) -> Citation: return Citation.create( workspace_id=workspace_id, space_id=space_id, document_id=uuid4(), document_version_id=uuid4(), chunk_id=uuid4(), parent_chunk_id=None, location={"page_start": 4, "section": "Refunds"}, excerpt=EVIDENCE_EXCERPT, source_uri="https://example.test/policy.md", acquired_at=datetime(2026, 8, 1, 12, tzinfo=UTC), retrieval_run_id=uuid4(), index_alias="rag_dense_hybrid_active", profile_version="hybrid-v1", ) async def build_mcp_fixture(*, grant_scopes: set[Scope] | None = None) -> McpFixture: workspace = Workspace(uuid4(), "MCP workspace", "mcp-workspace") space_id = uuid4() space = workspace.create_knowledge_space(id=space_id, name="MCP space", slug="mcp-space") application = workspace.create_application( id=uuid4(), name="MCP client", slug="mcp-client", scopes=frozenset( grant_scopes or { Scope.SEARCH, Scope.CONTEXT, Scope.ANSWER, Scope.DOCUMENTS_READ, Scope.CITATIONS_READ, } ), default_knowledge_space_ids=frozenset({space_id}), ) service_account = ServiceAccount( id=uuid4(), workspace_id=workspace.id, application_id=application.id, name="MCP service account", scopes=application.scopes, ) async def load_service_account(account_id: UUID) -> ServiceAccount | None: return service_account if account_id == service_account.id else None credential_service = ServiceAccountCredentialService( InMemoryCredentialRepository(), CREDENTIAL_PEPPER, service_account_loader=load_service_account, ) issued = await credential_service.issue(service_account, scopes=service_account.scopes) citation = _citation(workspace.id, space_id) candidate = Candidate( point_id=uuid4(), score=0.87, rank=1, backend=CandidateBackendKind.DENSE, provenance=CandidateProvenance( workspace_id=workspace.id, space_id=space_id, document_id=citation.document_id, document_version_id=citation.document_version_id, chunk_id=citation.chunk_id, source_id=uuid4(), parent_chunk_id=None, status="published", content_type="text/markdown", language="en", trust_level="trusted_internal", ), ) hit = SearchHit( fused=FusedCandidate(candidate, 1 / 61, 1, None), text=EVIDENCE_EXCERPT, title="Refund policy", citation=citation, dense_score=0.87, ) class _SearchExecutor: async def execute(self, request: Any) -> Any: refreshed = replace( hit, citation=Citation.create( workspace_id=hit.citation.workspace_id, space_id=hit.citation.space_id, document_id=hit.citation.document_id, document_version_id=hit.citation.document_version_id, chunk_id=hit.citation.chunk_id, parent_chunk_id=hit.citation.parent_chunk_id, location=hit.citation.location, excerpt=hit.citation.excerpt, source_uri=hit.citation.source_uri, acquired_at=hit.citation.acquired_at, retrieval_run_id=request.retrieval_run_id, index_alias=hit.citation.index_alias, profile_version=hit.citation.profile_version, ), ) return type( "Result", (), { "hits": (refreshed,), "degraded": False, "degradation_codes": (), "timings_ms": {}, }, )() inputs = _FixtureInputs(workspace, space, application) search_service = SearchService( authorization=RetrievalAuthorizationService(), inputs=inputs, executor=_SearchExecutor(), ) context_candidate = ContextCandidate(citation, EVIDENCE_EXCERPT, 1, 1.0) class _ContextExecutor: async def execute(self, request: ContextExecutionRequest) -> ContextExecutionResult: refreshed = Citation.create( workspace_id=citation.workspace_id, space_id=citation.space_id, document_id=citation.document_id, document_version_id=citation.document_version_id, chunk_id=citation.chunk_id, parent_chunk_id=citation.parent_chunk_id, location=citation.location, excerpt=citation.excerpt, source_uri=citation.source_uri, acquired_at=citation.acquired_at, retrieval_run_id=request.retrieval_run_id, index_alias=citation.index_alias, profile_version=citation.profile_version, ) return ContextExecutionResult( candidates=(replace(context_candidate, citation=refreshed),) ) context_service = ContextService( authorization=RetrievalAuthorizationService(), inputs=inputs, executor=_ContextExecutor() ) import json import re citation_id_pattern = re.compile(r"citation_ids=([0-9a-fA-F-]{36})") class _Generator: async def generate(self, *, prompt: str, generation_profile: str, spaces: Any) -> str: match = citation_id_pattern.search(prompt) assert match is not None return json.dumps( { "status": "answerable", "answer": "The refund window is thirty days from purchase.", "citations": [match.group(1)], } ) answer_service = AnswerService( authorization=RetrievalAuthorizationService(), inputs=inputs, context_executor=_ContextExecutor(), generator=_Generator(), ) document_repository = InMemoryDocumentRepository() citation_repository = InMemoryCitationRepository() citation_repository.add(citation) class _AuthorizationInputsForMcp: async def load(self, principal: Principal) -> AuthorizationInputs: loaded = await inputs.load(principal) return AuthorizationInputs( spaces=loaded.spaces, grants=loaded.grants, user_grants=loaded.user_grants, application=loaded.application, ) deps = MCPDependencies( search_application=search_service, context_application=context_service, answer_application=answer_service, authorization_inputs=_AuthorizationInputsForMcp(), credential_service=credential_service, citation_repository=citation_repository, document_repository=document_repository, ) server = build_mcp_server(deps) return McpFixture( server=server, deps=deps, workspace=workspace, application=application, space_id=space_id, token=issued.token, document_repository=document_repository, citation_repository=citation_repository, ) _MCP_TEST_URL = "http://127.0.0.1:8765" @contextlib.asynccontextmanager async def mcp_session( server: MCPServer, *, token: str | None = None ) -> AsyncIterator[ClientSession]: """A real streamable-HTTP MCP session over `httpx2.ASGITransport` -- genuine Starlette `Request`/header handling, no real TCP socket needed. `ASGITransport` never runs the ASGI app's own lifespan protocol (that's normally what starts the session manager's task group when a real server like uvicorn owns the app), so `session_manager.run()` is entered explicitly here instead, mirroring what a real deployment's lifespan would do. """ app = server.streamable_http_app(stateless_http=True) headers = {"authorization": f"Bearer {token}"} if token else {} async with ( server.session_manager.run(), httpx2.AsyncClient( transport=httpx2.ASGITransport(app=app), base_url=_MCP_TEST_URL, headers=headers ) as http_client, streamable_http_client(f"{_MCP_TEST_URL}/mcp", http_client=http_client) as (read, write), ClientSession(read, write) as session, ): await session.initialize() yield session