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

@ -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