Files

139 lines
6.5 KiB
Python
Raw Permalink Normal View History

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.application.reading import CanonicalNodeReader
from gewu.bootstrap.app import create_app
from tests.domain.proposal_fixtures import NODE, SOURCE_REVISION
def test_mcp_exposes_only_authenticated_canonical_read(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.source_id.encode()).hexdigest()
source_revision_key = hashlib.sha256(SOURCE_REVISION.revision_id.encode()).hexdigest()
repository.publish_files(
expected_head=repository.head(),
files={
f"projects/{project_key}/nodes/{node_key}.yaml": yaml.safe_dump(
NODE.model_dump(mode="json")
).encode(),
f"sources/{source_key}/{source_revision_key}.yaml": yaml.safe_dump(
SOURCE_REVISION.model_dump(mode="json")
).encode(),
},
message="Publish test node",
index_directory=tmp_path / "publishing",
)
identities = SQLiteIdentityStore(tmp_path / "identity.sqlite3")
token = identities.provision(
credential_id="reader-key",
principal_id="research-agent",
roles=("agent",),
project_ids=(NODE.project_id,),
)
app = create_app(
identity_provider=identities,
node_reader=CanonicalNodeReader(repository),
)
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:
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",
}
diagnostic_result = await mcp.call_tool(
"diagnostics", {"projects": [NODE.project_id]}
)
assert not diagnostic_result.is_error
diagnostic_text = diagnostic_result.content[0]
assert isinstance(diagnostic_text, TextContent)
diagnostics = json.loads(diagnostic_text.text)
assert diagnostics["transport"] == "mcp"
assert diagnostics["selected_scope"]["projects"] == [NODE.project_id]
assert diagnostics["index_state"] == "not_configured"
assert "get_node" in diagnostics["authorized_operations"]
assert "proposal_submit" not in diagnostics["authorized_operations"]
assert token not in diagnostic_text.text
denied_diagnostics = await mcp.call_tool(
"diagnostics", {"projects": ["interactive-video"]}
)
assert denied_diagnostics.is_error
result = await mcp.call_tool(
"get_node",
{"project_id": NODE.project_id, "node_id": NODE.node_id},
)
assert not result.is_error
content = result.content[0]
assert content.type == "text"
envelope = json.loads(content.text)
assert envelope["canonical_revision"] == repository.head()
assert envelope["data"][0]["node_id"] == NODE.node_id
assert envelope["citations"][0]["quote"] == NODE.claims[0].evidence[0].quote
history = await mcp.call_tool(
"node_history",
{"project_id": NODE.project_id, "node_id": NODE.node_id},
)
assert not history.is_error
assert isinstance(history.content[0], TextContent)
assert len(json.loads(history.content[0].text)["data"]) == 1
source = await mcp.call_tool(
"get_source",
{
"project_id": NODE.project_id,
"revision_id": SOURCE_REVISION.revision_id,
},
)
assert not source.is_error
assert isinstance(source.content[0], TextContent)
assert json.loads(source.content[0].text)["data"][0]["revision_id"] == (
SOURCE_REVISION.revision_id
)
assert identities.revoke("reader-key")
revoked = await mcp.call_tool(
"get_node",
{"project_id": NODE.project_id, "node_id": NODE.node_id},
)
assert revoked.is_error
revoked_message = revoked.content[0]
assert isinstance(revoked_message, TextContent)
assert NODE.node_id not in revoked_message.text
async with httpx2.AsyncClient(
transport=httpx2.ASGITransport(app=app),
base_url="http://localhost",
) as http:
async with Client(
streamable_http_client("http://localhost/mcp", http_client=http)
) as mcp:
unauthenticated = await mcp.call_tool(
"get_node",
{"project_id": NODE.project_id, "node_id": NODE.node_id},
)
assert unauthenticated.is_error
error_message = unauthenticated.content[0]
assert isinstance(error_message, TextContent)
assert NODE.node_id not in error_message.text
asyncio.run(exercise())