diff --git a/packages/mcp/src/sagasmith_coc_mcp/server.py b/packages/mcp/src/sagasmith_coc_mcp/server.py index d7ef78d..eddd626 100644 --- a/packages/mcp/src/sagasmith_coc_mcp/server.py +++ b/packages/mcp/src/sagasmith_coc_mcp/server.py @@ -391,7 +391,6 @@ "branches", "campaigns", "characters", - "constraints", "conversations", "events", "history", @@ -413,6 +412,142 @@ } +def _continuity_context_output_schema(properties: dict[str, Any]) -> dict[str, Any]: + """Describe every authoritative continuity projection without weakening validation.""" + + string_list = { + "type": "array", + "maxItems": 1_000, + "items": {"type": "string"}, + "uniqueItems": True, + } + actor_memory_item = { + "type": "object", + "required": ["basis_ref", "source", "content", "refs", "record", "score"], + "properties": { + "basis_ref": {"type": "string", "minLength": 1}, + "source": {"enum": ["actor_state", "actor_knowledge", "event"]}, + "content": {"type": "string"}, + "refs": deepcopy(string_list), + "record": {"type": "object"}, + "score": {"type": "integer"}, + }, + "additionalProperties": False, + } + actor_memory = { + "type": "object", + "required": ["identity", "motivational", "semantic", "episodic", "diagnostics"], + "properties": { + name: { + "type": "array", + "maxItems": 1_000, + "items": deepcopy(actor_memory_item), + } + for name in ("identity", "motivational", "semantic", "episodic") + } + | {"diagnostics": {"type": "object"}}, + "additionalProperties": False, + } + properties.update( + { + "schema_version": {"type": "integer", "const": 1}, + "purpose": { + "type": "string", + "enum": [ + "actor_memory", + "actor_turn", + "audience_render", + "faction_turn", + "campaign_expansion", + "source_interpretation", + "bounded_ruling", + ], + }, + "actor_id": {"type": "string"}, + "memory": actor_memory, + "branch": {"type": "object"}, + "facts": {"type": "array", "maxItems": 100, "items": {"type": "object"}}, + "events": {"type": "array", "maxItems": 100, "items": {"type": "object"}}, + "actor_knowledge": { + "type": "array", + "maxItems": 100, + "items": {"type": "object"}, + }, + "module_evidence": { + "type": "array", + "maxItems": 1_000, + "items": {"type": "object"}, + }, + "scoped_scene": {"type": ["object", "null"]}, + "retrieval": {"type": "object"}, + "subject": { + "type": "object", + "required": ["kind", "id", "name"], + "properties": { + "kind": {"type": "string"}, + "id": {"type": "string"}, + "name": {"type": "string"}, + }, + "additionalProperties": False, + }, + "stimulus": {"type": ["object", "null"]}, + "context": {"type": "object"}, + "constraints": { + "type": "object", + "required": [ + "allowed_basis_refs", + "allowed_target_refs", + "may_roll_dice", + "may_call_tools", + "may_write_state", + "output_contract", + ], + "properties": { + "allowed_basis_refs": deepcopy(string_list), + "allowed_target_refs": deepcopy(string_list), + "may_roll_dice": {"type": "boolean", "const": False}, + "may_call_tools": {"type": "boolean", "const": False}, + "may_write_state": {"type": "boolean", "const": False}, + "output_contract": { + "type": "string", + "enum": sorted(BOUNDED_OUTPUT_CONTRACTS.values()), + }, + }, + "additionalProperties": False, + }, + "delegation": { + "type": "object", + "required": [ + "schema_version", + "task", + "execution", + "inherit_agent_history", + "tools_exposed", + "persist_worker_session", + "authoritative_result", + ], + "properties": { + "schema_version": {"type": "integer", "const": 1}, + "task": {"type": "string", "pattern": "^propose_"}, + "execution": {"type": "string", "const": "awaited_fresh_context"}, + "inherit_agent_history": {"type": "boolean", "const": False}, + "tools_exposed": {"type": "boolean", "const": False}, + "persist_worker_session": {"type": "boolean", "const": False}, + "authoritative_result": {"type": "boolean", "const": False}, + }, + "additionalProperties": False, + }, + "bundle_receipt": {"type": "object"}, + } + ) + return { + "type": "object", + "description": "Structured authoritative result for the continuity_context tool.", + "properties": properties, + "additionalProperties": False, + } + + def _argument_error(code: str, message: str, *, retryable: bool, recovery: str) -> dict[str, Any]: return { "error": {"code": code, "message": message, "retryable": retryable, "recovery": recovery} @@ -559,6 +694,8 @@ def _output_schema(tool_name: str) -> dict[str, Any]: }, "additionalProperties": False, } + if tool_name == "continuity_context": + return _continuity_context_output_schema(properties) return { "type": "object", "description": f"Structured authoritative result for the {tool_name} tool.", diff --git a/packages/mcp/tests/test_collection_pagination_contract.py b/packages/mcp/tests/test_collection_pagination_contract.py index b9815d4..ccff7be 100644 --- a/packages/mcp/tests/test_collection_pagination_contract.py +++ b/packages/mcp/tests/test_collection_pagination_contract.py @@ -6,6 +6,7 @@ from dataclasses import dataclass from pathlib import Path +from jsonschema import Draft202012Validator from mcp import Client, StdioServerParameters from sagasmith_core import RevisionService @@ -171,6 +172,54 @@ async def exercise() -> None: asyncio.run(exercise()) +def test_real_modern_client_validates_every_continuity_result_shape(tmp_path: Path) -> None: + async def exercise() -> None: + campaign_id, actor_id = await _seed_campaign(create_server(_config(tmp_path))) + parameters = StdioServerParameters( + command=sys.executable, + args=["-m", "sagasmith_coc_mcp.server"], + env=_environment(tmp_path), + ) + async with Client(parameters, mode="2026-07-28") as client: + catalog = {tool.name: tool for tool in (await client.list_tools()).tools} + schema = catalog["continuity_context"].output_schema + assert schema is not None + Draft202012Validator.check_schema(schema) + validator = Draft202012Validator(schema) + + results = [ + await client.call_tool("continuity_context", {"campaign_id": campaign_id}), + await client.call_tool( + "continuity_context", + { + "campaign_id": campaign_id, + "actor_id": actor_id, + "purpose": "actor_memory", + }, + ), + await client.call_tool( + "continuity_context", + { + "campaign_id": campaign_id, + "purpose": "source_interpretation", + "query": "Interpret the lamp's soot against known evidence.", + }, + ), + ] + for result in results: + assert result.is_error is False, result.content + assert result.structured_content is not None + assert list(validator.iter_errors(result.structured_content)) == [] + + actor_memory = results[1].structured_content + assert actor_memory["memory"]["identity"] + bounded = results[2].structured_content + assert bounded["constraints"]["may_write_state"] is False + assert schema["properties"]["constraints"]["type"] == "object" + + asyncio.run(exercise()) + + def test_event_cursor_reaches_records_beyond_first_hundred(tmp_path: Path) -> None: async def exercise() -> None: server = create_server(_config(tmp_path))