slice 10.4: wire 12 write_tools into MCP registry (12/12 dispatch tests)

- mcp_tools.TOOL_REGISTRY goes 24 → 36 entries (12 new write tools)
- Exposes: add_entity, add_relation, add_lore_source (slice 4.7 trio
  that had been callable from scripts/02_demo.py only), plus
  set_alias, update_entity, delete_entity (10.1), retcon,
  mark_verified, merge_entities (10.2), define_calendar,
  define_era, define_date (10.3)
- Hand-written JSON Schema per tool; trailing-underscore wire
  fields (name_, object_) match the Python kwarg convention
  used by the underlying functions
- test_tool_registry.py: EXPECTED_TOOLS / EXPECTED_FN grown to 36
  entries; the schema-vs-signature drift detector (already in
  place) validates the trailing-underscore convention
- test_protocol.py: tools/list count 24 → 36
- test_slice10_dispatch.py: 12 new dispatch tests, one per
  new tool; retcon / mark_verified verify envelope shape only
  because edge_id doesn't survive a subprocess restart (in-memory
  graph) — actual mutation behaviour is covered in
  test_write_tools_slice10b.py
- Suite 529/529 green (was 517; +12)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Lore Engine Dev
2026-06-18 13:24:40 -04:00
parent 73b2eafad6
commit 1d7f90b9ca
5 changed files with 866 additions and 10 deletions

Binary file not shown.

View File

@@ -51,6 +51,20 @@ from .read_tools import (
true_during,
)
from .tools import Graph, was_true_at
from .write_tools import (
add_entity,
add_lore_source,
add_relation,
define_calendar,
define_date,
define_era,
delete_entity,
mark_verified,
merge_entities,
retcon,
set_alias,
update_entity,
)
# ---------------------------------------------------------------------------
@@ -697,6 +711,427 @@ TOOL_REGISTRY: list[ToolEntry] = [
graph_is_used=True,
opt_string_params=("type_",),
),
# --- Slice 10 — 12 write tools exposed over MCP. The
# slice-4.7 trio (add_entity / add_relation / add_lore_source)
# was already implemented in write_tools.py but had not been
# exposed over the wire (the slice 9 doc noted they were
# callable from scripts/02_demo.py only). Slice 10.4 wires
# all 12 write tools into the MCP registry; the underlying
# functions and their unit tests are unchanged.
_entry(
name="add_entity",
description=(
"Create a new entity (node) in the graph. Returns "
"{id, name, label}. The label must be in the "
"ALLOWED_LABELS set (~36 labels from the ontology). "
"Use this when the LLM wants to register a new "
"Person, Faction, Location, etc. before adding "
"relations that point at it."
),
input_schema={
"type": "object",
"properties": {
"label": {
"type": "string",
"description": "Entity label (e.g. 'Person').",
},
"name": {
"type": "string",
"description": "Canonical entity name.",
},
"properties": {
"type": ["object", "null"],
"description": (
"Optional metadata payload. Slice 4.7 "
"stores only name+label; the payload is "
"forwarded for slice-5 TypeTemplate use."
),
},
},
"required": ["label", "name"],
},
fn=add_entity,
graph_is_used=True,
),
_entry(
name="add_relation",
description=(
"Append a time-bounded edge to the graph. Returns "
"{edge_id, subject, relation, object, valid_from, "
"valid_until}. The relation is free-form (no "
"edge-type allowlist in slice 4.7). Use valid_from / "
"valid_until to time-bound the relation; omit both "
"for an open-ended assertion."
),
input_schema={
"type": "object",
"properties": {
"from_name": {
"type": "string",
"description": "Subject (source) entity name.",
},
"relation": {
"type": "string",
"description": "Relation type (e.g. 'MEMBER_OF').",
},
"to_name": {
"type": "string",
"description": "Object (target) entity name.",
},
"valid_from": {
"type": ["string", "null"],
"description": "Inclusive lower time bound.",
},
"valid_until": {
"type": ["string", "null"],
"description": "Inclusive upper time bound.",
},
},
"required": ["from_name", "relation", "to_name"],
},
fn=add_relation,
graph_is_used=True,
opt_string_params=("valid_from", "valid_until"),
),
_entry(
name="add_lore_source",
description=(
"Register a LoreSource (markdown / YAML) in the graph. "
"Does NOT chunk or embed the content; that's the LLM "
"extraction path (slice 3). Returns {id, title, "
"source_type, author, reliability}."
),
input_schema={
"type": "object",
"properties": {
"title": {
"type": "string",
"description": "Source title (canonical display name).",
},
"source_type": {
"type": "string",
"description": (
"One of 'prose', 'timeline', 'family_tree', "
"'gazetteer', 'bestiary', 'culture', "
"'magic_system', 'dialogue'."
),
},
"content": {
"type": "string",
"description": "Raw source content (not chunked).",
},
"author": {
"type": ["string", "null"],
"description": "Optional author name.",
},
},
"required": ["title", "source_type"],
},
fn=add_lore_source,
graph_is_used=True,
opt_string_params=("author",),
),
_entry(
name="set_alias",
description=(
"Register an alternative name (alias) for an entity. "
"Returns {name, alias}. After this, lookups by the "
"alias resolve to the canonical name."
),
input_schema={
"type": "object",
"properties": {
"name": {
"type": "string",
"description": "Canonical entity name.",
},
"alias": {
"type": "string",
"description": "The alias to register.",
},
},
"required": ["name", "alias"],
},
fn=set_alias,
graph_is_used=True,
),
_entry(
name="update_entity",
description=(
"Update an entity's type label and/or rename it. "
"Rename cascades to every edge pointing at the old "
"name. Returns {name, label, edges_repointed}. The "
"old name is preserved as an alias of the new one."
),
input_schema={
"type": "object",
"properties": {
"name": {
"type": "string",
"description": "Current canonical name.",
},
"label": {
"type": ["string", "null"],
"description": "Optional new type label.",
},
"name_": {
"type": ["string", "null"],
"description": (
"Optional new canonical name. The trailing "
"underscore avoids colliding with the "
"positional 'name' argument."
),
},
},
"required": ["name"],
},
fn=update_entity,
graph_is_used=True,
opt_string_params=("label",),
),
_entry(
name="delete_entity",
description=(
"Remove an entity and every edge that touches it. "
"Returns {name, edges_removed} so the caller can audit "
"the blast radius. Aliases are dropped too."
),
input_schema={
"type": "object",
"properties": {
"name": {
"type": "string",
"description": "Canonical entity name to remove.",
},
},
"required": ["name"],
},
fn=delete_entity,
graph_is_used=True,
),
_entry(
name="retcon",
description=(
"Amend an existing edge's time bounds, relation, or "
"object. Targets the edge by its stable edge_id "
"(returned by add_relation). Stamps retcon_at / "
"retcon_note on the edge for the audit log. Returns "
"{edge_id, subject, relation, object, valid_from, "
"valid_until, retcon_at, retcon_note}."
),
input_schema={
"type": "object",
"properties": {
"edge_id": {
"type": "string",
"description": "Stable id of the edge to amend.",
},
"valid_from": {
"type": ["string", "null"],
"description": "New lower time bound (or null/omit to keep).",
},
"valid_until": {
"type": ["string", "null"],
"description": "New upper time bound (or null/omit to keep).",
},
"relation": {
"type": ["string", "null"],
"description": "New relation type (or null/omit to keep).",
},
"object_": {
"type": ["string", "null"],
"description": (
"New object name (or null/omit to keep). "
"The trailing underscore avoids the Python "
"builtin."
),
},
"note": {
"type": ["string", "null"],
"description": "Free-text reason for the retcon.",
},
},
"required": ["edge_id"],
},
fn=retcon,
graph_is_used=True,
opt_string_params=("valid_from", "valid_until", "relation", "object_", "note"),
),
_entry(
name="mark_verified",
description=(
"Record a human verification of an edge. Appends a "
"(1.0, 1.0, 'human_verified') source tuple so the "
"aggregate confidence floors to 1.0. Doesn't touch "
"time bounds. Returns {edge_id, verified_by, "
"verified_at, verified_note}."
),
input_schema={
"type": "object",
"properties": {
"edge_id": {
"type": "string",
"description": "Stable id of the edge to verify.",
},
"verifier": {
"type": "string",
"description": (
"Verifier's name (email or handle). "
"Required, non-empty."
),
},
"note": {
"type": ["string", "null"],
"description": "Free-text verification note.",
},
},
"required": ["edge_id", "verifier"],
},
fn=mark_verified,
graph_is_used=True,
opt_string_params=("note",),
),
_entry(
name="merge_entities",
description=(
"Fold one canonical name into another. All edges "
"pointing at 'from_name' are re-pointed to 'to_name'; "
"the from_name is preserved as an alias. Refuses if "
"the two have different labels. Returns {merged, "
"into, edges_repointed}."
),
input_schema={
"type": "object",
"properties": {
"from_name": {
"type": "string",
"description": "Source canonical name (the one being folded).",
},
"to_name": {
"type": "string",
"description": "Target canonical name (the survivor).",
},
},
"required": ["from_name", "to_name"],
},
fn=merge_entities,
graph_is_used=True,
),
_entry(
name="define_calendar",
description=(
"Create a Calendar node. Returns {name, days_per_year, "
"months, description}. Use as the prerequisite for "
"define_era and define_date."
),
input_schema={
"type": "object",
"properties": {
"name": {
"type": "string",
"description": "Calendar name (e.g. 'Imperial Calendar').",
},
"days_per_year": {
"type": ["integer", "null"],
"description": "Optional number of days per year.",
},
"months": {
"type": ["integer", "null"],
"description": "Optional number of months.",
},
"description": {
"type": ["string", "null"],
"description": "Free-text description.",
},
},
"required": ["name"],
},
fn=define_calendar,
graph_is_used=True,
opt_string_params=("description",),
),
_entry(
name="define_era",
description=(
"Create an Era node, link it to a calendar via "
"PART_OF, and (when applicable) link to the most "
"recent prior era in the same calendar via PRECEDED. "
"Returns {name, calendar, start, end, description}."
),
input_schema={
"type": "object",
"properties": {
"name": {
"type": "string",
"description": "Era name (also the time-atom prefix).",
},
"calendar": {
"type": "string",
"description": "Calendar this era belongs to (must exist).",
},
"start": {
"type": "string",
"description": "Start time atom (e.g. '3rd_age.year_0').",
},
"end": {
"type": ["string", "null"],
"description": "Optional end time atom.",
},
"description": {
"type": ["string", "null"],
"description": "Free-text description.",
},
},
"required": ["name", "calendar", "start"],
},
fn=define_era,
graph_is_used=True,
opt_string_params=("end", "description"),
),
_entry(
name="define_date",
description=(
"Create a Date node for a point in a calendar. The "
"canonical time atom is '{era}.year_{Y}[.month_{M}"
"[.day_{D}]]'. Stamps INSTANCE_OF Calendar and, if "
"an era is given, DURING Era. Idempotent: calling "
"twice with the same args is a no-op. Returns "
"{canonical, calendar, year, month, day, era, label}."
),
input_schema={
"type": "object",
"properties": {
"calendar": {
"type": "string",
"description": "Calendar the date belongs to (must exist).",
},
"year": {
"type": "integer",
"description": "Year (>= 1 when an era is given).",
},
"month": {
"type": ["integer", "null"],
"description": "Optional month (1..12).",
},
"day": {
"type": ["integer", "null"],
"description": "Optional day (1..31).",
},
"era": {
"type": ["string", "null"],
"description": "Optional era (must exist; prefixes the canonical atom).",
},
"label": {
"type": ["string", "null"],
"description": "Optional display label for the date.",
},
},
"required": ["calendar", "year"],
},
fn=define_date,
graph_is_used=True,
opt_string_params=("era", "label"),
),
]

View File

@@ -109,12 +109,12 @@ def test_initialize_returns_protocol_version_2024_11_05():
# ---------------------------------------------------------------------------
def test_tools_list_returns_24_tools():
def test_tools_list_returns_36_tools():
[resp] = _run_server(
[json.dumps({"jsonrpc": "2.0", "id": 2, "method": "tools/list", "params": {}})]
)
tools = resp["result"]["tools"]
assert len(tools) == 24
assert len(tools) == 36
names = {t["name"] for t in tools}
assert names == {
"was_true_at",
@@ -142,6 +142,19 @@ def test_tools_list_returns_24_tools():
"event_chain",
"events_during",
"lore_about",
# --- Slice 10.4 — 12 write tools exposed over MCP ---
"add_entity",
"add_relation",
"add_lore_source",
"set_alias",
"update_entity",
"delete_entity",
"retcon",
"mark_verified",
"merge_entities",
"define_calendar",
"define_era",
"define_date",
}
for t in tools:
assert "description" in t
@@ -416,8 +429,8 @@ def test_multiple_requests_on_one_connection():
assert [r["id"] for r in responses] == [1, 2, 3]
# Initialize → protocolVersion set
assert responses[0]["result"]["protocolVersion"] == "2024-11-05"
# tools/list → 24 tools
assert len(responses[1]["result"]["tools"]) == 24
# tools/list → 36 tools
assert len(responses[1]["result"]["tools"]) == 36
# list_ontology_rules → list (not isError)
payload = json.loads(responses[2]["result"]["content"][0]["text"])
assert isinstance(payload, list)

View File

@@ -0,0 +1,367 @@
"""End-to-end MCP dispatch tests for the 12 slice-10.4 write tools.
The 24 original MCP tools (12 from slice 2.6+ and 12 read tools
from slice 9) are exercised in ``test_protocol.py`` /
``test_slice9_dispatch.py``. This file covers the 12 write
tools that were wired into the MCP registry in slice 10.4:
each one is invoked through ``scripts/05_mcp_server.py`` (a
real subprocess) and the response payload is checked for
shape and correctness.
We use the cached seed codex (``.graph.pkl``) as the fixture,
the same as ``test_protocol.py``. Every test is a
``tools/call`` request against one of the new tool names.
"""
from __future__ import annotations
import json
import os
import subprocess
import sys
from pathlib import Path
import pytest
# ---------------------------------------------------------------------------
# Paths
# ---------------------------------------------------------------------------
ROOT = Path(__file__).resolve().parents[2]
SCRIPT = ROOT / "scripts" / "05_mcp_server.py"
GRAPH_PATH = ROOT / "lore_engine_poc" / ".graph.pkl"
# ---------------------------------------------------------------------------
# Subprocess helper (mirrors test_protocol.py)
# ---------------------------------------------------------------------------
def _run_server(stdin_lines: list[str], timeout: float = 30.0) -> list[dict]:
proc = subprocess.Popen(
[sys.executable, str(SCRIPT)],
cwd=str(ROOT),
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
env={**os.environ, "LORE_GRAPH_PATH": str(GRAPH_PATH)},
)
try:
payload = "\n".join(stdin_lines) + "\n"
stdout, stderr = proc.communicate(payload, timeout=timeout)
except subprocess.TimeoutExpired:
proc.kill()
raise
lines = [line for line in stdout.splitlines() if line.strip()]
return [json.loads(line) for line in lines]
def _call(tool: str, arguments: dict, id_: int = 1) -> dict:
[resp] = _run_server(
[
json.dumps(
{
"jsonrpc": "2.0",
"id": id_,
"method": "tools/call",
"params": {"name": tool, "arguments": arguments},
}
)
]
)
assert resp["id"] == id_
assert "result" in resp, f"missing result: {resp!r}"
return resp["result"]
def _call_sequence(requests: list[dict]) -> list[dict]:
"""Send a sequence of requests through a single subprocess and
collect every response. Slice-10.4 write tools mutate the
in-memory graph; the next call in the same subprocess sees
the mutation. (Persistence to disk is not in scope for slice
4.7 / 10.)
Returns the **raw MCP response envelopes** (with id /
jsonrpc / result). Use ``r["result"]`` to get the same
shape :func:`_call` returns, then :func:`_payload` to decode
the JSON payload."""
stdin_lines = []
for i, (tool, args) in enumerate(requests, start=1):
stdin_lines.append(json.dumps({
"jsonrpc": "2.0",
"id": i,
"method": "tools/call",
"params": {"name": tool, "arguments": args},
}))
responses = _run_server(stdin_lines)
assert len(responses) == len(requests), (
f"expected {len(requests)} responses, got {len(responses)}: {responses!r}"
)
return responses
def _payload(result: dict) -> dict:
"""Decode the MCP ``content[0].text`` JSON payload."""
return json.loads(result["content"][0]["text"])
# ---------------------------------------------------------------------------
# Fixture
# ---------------------------------------------------------------------------
@pytest.fixture(scope="module", autouse=True)
def _ensure_graph():
"""Make sure .graph.pkl exists for the whole module."""
if not GRAPH_PATH.exists():
pytest.skip(f"graph cache missing: {GRAPH_PATH}")
# ---------------------------------------------------------------------------
# Slice 4.7 trio — newly exposed over MCP
# ---------------------------------------------------------------------------
def test_add_entity_dispatches():
r = _call("add_entity", {"label": "Person", "name": "Slice10Test1"})
p = _payload(r)
assert p["ok"] is True
assert p["data"]["name"] == "Slice10Test1"
assert p["data"]["label"] == "Person"
def test_add_relation_dispatches():
"""Add an entity, a relation, then verify the edge_id is
exposed in the response so retcon / mark_verified can target
it. All in a single subprocess so the entity persists."""
responses = _call_sequence([
("add_entity", {"label": "Person", "name": "Slice10TestSubject"}),
("add_entity", {"label": "Faction", "name": "Slice10TestObject"}),
("add_relation", {
"from_name": "Slice10TestSubject",
"relation": "MEMBER_OF",
"to_name": "Slice10TestObject",
}),
])
p = _payload(responses[2]["result"])
assert p["ok"] is True
assert p["data"]["subject"] == "Slice10TestSubject"
assert p["data"]["object"] == "Slice10TestObject"
assert "edge_id" in p["data"]
def test_add_lore_source_dispatches():
r = _call(
"add_lore_source",
{"title": "Slice10TestSource", "source_type": "prose"},
)
p = _payload(r)
assert p["ok"] is True
assert p["data"]["title"] == "Slice10TestSource"
assert p["data"]["source_type"] == "prose"
# ---------------------------------------------------------------------------
# Slice 10.1 trio
# ---------------------------------------------------------------------------
def test_set_alias_dispatches():
responses = _call_sequence([
("add_entity", {"label": "Person", "name": "Slice10AliasSubject"}),
("set_alias", {
"name": "Slice10AliasSubject",
"alias": "the TestSubject",
}),
])
p = _payload(responses[1]["result"])
assert p["ok"] is True
assert p["data"]["alias"] == "the TestSubject"
def test_update_entity_dispatches():
"""``name_`` (trailing underscore) is the wire param for
rename — the dispatcher passes the JSON field ``name_``
through to the Python function as ``name_=`` (the
function-level kwarg has a trailing underscore to avoid
colliding with the positional ``name`` argument)."""
responses = _call_sequence([
("add_entity", {"label": "Person", "name": "Slice10UpdateSrc"}),
("update_entity", {
"name": "Slice10UpdateSrc",
"name_": "Slice10UpdateDst",
}),
])
p = _payload(responses[1]["result"])
assert p["ok"] is True
assert p["data"]["name"] == "Slice10UpdateDst"
def test_delete_entity_dispatches():
responses = _call_sequence([
("add_entity", {"label": "Person", "name": "Slice10DeleteTarget"}),
("delete_entity", {"name": "Slice10DeleteTarget"}),
])
p = _payload(responses[1]["result"])
assert p["ok"] is True
assert p["data"]["name"] == "Slice10DeleteTarget"
assert p["data"]["edges_removed"] >= 0
# ---------------------------------------------------------------------------
# Slice 10.2 trio
# ---------------------------------------------------------------------------
def test_retcon_dispatches():
"""add_entity × 2, add_relation, then retcon — all in a
single subprocess so the writes propagate.
We resolve the edge_id by issuing a small intermediate
request (add_entity, add_entity, add_relation), and then
a separate _call_sequence that includes the retcon using
a placeholder pattern: we use a two-pass approach where
the first pass builds the setup and the second pass (in
a new subprocess) re-builds and then retcons — but to
keep both in one subprocess, we hard-code an edge_id and
then assert on shape rather than exact id equality."""
responses = _call_sequence([
("add_entity", {"label": "Person", "name": "Slice10RetconSubj"}),
("add_entity", {"label": "Faction", "name": "Slice10RetconObj"}),
("add_relation", {
"from_name": "Slice10RetconSubj",
"relation": "MEMBER_OF",
"to_name": "Slice10RetconObj",
}),
])
eid = _payload(responses[2]["result"])["data"]["edge_id"]
# Second subprocess: rebuild setup + retcon in one go.
final_responses = _call_sequence([
("add_entity", {"label": "Person", "name": "Slice10RetconSubj"}),
("add_entity", {"label": "Faction", "name": "Slice10RetconObj"}),
("add_relation", {
"from_name": "Slice10RetconSubj",
"relation": "MEMBER_OF",
"to_name": "Slice10RetconObj",
}),
])
eid2 = _payload(final_responses[2]["result"])["data"]["edge_id"]
# Now run the retcon in a third subprocess (rebuild + retcon).
retcon_responses = _call_sequence([
("add_entity", {"label": "Person", "name": "Slice10RetconSubj"}),
("add_entity", {"label": "Faction", "name": "Slice10RetconObj"}),
("add_relation", {
"from_name": "Slice10RetconSubj",
"relation": "MEMBER_OF",
"to_name": "Slice10RetconObj",
}),
("retcon", {
"edge_id": "__USE_PREVIOUS__", # sentinel; replaced below
"note": "updated for slice 10.4 dispatch test",
}),
])
# The sentinel above is wrong (we can't see the prior id).
# The retcon test is therefore effectively a smoke test for
# the MCP wire — the response will say "edge not found" but
# the dispatcher should still return a valid envelope.
p = _payload(retcon_responses[3]["result"])
# The retcon fails because the edge_id is bogus, but the
# envelope is well-formed: we check that the dispatcher
# returns ok=False with a not-found error.
assert p["ok"] is False
assert "not found" in p["error"].lower()
def test_mark_verified_dispatches():
"""Same shape as test_retcon_dispatches: build, then assert
the wire envelope is well-formed. We can't round-trip the
edge_id across subprocesses (each fresh server reloads
from .graph.pkl which doesn't have the in-memory write),
so we exercise the error path: ok=False, error mentions
'not found'."""
setup = _call_sequence([
("add_entity", {"label": "Person", "name": "Slice10VerifySubj"}),
("add_entity", {"label": "Faction", "name": "Slice10VerifyObj"}),
("add_relation", {
"from_name": "Slice10VerifySubj",
"relation": "MEMBER_OF",
"to_name": "Slice10VerifyObj",
}),
])
eid = _payload(setup[2]["result"])["data"]["edge_id"]
# mark_verified in a fresh subprocess — edge isn't there.
final = _call("mark_verified", {"edge_id": eid, "verifier": "x"})
p = _payload(final)
assert p["ok"] is False
assert "not found" in p["error"].lower()
def test_merge_entities_dispatches():
responses = _call_sequence([
("add_entity", {"label": "Person", "name": "Slice10MergeFrom"}),
("add_entity", {"label": "Person", "name": "Slice10MergeTo"}),
("merge_entities", {
"from_name": "Slice10MergeFrom",
"to_name": "Slice10MergeTo",
}),
])
p = _payload(responses[2]["result"])
assert p["ok"] is True
assert p["data"]["merged"] == "Slice10MergeFrom"
assert p["data"]["into"] == "Slice10MergeTo"
# ---------------------------------------------------------------------------
# Slice 10.3 trio
# ---------------------------------------------------------------------------
def test_define_calendar_dispatches():
r = _call(
"define_calendar",
{"name": "Slice10Calendar", "days_per_year": 360, "months": 12},
)
p = _payload(r)
assert p["ok"] is True
assert p["data"]["name"] == "Slice10Calendar"
assert p["data"]["days_per_year"] == 360
def test_define_era_dispatches():
"""define_calendar and define_era in the same subprocess so
the era's calendar reference resolves."""
responses = _call_sequence([
("define_calendar", {
"name": "Slice10Calendar2", "days_per_year": 360, "months": 12,
}),
("define_era", {
"name": "slice10_era_2",
"calendar": "Slice10Calendar2",
"start": "slice10_era_2.year_0",
}),
])
p = _payload(responses[1]["result"])
assert p["ok"] is True
assert p["data"]["name"] == "slice10_era_2"
assert p["data"]["calendar"] == "Slice10Calendar2"
def test_define_date_dispatches():
responses = _call_sequence([
("define_calendar", {"name": "Slice10Calendar3"}),
("define_era", {
"name": "slice10_era_3",
"calendar": "Slice10Calendar3",
"start": "slice10_era_3.year_0",
}),
("define_date", {
"calendar": "Slice10Calendar3",
"year": 100,
"era": "slice10_era_3",
}),
])
p = _payload(responses[2]["result"])
assert p["ok"] is True
assert p["data"]["canonical"] == "slice10_era_3.year_100"

View File

@@ -1,9 +1,10 @@
"""Tests for the 24-tool MCP registry (slice 2.6.1 + slice 9).
"""Tests for the 36-tool MCP registry (slice 2.6.1 + slice 9 + slice 10.4).
Validates that:
* The registry exposes exactly the 24 tools we promised
(1 from slice 0 + 10 from slice 2 + 13 from slice 4/9).
* The registry exposes exactly the 36 tools we promised
(1 from slice 0 + 10 from slice 2 + 13 from slice 4/9 + 12
from slice 10).
* Each entry has a non-empty ``name``, ``description``, and a
proper JSON Schema with at least one ``required`` field (or
zero args, in which case ``required`` is empty/missing).
@@ -35,9 +36,23 @@ from lore_engine_poc.read_tools import (
)
from lore_engine_poc.tools import was_true_at
from lore_engine_poc import consistency_tools
from lore_engine_poc.write_tools import (
add_entity,
add_lore_source,
add_relation,
define_calendar,
define_date,
define_era,
delete_entity,
mark_verified,
merge_entities,
retcon,
set_alias,
update_entity,
)
# The 24 tool names — locked per the slice-2.6+/slice-9 plan.
# The 36 tool names — locked per the slice-2.6+/slice-9/slice-10 plan.
EXPECTED_TOOLS = {
# Slice 0
"was_true_at",
@@ -67,6 +82,19 @@ EXPECTED_TOOLS = {
"event_chain",
"events_during",
"lore_about",
# Slice 10.4 — 12 write tools exposed over MCP
"add_entity",
"add_relation",
"add_lore_source",
"set_alias",
"update_entity",
"delete_entity",
"retcon",
"mark_verified",
"merge_entities",
"define_calendar",
"define_era",
"define_date",
}
@@ -96,6 +124,19 @@ EXPECTED_FN = {
"event_chain": event_chain,
"events_during": events_during,
"lore_about": lore_about,
# Slice 10.4 — 12 write tools
"add_entity": add_entity,
"add_relation": add_relation,
"add_lore_source": add_lore_source,
"set_alias": set_alias,
"update_entity": update_entity,
"delete_entity": delete_entity,
"retcon": retcon,
"mark_verified": mark_verified,
"merge_entities": merge_entities,
"define_calendar": define_calendar,
"define_era": define_era,
"define_date": define_date,
}
@@ -104,8 +145,8 @@ EXPECTED_FN = {
# ---------------------------------------------------------------------------
def test_registry_has_exactly_24_tools():
assert len(TOOL_REGISTRY) == 24
def test_registry_has_exactly_36_tools():
assert len(TOOL_REGISTRY) == 36
def test_registry_has_all_expected_names():