Skip to content

release: engine v0.9.0 - #16

Merged
svd merged 19 commits into
mainfrom
dev
Aug 27, 2026
Merged

release: engine v0.9.0#16
svd merged 19 commits into
mainfrom
dev

Conversation

@svd

@svd svd commented Aug 27, 2026

Copy link
Copy Markdown
Owner

Engine release 0.9.0 (bundles plugin v0.3.0, already bumped on dev).

Engine

  • Fix: an optional discriminator is no longer forced onto every call — _render_overloaded now renders an omission overload and forwards the argument only when supplied.

Plugin (v0.3.0)

  • generate-mcp-wrappers SKILL.md split into core + references (mutating-tools, probe-failures, shape-spec, subagent-execution).
  • Non-interactive path documented as the default; smoke-test runner generated by default; probe batching, shell-hygiene guard, discriminator precondition.

Skill version floors stay at mcpgen >= 0.7.0 — no new CLI feature required.

Gates: ruff check/format, mypy, 676 tests all pass locally.

After merge: tag v0.9.0 (fires PyPI publish) then plugin-v0.3.0 on main.

svd added 19 commits August 26, 2026 09:31
Step 0 checked a bare `mcpgen` on PATH, which is absent in a uv-managed
project, and every command block below it repeated that bare name. Step 0
now probes `mcpgen`, `uv run mcpgen`, and `.venv/bin/mcpgen`, keeps the
first that both answers and clears the version floor, and every runnable
command block writes `<mcpgen>` for the agent to substitute.

Raises the floor to 0.3.0, where a string-valued discriminator renders:
below it the overload renderer coerces every variant key with `int()`, so
step 4 option 1 dies in step 5 after the whole probe sweep is paid for.
The check told the agent to run `json.loads()` on a raw probe payload. On
prose that raises, and a bare `json.loads()` in a shell snippet exits
non-zero, so an expected negative result was recorded as a tool error.
The check is now a guarded snippet that prints `JSON_UNWRAP` or
`NOT_JSON` and states that `NOT_JSON` is an expected outcome.

Also splits the unwrap case in two. `_dig` / `_dig_list` are emitted only
for a tool with a non-empty `unwrap`, so a parsed object that *is* the
record has no key path: `unwrap` stays empty and `return_model` stays
null rather than claiming a dict the wrapper never returns.
Step 7 asked the user whether to generate a runner and told a subagent
that could not reach `AskUserQuestion` to skip the step, so the runner
was never generated in a non-interactive run. Generation adds one new
file, so it is now the default and the question is the exception.

Names the runner floor (0.7.0, above step 0's) and the two things the
runner skill cannot re-derive: the read-only tool set, whose keyword list
is narrower than step 2b's, and the resolved `mcpgen` invocation, since
the runner skill gates on a bare `mcpgen`.
Nothing said that probes of distinct tools are independent, so agents
spent one turn per tool. Distinct tools write distinct part files, so
every probe now goes out in a single shell invocation, with `set -e` for
fail-fast and an accumulator variant for collect-everything runs.

Bounds the parallelism the execution model promised: batch subagents are
a local `stdio` optimization, because parallel agents cannot hold the
hosted probe interval. Moves recon and batch dispatch behind the ignore
preflight, since both make live calls, and states the prune-before-sizing
order — the execution model picks its shape from the selected count.
Step 2's discriminator step read a missing advisory as "no candidates",
which is also what an unread stderr looks like. It now states the four
conditions a candidate must meet — same name, same case, a top-level
scalar `type`, and absence from the engine's own denylist — so a
genuinely absent advisory is distinguishable from an unread one.

Pass 1 no longer re-checks what `list` already drops, and breadth is no
longer a disqualifier. Pass 2 confirms by comparing response shapes
across three values rather than looking for the parameter in the
response, since a server can switch shape on an argument it never echoes
back. An inconclusive Pass 2 leaves the tool polymorphic-suspect for
step 4 rather than clearing it.
Three shell hazards had no rule. Piping a command that talks to a server
discards the artifact on stdout and hands back the pipe's exit status, so
a failed run reads as a success; `2>&1` does not fix it, because the
bridge and the launchers write their own noise there. Two `call`s chained
in one invocation report only the last status and can overwrite one
`--out`. A `timeout` binary is not installed on macOS, so the wrapped
command dies with 127 and never starts.

Also writes the transport tails out in full: an unquoted variable does
not word-split in zsh, and a server name can hold spaces or globs.
A bot-protection interstitial was read as a permanent auth failure and
the tool was abandoned. The two classes are now separated by what the
exception looks like, since `probe` lets httpx and SDK errors raise raw
and the body never reaches the agent: 403, any 5xx, `Connection closed`,
and a post-handshake stall are challenges and retry on a 60/120 s
backoff; a 429 on the tool call and any credential-naming `[mcpgen]
error:` line are auth or quota and do not retry. Once one tool exhausts
both retries on a marker, that marker stops being read as a challenge.

Failure recording splits by whether a payload came back at all — a call
that never returned has no shape, and `_observed_shape: "str"` must not
be invented for it. Hosted endpoints get a >= 2 s probe interval, widened
to ~10 s after a challenge, and the raw payload is captured with `call
--out` before any failure is classified, because `probe` keeps structure
and drops the words the phrase list matches on.
The keyword fallback missed `add`, `append`, `insert`, `upsert`, `push`,
`close`, and `revoke`, and matched substrings against the description as
well as the name, so `list_recently_created_items` flagged while
`append_row` did not. The test is now defined once, matches whole words
in the name only, and carries a single judgment call: a leading read verb
exempts a matched noun (`list_add_ons`) but not a second action
(`get_or_create_entity`).

A `readOnlyHint: true` is still trusted, but two free checks can dispute
it — self-contradicting annotations, and a name that passes the keyword
test. Every verdict reached without a `readOnlyHint: false` is now
recorded: `_mutating_suspect` / `_mutating_reason` in the shape-spec for
a probed tool, added after `mcpgen merge` because merge replaces the
entry wholesale, or under `## mutating-skipped` in `session-overview.md`
for a skipped one.
`npx` writes its install summary to stdout, the stream that carries
JSON-RPC frames, so the client logs `Failed to parse JSONRPC message from
server` with a full pydantic traceback for each line. Agents recorded
those as probe failures. They come from the SDK's own logger, the error
is discarded by a handler, and the handshake completes on the first valid
frame — so where a part file landed, they are noise. `uv` writes its
progress to stderr and causes no parse error.

The same lines mean something else twice: a server that logs to stdout
repeats them on every call, and a mis-launched server never frames at
all. A stdio server that has never framed is not a challenge — no
backoff repairs a wrong launch command — while one that framed earlier
and then died keeps the challenge routing.
Step 2 was written as an interactive gate with a subagent fallback in a
blockquote, so a non-interactive run read the step as inapplicable and
dropped it. The default path is now 2c — every non-mutating tool, pruned
on the transport — and the question is 2d, an exception taken only on the
main thread of a session a user opened directly.

`AskUserQuestion` being available is not the test: it sits in the roster
on a headless run too, where nothing answers it, so an unanswered
question falls back to 2c. Every remaining interactive gate is keyed to
that same condition — the >20-variant cap, the sample-id ask, and step
4's option-2 fallback — and each names what to do when it is not met.
Claims the file made about the engine that the code does not support,
found while checking the batch above against `mcpgen`:

- `codegen --out` does not create parent directories, so a folder target
  needs `mkdir -p` first.
- Only the config form forwards a server's `env` block; direct `--stdio`
  needs `--env` per variable.
- The verify sidecar is named from `<shapes-path>` with `.shapes.json`
  stripped, not from `<server>`, and a re-probe with empty args does not
  overwrite its entry.
- `--shapes` disables the in-memory parts fallback and takes the file as
  given, so auto-detection is the only path that tolerates a skipped
  merge — and it looks for a name built from the server, which a URL-form
  server argument does not produce.
- `merge` widens concrete conflicts to `Any | None` when a null was seen.
- `_dig_list` falls back to the last path key at top level.
- An empty-list field is omitted from the skeleton and has to be added by
  hand as the one allowed non-scalar in `fields`.
- Media blocks surface as metadata dicts, not as `"str"`.
- A required arg that references an existing object needs a real value;
  only free-text args may be invented.
- Enum members render as `Literal` only when scalar.

Adds the ignore-rule preflight that has to pass before the first live
call, since probing writes ids and PII into three artifacts and `mcpgen`
installs no ignore rules.
Line-budget pass over the whole file, measured after the edits above.
Cuts the deferred `--check` guard, which describes a mode that does not
exist yet, and the enum-`Literal` guard, now stated where enums are
chosen in step 3. Compresses the intro, the over-modelling guard, and the
single-variant guard without changing what any of them require.
SKILL.md had grown to 1035 lines (69.6 KB) across 89 commits of appended
fixes, all of it loaded into context on every trigger. Move the material
that only applies on a conditional path into references/, keeping the
procedure spine and the rules that measurably change output inline.

Per-run context drops 69.6 KB -> 42.5 KB (-39%). Every rule is preserved,
and the frontmatter is byte-identical, so triggering behaviour is unchanged.

  references/probe-failures.md      error taxonomy; read only on a failure
  references/mutating-tools.md      step 2b safe-to-probe classification
  references/shape-spec.md          step 4 field reference and scrub rules
  references/subagent-execution.md  fan-out for >4 tools on the main thread

Discriminator detection (step 2.e) and the media / JSON-in-string rules
stay inline. The 27-server eval baseline shows those are the only
additions since the 516-line version that changed generated output:
Mode D overloads became reachable, and three tools are now deliberately
left untyped rather than modelled from a payload the probe never saw.
The failure taxonomy, by contrast, fired on none of the 27 passing runs.

Also consolidate the shell rules that were restated in four separate
places into one block, and reduce the four Guards that merely restated
inline rules to pointers.

Bump plugin 0.2.0 -> 0.2.1; skill-only change, engine unchanged at
0.9.0.dev1 and both version floors (0.3.0, 0.7.0) stay put.

Claude-Session: https://claude.ai/code/session_01WLrTJYE9Rz6oRBtHhMYM7L
Two passes over what the split left behind. The engine floor was stated
in three places, once per requirement; it collapses to the single 0.3.0
line, since that is the only number an agent acts on. Drops the
engine-internals prose that survived into the core file — how `merge`
deep-merges, what `_atomic_write_text` is for, why the SDK's logger
reaches stderr — none of which carries an instruction the agent can act
on. What the agent must do about each is stated where it acts.
The advisory only fires for a parameter two or more tools share, so a
single-tool discriminator — the common case on a server that exposes one
polymorphic search — was never a candidate and its tool was typed from
one variant. Step 2.e now scans the schemas directly for a scalar `enum`
param whose name reads as a type switch, and treats what it finds as a
candidate the advisory did not report.

States both preconditions on step 4's option 1: the variants must be
enumerable and probed, and the discriminator must be a scalar the
overload renderer can pin to a `Literal`. A candidate failing either goes
to option 2.
_render_overloaded checked `pname == disc` before consulting the schema's
required set, so the discriminator was always emitted without a default
and always forwarded in the request body. A parameter the server treats
as optional became mandatory on every call, and the wrapper sent it
whether or not the caller had chosen a value.

The failure was silent. Overload parameters are keyword-only, so Python
accepts a non-defaulted parameter after defaulted ones; the module
imported, ast.parse passed, and the generated signature simply rejected
calls that had worked before.

An optional discriminator now renders an extra overload for the omission
case. It returns the variant union rather than a specific model because
the schema cannot say which variant the server picks when the argument is
absent -- guessing one would restate the same false precision the
discriminator support exists to avoid. The impl takes `| None = None` and
forwards the value only when supplied. A required discriminator renders
byte-identically to before.

test_discriminator_always_required_in_body asserted the old behaviour
directly, down to its name. It is replaced by a pair covering both
directions: optional keeps its default and its None-check, required keeps
neither.

Claude-Session: https://claude.ai/code/session_01WLrTJYE9Rz6oRBtHhMYM7L
Minor rather than patch: 0.2.1 covered the reference split, which was
output-neutral by design and measured that way. What followed changed
what the skill produces. Step 2.e now sweeps tool descriptions for
discriminators the list advisory cannot report, and step 4 states that
option 1 needs a scalar discriminator.

Validated across the full 27-server corpus at skill v0.0.4-161-gbf232ef:
23 pass, 4 partial, 0 fail, no mixed versions. Every partial is
probe_inconclusive -- tools with no observable success payload -- not a
skill defect. Five artifacts changed against the previous run and all
five are improvements: github found three real discriminators (one of
them on a parameter the engine denylist drops), aws-knowledge and semgrep
gained typing, linear's oauth skip is classified correctly, and
filesystem shed a return type that was false -- directory_tree annotated
list[DirectoryNode] while returning a JSON string.

Catalog top-level version in marketplace.json is untouched; it tracks the
listing, not the plugin. The skill's engine floor stays 0.7.0, below the
published 0.8.0, so the tag promises a version users can install.

Claude-Session: https://claude.ai/code/session_01WLrTJYE9Rz6oRBtHhMYM7L
@svd
svd merged commit 6f24eda into main Aug 27, 2026
11 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant