Initial curated RAGcore source from c16667f0c9397e2bfc8dd0255b114fba23c8d05e
This commit is contained in:
commit
7ca726caf2
659 files changed
+98171
No files matched your search
@@ -0,0 +1,376 @@
|
||||
"""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
|
||||
@@ -0,0 +1,225 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import replace
|
||||
|
||||
from mcp_fixtures import build_mcp_fixture, mcp_session
|
||||
|
||||
from ragcore.application.audit.service import AuditEventService
|
||||
from ragcore.application.limits.models import RateLimitRule
|
||||
from ragcore.application.limits.rate_limiter import RateLimiter, RateLimitRegistry
|
||||
from ragcore.domain.control import Scope
|
||||
from ragcore.mcp.server import build_mcp_server
|
||||
|
||||
EXPECTED_TOOLS = {
|
||||
"rag_search",
|
||||
"rag_get_context",
|
||||
"rag_answer",
|
||||
"rag_list_spaces",
|
||||
"rag_get_document",
|
||||
"rag_get_citation",
|
||||
}
|
||||
|
||||
|
||||
async def test_tool_surface_is_exactly_the_agreed_read_only_tools() -> None:
|
||||
fixture = await build_mcp_fixture()
|
||||
async with mcp_session(fixture.server, token=fixture.token) as session:
|
||||
listed = await session.list_tools()
|
||||
|
||||
names = {tool.name for tool in listed.tools}
|
||||
assert names == EXPECTED_TOOLS
|
||||
for tool in listed.tools:
|
||||
assert tool.annotations is not None
|
||||
assert tool.annotations.read_only_hint is True
|
||||
assert tool.annotations.destructive_hint is False
|
||||
|
||||
|
||||
async def test_rag_search_returns_typed_results() -> None:
|
||||
fixture = await build_mcp_fixture()
|
||||
async with mcp_session(fixture.server, token=fixture.token) as session:
|
||||
result = await session.call_tool(
|
||||
"rag_search", {"query": "refund", "spaces": [str(fixture.space_id)]}
|
||||
)
|
||||
|
||||
assert result.is_error is not True
|
||||
body = result.structured_content
|
||||
assert body["effective_space_ids"] == [str(fixture.space_id)]
|
||||
assert body["results"][0]["citation"]["excerpt_sha256"]
|
||||
|
||||
|
||||
async def test_rag_get_context_returns_typed_blocks() -> None:
|
||||
fixture = await build_mcp_fixture()
|
||||
async with mcp_session(fixture.server, token=fixture.token) as session:
|
||||
result = await session.call_tool(
|
||||
"rag_get_context", {"query": "refund", "spaces": [str(fixture.space_id)]}
|
||||
)
|
||||
|
||||
assert result.is_error is not True
|
||||
body = result.structured_content
|
||||
assert body["citations"]
|
||||
assert body["blocks"][0]["citation_ids"]
|
||||
|
||||
|
||||
async def test_rag_answer_returns_grounded_answer() -> None:
|
||||
fixture = await build_mcp_fixture()
|
||||
async with mcp_session(fixture.server, token=fixture.token) as session:
|
||||
result = await session.call_tool(
|
||||
"rag_answer", {"query": "refund window", "spaces": [str(fixture.space_id)]}
|
||||
)
|
||||
|
||||
assert result.is_error is not True
|
||||
body = result.structured_content
|
||||
assert body["answer"]
|
||||
assert body["citations"]
|
||||
assert all(claim["citation_ids"] for claim in body["claims"])
|
||||
|
||||
|
||||
async def test_rag_list_spaces_lists_only_granted_spaces() -> None:
|
||||
fixture = await build_mcp_fixture()
|
||||
async with mcp_session(fixture.server, token=fixture.token) as session:
|
||||
result = await session.call_tool("rag_list_spaces", {})
|
||||
|
||||
assert result.is_error is not True
|
||||
spaces = result.structured_content["result"]
|
||||
assert [s["id"] for s in spaces] == [str(fixture.space_id)]
|
||||
|
||||
|
||||
async def test_rag_get_document_returns_the_published_version() -> None:
|
||||
from datetime import UTC, datetime
|
||||
from uuid import uuid4
|
||||
|
||||
from ragcore.application.documents.models import (
|
||||
DocumentRecord,
|
||||
DocumentVersionRecord,
|
||||
DocumentVersionState,
|
||||
)
|
||||
|
||||
fixture = await build_mcp_fixture()
|
||||
document = DocumentRecord(
|
||||
id=uuid4(),
|
||||
workspace_id=fixture.workspace.id,
|
||||
space_id=fixture.space_id,
|
||||
source_key="policy.md",
|
||||
external_id="policy",
|
||||
title="Refund policy",
|
||||
)
|
||||
version = DocumentVersionRecord(
|
||||
id=uuid4(),
|
||||
document_id=document.id,
|
||||
source_fingerprint="fp",
|
||||
content_hash="hash",
|
||||
parser_version="v1",
|
||||
chunk_profile_version="v1",
|
||||
state=DocumentVersionState.PUBLISHED,
|
||||
created_at=datetime(2026, 8, 1, tzinfo=UTC),
|
||||
updated_at=datetime(2026, 8, 1, tzinfo=UTC),
|
||||
)
|
||||
fixture.document_repository.add_document(document)
|
||||
fixture.document_repository.add_version(version)
|
||||
|
||||
async with mcp_session(fixture.server, token=fixture.token) as session:
|
||||
result = await session.call_tool("rag_get_document", {"document_id": str(document.id)})
|
||||
|
||||
assert result.is_error is not True
|
||||
body = result.structured_content
|
||||
assert body["id"] == str(document.id)
|
||||
assert body["version"]["id"] == str(version.id)
|
||||
|
||||
|
||||
async def test_rag_get_document_hides_a_document_outside_granted_spaces() -> None:
|
||||
from uuid import uuid4
|
||||
|
||||
from ragcore.application.documents.models import DocumentRecord
|
||||
|
||||
fixture = await build_mcp_fixture()
|
||||
other_space_document = DocumentRecord(
|
||||
id=uuid4(),
|
||||
workspace_id=fixture.workspace.id,
|
||||
space_id=uuid4(),
|
||||
source_key="secret.md",
|
||||
external_id="secret",
|
||||
title="Not granted",
|
||||
)
|
||||
fixture.document_repository.add_document(other_space_document)
|
||||
|
||||
async with mcp_session(fixture.server, token=fixture.token) as session:
|
||||
result = await session.call_tool(
|
||||
"rag_get_document", {"document_id": str(other_space_document.id)}
|
||||
)
|
||||
|
||||
assert result.is_error is True
|
||||
|
||||
|
||||
async def test_rag_get_citation_returns_a_granted_citation() -> None:
|
||||
fixture = await build_mcp_fixture()
|
||||
(citation,) = fixture.citation_repository._citations.values()
|
||||
|
||||
async with mcp_session(fixture.server, token=fixture.token) as session:
|
||||
result = await session.call_tool("rag_get_citation", {"citation_id": str(citation.id)})
|
||||
|
||||
assert result.is_error is not True
|
||||
assert result.structured_content["id"] == str(citation.id)
|
||||
|
||||
|
||||
async def test_rag_get_citation_hides_an_out_of_scope_citation() -> None:
|
||||
from uuid import uuid4
|
||||
|
||||
fixture = await build_mcp_fixture()
|
||||
outside = fixture.citation_repository
|
||||
from mcp_fixtures import _citation
|
||||
|
||||
stray = _citation(fixture.workspace.id, uuid4())
|
||||
outside.add(stray)
|
||||
|
||||
async with mcp_session(fixture.server, token=fixture.token) as session:
|
||||
result = await session.call_tool("rag_get_citation", {"citation_id": str(stray.id)})
|
||||
|
||||
assert result.is_error is True
|
||||
|
||||
|
||||
async def test_no_token_is_rejected() -> None:
|
||||
fixture = await build_mcp_fixture()
|
||||
async with mcp_session(fixture.server, token=None) as session:
|
||||
result = await session.call_tool("rag_search", {"query": "refund"})
|
||||
|
||||
assert result.is_error is True
|
||||
|
||||
|
||||
async def test_wrong_scope_token_is_rejected() -> None:
|
||||
fixture = await build_mcp_fixture(grant_scopes={Scope.SEARCH})
|
||||
async with mcp_session(fixture.server, token=fixture.token) as session:
|
||||
result = await session.call_tool("rag_answer", {"query": "refund"})
|
||||
|
||||
assert result.is_error is True
|
||||
|
||||
|
||||
async def test_malformed_token_is_rejected() -> None:
|
||||
fixture = await build_mcp_fixture()
|
||||
async with mcp_session(fixture.server, token="not-a-real-token") as session:
|
||||
result = await session.call_tool("rag_search", {"query": "refund"})
|
||||
|
||||
assert result.is_error is True
|
||||
|
||||
|
||||
async def test_rate_limit_is_enforced_via_the_shared_rate_limiter() -> None:
|
||||
fixture = await build_mcp_fixture()
|
||||
# A default rule (keyed by operation only, no subject) applies to any
|
||||
# caller -- the same `RateLimitRegistry` primitive M6-02 built for REST,
|
||||
# reused unchanged here. `AuditEventService`'s repository is never
|
||||
# touched: only `set_override` (not used by this test) calls `.record()`.
|
||||
registry = RateLimitRegistry(
|
||||
default_rules={"mcp:rag_search": RateLimitRule(max_requests=1, window_seconds=60.0)},
|
||||
audit=AuditEventService(repository=None), # type: ignore[arg-type]
|
||||
)
|
||||
limiter = RateLimiter(registry=registry)
|
||||
limited_server = build_mcp_server(replace(fixture.deps, rate_limiter=limiter))
|
||||
|
||||
async with mcp_session(limited_server, token=fixture.token) as session:
|
||||
first = await session.call_tool(
|
||||
"rag_search", {"query": "refund", "spaces": [str(fixture.space_id)]}
|
||||
)
|
||||
second = await session.call_tool(
|
||||
"rag_search", {"query": "refund", "spaces": [str(fixture.space_id)]}
|
||||
)
|
||||
|
||||
assert first.is_error is not True
|
||||
assert second.is_error is True
|
||||
@@ -0,0 +1,125 @@
|
||||
"""One real MCP smoke flow: ragcore.mcp.main's own composition root, real
|
||||
Postgres and real Qdrant -- not just the fixture-backed protocol tests in
|
||||
test_mcp.py (which deliberately stay on fake repositories to prove the MCP
|
||||
adapter's own auth/mapping/rate-limit layer, not re-prove infrastructure
|
||||
wiring). This proves the actual production dependency graph
|
||||
(``build_dependencies``/``build_mcp_server``) authenticates a real bearer
|
||||
credential against real Postgres and serves a real tool call end to end.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import sys
|
||||
from collections.abc import AsyncIterator
|
||||
from uuid import uuid4
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from mcp_fixtures import mcp_session
|
||||
from qdrant_client import AsyncQdrantClient
|
||||
from sqlalchemy.ext.asyncio import AsyncEngine
|
||||
|
||||
from ragcore.application.identity.credentials import ServiceAccountCredentialService
|
||||
from ragcore.config import Settings
|
||||
from ragcore.domain.control import Scope, ServiceAccount, Workspace
|
||||
from ragcore.infrastructure.database import create_database_engine, create_session_factory
|
||||
from ragcore.infrastructure.database.control import ControlPlaneRepository
|
||||
from ragcore.infrastructure.database.identity.repository import PostgresCredentialRepository
|
||||
from ragcore.mcp.main import build_dependencies
|
||||
from ragcore.mcp.server import build_mcp_server
|
||||
from ragcore.platform.telemetry import configure_telemetry
|
||||
|
||||
pytestmark = pytest.mark.integration
|
||||
|
||||
_TEST_PEPPER = "test-credential-pepper-that-is-long-enough-for-real"
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def event_loop_policy() -> asyncio.AbstractEventLoopPolicy:
|
||||
if sys.platform == "win32":
|
||||
return asyncio.WindowsSelectorEventLoopPolicy()
|
||||
return asyncio.get_event_loop_policy()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def db_engine() -> AsyncIterator[AsyncEngine]:
|
||||
engine = create_database_engine(Settings(env="test"))
|
||||
try:
|
||||
yield engine
|
||||
finally:
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
async def test_real_composition_root_authenticates_and_serves_a_real_tool_call(
|
||||
db_engine: AsyncEngine,
|
||||
) -> None:
|
||||
session_factory = create_session_factory(db_engine)
|
||||
suffix = uuid4().hex[:8]
|
||||
|
||||
async with session_factory() as session, session.begin():
|
||||
control = ControlPlaneRepository(session)
|
||||
workspace = await control.create_workspace(
|
||||
Workspace(uuid4(), "MCP Real Root", f"mcp-real-root-{suffix}")
|
||||
)
|
||||
application = await control.create_application(
|
||||
workspace.create_application(
|
||||
id=uuid4(),
|
||||
name="MCP Real Root Client",
|
||||
slug=f"mcp-real-root-client-{suffix}",
|
||||
scopes=frozenset({Scope.SEARCH, Scope.CONTEXT, Scope.ANSWER}),
|
||||
)
|
||||
)
|
||||
service_account = await control.create_service_account(
|
||||
ServiceAccount(
|
||||
id=uuid4(),
|
||||
workspace_id=workspace.id,
|
||||
application_id=application.id,
|
||||
name="MCP Real Root Service Account",
|
||||
scopes=application.scopes,
|
||||
)
|
||||
)
|
||||
credential_service = ServiceAccountCredentialService(
|
||||
PostgresCredentialRepository(session),
|
||||
_TEST_PEPPER,
|
||||
control.get_service_account_by_id,
|
||||
)
|
||||
issued = await credential_service.issue(service_account, scopes=service_account.scopes)
|
||||
|
||||
# Same pepper the credential above was issued with, so
|
||||
# build_dependencies' own session-scoped credential service (which reads
|
||||
# settings.api_token_pepper) can verify it for real.
|
||||
settings = Settings(env="test", api_token_pepper=_TEST_PEPPER)
|
||||
qdrant_client = AsyncQdrantClient(
|
||||
url=settings.qdrant_url, api_key=settings.qdrant_api_key.get_secret_value(), timeout=30
|
||||
)
|
||||
http_client = httpx.AsyncClient(follow_redirects=False)
|
||||
telemetry = configure_telemetry(settings, service_name="test-mcp")
|
||||
|
||||
try:
|
||||
deps = build_dependencies(
|
||||
settings,
|
||||
engine=db_engine,
|
||||
qdrant_client=qdrant_client,
|
||||
http_client=http_client,
|
||||
telemetry=telemetry,
|
||||
)
|
||||
server = build_mcp_server(deps)
|
||||
|
||||
async with mcp_session(server, token=issued.token) as mcp_client_session:
|
||||
listed = await mcp_client_session.list_tools()
|
||||
result = await mcp_client_session.call_tool("rag_list_spaces", {})
|
||||
finally:
|
||||
await http_client.aclose()
|
||||
await qdrant_client.close()
|
||||
telemetry.shutdown()
|
||||
|
||||
assert {tool.name for tool in listed.tools} == {
|
||||
"rag_search",
|
||||
"rag_get_context",
|
||||
"rag_answer",
|
||||
"rag_list_spaces",
|
||||
"rag_get_document",
|
||||
"rag_get_citation",
|
||||
}
|
||||
assert result.is_error is not True
|
||||
Reference in new issue
Block a user