Skip to content

Add structural MCP eval loop (v0) - #6

Open
jeremy wants to merge 5 commits into
mainfrom
eval-loop-v0
Open

Add structural MCP eval loop (v0)#6
jeremy wants to merge 5 commits into
mainfrom
eval-loop-v0

Conversation

@jeremy

@jeremy jeremy commented Aug 30, 2026

Copy link
Copy Markdown
Member

What this is

The PROVISIONAL v0 MCP eval loop — a runnable end-to-end slice, not a plan. It reads a live gateway server's own wire surface as the spec, generates deterministic scenarios, asks a cheap model to pick {tool, action, params}, grades by rule, and prints a scored table with a measured cost.

Repo decision: in the toolkit as an eval/ package, per the brief — the catalog is the spec and it lives here, so the eval speaks each catalog's {action, params} vocabulary over the wire and is inherited by all three servers (basecamp / hey / fizzy) for free. No new repo, no catalog duplication.

Scope (v0): structural-only — no backend, no judge, no cassettes. Proves the loop turns on the free deterministic layer.

The loop

  1. Spec from the server (spec.go) — tools/list + the gateway describe action yield each action's identity, safety annotations, and param/body schema. Served from the catalog, never the product API, so it is hermetic.
  2. Deterministic, seedable generation (scenario.go) — a pure function of (specs, seed, N), sampling distinct actions weighted toward the destructive and idempotent classes, rendering an NL framing + gold resolution. Cached, hand-checkable corpus at testdata/scenarios/fizzy.json.
  3. A real cheap-model turn (model.go, prompt.go) — static catalog as the (cacheable) system prompt, the framing as the user turn. Backends: oracle (deterministic, no spend), cli (local claude, no API key), api (Anthropic Messages API, exact usage).
  4. Rule grading (grade.go) — exact tool+action match, JSON-Schema param validation, and the read-only safety rule (a read/lookup framing must never resolve to a destructive action).
  5. Scored report + measured cost (report.go) — scenario×model table, per-model pass/params/safety rates, total $. Cost is deterministic (tokens × published price table), so the frugality metric is reproducible and free to compute.

The captured v0 fizzy run

go run ./eval/cmd/eval --server fizzy --server-cmd "…/fizzy-mcp stdio --writes" --backend cli --models haiku --scenarios eval/testdata/scenarios/fizzy.jsoneval/results/fizzy-v0.jsonl:

model       pass      params    safety    in_tok     out_tok    cost_usd
haiku       12/12     12/12     12/12     19192      282        $0.0165

~16 cents proves the loop turns and the cost story is real. A capable model clears this small, unambiguous corpus cleanly — v0 proves the machinery, not model discrimination (that's the hillclimb).

Frugality

Tokens and dollars print on every run. The only spend in the whole loop is the per-scenario model turn; spec, generation, grading, and reporting are free. CI (make eval-smoke + eval.yml) runs the loop against the in-process fake with the oracle backend: no live model calls, no network, zero cost.

Tests / CI

Generator tests (determinism, seed sensitivity, distinct-action sampling, gold validity) + grader tests (every dimension, types, enum, safety) + a hermetic end-to-end smoke, all in the normal make test. New eval.yml runs the runnable smoke command with no model calls.

Deferred (hillclimb, each independent)

Harder scenarios with distractors · a live-API layer (cassettes → real calls, grading the dispatched result) · an LLM judge · prompt caching on the API backend · the other two servers + catalog-SHA regression triggers · a gated tiny real-model CI set.


Caveat, flagged honestly: the captured run's answers came through the local claude CLI, which in this environment routes the haiku alias to a stronger model; the dollar figure is priced at Haiku list price as the intended cheap-model cost, and the input tokens are the uncached per-scenario upper bound (prompt caching, a hillclimb item, collapses them). The api backend records exact usage when a key is present.

A hermetic, rule-graded eval for the gateway servers built on this
toolkit. It reads a live server's own wire surface as the spec
(tools/list + the describe action), generates deterministic, seedable
natural-language scenarios weighted toward the destructive and
idempotent classes, asks a cheap model to pick {tool, action, params},
and grades by rule: exact tool+action match, JSON-Schema param
validation, and a read-only safety check. No product backend, judge, or
cassette.

Because it speaks each catalog's own vocabulary over the wire, it is
product-agnostic and inherited by every server for free. It drives the
in-process fake catalog (CI, zero spend) or a real product stdio server
(fizzy shown), with the server run structurally-only against no backend.

Frugality is a first-class metric: the report prints per-scenario tokens
and a total dollar cost on every run, computed deterministically from the
prompt and answer at a published price table.

Includes generator + grader tests, a hermetic end-to-end smoke, a cheap
CI workflow with no live model calls, the cached fizzy corpus, and the
captured v0 run (12/12 pass, safety respected, ~$0.016).
Copilot AI balanced review requested due to automatic review settings August 30, 2026 18:04
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Aug 30, 2026

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review Completed 2026-08-30T21:42:58.428984Z 619de0d New commits
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds a provisional structural MCP evaluation loop that derives specs from gateway servers, generates scenarios, evaluates model routing, and reports accuracy and cost.

Changes:

  • Adds spec discovery, scenario generation, prompting, grading, and reporting.
  • Supports oracle, Claude CLI, and Anthropic API backends.
  • Adds fixtures, tests, documentation, and a hermetic CI smoke workflow.

Tip

If you aren't ready for review, convert to a draft PR.
Click "Convert to draft" or run gh pr ready --undo.
Click "Ready for review" or run gh pr ready to reengage.

Reviewed changes

Copilot reviewed 15 out of 15 changed files in this pull request and generated 6 comments.

Show a summary per file
File Description
Makefile Adds the eval smoke target.
.github/workflows/eval.yml Runs the hermetic smoke in CI.
eval/README.md Documents architecture, usage, results, and deferred work.
eval/spec.go Derives action specifications over MCP.
eval/scenario.go Generates deterministic weighted scenarios.
eval/prompt.go Builds prompts and parses proposals.
eval/model.go Implements model backends, token estimation, and pricing.
eval/grade.go Grades routing, parameters, and safety.
eval/run.go Coordinates evaluations and records results.
eval/report.go Produces JSONL and aggregate reports.
eval/fake.go Provides the in-process fake gateway.
eval/eval_test.go Tests generation, grading, parsing, cost, and smoke behavior.
eval/cmd/eval/main.go Adds the eval command-line runner.
eval/testdata/scenarios/fizzy.json Captures the Fizzy scenario corpus.
eval/results/fizzy-v0.jsonl Records the captured Fizzy run.
Suppressed comments (2)

eval/scenario.go:188

  • syntheticValue has no object case, so a required object-valued parameter falls into default and receives a string; the generated gold proposal then fails this package's own object type validation. Generate an object (and nil for a null schema) explicitly.
	default:

eval/README.md:104

  • Deferring Basecamp and HEY leaves this shared toolkit package proven against only Fizzy. That conflicts with the repository's two-instance proof/extraction rule; demonstrate the duplicated loop in at least two product servers before extracting it here, or keep v0 product-local until that proof exists.
- **The other two servers** (basecamp, hey) — same loop, their catalogs; and
  **catalog-SHA regression triggers** computed from the toolkit's snapshot
  testdata, so the eval reruns when a catalog changes.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread eval/model.go Outdated
Comment thread eval/eval_test.go Outdated
Comment thread eval/run.go Outdated
Comment thread eval/grade.go Outdated
Comment thread eval/scenario.go Outdated
Comment thread eval/README.md Outdated

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 31fb498b67

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread eval/spec.go
Comment on lines +1 to +5
// Package eval is a structural evaluation loop for the MCP gateway servers
// built on this toolkit (basecamp/hey/fizzy). It reads a live server's own
// wire surface — the tool listing and the gateway describe payloads — as the
// specification, generates deterministic natural-language scenarios from it,
// asks a cheap model to pick the right {tool, action, params}, and grades the

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Prove the eval in a second product before extracting it

The new shared eval framework is demonstrated only with Fizzy—the committed corpus/results are Fizzy-specific, while the README explicitly defers Basecamp and HEY—so this lands a toolkit abstraction before two product instances have proven it by duplication. Keep the implementation in the product until a second instance exists, then extract the duplicated design.

AGENTS.md reference: AGENTS.md:L8-L9

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Declining this one, with reasoning here so it isn't re-litigated.

The rule this cites guards against prematurely abstracting duplicated code before a second instance proves the shape. That's not what this is: the eval couples to the toolkit's own gateway contract — tools/list + the reserved describe action + the annotation surface — which every server built on this toolkit already emits by construction. It imports no product; SpecFromSession reads the wire, so there is nothing product-specific to duplicate or later de-duplicate.

It's also already exercised by two independent catalog instances, not one: the in-process fake catalog (the CI smoke + unit tests) and the real fizzy stdio server. Only the cached corpus and results are fizzy-specific, and those are namespaced (testdata/scenarios/fizzy.json, results/fizzy-v0.jsonl) exactly so basecamp/hey drop in beside them.

And the alternative the rule would point to — keep it in a product until a second appears — is the more-coupled option here: the harness lives below the products, so putting it inside fizzy would force hey and basecamp to import fizzy to reuse it (a module cycle against the toolkit). The brief's repo decision weighed this and chose in-toolkit deliberately. Happy to revisit if the coupling ever reaches into a product, but today it points the other way.

Comment thread eval/scenario.go Outdated
Comment thread eval/model.go Outdated
Comment thread eval/model.go
Comment thread eval/scenario.go
Comment thread eval/cmd/eval/main.go Outdated
Comment thread eval/grade.go Outdated
Comment thread eval/run.go
Comment thread eval/report.go
Comment thread eval/spec.go Outdated
jeremy added 2 commits August 30, 2026 11:13
…l typing, mutation in write scenarios

- Price the oracle at zero and stop fabricating a Haiku cost for unknown
  labels, so the zero-spend smoke reports $0.00 (costOf falls back to
  Haiku only for a genuinely unknown paid label).
- On a failed model call, record only backend-reported usage, never a
  prompt-size estimate: a call that never reached a model is not paid.
- JSON Schema type "null" now rejects any non-nil value instead of
  passing as unconstrained.
- Write scenarios now name at least one body field, so an update is
  framed with a real change to make, not just its ids.
- Correct the README cost wording ($0.0165 is under two cents) and
  refresh the captured run.

Regenerated the fizzy corpus and re-ran: still 12/12 pass, safety
respected, $0.0165.
…ing, safety-on-error

- Preserve non-string enum constraints: ParamSpec.Enum is now []any, the
  prompt renders every member, and the grader validates by JSON-value
  equality — an integer or boolean enum now actually constrains.
- Pin temperature 0 on API requests, matching the reproducible v0 config.
- Generate an object value for object-typed params so a catalog with a
  required object param does not spuriously fail the oracle.
- Parse --server-cmd with quote awareness and reject an empty command,
  so a quoted path with spaces launches and a blank value cannot panic.
- Keep framings unique at generation, so the framing-keyed oracle never
  maps two distinct actions to one answer.
- Treat an errored or unparseable cell as safe (no action was proposed),
  so a transient backend failure no longer lowers the safety rate.

Added tests for non-string enums, object generation, null typing,
oracle/unknown pricing, framing disambiguation, and command splitting.
The fizzy corpus is byte-identical after these changes, so the captured
v0 run stands.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: a959b32f61

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread eval/grade.go
Comment on lines +68 to +69
valid, reasons := validateParams(spec, p.Params)
r.ParamsValid = valid

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Grade the parameter values requested by the scenario

The new corpus now names concrete mutation values, but this call only validates p.Params against the action schema and never compares them with s.GoldParams. For the updated update_column scenario, a matching tool/action with different IDs—or only the required IDs and no requested color—still receives ParamsValid and a score of 1, so the headline rates can credit a call that ignores the requested mutation or targets the wrong resource.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Grading now requires the proposal to reproduce the requested values, not just be schema-valid: honorsGold (6b5d45a) fails a call that keeps only the ids, targets a different resource, or supplies a different value — compared in JSON value space so a gold int matches a decoded float64. This supersedes the old "validate against schema, not these values" note in scenario.go, which contradicted the mutation-value change (the framing now names concrete values). Recommendation: keep it — high confidence — because without it the mutation-value work is inert and params_match credits calls that ignore the request. Leaving this unresolved for your confirmation since it reverses a documented v0 decision; the code is in and green (TestGradeRequiresRequestedValues covers drop/wrong-value/wrong-resource).

Comment thread eval/scenario.go Outdated
Comment thread eval/prompt.go Outdated
Comment thread eval/grade.go Outdated
Comment thread eval/run.go Outdated
Comment thread Makefile Outdated
Addresses the second Codex pass over the eval package:

- grade.go: params_match now requires the proposal to reproduce the values the
  framing named (not just be schema-valid), so a call that keeps only the ids
  or targets a different resource no longer passes. Read-only-framed requests
  must resolve to a read-only action — any write, not just a destructive one,
  is now a safety violation.
- scenario.go: colliding framings disambiguate with a neutral ordinal instead
  of the gold action name, which the catalog prompt exposes and would have
  leaked the answer.
- prompt.go: proposal parsing scans every top-level brace block and returns the
  first that decodes, so an earlier aside in prose no longer masks the answer.
- run.go / cmd: cached corpora carry their server; the CLI refuses to grade a
  corpus against a different live catalog.
- Makefile / cmd: eval-smoke runs with --require-pass so a regression that makes
  the deterministic oracle emit FAIL/ERR? turns the smoke red.
@jeremy

jeremy commented Aug 30, 2026

Copy link
Copy Markdown
Member Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 6b5d45a4a2

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread eval/model.go
Comment on lines +56 to +59
p, ok := PricingFor(label)
if !ok {
p = pricingTable["haiku"]
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reject models without known pricing

When --models contains a custom CLI/API model ID, costOf silently charges it at Haiku rates, so the report's advertised measured cost can substantially understate or overstate the experiment. Fresh evidence in this revision is that although PricingFor now rejects unknown labels, this caller immediately restores the Haiku fallback; reject unsupported pricing labels or require an explicit price instead.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not writing a third fix here — flagging the pattern instead. This is the third pass on one mechanism: unknown-label pricing (djBSy from Copilot, djCQf from Codex, now this). The prior rounds landed a deliberate, documented choice: PricingFor returns (Pricing, bool) so any caller CAN detect an unknown label and refuse it, while costOf keeps a Haiku floor purely so a custom model's cost line reads as a plausible non-zero figure instead of $0.00 (rationale is in the costOf doc comment). Codex wants the opposite — a hard reject. That is a genuine design call, not a bug: reject-unknown gives an honest error but breaks any ad-hoc '--models some-new-id' run until the table is updated; floor-to-Haiku keeps such runs working at a known-wrong-but-flagged price. Recommendation (medium confidence): for v0 keep the floor but make it visible — stamp the record/report as estimated pricing when the label is unknown, rather than silently substituting Haiku. That closes the understates/overstates concern without failing exploratory runs. Leaving this for @jeremy since it reverses a documented choice; I have deliberately not written a third selector on it.

Comment thread eval/scenario.go
Comment thread eval/grade.go
// The framing names the concrete ids and mutation values, so a correct answer
// carries them; a call that supplies only the required ids, or names a
// different resource, is schema-valid but has not done what was asked. The
// proposal may add other declared params — this checks only that the requested

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reject unrequested optional parameters

When a proposal reproduces every gold value but also invents additional declared parameters, honorsGold still accepts it and the record can receive a full score. For an update request that names only one mutation, the model can therefore add another optional field and change data the user never mentioned; read actions can likewise add filters that alter the result set. Require proposal parameters to match the requested set rather than treating arbitrary declared extras as correct.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Recommendation, not a fix — this tightens the grading semantics I just introduced in honorsGold (thread on grade.go:80), which is itself still open for your confirmation, so I do not want to stack a second unilateral grading change on top. The finding is real: honorsGold checks gold is a subset of the proposal, so a model can add an optional field the request never mentioned (a stray mutation on a write, an extra filter on a read) and still score. But requiring an EXACT param set has real false-positive risk: a model that adds a schema default, a pagination cursor on a read, or an idempotency token would FAIL despite doing what was asked. Recommendation (medium confidence): do not require exact-set in v0. If we want to catch unrequested mutations, extend the SAFETY dimension instead — an unrequested body param on a write is an over-action — while leaving unrequested query/path params (filters, pagination) alone. That is a cleaner split than reject-all-extras. Bundling this with the honorsGold decision since they are the same knob: say how strict you want v0 params grading and I will set both.

Strict null grading (typeMatches("null") == nil) needs a matching generator:
a required null-typed param fell through syntheticValue's default to a string,
so the gold proposal failed its own validation. Emit nil and render it as
"null" in the framing.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 619de0d77b

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread eval/cmd/eval/main.go
if corpus.Server != "" && corpus.Server != *server {
return fmt.Errorf("corpus %s was generated for server %q but --server is %q", *scenPath, corpus.Server, *server)
}
scenarios = corpus.Scenarios

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reject empty cached scenario corpora

When a valid corpus contains "scenarios": [], unmarshalling produces a non-nil empty slice, so both buildModels and Run skip generation, the oracle emits zero records, and --require-pass succeeds because FailingRecords is empty. Fresh evidence beyond the earlier smoke-check fix is that its new failure condition is vacuously satisfied when no cells execute; reject an empty loaded corpus so a corrupted or accidentally emptied cache cannot produce a green eval without evaluating anything.

Useful? React with 👍 / 👎.

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.

2 participants