feat(mcp): the server carries the working method, and describe fans out

**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>
This commit is contained in:
Kjell Tore Guttormsen 2026-09-21 00:03:59 +02:00
commit e3408435d0
5 changed files with 289 additions and 13 deletions

View file

@ -20,6 +20,19 @@ okf project ~/my-documents # folder in: bundle + Claude Code skill, in this di
claude # start Claude Code here
```
**Or connect every bundle at once.** Register the server once, on user scope,
and every project you open can ask any bundle under that directory — no skill
to install per project, and nothing to regenerate when a bundle is rebuilt.
You run this line; `okf` never starts Claude Code:
```sh
claude mcp add --scope user okf -- okf mcp --root ~/okf
```
Subagents inherit MCP tools and do not inherit skills, so the server is also
the only way the same working method reaches an arm running below the main
thread.
Then ask in plain language. Three shapes of request work, and the skill states
the rules for each:
@ -85,8 +98,6 @@ here was measured; none of it is a plan.
`csv`, `json`, `odt` or `rtf` is unaccounted and therefore red. It is opt-in
(`--accounting PATH`) for that reason. The full list is under
[Build](#build).
- **A few rough edges, named rather than fixed.** `okf skill` requires `--out`
and exits 2 without it.
- **There is no context graph and no visualisation.** Nothing in this package
draws a bundle.
@ -1280,10 +1291,19 @@ says why it exists:
| tool | what it answers |
|---|---|
| `okf_list` | which bundles are reachable right now, with each one's content identity and concept count (multi-bundle servers only) |
| `okf_describe` | what one bundle is: id, ref, concept count, source documents, and how many concepts carry each conditionally-written field |
| `okf_describe` | what one bundle is: id, ref, concept count, source documents, and how many concepts carry each conditionally-written field. Omitting `bundle_id` on a multi-bundle server describes them all, as `okf_ask` does |
| `okf_ask` | one question, one bounded payload of excerpts, each with its bundle id, concept id, title and provenance locators. Omitting `bundle_id` on a multi-bundle server asks them all and splits the budget |
| `okf_fetch` | one named concept, verbatim, with its frontmatter and locators |
**The server carries the working method, because a subagent inherits MCP tools
and not skills.** Its `instructions` and the `okf_ask` description state the
short form — read the map, put the question into the bundle's own words, split
it into sub-questions, read what lay just outside the cut and ask again with
its words, then write one answer in the questioner's language. Claude Code
truncates both at 2 KB, so the long form stays in the skill, which has no such
cap; a test holds the short one under the limit with a control, because a
truncated method is worse than a missing one.
**Nothing is cached between calls, and that is the design.** Every call
re-reads the directories and recomputes the bundle's content identity, so the
identity in an answer is a fact about the bytes at the moment of the call

View file

@ -70,6 +70,38 @@ PROTOCOL_VERSION = "2025-06-18"
SERVER_NAME = "okf"
#: What a client keeps of `instructions` and of each tool description. Claude
#: Code truncates BOTH at 2 KB (`docs/en/mcp`), and truncation is worse than
#: rejection here: a reader gets the first half of a method and no sign that
#: the rest existed. The long form of the working method lives in the skill,
#: which has no such cap.
CLIENT_TRUNCATION_BYTES = 2048
#: The SHORT working method, and the reason it is here rather than only in the
#: skill: **a subagent inherits its session's MCP tools and not its skills.**
#: So a method stated only in a skill reaches the main thread and no arm below
#: it, and these few hundred bytes are the one place every caller sees. Held
#: under the cap by a test, with a control so the assertion is a measurement.
SERVER_INSTRUCTIONS = (
"Bundles are read-only and no call here runs a model.\n\n"
"HOW TO USE THIS SERVER. Read the bundle's map first with `okf_describe`, "
"then put the question into the bundle's own words -- its documents may be "
"written in another language than the question, and the ranking matches "
"words. Split a broad question into two to four sub-questions and call "
"`okf_ask` once per sub-question. After each call read BOTH what came back "
"and what lay just outside the cut: `withheld.nearest` names the "
"best-ranked concepts that missed, with their titles. If one of them is "
"what you wanted, that is a fact about the WORDS, not a closed door -- ask "
"again with that concept's own words, or fetch it by name with "
"`okf_fetch`. Several calls are normal and expected; there is no limit and "
"no penalty. Then write ONE answer, ordered by sub-question, in the "
"questioner's language and in ordinary prose, citing the document and the "
"section (and the bundle, when you read more than one). Say plainly what "
"the bundles do not cover.\n\n"
"Every excerpt carries the bundle id and concept id a claim must be "
"attributed to; the payload states what it withheld and why."
)
#: How deep a root is walked looking for bundles. A bundle is a directory with
#: an `index.md` carrying a `bundle_id`, and the walk does NOT descend into one
#: it has found -- a bundle inside a bundle is the door's own collision case,
@ -359,6 +391,9 @@ def tools(surface: Surface) -> tuple[Tool, ...]:
"What one bundle is: its id, its content identity, how many concepts "
"it holds, which source documents it was built from, and which "
"conditionally-written fields are present on how many concepts. "
"Read it BEFORE asking, so the question can be put into the "
"bundle's own words. On a multi-bundle server, omitting `bundle_id` "
"describes every served bundle, as `okf_ask` does. "
"Exists because an answer must be attributable -- a claim from a "
"bundle whose identity the caller cannot state is a claim with no "
"provenance -- and because a reader needs the denominators before it "
@ -375,7 +410,12 @@ def tools(surface: Surface) -> tuple[Tool, ...]:
"bundle id, concept id, title and provenance locators, plus what was "
"withheld and why. This is the library's only reading direction and "
"it calls no model. On a multi-bundle server, omitting `bundle_id` "
"asks every served bundle and splits the budget between them. Exists "
"asks every served bundle and splits the budget between them. "
"ASK IT MORE THAN ONCE: one call answers one wording of one "
"sub-question, and `withheld.nearest` names the best-ranked "
"concepts that just missed, with their titles -- if one of those is "
"what you wanted, ask again in that concept's own words, or fetch "
"it by name. Exists "
"because handing a client the whole bundle is not an answer, and "
"letting it choose files by name is the enumeration the consumption "
"contract forbids.",
@ -442,8 +482,30 @@ def call_list(surface: Surface, _arguments: Mapping[str, Any]) -> dict[str, Any]
def call_describe(surface: Surface, arguments: Mapping[str, Any]) -> dict[str, Any]:
served = surface.resolve(_string(arguments, "bundle_id"))
return card(served.root, profile=surface.profile)
"""One bundle's card, or every served bundle's when none is named.
It REFUSED in the second position until 2026-09-20, where `okf_ask` in the
same position fans out -- so the one tool a caller is told to read FIRST
was the one that required a name it did not have yet. A tool that refuses
the call its sibling accepts is a shape a client must be told out of band,
which is the configuration this server exists to remove.
The named call's shape is UNCHANGED: a caller that passes `bundle_id`, and
every one-to-one server, gets exactly the card they always got. The
fan-out shape is new where the old behaviour was an error, so there is no
caller whose bytes move.
"""
named = _string(arguments, "bundle_id")
if named or not surface.one_to_many:
served = surface.resolve(named)
return card(served.root, profile=surface.profile)
found = surface.discovery()
if not found.bundles:
raise ToolError("no bundle is served under the given roots", code="bundle_none_served")
return {
"asked": [served.bundle_id for served in found.bundles],
"cards": [card(served.root, profile=surface.profile) for served in found.bundles],
}
def call_ask(surface: Surface, arguments: Mapping[str, Any]) -> dict[str, Any]:
@ -589,12 +651,7 @@ def handle(surface: Surface, method: str, params: Mapping[str, Any]) -> dict[str
"protocolVersion": PROTOCOL_VERSION,
"capabilities": {"tools": {"listChanged": False}},
"serverInfo": {"name": SERVER_NAME, "version": _version()},
"instructions": (
"Bundles are read-only. Ask `okf_ask` a question in prose rather "
"than fetching concepts by name: every excerpt it returns carries "
"the bundle id and concept id a claim must be attributed to, and "
"the payload states what it withheld and why."
),
"instructions": SERVER_INSTRUCTIONS,
}
if method == "ping":
return {}

View file

@ -179,6 +179,15 @@ def summarise(
[
"",
f"NEXT: start claude again in {out} and ask your question.",
"",
"To reach this bundle -- and every other bundle under one directory --",
"from ANY project, register the server once instead. You run this line;",
f"{CLI_ID} never starts claude:",
"",
f" claude mcp add --scope user okf -- okf mcp --root {out.parent}",
"",
"Then no skill has to be installed per project, and nothing has to be",
"regenerated when a bundle is rebuilt.",
]
)
return "\n".join(lines)

View file

@ -731,7 +731,10 @@ def parse_args(argv: list[str] | None) -> argparse.Namespace:
help="the OKF bundle to instantiate a skill for. Only read with --for-bundle",
)
parser.add_argument(
"--out", type=Path, required=True, help="the skill directory to write (SKILL.md inside)"
"--out",
type=Path,
default=None,
help="the skill directory to write (SKILL.md inside). Required",
)
parser.add_argument(
"--example-question",
@ -764,6 +767,20 @@ def parse_args(argv: list[str] | None) -> argparse.Namespace:
def main(argv: list[str] | None = None) -> int:
args = parse_args(argv)
try:
# Checked here rather than by `required=True`, so the one flag
# everybody forgets refuses in the same `refused (<code>)` form every
# other refusal in this chain uses. The CODE is 2 either way -- "the
# run did not happen" -- which is what argparse already gave; what was
# wrong was that a caller parsing our form got one line that did not
# match.
if args.out is None:
print(
"refused (out_missing): name the skill directory with --out; "
"there is no default, because writing a skill into the current "
"directory is not a place anyone asked for",
file=sys.stderr,
)
return 2
if args.for_bundle and args.bundle is None:
print("refused (bundle_missing): --for-bundle needs a bundle", file=sys.stderr)
return 2

173
tests/test_mcp_entry.py Normal file
View file

@ -0,0 +1,173 @@
"""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