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
|
|
@ -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 {}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue