**A subagent inherits its session's MCP tools; it does not inherit its skills.** So the method A3 put in the skill reaches the main thread and no arm running below it, and the one place every caller sees is the server's own `instructions` and its tool descriptions. Both are truncated by Claude Code at 2 KB, and truncation is worse than rejection here -- a reader gets the first half of a method and no sign the rest existed -- so what travels is the SHORT form and the long one stays in the skill, which has no such cap. A test holds it under the limit WITH a control, so the assertion is a measurement and not a tautology. `okf_describe` without `bundle_id` now describes every served bundle, where it refused and `okf_ask` in the same position fanned out. The tool a caller is told to read FIRST was the one requiring a name it did not have yet, and a tool that refuses the call its sibling accepts is a shape a client must be told out of band -- the configuration this server exists to remove. The named call's shape is byte-unchanged, and so is every one-to-one server's: the fan-out replaces an ERROR, so no caller's bytes move. `okf project`'s closing lines and the README's first screen carry the one line the USER runs to register the server on user scope, verified against Claude Code's own MCP documentation (`claude mcp add [options] <name> -- <command>`). Nothing here starts Claude Code, and the line says whose it is. Measured: a project bundle at `<root>/<project>/.okf/<id>` is depth 3, inside `MAX_DISCOVERY_DEPTH`, so a `--root` server finds what `okf project` wrote -- a test builds one and discovers it rather than reasoning about the walk. A5, and it was free: `okf skill` without `--out` now refuses in the same `refused (<code>)` form as every other refusal in this chain. The exit code does not move -- 2 was already right, "the run did not happen" -- what was wrong was that a caller parsing our form got argparse's line on the one flag everybody forgets. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
173 lines
6.9 KiB
Python
173 lines
6.9 KiB
Python
"""MCP as the standard entry: the method travels, and describe fans out.
|
|
|
|
Subagents inherit a session's MCP tools; they do not inherit its skills. So a
|
|
working method that lives only in a skill reaches the main thread and no arm
|
|
below it, and the one place it can reach every caller is the server's own
|
|
`instructions` and tool descriptions -- both capped by Claude Code at 2 KB
|
|
each, which is why what travels is the SHORT version and the long one stays in
|
|
the skill.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
|
sys.path.insert(0, str(PROJECT_ROOT / "src"))
|
|
|
|
from llm_ingestion_okf import consume as okf_consume # noqa: E402
|
|
from llm_ingestion_okf import mcp_server, project # noqa: E402
|
|
|
|
GOLDEN = PROJECT_ROOT / "examples" / "ingest-golden-segmented-okf-v0-2" / "expected-bundle"
|
|
FIXTURE = PROJECT_ROOT / "tests" / "fixtures" / "consume-bundle"
|
|
|
|
#: Claude Code truncates server instructions and each tool description at 2 KB
|
|
#: (`docs-en-mcp.md`). A description over it is not rejected -- it is CUT, which
|
|
#: is worse: the reader gets the first half of a method and no sign that the
|
|
#: rest existed.
|
|
CLIENT_TRUNCATION_BYTES = 2048
|
|
|
|
|
|
@pytest.fixture
|
|
def served(tmp_path: Path) -> mcp_server.Surface:
|
|
root = tmp_path / "root"
|
|
(root / "a").mkdir(parents=True)
|
|
(root / "b").mkdir(parents=True)
|
|
for name, source in (("a", GOLDEN), ("b", FIXTURE)):
|
|
for path in source.rglob("*"):
|
|
if path.is_file():
|
|
target = root / name / path.relative_to(source)
|
|
target.parent.mkdir(parents=True, exist_ok=True)
|
|
target.write_bytes(path.read_bytes())
|
|
return mcp_server.Surface(roots=(root,), fixed=None, profile=okf_consume.DEFAULT_PROFILE)
|
|
|
|
|
|
def _instructions(surface: mcp_server.Surface) -> str:
|
|
result = mcp_server.handle(surface, "initialize", {})
|
|
assert isinstance(result["instructions"], str)
|
|
return result["instructions"]
|
|
|
|
|
|
def test_the_instructions_carry_the_short_working_method(served: mcp_server.Surface) -> None:
|
|
text = _instructions(served)
|
|
for mark in ("bundle's own words", "sub-question", "ask again", "outside the cut"):
|
|
assert mark in text, f"the instructions do not say {mark!r}"
|
|
|
|
|
|
def test_the_instructions_fit_inside_what_the_client_keeps(
|
|
served: mcp_server.Surface,
|
|
) -> None:
|
|
text = _instructions(served)
|
|
assert len(text.encode("utf-8")) <= CLIENT_TRUNCATION_BYTES
|
|
# The control: the limit is one this text could realistically cross, so
|
|
# the assertion above is a measurement and not a tautology.
|
|
assert len(text.encode("utf-8")) > CLIENT_TRUNCATION_BYTES // 4
|
|
|
|
|
|
def test_every_tool_description_fits_and_the_asking_one_carries_the_method(
|
|
served: mcp_server.Surface,
|
|
) -> None:
|
|
by_name = {tool.name: tool for tool in mcp_server.tools(served)}
|
|
for name, tool in by_name.items():
|
|
assert len(tool.description.encode("utf-8")) <= CLIENT_TRUNCATION_BYTES, name
|
|
assert "ask again" in by_name["okf_ask"].description
|
|
assert "withheld" in by_name["okf_ask"].description
|
|
|
|
|
|
def test_describe_without_a_bundle_id_answers_for_every_served_bundle(
|
|
served: mcp_server.Surface,
|
|
) -> None:
|
|
"""It refused instead, where `okf_ask` in the same position fans out.
|
|
|
|
A tool that refuses the call a sibling tool accepts is a shape a client
|
|
has to learn out of band, which is the configuration this server exists to
|
|
remove.
|
|
"""
|
|
result = mcp_server.call_describe(served, {})
|
|
assert sorted(result["asked"]) == ["b-golden-segmented-okf-v0-2", "consume-fixture"]
|
|
cards = result["cards"]
|
|
assert isinstance(cards, list) and len(cards) == 2
|
|
assert sorted(str(card["bundle_id"]) for card in cards) == sorted(result["asked"])
|
|
|
|
|
|
def test_describe_with_a_bundle_id_is_the_card_it_always_was(
|
|
served: mcp_server.Surface,
|
|
) -> None:
|
|
named = mcp_server.call_describe(served, {"bundle_id": "consume-fixture"})
|
|
assert named["bundle_id"] == "consume-fixture"
|
|
assert "cards" not in named
|
|
|
|
|
|
def test_a_one_to_one_server_still_answers_with_its_own_card(tmp_path: Path) -> None:
|
|
surface = mcp_server.Surface(
|
|
roots=(GOLDEN,), fixed="b-golden-segmented-okf-v0-2", profile=okf_consume.DEFAULT_PROFILE
|
|
)
|
|
assert mcp_server.call_describe(surface, {})["bundle_id"] == "b-golden-segmented-okf-v0-2"
|
|
|
|
|
|
def test_the_ask_answer_carries_the_compact_withheld_block(
|
|
served: mcp_server.Surface,
|
|
) -> None:
|
|
"""A2 reaches the MCP arm because the payload is the payload."""
|
|
result = mcp_server.call_ask(served, {"question": "Hva sier veiledningen om krav?"})
|
|
for answer in result["answers"]:
|
|
block = answer["payload"]["withheld"]
|
|
assert set(block) == {"total", "by_rule", "nearest", "complete"}
|
|
|
|
|
|
def test_the_readme_carries_the_one_line_that_registers_the_server() -> None:
|
|
"""The user runs it. This package never starts Claude Code."""
|
|
readme = (PROJECT_ROOT / "README.md").read_text(encoding="utf-8")
|
|
assert "claude mcp add --scope user okf -- okf mcp --root" in readme
|
|
|
|
|
|
def test_the_project_summary_says_what_to_do_next_with_the_server(tmp_path: Path) -> None:
|
|
folder = tmp_path / "Dokumenter"
|
|
folder.mkdir()
|
|
(folder / "krav.md").write_text(
|
|
"## 4 Grunnforhold\n\nGrunnen er morene over berg.\n", encoding="utf-8", newline=""
|
|
)
|
|
_, _, summary = project.create(folder, out=tmp_path / "project")
|
|
assert "claude mcp add --scope user okf -- okf mcp --root" in summary
|
|
|
|
|
|
def test_a_project_bundle_is_where_a_root_server_finds_it(tmp_path: Path) -> None:
|
|
"""`--root <the directory holding projects>` must reach `<project>/.okf/<id>`.
|
|
|
|
Measured rather than reasoned: the walk is bounded at
|
|
`MAX_DISCOVERY_DEPTH`, and `.okf` spends one level of it.
|
|
"""
|
|
projects = tmp_path / "okf"
|
|
folder = projects / "Mitt Prosjekt" / "kilder"
|
|
folder.mkdir(parents=True)
|
|
(folder / "krav.md").write_text(
|
|
"## 4 Grunnforhold\n\nGrunnen er morene over berg.\n", encoding="utf-8", newline=""
|
|
)
|
|
project.create(folder, out=projects / "Mitt Prosjekt", bundle_id="mitt-prosjekt")
|
|
found = mcp_server.discover((projects,))
|
|
assert [served.bundle_id for served in found.bundles] == ["mitt-prosjekt"]
|
|
|
|
|
|
def test_the_skill_command_refuses_a_missing_out_in_its_own_form(tmp_path: Path) -> None:
|
|
"""A5: exit 2 was already right; the TEXT was argparse's, not okf's.
|
|
|
|
Every other refusal in this chain reads `refused (<code>): <what>`, and a
|
|
caller parsing that form got one line that did not match on the one flag
|
|
everybody forgets. The code stays 2 -- "the run did not happen" -- because
|
|
that is what it is.
|
|
"""
|
|
import subprocess
|
|
|
|
result = subprocess.run(
|
|
[sys.executable, "-m", "llm_ingestion_okf.cli", "skill"],
|
|
capture_output=True,
|
|
text=True,
|
|
check=False,
|
|
cwd=PROJECT_ROOT,
|
|
)
|
|
assert result.returncode == 2
|
|
assert "refused (out_missing)" in result.stderr
|
|
assert "--out" in result.stderr
|