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:
parent
30edd3f5d8
commit
e3408435d0
5 changed files with 289 additions and 13 deletions
26
README.md
26
README.md
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
173
tests/test_mcp_entry.py
Normal 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
|
||||
Loading…
Add table
Add a link
Reference in a new issue