Skip to content

feat: scenario-scoped MCP tool fakes in fixtures - #490

Merged
jpr5 merged 9 commits into
mainfrom
feat/mcp-fakes
Oct 5, 2026
Merged

jpr5 merged 9 commits into
mainfrom
feat/mcp-fakes

Conversation

@jpr5

@jpr5 jpr5 commented Oct 3, 2026 •

Copy link
Copy Markdown
Contributor

What & why

Fixture files can now declare MCP tool fakes for a scenario, in an mcpFakes block next to the LLM turns of the same test. Each block names its scenario (a test id or a context). Inside a block you can:

  • script ordered answers per tool: the first call gets a timeout, the retry gets a result;
  • match arguments exactly, or accept any args for a call;
  • run a closed world (undeclaredTools: "deny"), so a call to any undeclared tool is an error;
  • fail loud: a mismatch, an exhausted tool or an undeclared tool under deny returns a JSON-RPC error with a stable code in error.data.aimock.code. The failure is also written to the journal, a metric and the server log.

An agent test has two inputs that change from run to run: what the LLM says, and what the tools return. aimock already pins the LLM side. For MCP tools, aimock could only give one fixed answer per tool for the whole server process. A test could not say "in this scenario, get_weather(Seattle) returns rain the first time and a timeout the second time, and any other call is a bug." So teams either ran real MCP servers in tests (side effects, drift, flaky runs) or wrote code handlers that cannot be shared as data and do not reset per test.

This works at the wire, over Streamable HTTP, so it is the same for LangGraph, CrewAI, ADK, PydanticAI, Mastra or a plain SDK client, in any language. In-process tool mocks, such as Mastra's Experiment Tool Mocks, only cover agents of one framework. Because the fakes live in fixture files, a later recording mode can write real MCP tool results into the same file as the LLM turns, so one file holds the whole scenario.

How to use

tickets/retry.json (from src/__tests__/fixtures/mcp-fakes/tickets/retry.json):

{
  "fixtures": [
    {
      "match": { "userMessage": "open a refund ticket", "hasToolResult": false },
      "response": {
        "toolCalls": [{ "name": "create_ticket", "arguments": { "title": "Refund" } }]
      }
    }
  ],
  "mcpFakes": {
    "scope": { "testId": "tickets › retry on timeout" },
    "tools": [
      {
        "name": "create_ticket",
        "calls": [
          { "id": "first-try", "args": { "title": "Refund" }, "error": "upstream timeout" },
          { "id": "retry", "args": { "title": "Refund" }, "result": "TICKET-42" }
        ]
      }
    ]
  }
}

Start aimock with --fixtures on that directory. aimock mounts an MCP mock at /mcp if there is none. Point the agent's MCP client at /mcp?testId=tickets%20%E2%80%BA%20retry%20on%20timeout (or send X-Test-Id). The first create_ticket call gets isError: true "upstream timeout", the second gets TICKET-42, and a third fails with MCP_FAKE_EXHAUSTED.

What changed

  1. Fake model, fixture contracts and request helpers. The mcpFakes types, the validator, the fake store (scope, ordering, matching, consumption, eviction), the error codes and the request identity helpers (header, query, session).
  2. Serve fakes from the MCP mount. tools/call goes to fakes first, with the error bodies for mismatch, exhausted, not declared, evicted and invalid override. tools/list includes faked tools. Journal entries record the body, test id, context and fake outcome. There is a failure metric. GET on the mount answers 405.
  3. Load mcpFakes from fixture files. New loadFixtureFileWithServices / loadFixturesFromDirWithServices (in src/fixture-loader-services.ts). The old free loaders throw on a file that holds mcpFakes instead of dropping it silently.
  4. Server and control API. Start-time hand-off to existing MCP mounts, auto-mount at /mcp, mount-conflict checks. POST /__aimock/fixtures accepts mcpFakes. New GET /__aimock/mcp/fakes. Reset and DELETE /__aimock/fixtures also unload fakes.
  5. LLMock, CLI, validator and test plugins. LLMock loads fakes before and after start. The CLI supports fakes-only starts, --watch and remote URLs, and reports every bad block. aimock validate checks blocks and cross-file id collisions. The vitest and jest plugins load fakes and fail beforeAll on a bad block. The pytest plugin's load_fixtures posts mcpFakes and detects a server that is too old. The MCP SDK v1 and v2 clients are pinned as dev dependencies for the real-client tests.
  6. Public exports. The new loaders, McpFakesAddError, MCP_FAKE_ERROR_CODES and the fake types, from the root and the ./mcp subpath.
  7. Docs. MCPMock page section on scenario fakes, plus the fixtures, strict mode, sequential responses, control API and Mastra pages, the homepage MCP card, the README and the Unreleased changelog entries.

User-visible changes

  • GET on an MCP mount answers 405 with Allow: POST, DELETE instead of falling through to the LLM routes. This includes a standalone MCPMock.
  • MCP tools/call journal entries carry the request body (was null), and response.mcpFake = { id, outcome } when a fake answered or failed.
  • MCP journal entries carry testId and context. GET /__aimock/journal?testId= lists an MCP request under the test id bound at initialize, or under the decoded header value, instead of "__default__" or the encoded string.
  • MCP requests read X-Test-Id and X-AIMock-Context. Answers on a mount without fakes do not change.
  • An MCP request that repeats a test id, context or undeclared-tools override on one path gets HTTP 400 MCP_DUPLICATE_IDENTITY. Nothing changes for clients that never do that.
  • New opt-in X-AIMock-MCP-Undeclared header or ?undeclared= query parameter (allow or deny) overrides the undeclared-tool policy for a request, or for the session when sent at initialize. With deny, any tools/call to a tool that no fake declares fails with MCP_FAKE_NOT_DECLARED, even on a mount with no fakes loaded, so onToolCall handlers do not run.
  • Any other value for that header or query parameter gets HTTP 400 MCP_INVALID_UNDECLARED on every MCP request, also on a mount without fakes.
  • POST /__aimock/fixtures: fixtures is optional when mcpFakes is sent, and the response adds mcpFakesAdded. Existing callers still get {"added": n}.
  • POST /__aimock/reset, DELETE /__aimock/fixtures, LLMock.reset() and LLMock.clearFixtures() also clear fakes.
  • Under undeclaredTools: "deny", an undeclared tool fails even when an onToolCall handler or a config result exists for it.
  • The CLI and aimock validate accept fakes-only files.
  • The recorder's snapshot merge keeps top-level keys it does not own (such as mcpFakes).
  • A tools/call whose name is not a string (for example 123) gets Invalid tool name: expected a string, got number instead of Unknown tool: 123. A missing or empty name still gets Missing tool name.
  • A JSON-RPC batch POST to an MCP mount is journaled as one entry per message, each with its own body, instead of one entry per HTTP request. Journal and getRequests() counts go up for batch callers.
  • The pytest plugin's load_fixtures still raises requests.HTTPError on a 400 with a JSON body, but the message is now aimock rejected fixtures from <path>: <error>, plus one line per details item, instead of the default requests text.
  • aimock validate --json always includes run.mcpFakeBlocks, which is 0 when no file has mcpFakes.
  • A bad mcpFakes block, a mount conflict, or a --watch reload whose mcpFakes changed fails the load instead of being skipped. On --watch the previous fixtures stay loaded. No effect without mcpFakes.
  • The old loaders now reject files with mcpFakes: the free functions loadFixtureFile and loadFixturesFromDir throw on a file with a top-level mcpFakes key. Use the ...WithServices loaders or the LLMock methods.
  • New public field unreadable on FixturesWithServices, the return type of the services loaders: the sources of files that could not be read or were not valid JSON.
  • An older aimock ignores mcpFakes silently. The MCPMock docs warn about this.
  • The MCPMock code API (addTool, onToolCall, addResource, addPrompt) does not change.

Proof

Red-green, same command on the base and on this branch:

pnpm build && AIMOCK_MCP_FAKES_13_1=1 pnpm vitest run src/__tests__/mcp-fakes-redgreen.test.ts

RED (base origin/main plus the test only):

 ❯ src/__tests__/mcp-fakes-redgreen.test.ts (1 test | 1 failed)
   × 13.1 red-green > serves the scripted retry sequence, then fails loud when exhausted
     → Streamable HTTP error: Error POSTing to endpoint: {"error":{"message":"Not found","type":"not_found"}}
 Test Files  1 failed (1)
      Tests  1 failed (1)

The base mounts nothing at /mcp and does not load the mcpFakes block.

GREEN (this branch):

 ✓ src/__tests__/mcp-fakes-redgreen.test.ts (1 test) 86ms
 Test Files  1 passed (1)
      Tests  1 passed (1)
exit=0

The env-var gate is removed on this branch, so plain pnpm test runs this test as well (287 files, 9349 passed at that point). Typecheck, lint, format check and build exit 0. Mutation checks on the CLI paths (watch guard, URL source label, fakes-only start, print every error) each turn one test red.

Value check with the built CLI (node dist/cli.js --fixtures <dir>), no real LLM calls:

  • Real agent. A LangGraph createReactAgent using @langchain/mcp-adapters MultiServerMCPClient (http transport) called the faked get_weather, got {"tempF":60,"conditions":"rain"}, and printed FINAL: It is 60F and raining in Seattle. (exit 0). The journal entry has response.mcpFake = {"id":"weather/seattle.json:get_weather#0","outcome":"answered"}.
  • Broken args. Changing the declared args to Portland gave MCP_FAKE_MISMATCH in error.data.aimock.code, with firstDifference: "$.city: expected \"Portland\", received \"Seattle\"". The journal recorded outcome: "mismatch" and the server log printed an MCP-FAKE: mismatch line.
  • Error bodies. With the MCP SDK v1 client, the retry (exhausted, -31010), any-args and closed-world (not declared, -32602) scenarios returned error bodies that match the documented shapes field for field.
  • Homepage card. Screenshots of the "Everything you need" grid and the MCP Protocol card at 375, 430 and 1440 px were checked. The card text fits and the grid is intact.

Note from the value check: LangGraph's default ToolNode (handleToolErrors: true) turns the MCP error into a tool message, so a default agent can still print the expected answer. The journal outcome and the server log catch that case.

Known limitations

  • Adding fakes across several existing mounts is not all-or-nothing. If a later mount rejects a block (for example, an entry-id collision), earlier mounts keep the blocks they already got. This applies to LLMock after start, the start-time hand-off to existing MCP mocks, and control-API adds that span several mounts. This is documented on the MCPMock page.
  • A journal test-id cap between 0 and 1 keeps 0 test ids. This behavior predates this PR.

Follow-ups (backlog)

  • Comment wording in a few places.
  • Test hygiene: some weak assertions.
  • Hardening for loggers, metrics or internal lookups that throw.
  • The /health body shape after auto-mount.
  • docs/integrate-maf shows the wrong command (@copilotkit/aimock --fixtures as npx arguments).

Release

Release surfaces are deferred to the release commit. There is no version bump in this PR. The changelog entries are under Unreleased.

🤖 Generated with Claude Code

https://claude.ai/code/session_01AKuRWApa4KRBia1X1F3anz

jpr5 added 7 commits October 2, 2026 16:50
Adds the mcpFakes fixture types, the fake store (claim, match, echo, error results), message and
echo text helpers, FixtureLoadError rules for MCP fakes, request identity helpers, and the journal
cap getter. Adds the MCP SDK dev dependencies and the real-client test harness.
MCPMock and the MCP handler answer tools/call from the fake store, with structured MCP_FAKE_*
errors, per-test-id scoping and journal records. Adds the auto-mount used by the server and LLMock.
Adds loaders that return fixtures together with service fixtures and MCP fake sources. The recorder
keeps top-level keys it does not own, such as mcpFakes, when it merges into an existing file.
The server owns the mount list, auto-mounts /mcp for loaded fakes, and adds the /__aimock MCP fakes
control routes and reset doors.
…ugins

LLMock and the CLI load mcpFakes blocks and auto-mount them. validate-cli checks mcpFakes. The
vitest, jest and pytest plugins load fake blocks and report load errors.
The package entry and the ./mcp subpath export the new loaders, FixtureLoadError, McpFakesAddError,
the error codes and the public types.
Adds the MCP fakes guide, control API reference, fixture and integration notes, and a test that runs
the documented samples against a real server.
@pkg-pr-new

pkg-pr-new Bot commented Oct 3, 2026 •

Copy link
Copy Markdown

Open in StackBlitz

npm i https://pkg.pr.new/@copilotkit/aimock@490

commit: e218c56

jpr5 and others added 2 commits October 4, 2026 12:33
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AKuRWApa4KRBia1X1F3anz
Document the MCPMock fake methods, the loaders and their return shape,
the exports, entry and block ids, the identity 400 body, the failure
metric, the test plugins and the pytest plugin, aimock --config loading,
and aimock validate with mcpFakes. Add mcpFakeBlocks to the validate
--json samples, and list the missing items in the changelog.
@jpr5
jpr5 marked this pull request as ready for review October 5, 2026 15:20
@jpr5
jpr5 merged commit 2dc1d81 into main Oct 5, 2026
29 checks passed
@jpr5
jpr5 deleted the feat/mcp-fakes branch October 5, 2026 15:20
jpr5 added a commit that referenced this pull request Oct 7, 2026
… comparison (#494)

**Current integrated candidate:**
`72ec91bf171d7138b711d18913120d55f6fccd61`, parents
`46b523f08bfacd9d6708850b2e6a2efac8b017a6` and main
`f3ff1d22bcd5ef593a4ca8f9ba1b06d457878dad` after #479. Independent
integration review: NO FINDINGS. Fresh full quality is CLEAN and bounded
local completion PASS: 9,561 tests passed, one unchanged optional
external-spec skip, 293 files (63.40s). Preparation build, format, lint,
three-project types, full tests, final build and exports passed. The
protected AC-power interval had no Sleep/Wake/DarkWake events; tree and
HEAD remained clean and unchanged. Push and body publication/exact
readback are complete. All 13 exact-head CI checks succeeded; GitHub
reports MERGEABLE and draft=false. Ready for authorized merge in the
prescribed order after this final status publication. Prior statuses and
execution SHAs below are historical; reuse unchanged proof only. No
merge is claimed yet. No new versions or release are requested. Only
adjacent #494/#479 Changed changelog entries conflicted; both were
retained and conflict markers removed. Homepage HTML remains
byte-identical, so accepted visual proof is reused;
src/scripts/version/workflow blobs equal new main.

**Current-main integration:**
`0b795bcb77585626ffbb7a06be88f66e87901593`, parents
`ffbc2c4901a59e03e9140715306c75ad93023426` and main
`0002210a42fad8fc68585c26f58fc4a53bfc1677`. New-head push, revised body
publication/readback and CI are pending. Earlier candidate identities,
quality and publication statements below remain historical.
Authorization status is superseded by the current gated instruction
above. Versions/workflows remain unchanged; no new versions or release
are requested. PR #494 remains draft. Independent integration review
reports NO FINDINGS and full integrated local quality is CLEAN at this
exact head; new-head push and CI remain pending.

**Current documentation successor:**
`ffbc2c4901a59e03e9140715306c75ad93023426`, from
`3f298d6536f9d3f12d5d6f89fce5387e96175d1b`. Only `CHANGELOG.md` changed;
those blobs exactly match independently reviewed concern
`cfe96ab2641451a854ff87f4b0b0271fc670c26c` (NO FINDINGS). Runtime,
tests, versions and workflows remain byte-identical, so prior
quality/review evidence is reused without a new full-suite claim.
New-head push, body publication/readback and CI are PENDING. Earlier
publication/CI statements below are historical predecessor snapshots.
Merge only when the final candidate is ready and CI is green, after
required landing-order/latest-main checks; no new versions, merge or
release are claimed. PR #494 remains draft.

**Preliminary publication:** branch head
`3f298d6536f9d3f12d5d6f89fce5387e96175d1b` was pushed successfully by
normal fast-forward and verified with `ls-remote`. This revised PR-body
publication/readback is pending. CI has just been triggered; no passing
result is asserted. Final latest-main integration in the approved
landing order, affected validation and remaining shipping gates are
still required. Merge and release have not occurred. PR #494 remains a
draft.

This adds MockServer and WireMock columns to the homepage comparison,
two scenario-scoped MCP-fakes rows, and an Agent tool mocking comparison
with Mastra and MockServer. It retains the original responsive-table
changes: contained horizontal scrolling below 1140px and the desktop
sticky header.

Only `docs/index.html` changes. The reviewed corrections make WireMock's
partial support explicit, label MCP fakes as **preview-only**, provide a
tested commit-pinned install, and keep that warning and command visible
after either Preview link is clicked.

**Draft status:** bounded documentation review is clean at reviewed
candidate `ee0653b14b05514268ecd2bec1c2b0bafd9c5e14`. This description
is prepared before shipping; the remote PR was still draft at
`d0398c03e6134ad8abff2817c816c172fdcd74ab` when read. Refresh after #488
lands, affected-proof revalidation, final shipping quality,
final-candidate commit grouping, completion checks, final-candidate
publication, exact-head CI, and merge remain pending. No fresh CI
success or readiness to merge is claimed.

**Current candidate:** `3f298d6536f9d3f12d5d6f89fce5387e96175d1b` after
metadata-only message repair; commitlint passes. Its tree
`2a8b814131e09001b0f1425957db9da67d0285e9` equals reviewed/tested
`ee0653b14b05514268ecd2bec1c2b0bafd9c5e14` exactly. Every mapped commit
retains its tree, author and author date; original contributor commits
are unchanged. Prior proof, review and quality results are reused by
that identity, not claimed as new executions.

## Comparison and release wording

- WireMock's Chaos testing cell now says **HTTP faults and delays**,
linked to its [primary fault-simulation
documentation](https://wiremock.org/docs/simulating-faults/#bad-responses).
This describes its fixed/random delays and HTTP faults without implying
parity with every aimock chaos control.
- The two new aimock cells say **Preview**, the agent-tools header says
**aimock MCP fakes (preview)**, and the note explicitly says these
features are absent from npm release `1.43.0`.
- The documented install is `npm install
https://pkg.pr.new/@copilotkit/aimock@d0398c0`. The preview and npm
release both report version `1.43.0`; the resolved distribution URL and
integrity distinguish them.
- The preview paragraph uses only `scroll-margin-top: calc(var(--nav-h)
+ 3rem)` to clear the existing fixed navigation and reveal translation.
The command wraps on mobile. No runtime implementation, package version,
or release is changed.

## Comparison source record

The following preserves the original source mapping checked on
2026-10-05, with the WireMock correction rechecked on 2026-10-06.
Unchanged source reviews are reused; this does not claim a new
exhaustive runtime certification of competitors.


Each competitor cell is backed by a documented source, checked on
2026-10-05. The aimock MCP-fakes cells describe the explicitly qualified
preview feature merged in #490, not availability in the default npm
release. The ✗ cells for MSW, VidaiMock, mock-llm, piyook/llm-mock and
mokksy/ai-mocks in the two new rows follow from their existing ✗ in "MCP
tool mocking".

<details>
<summary>Main table: MockServer and WireMock cells → source</summary>

MockServer pages are under `https://www.mock-server.com/mock_server/`.
WireMock pages are under `https://wiremock.org/docs/`.

| Row | MockServer | Source | WireMock | Source |
|---|---|---|---|---|
| Cross-process interception | ✓ | https://www.mock-server.com/ | ✓ |
standalone/java-jar/, standalone/docker/, solutions/kubernetes/ |
| Chat Completions SSE | ✓ | llm_response_mocking.html | Not documented
| solutions/ai/ |
| Responses API SSE | ✓ | llm_response_mocking.html | Not documented |
solutions/ai/ |
| Claude Messages API | ✓ | llm_response_mocking.html | Not documented |
solutions/ai/ |
| Gemini streaming | ✓ | llm_response_mocking.html | Not documented |
solutions/ai/ |
| Multi-provider support | ✓ | llm_response_mocking.html | Not
documented | solutions/ai/ |
| OpenRouter router / fallback simulation | Partial (OpenAI-compatible
endpoint only; no fallback routing) | llm_response_mocking.html | Not
documented | solutions/ai/ |
| WebSocket APIs | Partial (Java client only) |
llm_response_mocking.html | Partial (4.x beta, generic WebSocket only) |
websockets/ |
| Embeddings API | ✓ | llm_response_mocking.html | Not documented |
solutions/ai/ |
| Image generation | ✗ | llm_response_mocking.html ("does not ...
generate image/audio responses") | Not documented | solutions/ai/ |
| Image editing | ✗ | llm_response_mocking.html | Not documented |
solutions/ai/ |
| Text-to-Speech | ✗ | llm_response_mocking.html | Not documented |
solutions/ai/ |
| Audio transcription | Not documented | all 93 pages in
https://www.mock-server.com/sitemap.xml | Not documented | solutions/ai/
|
| Audio translation | Not documented | sitemap scan, as above | Not
documented | solutions/ai/ |
| Non-speech audio | ✗ | llm_response_mocking.html | Not documented |
solutions/ai/ |
| Video generation | Not documented | sitemap scan, as above | Not
documented | solutions/ai/ |
| AG-UI event mocking | Not documented | sitemap scan, as above | Not
documented | all 76 pages in https://wiremock.org/sitemap-0.xml |
| MCP tool mocking | ✓ | ai_protocol_mocking.html | Not documented |
solutions/ai/ |
| Scenario-scoped MCP tool fakes (new) | Not documented |
ai_protocol_mocking.html | Not documented | solutions/ai/ |
| Fail on undeclared MCP tool (new) | Not documented |
ai_protocol_mocking.html | Not documented | solutions/ai/ |
| A2A agent mocking | ✓ | ai_protocol_mocking.html | Not documented |
solutions/ai/ |
| Vector DB mocking | Not documented | sitemap scan, as above | Not
documented | WireMock sitemap scan, as above |
| Search &amp; rerank | Partial (rerank only; no web search) |
llm_response_mocking.html | Not documented | WireMock sitemap scan, as
above |
| Record &amp; replay | ✓ | ai_traffic_inspection.html,
configuration_properties.html | ✓ HTTP | record-playback/ |
| Drift detection | Partial (HTTP stubs; LLM cassette drift named, not
described) | drift_detection.html | ✗ OSS | wiremock-cloud/ (OSS vs
Cloud table) |
| Sequential / stateful responses | ✓ | llm_response_mocking.html | ✓
HTTP | stateful-behaviour/ |
| Request journal | ✓ | debugging_issues.html | ✓ | verifying/ |
| Error injection | ✓ | https://www.mock-server.com/ | ✓ HTTP |
simulating-faults/ |
| Chaos testing | ✓ | https://www.mock-server.com/ | HTTP faults and
delays | https://wiremock.org/docs/simulating-faults/#bad-responses |
| Streaming physics | ✓ | llm_response_mocking.html | Partial (chunked
dribble delay only) | simulating-faults/ |
| Structured output / JSON mode | ✓ | creating_expectations.html | Not
documented | solutions/ai/ |
| Programmatic API | ✓ 8 languages | https://www.mock-server.com/ | ✓
Java/JVM | java-usage/ |
| Fixture files | ✓ JSON | initializing_expectations.html | ✓ JSON |
standalone/java-jar/ |
| GitHub Action | ✓ |
https://github.com/marketplace/actions/setup-mockserver | Not documented
| WireMock sitemap scan, as above |
| Vitest / Jest plugins | Partial (framework-agnostic helper, hooks
wired by hand) | mockserver_clients.html | Not documented | WireMock
sitemap scan, as above |
| Docker + Helm | ✓ | https://www.mock-server.com/ | ✓ (Helm
experimental) | standalone/docker/, solutions/kubernetes/ |
| Prometheus metrics | ✓ | https://www.mock-server.com/ | Partial
(community extension only) | external-resources/ |
| Streaming usage chunks | Partial (realtime WebSocket only) |
llm_response_mocking.html | Not documented | solutions/ai/ |
| Rate limiting headers | Partial (generic 429 rate-limit clause only) |
llm_response_mocking.html, configuration_properties.html | Not
documented | solutions/ai/ |
| Dependencies | JVM, or JVM-less binary | https://www.mock-server.com/
| JVM | standalone/java-jar/ |

</details>

<details>
<summary>Agent tool mocking table: Mastra and MockServer cells →
source</summary>

Mastra sources: docs https://mastra.ai/docs/evals/experiments and blog
https://mastra.ai/blog/introducing-experiment-tool-mocks. MockServer
sources are under `https://www.mock-server.com/mock_server/`.

| Row | Mastra | Source | MockServer | Source |
|---|---|---|---|---|
| Works with any agent framework | ✗ (agent targets only) | docs | ✓
(over MCP HTTP) | ai_protocol_mocking.html |
| MCP tools | Not documented | docs, blog | ✓ | ai_protocol_mocking.html
|
| In-process (non-network) tools | ✓ | docs | n/a (a network mock cannot
see in-process calls) | — |
| Ordered answers per tool | ✓ | docs | Not documented |
ai_protocol_mocking.html |
| Exact-argument matching | ✓ | docs | Not documented |
ai_protocol_mocking.html |
| Any-argument matching | ✓ | docs | ✓ (one answer for every call) |
ai_protocol_mocking.html |
| Fail on undeclared tool | ✓ | docs | Not documented |
ai_protocol_mocking.html |
| Scripted tool errors | Not documented | docs | ✓ |
ai_protocol_mocking.html |
| Per-test state and reset | ✓ | docs | Not documented |
ai_protocol_mocking.html |
| LLM responses mocked in the same scenario | Not documented | docs,
blog | Partial (same server; tool mocks not tied to the LLM scenario) |
llm_response_mocking.html, ai_protocol_mocking.html |
| Create fakes from recorded runs | Partial (how a mock is made from a
trace is not described) | docs | ✓ | ai_traffic_inspection.html,
ai_mcp_tools.html |
| Report unused mocks | ✓ | docs | Not documented |
ai_protocol_mocking.html |
| Workflows | Planned | blog | n/a | — |

</details>

## Local proof and exact revisions

| Correction | Local RED / pre-edit revision | Proven fix / accepted
integration |
|---|---|---|
| WireMock cell | `06702702d245676e81fc0183f6626246a8c3bd6f` | Executor
`88f13ed6973d29b7cd46f3e700a5fd25e40d5897`; accepted
`31191e099c85727dde092e2060bfb297c5f682aa` (same tree) |
| Preview qualification | `31191e099c85727dde092e2060bfb297c5f682aa` |
Executor `b328ca9b5d2047fd919349b8cdc26749b3bfd10d`; accepted
`82e7f11a943abbcb6b808194cee8fee95db0bfa0` |
| Preview anchor visibility | `82e7f11a943abbcb6b808194cee8fee95db0bfa0`
| Executor `97acc75c21edff093ec4583f328886d524a483ee`; candidate
`ee0653b14b05514268ecd2bec1c2b0bafd9c5e14` |

These are historical proof identities, not a claim that the eventual
pushed/reorganized commits keep the same SHAs. Final candidate and
anchor-fix executor have identical HTML blob
`b19bfa8ae0663ed06c9fd9ab786cc8235de9f628`. Browser probes record
pre-commit HEAD plus the actual working patch; a GREEN line retaining
its RED HEAD is not a test of unchanged content.

<details>
<summary>WireMock: same rendered-cell assertion fails before and passes
after at three sizes</summary>

Recorded commands: `node probe.cjs red` (exit 1) before the cell edit;
`node probe.cjs green` (exit 0) afterward. The original assertion script
was retained as `probe-original.cjs`. Both runs locate the actual
WireMock table header and Chaos testing row and check text, source URL,
and absence of the No label.

```jsonl
{"phase":"red","url":"http://127.0.0.1:59889/","head":"06702702d245676e81fc0183f6626246a8c3bd6f","diff":""}
{"viewport":"375x667","column":"WireMock","row":"Chaos testing","actual":{"text":"✗","href":null,"no":1},"assertion":"FAIL","screenshot":"red-375x667.png"}
{"viewport":"430x932","column":"WireMock","row":"Chaos testing","actual":{"text":"✗","href":null,"no":1},"assertion":"FAIL","screenshot":"red-430x932.png"}
{"viewport":"1440x900","column":"WireMock","row":"Chaos testing","actual":{"text":"✗","href":null,"no":1},"assertion":"FAIL","screenshot":"red-1440x900.png"}
RED: rendered WireMock cell falsely says No
```
GREEN raw output:
```jsonl
{"phase":"green","url":"http://127.0.0.1:59979/","head":"06702702d245676e81fc0183f6626246a8c3bd6f","diff":"diff --git a/docs/index.html b/docs/index.html\nindex 9005c9cb..4889aa83 100644\n--- a/docs/index.html\n+++ b/docs/index.html\n@@ -3363,7 +3363,11 @@\n                 <td><span class=\"no\" role=\"img\" aria-label=\"No\">&#10007;</span></td>\n                 <td><span class=\"no\" role=\"img\" aria-label=\"No\">&#10007;</span></td>\n                 <td><span class=\"yes\" role=\"img\" aria-label=\"Yes\">&#10003;</span></td>\n-                <td><span class=\"no\" role=\"img\" aria-label=\"No\">&#10007;</span></td>\n+                <td>\n+                  <a href=\"https://wiremock.org/docs/simulating-faults/#bad-responses\"\n+                    >HTTP faults and delays</a\n+                  >\n+                </td>\n               </tr>\n               <tr>\n                 <th scope=\"row\">Streaming physics</th>\n"}
{"viewport":"375x667","column":"WireMock","row":"Chaos testing","actual":{"text":"HTTP faults and delays","href":"https://wiremock.org/docs/simulating-faults/#bad-responses","no":0},"assertion":"PASS","screenshot":"green-375x667.png"}
{"viewport":"430x932","column":"WireMock","row":"Chaos testing","actual":{"text":"HTTP faults and delays","href":"https://wiremock.org/docs/simulating-faults/#bad-responses","no":0},"assertion":"PASS","screenshot":"green-430x932.png"}
{"viewport":"1440x900","column":"WireMock","row":"Chaos testing","actual":{"text":"HTTP faults and delays","href":"https://wiremock.org/docs/simulating-faults/#bad-responses","no":0},"assertion":"PASS","screenshot":"green-1440x900.png"}
GREEN: qualified linked cell at all three viewports
```


The early screenshots caught existing reveal/scroll animation before it
settled and are **not** the final visual evidence. Supplemental
`green-visible` and committed captures used instant scrolling and waited
for opacity transitions without changing the DOM assertions. Those
images were opened and inspected: the qualified link is legible and
wraps within the Chaos testing cell at 375×667, 430×932, and 1440×900.
Mobile captures scroll the existing table to WireMock while retaining
the row label.

</details>

<details>
<summary>MCP readiness: default npm failure, identical real HTTP smoke
succeeds on the pinned preview</summary>

Recorded distribution commands (machine-local output directories
abbreviated):

```sh
npm view @copilotkit/aimock version dist-tags --json
npm install --prefix published --ignore-scripts --no-audit --no-fund @copilotkit/aimock
npm install --prefix preview --ignore-scripts --no-audit --no-fund https://pkg.pr.new/@copilotkit/aimock@d0398c0
# Within each respective directory, with identical fixture, loader, and smoke:
node smoke.mjs
```

The registry then reported
`{"version":"1.43.0","dist-tags":{"latest":"1.43.0"}}`. For reproducing
that baseline later, pin `@copilotkit/aimock@1.43.0` instead of relying
on a moving dist-tag.


Registry output:
```json
{
  "version": "1.43.0",
  "dist-tags": {
    "latest": "1.43.0"
  }
}
```
`node smoke.mjs` in published directory exits1:
```text
Published package: 1.43.0
file:///Users/jpr5/.local/share/copilotkit/cr/aimock-pr-resolution-20261006/pr494/494-mcp-readiness/published/programmatic/load-fakes.mjs:11
  billing.loadFakes({
          ^

TypeError: billing.loadFakes is not a function
    at startMock (file:///Users/jpr5/.local/share/copilotkit/cr/aimock-pr-resolution-20261006/pr494/494-mcp-readiness/published/programmatic/load-fakes.mjs:11:11)
    at file:///Users/jpr5/.local/share/copilotkit/cr/aimock-pr-resolution-20261006/pr494/494-mcp-readiness/published/smoke.mjs:4:19

Node.js v26.8.1
```

## Preview actual HTTP GREEN

The same script in preview directory exits0:
```text
Published package: 1.43.0
Started http://127.0.0.1:63963
{"mount":"/mcp","status":200,"data":{"jsonrpc":"2.0","id":1,"result":{"content":[{"type":"text","text":"{\"tempF\":60,\"conditions\":\"rain\"}"}],"structuredContent":{"tempF":60,"conditions":"rain"},"isError":false}}}
{"mount":"/billing","status":200,"data":{"jsonrpc":"2.0","id":1,"result":{"content":[{"type":"text","text":"refunded 10"}],"isError":false}}}
PASS real HTTP published package advertised docs samples
```

Smoke script (MCP initialize + initialized notification before real
tools/call):
```js
import assert from 'node:assert/strict';
import { startMock } from './programmatic/load-fakes.mjs';
console.log('Published package:', JSON.parse((await import('node:fs')).readFileSync('./node_modules/@copilotkit/aimock/package.json')).version);
const llm = await startMock();
try {
 const url = llm.url;
 console.log('Started', url);
 for (const [mount, testId, name, args, expected] of [
 ['/mcp', 'weather › seattle', 'get_weather', {city:'Seattle'}, '{"tempF":60,"conditions":"rain"}'],
 ['/billing', 'billing › refund', 'refund', {amount:10}, 'refunded 10']]) {
  const init = await fetch(url+mount, {method:'POST', headers:{'content-type':'application/json','X-Test-Id':encodeURIComponent(testId)}, body:JSON.stringify({jsonrpc:'2.0',id:0,method:'initialize',params:{protocolVersion:'2025-03-26',capabilities:{},clientInfo:{name:'readiness-smoke',version:'1'}}})});
  assert.equal(init.status,200); const session=init.headers.get('mcp-session-id'); assert.ok(session);
  const ready=await fetch(url+mount,{method:'POST',headers:{'content-type':'application/json','mcp-session-id':session},body:JSON.stringify({jsonrpc:'2.0',method:'notifications/initialized'})}); assert.equal(ready.status,202);
  const response = await fetch(url+mount, {method:'POST', headers:{'mcp-session-id':session,'content-type':'application/json', 'X-Test-Id':encodeURIComponent(testId)}, body:JSON.stringify({jsonrpc:'2.0',id:1,method:'tools/call',params:{name,arguments:args}})});
  const data = await response.json(); console.log(JSON.stringify({mount,status:response.status,data}));
  assert.equal(response.status,200); assert.equal(data.result.content[0].text,expected);
 }
 console.log('PASS real HTTP published package advertised docs samples');
} finally { await llm.stop(); }
```



The final v3 smoke initializes actual MCP sessions, sends
`notifications/initialized`, then calls tools over HTTP with the
returned session IDs and scenario IDs. Earlier harness attempts missing
initialization were discarded as product evidence. Default npm still
fails: GREEN demonstrates preview support and truthful documentation,
not a repaired default release. Two successful documented calls are
covered; undeclared-tool errors and every other MCP option were not
freshly exercised.

A later install into a new directory retrieved the same preview and the
unchanged smoke again returned HTTP 200 for weather and refund. Its
lockfile resolves:

```text
https://pkg.pr.new/@copilotkit/aimock@d0398c0
sha512-/P8JxS1ClezOTllLqt6uikN9sgw/alsXBi/jyHQJgSfA/8zE907hwa/Jep2aQanEzqGTrr0g+pFn+vTs0iY0WQ==
```

The generic smoke label “Published package: 1.43.0” does not identify
the distribution. The npm output supports successful installation and
the lockfile identifies the artifact; no independent cache-miss proof or
guarantee of future preview-host availability is claimed. A web client's
403 was not an npm-install failure.

To reproduce the exact smoke above without access to local evidence
files, save these two unchanged linked-doc samples alongside `smoke.mjs`
in **each** installation directory. Create the `programmatic` and
`weather` subdirectories first. They were extracted from the branch's
[MCP example
page](https://github.com/CopilotKit/aimock/blob/d0398c03e6134ad8abff2817c816c172fdcd74ab/docs/mcp-mock/index.html).

`programmatic/load-fakes.mjs`:
```js
import { LLMock, MCPMock } from "@copilotkit/aimock";

export async function startMock() {
  const llm = new LLMock({ port: 0 });

  // A fixture file: its LLM fixtures and its mcpFakes load together.
  llm.loadFixtureFile("weather/seattle.json");

  // Fakes from code, on a mount you create.
  const billing = new MCPMock();
  billing.loadFakes({
    scope: { testId: "billing › refund" },
    tools: [{ name: "refund", calls: [{ args: { amount: 10 }, result: "refunded 10" }] }],
  });
  llm.mount("/billing", billing);

  await llm.start();
  return llm;
}
```

`weather/seattle.json`:
```json
{
  "fixtures": [
    {
      "match": { "userMessage": "weather in Seattle", "hasToolResult": false },
      "response": { "toolCalls": [{ "name": "get_weather", "arguments": { "city": "Seattle" } }] }
    },
    {
      "match": { "userMessage": "weather in Seattle", "hasToolResult": true },
      "response": { "content": "It is 60F and raining in Seattle." }
    }
  ],
  "mcpFakes": {
    "scope": { "testId": "weather › seattle" },
    "tools": [
      {
        "name": "get_weather",
        "calls": [
          { "args": { "city": "Seattle" }, "result": { "tempF": 60, "conditions": "rain" } }
        ]
      }
    ]
  }
}
```

</details>

<details>
<summary>Rendered preview qualification: real homepage RED/GREEN at all
three sizes</summary>

Recorded commands: `node visual.cjs red` before the qualification edit
and `node visual.cjs final` afterward. The assertions stayed the same;
supplementary screenshot positioning centered the new note. The final
mobile image also verified the bounded command-wrapping correction.

```text
visual-red.log
{"viewport":"375x667","actual":{"text":"aimock MCP fakes","href":"/mcp-mock","preview":0,"labels":["Built-in ✓","Built-in ✓"],"command":[]},"assertion":"FAIL","screenshot":"red-375x667.png"}
{"viewport":"430x932","actual":{"text":"aimock MCP fakes","href":"/mcp-mock","preview":0,"labels":["Built-in ✓","Built-in ✓"],"command":[]},"assertion":"FAIL","screenshot":"red-430x932.png"}
{"viewport":"1440x900","actual":{"text":"aimock MCP fakes","href":"/mcp-mock","preview":0,"labels":["Built-in ✓","Built-in ✓"],"command":[]},"assertion":"FAIL","screenshot":"red-1440x900.png"}
FAIL: missing preview qualification for unpublished MCP fakes
visual-final.log
{"viewport":"375x667","actual":{"text":"aimock MCP fakes (preview)","href":"/mcp-mock","preview":1,"labels":["Preview ✓","Preview ✓"],"command":["npm install https://pkg.pr.new/@copilotkit/aimock@d0398c0"]},"assertion":"PASS","screenshot":"final-375x667.png"}
{"viewport":"430x932","actual":{"text":"aimock MCP fakes (preview)","href":"/mcp-mock","preview":1,"labels":["Preview ✓","Preview ✓"],"command":["npm install https://pkg.pr.new/@copilotkit/aimock@d0398c0"]},"assertion":"PASS","screenshot":"final-430x932.png"}
{"viewport":"1440x900","actual":{"text":"aimock MCP fakes (preview)","href":"/mcp-mock","preview":1,"labels":["Preview ✓","Preview ✓"],"command":["npm install https://pkg.pr.new/@copilotkit/aimock@d0398c0"]},"assertion":"PASS","screenshot":"final-1440x900.png"}
PASS: preview-qualified MCP fakes at all three viewports
```


Final-note images were opened at all three required sizes and the
complete preview warning and wrapping install command were inspected.
The separate click-navigation failure discovered afterward was fixed and
verified below.

</details>

<details>
<summary>Preview navigation: both links fail before, then expose the
full warning and command at all three sizes</summary>

Recorded commands: `node probe.cjs red` (exit 1) before the
paragraph-only offset; `node probe.cjs green` (exit 0) after. Both run
actual clicks for both Preview links at 375×667, 430×932, and 1440×900.
The same click/destination assertions were used.

```text
red.log
{"viewport":"375x667","linkIndex":0,"actual":{"text":"MCP fakes require a preview package; they are not included in npm release 1.43.0. To try\n          the MCP fakes examples, install the verified\n          preview:\n          npm install https://pkg.pr.new/@copilotkit/aimock@d0398c0.","top":-30.015625,"bottom":126.546875,"navHeight":105,"margin":"0px"},"assertion":"FAIL","screenshot":"red-clicked0-375x667.png"}
{"viewport":"375x667","linkIndex":1,"actual":{"text":"MCP fakes require a preview package; they are not included in npm release 1.43.0. To try\n          the MCP fakes examples, install the verified\n          preview:\n          npm install https://pkg.pr.new/@copilotkit/aimock@d0398c0.","top":-0.015625,"bottom":156.546875,"navHeight":105,"margin":"0px"},"assertion":"FAIL","screenshot":"red-clicked1-375x667.png"}
{"viewport":"430x932","linkIndex":0,"actual":{"text":"MCP fakes require a preview package; they are not included in npm release 1.43.0. To try\n          the MCP fakes examples, install the verified\n          preview:\n          npm install https://pkg.pr.new/@copilotkit/aimock@d0398c0.","top":-30.015625,"bottom":127.546875,"navHeight":105,"margin":"0px"},"assertion":"FAIL","screenshot":"red-clicked0-430x932.png"}
{"viewport":"430x932","linkIndex":1,"actual":{"text":"MCP fakes require a preview package; they are not included in npm release 1.43.0. To try\n          the MCP fakes examples, install the verified\n          preview:\n          npm install https://pkg.pr.new/@copilotkit/aimock@d0398c0.","top":-0.015625,"bottom":157.546875,"navHeight":105,"margin":"0px"},"assertion":"FAIL","screenshot":"red-clicked1-430x932.png"}
{"viewport":"1440x900","linkIndex":0,"actual":{"text":"MCP fakes require a preview package; they are not included in npm release 1.43.0. To try\n          the MCP fakes examples, install the verified\n          preview:\n          npm install https://pkg.pr.new/@copilotkit/aimock@d0398c0.","top":-29.703125,"bottom":23.484375,"navHeight":70,"margin":"0px"},"assertion":"FAIL","screenshot":"red-clicked0-1440x900.png"}
{"viewport":"1440x900","linkIndex":1,"actual":{"text":"MCP fakes require a preview package; they are not included in npm release 1.43.0. To try\n          the MCP fakes examples, install the verified\n          preview:\n          npm install https://pkg.pr.new/@copilotkit/aimock@d0398c0.","top":0.296875,"bottom":53.484375,"navHeight":70,"margin":"0px"},"assertion":"FAIL","screenshot":"red-clicked1-1440x900.png"}
FAIL: Preview click hides warning/command behind fixed navigation
green.log
{"viewport":"375x667","linkIndex":0,"actual":{"text":"MCP fakes require a preview package; they are not included in npm release 1.43.0. To try\n          the MCP fakes examples, install the verified\n          preview:\n          npm install https://pkg.pr.new/@copilotkit/aimock@d0398c0.","top":122.984375,"bottom":279.546875,"navHeight":105,"margin":"153px"},"assertion":"PASS","screenshot":"green-clicked0-375x667.png"}
{"viewport":"375x667","linkIndex":1,"actual":{"text":"MCP fakes require a preview package; they are not included in npm release 1.43.0. To try\n          the MCP fakes examples, install the verified\n          preview:\n          npm install https://pkg.pr.new/@copilotkit/aimock@d0398c0.","top":152.984375,"bottom":309.546875,"navHeight":105,"margin":"153px"},"assertion":"PASS","screenshot":"green-clicked1-375x667.png"}
{"viewport":"430x932","linkIndex":0,"actual":{"text":"MCP fakes require a preview package; they are not included in npm release 1.43.0. To try\n          the MCP fakes examples, install the verified\n          preview:\n          npm install https://pkg.pr.new/@copilotkit/aimock@d0398c0.","top":122.984375,"bottom":280.546875,"navHeight":105,"margin":"153px"},"assertion":"PASS","screenshot":"green-clicked0-430x932.png"}
{"viewport":"430x932","linkIndex":1,"actual":{"text":"MCP fakes require a preview package; they are not included in npm release 1.43.0. To try\n          the MCP fakes examples, install the verified\n          preview:\n          npm install https://pkg.pr.new/@copilotkit/aimock@d0398c0.","top":152.984375,"bottom":310.546875,"navHeight":105,"margin":"153px"},"assertion":"PASS","screenshot":"green-clicked1-430x932.png"}
{"viewport":"1440x900","linkIndex":0,"actual":{"text":"MCP fakes require a preview package; they are not included in npm release 1.43.0. To try\n          the MCP fakes examples, install the verified\n          preview:\n          npm install https://pkg.pr.new/@copilotkit/aimock@d0398c0.","top":88.296875,"bottom":141.484375,"navHeight":70,"margin":"118px"},"assertion":"PASS","screenshot":"green-clicked0-1440x900.png"}
{"viewport":"1440x900","linkIndex":1,"actual":{"text":"MCP fakes require a preview package; they are not included in npm release 1.43.0. To try\n          the MCP fakes examples, install the verified\n          preview:\n          npm install https://pkg.pr.new/@copilotkit/aimock@d0398c0.","top":118.296875,"bottom":171.484375,"navHeight":70,"margin":"118px"},"assertion":"PASS","screenshot":"green-clicked1-1440x900.png"}
PASS: both Preview links expose complete warning and command at all three viewports
```


`final-visual.cjs final` changes only the source-cell horizontal
positioning from centered to nearest so source row names remain visible.
Both independent final reviewers inspected all 12 final screenshots:
both source rows and both clicked destinations, each at all three sizes.
They verified the release warning, examples link, and **entire** install
command visibly below navigation. Red destination pixels corroborate the
original occlusion. Geometry is supplementary, not the visual verdict.
Evidence hashes and exact HTML blob equality were checked before reusing
the captures.

</details>

<details>
<summary>Portable browser repro scripts: source, commands, and visual
inspection procedure</summary>

These copies make the recorded probes reproducible without machine-local
scripts. Only environment setup is adapted: `require('playwright')`,
Playwright's installed Chromium, and `DOCS_REPO` instead of the original
absolute worktree path. They were not freshly executed while drafting
this description; recorded results above belong to the original
scripts/revisions.

In a separate proof directory, install Playwright and Chromium, then
save the scripts below under their shown names:

```sh
npm install --no-save playwright
npx playwright install chromium
# Point at the actual checked-out before or after revision; use separate worktrees.
DOCS_REPO=/absolute/path/to/aimock-before node wiremock.cjs red
DOCS_REPO=/absolute/path/to/aimock-after node wiremock.cjs green
DOCS_REPO=/absolute/path/to/aimock-before node qualification.cjs red
DOCS_REPO=/absolute/path/to/aimock-after node qualification.cjs final
DOCS_REPO=/absolute/path/to/aimock-before node anchor.cjs red
DOCS_REPO=/absolute/path/to/aimock-after node anchor.cjs green
```

Use each correction's before/after identities in the revision table;
local fix SHAs become externally retrievable only when published. The
original PR head also contains the pre-correction WireMock and
preview-qualification failures. The anchor RED requires the intermediate
qualified-page revision. To reproduce the anchor failure on a final copy
if that intermediate history is unavailable after regrouping, remove
**only** the target paragraph's added `scroll-margin-top` declaration;
label that run a reconstructed before-state, not an original historical
execution.

Open generated PNGs and inspect actual pixels at all sizes. The original
WireMock script deliberately preserves its original timing for RED/GREEN
parity; for settled visual captures use instant `scrollIntoView` and
wait for the existing reveal transition, leaving assertions unchanged.
For final navigation source-row framing, change only `inline:'center'`
to `inline:'nearest'`; keep the same clicks and destination checks. Do
not treat bounding boxes alone as visual success.

`wiremock.cjs`:
```js
const { chromium } = require('playwright');
const http = require('node:http');
const fs = require('node:fs');
const path = require('node:path');
const { execFileSync } = require('node:child_process');
const repo = process.env.DOCS_REPO;
if (!repo) throw new Error('DOCS_REPO must name the checked-out aimock worktree');
const out = __dirname;
const phase = process.argv[2];
(async () => {
 const server = http.createServer((req,res) => {
  const file = path.resolve(repo,'docs','.' + decodeURI(req.url.split('?')[0] === '/' ? '/index.html' : req.url.split('?')[0]));
  if (!file.startsWith(path.join(repo,'docs') + '/')) { res.writeHead(403).end(); return; }
  try { res.setHeader('Content-Type',file.endsWith('.html')?'text/html':file.endsWith('.css')?'text/css':file.endsWith('.js')?'text/javascript':'application/octet-stream'); res.end(fs.readFileSync(file)); } catch { res.writeHead(404).end(); }
 });
 await new Promise(resolve => server.listen(0,'127.0.0.1',resolve));
 const url = `http://127.0.0.1:${server.address().port}/`;
 const browser = await chromium.launch({headless:true});
 let passed = true;
 try {
 console.log(JSON.stringify({phase,url,head:execFileSync('git',['rev-parse','HEAD'],{cwd:repo,encoding:'utf8'}).trim(),diff:execFileSync('git',['diff','--','docs/index.html'],{cwd:repo,encoding:'utf8'})}));
 for (const [width,height] of [[375,667],[430,932],[1440,900]]) {
  const page = await browser.newPage({viewport:{width,height}});
  await page.goto(url); await page.evaluate(() => document.fonts.ready);
  const table = page.locator('table').filter({has:page.getByRole('columnheader',{name:'WireMock',exact:true})});
  const headers = await table.locator('thead th').allTextContents();
  const index = headers.findIndex(text => text.trim() === 'WireMock');
  if (index < 0) throw new Error('WireMock header missing');
  const row = table.getByRole('row').filter({has:page.getByRole('rowheader',{name:'Chaos testing',exact:true})});
  const cell = row.locator('td').nth(index-1);
  await cell.evaluate(el=>el.scrollIntoView({block:'center',inline:'center'}));
  const actual = {text:(await cell.innerText()).trim(),href:await cell.locator('a').count() ? await cell.locator('a').getAttribute('href'):null,no:await cell.locator('[aria-label="No"]').count()};
  const ok = actual.text === 'HTTP faults and delays' && actual.href === 'https://wiremock.org/docs/simulating-faults/#bad-responses' && actual.no === 0;
  passed &&= ok;
  await page.screenshot({path:path.join(out,`${phase}-${width}x${height}.png`)});
  console.log(JSON.stringify({viewport:`${width}x${height}`,column:headers[index].trim(),row:'Chaos testing',actual,assertion:ok?'PASS':'FAIL',screenshot:`${phase}-${width}x${height}.png`}));
  await page.close();
 }
 } finally { await browser.close(); await new Promise(resolve=>server.close(resolve)); }
 console.log(passed?'GREEN: qualified linked cell at all three viewports':'RED: rendered WireMock cell falsely says No');
 process.exitCode = passed?0:1;
})().catch(e=>{ console.error(e); process.exit(2); });
```

`qualification.cjs`:
```js
const { chromium } = require('playwright');
const http = require('node:http');
const fs = require('node:fs');
const path = require('node:path');
const { execFileSync } = require('node:child_process');
const repo = process.env.DOCS_REPO;
if (!repo) throw new Error('DOCS_REPO must name the checked-out aimock worktree');
const out = __dirname;
const phase = process.argv[2];
(async () => {
 const server = http.createServer((req,res) => {
  const file = path.resolve(repo,'docs','.' + decodeURI(req.url.split('?')[0] === '/' ? '/index.html' : req.url.split('?')[0]));
  if (!file.startsWith(path.join(repo,'docs') + '/')) { res.writeHead(403).end(); return; }
  try { res.setHeader('Content-Type',file.endsWith('.html')?'text/html':file.endsWith('.css')?'text/css':file.endsWith('.js')?'text/javascript':'application/octet-stream'); res.end(fs.readFileSync(file)); } catch { res.writeHead(404).end(); }
 });
 await new Promise(resolve => server.listen(0,'127.0.0.1',resolve));
 const url = `http://127.0.0.1:${server.address().port}/`;
 const browser = await chromium.launch({headless:true});
 let passed = true;
 try {
 console.log(JSON.stringify({phase,url,head:execFileSync('git',['rev-parse','HEAD'],{cwd:repo,encoding:'utf8'}).trim(),diff:execFileSync('git',['diff','--','docs/index.html'],{cwd:repo,encoding:'utf8'})}));
 for (const [width,height] of [[375,667],[430,932],[1440,900]]) {
  const page = await browser.newPage({viewport:{width,height}});
  await page.goto(url); await page.evaluate(() => document.fonts.ready);
  const table = page.locator('.agent-tools-table');
  await table.evaluate(el=>el.scrollIntoView({block:'center',behavior:'instant'}));
  await page.waitForTimeout(1500);
  const link = table.getByRole('link',{name:/aimock MCP fakes/});
  const actual = {text:await link.innerText(),href:await link.getAttribute('href')};
  actual.preview = await page.locator('#mcp-fakes-preview').count(); actual.labels = await page.locator('.comparison-table tr').filter({has:page.getByRole('rowheader',{name:/^(Scenario-scoped MCP tool fakes|Fail on undeclared MCP tool)$/})}).locator('.col-aimock').allTextContents(); actual.command = await page.locator('#mcp-fakes-preview code').allTextContents(); const ok = actual.href === '/mcp-mock' && actual.text.includes('preview') && actual.preview === 1 && actual.labels.length === 2 && actual.labels.every(t=>t.includes('Preview')) && actual.command.includes('npm install https://pkg.pr.new/@copilotkit/aimock@d0398c0'); passed &&= ok;
  await page.screenshot({path:path.join(out,`${phase}-${width}x${height}.png`)});
  console.log(JSON.stringify({viewport:`${width}x${height}`,actual,assertion:ok?'PASS':'FAIL',screenshot:`${phase}-${width}x${height}.png`}));
  if (actual.preview) { await page.locator('#mcp-fakes-preview').evaluate(el=>el.scrollIntoView({block:'center',behavior:'instant'})); await page.waitForTimeout(1000); await page.screenshot({path:path.join(out,`${phase}-note-${width}x${height}.png`)}); }
  await page.close();
 }
 } finally { await browser.close(); await new Promise(resolve=>server.close(resolve)); }
 console.log(passed?'PASS: preview-qualified MCP fakes at all three viewports':'FAIL: missing preview qualification for unpublished MCP fakes');
 process.exitCode = passed?0:1;
})().catch(e=>{ console.error(e); process.exit(2); });
```

`anchor.cjs`:
```js
const { chromium } = require('playwright');
const http = require('node:http');
const fs = require('node:fs');
const path = require('node:path');
const { execFileSync } = require('node:child_process');
const repo = process.env.DOCS_REPO;
if (!repo) throw new Error('DOCS_REPO must name the checked-out aimock worktree');
const out = __dirname;
const phase = process.argv[2];
(async () => {
 const server = http.createServer((req,res) => {
  const file = path.resolve(repo,'docs','.' + decodeURI(req.url.split('?')[0] === '/' ? '/index.html' : req.url.split('?')[0]));
  if (!file.startsWith(path.join(repo,'docs') + '/')) { res.writeHead(403).end(); return; }
  try { res.setHeader('Content-Type',file.endsWith('.html')?'text/html':file.endsWith('.css')?'text/css':file.endsWith('.js')?'text/javascript':'application/octet-stream'); res.end(fs.readFileSync(file)); } catch { res.writeHead(404).end(); }
 });
 await new Promise(resolve => server.listen(0,'127.0.0.1',resolve));
 const url = `http://127.0.0.1:${server.address().port}/`;
 const browser = await chromium.launch({headless:true});
 let passed = true;
 try {
 console.log(JSON.stringify({phase,url,head:execFileSync('git',['rev-parse','HEAD'],{cwd:repo,encoding:'utf8'}).trim(),diff:execFileSync('git',['diff','--','docs/index.html'],{cwd:repo,encoding:'utf8'})}));
 for (const [width,height] of [[375,667],[430,932],[1440,900]]) {
  const page = await browser.newPage({viewport:{width,height}});
  await page.goto(url); await page.evaluate(() => document.fonts.ready);
  const links = page.locator('a[href="#mcp-fakes-preview"]');
  for (let index=0;index<2;index++) {
   const link=links.nth(index);
   await link.evaluate(el=>el.scrollIntoView({block:'center',inline:'center',behavior:'instant'}));
   await page.waitForTimeout(1200);
   await page.screenshot({path:path.join(out,`${phase}-row${index}-${width}x${height}.png`)});
   await link.click();
   await page.waitForTimeout(1600);
   const actual = await page.locator('#mcp-fakes-preview').evaluate(el=>({text:el.textContent.trim(),top:el.getBoundingClientRect().top,bottom:el.getBoundingClientRect().bottom,navHeight:parseFloat(getComputedStyle(document.documentElement).getPropertyValue('--nav-h')),margin:getComputedStyle(el).scrollMarginTop}));
   const ok = actual.top >= actual.navHeight && actual.bottom <= height;
   passed &&= ok;
   await page.screenshot({path:path.join(out,`${phase}-clicked${index}-${width}x${height}.png`)});
   console.log(JSON.stringify({viewport:`${width}x${height}`,linkIndex:index,actual,assertion:ok?'PASS':'FAIL',screenshot:`${phase}-clicked${index}-${width}x${height}.png`}));
  }
  await page.close();
 }
 } finally { await browser.close(); await new Promise(resolve=>server.close(resolve)); }
 console.log(passed?'PASS: both Preview links expose complete warning and command at all three viewports':'FAIL: Preview click hides warning/command behind fixed navigation');
 process.exitCode = passed?0:1;
})().catch(e=>{ console.error(e); process.exit(2); });
```

</details>

## Checks and review boundaries

Recorded targeted commands:

```sh
pnpm exec prettier --check docs/index.html
pnpm exec vitest run src/__tests__/competitive-matrix-homepage.test.ts
pnpm typecheck
git diff --check
```

The fix evidence records **40 homepage tests passed**, formatting
passed, and diff-check passed. The final candidate pre-audit records all
three TypeScript configurations passing. Both final independent
documentation roles returned **NO FINDINGS** at
`ee0653b14b05514268ecd2bec1c2b0bafd9c5e14`, with zero deferred findings
in the bounded subject. They inspected provenance-verified raw execution
evidence and pixels; they did not rerun installs or browser probes
during confirmation. Unchanged earlier factual proof was reused.

Coverage is Chromium at 375×667, 430×932, and 1440×900. Safari/Firefox,
arbitrary zoom/viewports, keyboard or assistive technology, reduced
motion, no JavaScript, and direct initial fragment loading were not
freshly tested. No deployed routing audit or exhaustive competitor/MCP
certification is claimed. The original author reported additional
responsive widths; this correction pass does not recertify those
historical captures.

## Remaining work and limits

- Reintegrate current main after #488, recheck affected proof and source
identity, and complete applicable final shipping quality,
final-candidate commit grouping, completion checks, final-candidate
publication/description update, exact-head CI, and merge. This draft is
not evidence those steps have happened.
- The desktop table has limited width headroom; another column requires
another layout check. Between 769 and 1139px, its horizontal scrollbar
remains at the bottom of the tall table.
- The weekly competitor updater does not yet track MockServer/WireMock
columns. Pre-existing migration-page provider-count behavior remains
outside this PR.
- Merging documentation does **not** release MCP fakes. Default npm
`1.43.0` remains unsupported for these examples; the explicit tested
preview path is the supported claim of this page. No release is
requested by this PR.

## Preparatory local quality — CLEAN

Candidate `ee0653b14b05514268ecd2bec1c2b0bafd9c5e14`: Passing format,
lint and all-three-project typecheck evidence was reused on the
unchanged candidate; test, build and exports were rerun serially. `pnpm
test`, `pnpm build`, and `pnpm test:exports` exited 0. Full suite:
**9,521 passed, 1 skipped** (9,522 total), 291 files passed. The
unchanged optional spec-heading check skipped because
`AIMOCK_MCP_FAKES_SPEC` was absent. Existing built CLI was exercised; no
test was newly skipped or disabled. Builds retain three nonfatal tsdown
`define` warnings. Earlier failed runs remain preserved; this serial
recovery changed no source, dependency, configuration or timeout.

This is exact-candidate preparatory evidence. Final shipping requires
the approved landing order, latest-main integration, applicable
affected-proof and quality revalidation, final-candidate commit
grouping/authorship, completion checks, final-candidate publication/body
update and exact-head CI. Merge remains pending; no release or CI
success is claimed.


### Documentation follow-up proof

The same actual-file check ran before edits (exit 1) and on the
committed worker (exit 0). This is missing/present documentation proof,
not a runtime defect. The GREEN output includes the actual Unreleased
entry. Recorded command:

```sh
python3 /Users/jpr5/.local/share/copilotkit/cr/aimock-pr-resolution-20261006/pr494/docs-followup/doc-proof.py
```

RED:
```text
HEAD 3f298d6536f9d3f12d5d6f89fce5387e96175d1b
Unreleased/Changed subject coverage: ["comparisons", "WireMock", "MCP", "preview"] matching entries: 0
FAIL: actual Unreleased section omits this reviewed PR subject
Documentation check FAIL
```
GREEN (worker execution, not relabelled as integration execution):
```text
HEAD cfe96ab2641451a854ff87f4b0b0271fc670c26c
Unreleased/Changed subject coverage: ["comparisons", "WireMock", "MCP", "preview"] matching entries: 1
PASS: - Expand the landing-page comparisons, qualify WireMock fault support, and label MCP scenario fakes as preview-only with a pinned preview installation (#494).
Documentation check PASS
```

Save this original probe outside the checkout and run from its root for
reproduction without local artifact access:
```python
from pathlib import Path
import subprocess,re,sys,json
p=Path.cwd()
print('HEAD',subprocess.check_output(['git','rev-parse','HEAD'],text=True).strip())
s=(p/'CHANGELOG.md').read_text()
u=re.split(r'\n## ',s)[1]
category='Changed'
section=re.search(r'(?ms)^### '+re.escape(category)+r'\n(.*?)(?=^### |\Z)',u)
bullets=[] if section is None else re.findall(r'(?m)^- .*',section.group(1))
required=['comparisons', 'WireMock', 'MCP', 'preview']
matches=[b for b in bullets if all(t in b for t in required)]
print('Unreleased/'+category+' subject coverage:',json.dumps(required),'matching entries:',len(matches))
fail=not matches
if fail:print('FAIL: actual Unreleased section omits this reviewed PR subject')
else:print('PASS:',matches[0])
if 494==451:
 text=(p/'README.md').read_text()
 checks=['/__aimock/openapi.json','/__aimock/routes','{ count, routes }','buildOpenApiDocument','CATALOG_ROUTES','ROUTE_DEFINITIONS']
 missing=[t for t in checks if t not in text]
 print('README catalog guidance missing:',json.dumps(missing))
 fail=fail or bool(missing)
 print('FAIL: public catalog usage is undocumented' if missing else 'PASS: README documents real catalog endpoints, envelope and exports')
print('Documentation check', 'FAIL' if fail else 'PASS')
sys.exit(1 if fail else 0)
```

Markdown formatting, diff checks and commitlint passed for the
documentation concern. No new runtime tests or version/workflow changes
were made.

### Clean integration with current main

Merged `0002210a42fad8fc68585c26f58fc4a53bfc1677` into published
`ffbc2c4901a59e03e9140715306c75ad93023426` without conflict or manual
edits. The file historically named `merge-red.log` records a successful
merge, not RED; no failure is invented.

Command: `git merge --no-commit --no-ff
0002210a42fad8fc68585c26f58fc4a53bfc1677`. Actual output:
```text
Auto-merging CHANGELOG.md
Auto-merging docs/index.html
Automatic merge went well; stopped before committing as requested
```

Only `CHANGELOG.md` and `docs/index.html` differ from integrated main.
Runtime source and the competitive-matrix script equal main. Incoming
HTML changes set the mock-llm and piyook/llm-mock **Responses API SSE**
cells to No; reviewed WireMock HTTP faults/delays, preview labels,
pinned install and navigation offset remain intact.

Fresh direct validation:
```sh
pnpm exec vitest run src/__tests__/competitive-matrix
pnpm exec prettier --check docs/index.html CHANGELOG.md
```
```text
Test Files  9 passed (9)
Tests  241 passed (241)
Checking formatting...
All matched files use Prettier code style!
```

Actual locally served merged-page browser checks passed both Preview
links and incoming Responses API SSE cells at 375×667, 430×932 and
1440×900. Screenshots were opened and visually inspected, including the
warning/command below the fixed navigation and the incoming No cells.
The initial probe targeted the wrong Chat Completions row; it was
corrected without editing the page and is not credited as product RED.
Existing pinned-artifact evidence remains valid. Independent integration
review now reports NO FINDINGS at
`0b795bcb77585626ffbb7a06be88f66e87901593`, after inspecting all 21
screenshots. Fresh full integrated local quality is CLEAN: format, lint,
source/test/scripts typecheck, full tests, build and package exports
passed; 9,558 tests passed and one unchanged optional external-spec
check skipped across 292 files. CLI tests used fresh dist; the existing
three nonfatal `define` warnings remain disclosed. New-head push and CI
remain pending; administrator merging remains subject to the current
readiness/CI gates.

The integration reviewer verified staged-merge screenshot provenance:
capture logs retain the first-parent HEAD because images preceded the
merge commit. The final HTML contains exactly the two incoming No cells
plus unchanged accepted preview/WireMock surfaces. The 21 images are not
represented as having logged the final commit SHA; no fresh
cross-browser or accessibility claim is made.

### Integration after #492 landed

Actual command: `git merge --no-commit --no-ff
c35471dbd262ed7eb2248c74382d531f9461870e`. Successful output (not RED):
```text
Auto-merging CHANGELOG.md
Automatic merge went well; stopped before committing as requested
```

The complete homepage HTML remains blob
`45008fac769cf99bdc63b8966ea8b05c876bbe3b`, identical to accepted parent
`0b795bcb77585626ffbb7a06be88f66e87901593`. The independent reviewer
reuses the 21 previously inspected screenshots and accepted Preview
navigation, WireMock and Responses API SSE evidence; no rerender or new
screenshot execution is claimed. Incoming runtime/OpenRouter
documentation equals main, and only CHANGELOG.md plus docs/index.html
differ from main. The #494 entry remains under **Changed**;
#492/#488/#480 entries remain under **Fixed**. No entry was lost or
duplicated.

Independent integration review: **NO FINDINGS**. The earlier full local
quality attempt FAILED: `pnpm test` exited 1 with 17 failed / 9,541
passed / 1 skipped tests across 17 failed and 275 passed files, plus 4
unhandled errors. That failure and its diagnosis remain preserved; no
source fix is claimed. A subsequently authorized recovery on the
identical candidate completed successfully, as recorded below. The
failed run is not relabelled as passing. Push, publication/readback,
exact-head CI and merge remain pending.

Version, package and workflow files remain unchanged. For the
already-landed #492 merge, actual Release run 37567151181 succeeded with
npm publish skipped and Python build/publish steps skipped; no new
version was published by that run. This predecessor result does not
claim a future release-check outcome for this PR. No merge or new
release is asserted here.

### Exact-head local quality recovery

On unchanged `46b523f08bfacd9d6708850b2e6a2efac8b017a6`, one authorized
standard recovery run passed preparation build, format, lint, all three
TypeScript projects, full tests, final build and exports. **9,558 tests
passed, one unchanged optional external-spec check skipped; 292 files
passed.** Local completion is PASS. There were no
source/configuration/timeout changes or further attempts within that
recovery; earlier failure artifacts remain intact.

Temporary process-scoped `caffeinate -is` protected the 84.24-second
gate interval on AC power. OS power logs contain no Sleep, Wake or
DarkWake transitions during that interval; the assertion process exited
afterward, without persistent power-setting changes. Full tests took
63.51 seconds and the earlier 17 failures did not recur. Existing
`define` build warnings remain disclosed. Accepted exact-head
integration review and unchanged visual proof remain applicable.

This establishes recovered local quality only. Root verified successful
push of `46b523f08bfacd9d6708850b2e6a2efac8b017a6`; revised body
publication/readback and exact-head CI remain pending; no merge, release
or new version is claimed.

### Current merge conflict evidence

## Real local RED

```json
{
  "command": "git merge --no-commit --no-ff f3ff1d22bcd5ef593a4ca8f9ba1b06d457878dad",
  "exit_code": 1,
  "stdout": "Auto-merging CHANGELOG.md\nCONFLICT (content): Merge conflict in CHANGELOG.md\nAutomatic merge failed; fix conflicts and then commit the result.\n",
  "stderr": "Recorded preimage for 'CHANGELOG.md'\n"
}
```
Only CHANGELOG.md conflicted, at adjacent #494/#479 Changed entries.
Before editing the actual conflict was inspected. Resolution retained
both entire bullet lines and removed only marker lines, including diff3
base marker. No other manual edit.

## Same-merge GREEN

```sh
git add -- CHANGELOG.md
pnpm exec prettier --check CHANGELOG.md
git diff --cached --check
git commit -m 'Merge main after decimal parser cleanup into PR 494'
```
Before commit checks:
```json
{
  "checks": [
    {
      "command": "git diff --name-only --diff-filter=U",
      "exit": 0,
      "stdout": "",
      "stderr": ""
    },
    {
      "command": "git diff --cached --exit-code 46b523f08bfacd9d6708850b2e6a2efac8b017a6 -- docs/index.html",
      "exit": 0,
      "stdout": "",
      "stderr": ""
    },
    {
      "command": "git diff --cached --exit-code f3ff1d22bcd5ef593a4ca8f9ba1b06d457878dad -- src scripts package.json pnpm-lock.yaml .github",
      "exit": 0,
      "stdout": "",
      "stderr": ""
    }
  ],
  "changelog": "Both #479 and #494 Changed entries retained; #492/#488/#480 also retained; no markers"
}
```

The resulting merge is clean; both Changed entries and #492/#488/#480
Fixed entries remain. No new visual execution or runtime fix is claimed.
Independent integration review reports NO FINDINGS; fresh full quality
is CLEAN and bounded local completion PASS at this new head. Push is
complete; body publication/readback and exact-head CI remain pending.
jpr5 added a commit that referenced this pull request Oct 21, 2026
Refreshes the docs overview "What's New" cards for the last month of
work, and adds three verified rows to the comparison matrix (homepage
and the four scanned migration pages).

## What's New (`docs/docs/index.html`)

The cards were last updated in May. The 5 old cards are replaced with 8
cards (two full rows of the 4-column grid). They cover CHANGELOG 1.43.0
(2026-09-22) through `[Unreleased]` (1.46.0, #562):

| Card | Covers | Links to |
| --- | --- | --- |
| Model Catalogs & Token Counts | Native Anthropic/Gemini model list and
detail routes. The Anthropic list on shared `/v1/models` is opt-in
(`modelCatalog`). Anthropic `count_tokens` and Gemini `countTokens`
estimates (#542, #551, #568) | `/claude-messages#model-catalog` |
| Native Auth Errors | Opt-in provider-native 401 bodies (`authErrors`)
and the opt-in required-header policy (`requiredHeaders`) (#542, #566) |
`/claude-messages#authentication-errors` |
| Model Misbehavior | Opt-in misbehavior faults (#508, #518) |
`/model-misbehavior` |
| MCP Record & Replay | MCP recording/replay, scenario-scoped tool
fakes, per-test fake reports (#490, #509, #517) |
`/mcp-mock#recorded-fakes` |
| Stream Realism | Anthropic streams stop and journal on client
disconnect (#557). Opt-in `nonStreamingTiming` for OpenAI non-streaming
replies (#545, #561, #567) | `/streaming-physics#non-streaming` |
| Responses API Tools | Opt-in (`responsesTools: "extended"`) namespaced
and custom tool calls, plus message phases (#511, #513, #531) |
`/responses-api#namespaced-and-custom-tool-calls` |
| OpenAI Platform APIs | Files, Batches, Fine-tuning jobs, Vector Stores
(#445, #446, #447, #497) | `/vector-stores` |
| GPT-Live Sessions | GPT-Live mock, record and replay (#468) |
`/websocket#openai-live` |

The Model Catalogs & Token Counts, Native Auth Errors and Stream Realism
cards carry a "New in 1.46.0" label, and the Responses API Tools card
marks its message phases "(new in 1.46.0)". Every opt-in is called out
on its card: `requiredHeaders`, `authErrors`, `modelCatalog` (the
Anthropic shared list), `nonStreamingTiming`, misbehavior and extended
Responses tools. The version banner is not touched.

## Comparison matrix

### New rows

Homepage (`docs/index.html`), inserted before "Dependencies":

| Row | aimock | MSW | VidaiMock | mock-llm | piyook/llm-mock |
mokksy/ai-mocks | MockServer | WireMock |
| --- | --- | --- | --- | --- | --- | --- | --- | --- |
| Native model catalogs (Anthropic, Gemini) | Built-in ✓ | ✗ | OpenAI
format only | OpenAI format only | ✗ | ✗ | ✗ | ND |
| Token-count endpoints | Built-in ✓ | ✗ | Gemini only (fixed count) | ✗
| ✗ | ✗ | ✗ | ND |
| Native 401 auth errors | Opt-in ✓ | manual | ✓ | manual | partial |
manual | partial | manual |

The migration pages for VidaiMock, mock-llm, piyook and Mokksy get the
same three rows, in each page's own style: VidaiMock uses text cells,
and the other three use the ✓/✗ glyphs with text for partial or manual
values. No new legend marks are needed. The MSW, Python-mocks and
openai-responses pages have different columns, so they are unchanged.

Every aimock ✓ cell on the three new rows carries a muted "1.46.0+"
label, on the homepage and on the four migration pages. The label uses a
`.version-note` class in the muted style of the existing "Requires
1.45.0+" table note.

### Rows considered and not added

- Already on the matrix: record & replay, drift detection, MCP / A2A /
AG-UI, streaming physics, chaos testing, model misbehavior, provider
count.
- Client-disconnect handling: dropped. Most competitors' source and docs
say nothing about it, so the row would be mostly ND. A server that drops
a closed stream is normal framework behavior, not something a
competitor's docs state.
- Recorded timing replay: dropped. I could not verify the MockServer and
WireMock cells from their docs, and Streaming physics already covers
timing.

### Per-cell evidence (checked 2026-10-20)

Competitor sources are the repos listed in
`scripts/update-competitive-matrix.ts`, at the default-branch HEAD on
that date: VidaiMock `e61b8ba`, mock-llm `aeb04d6`, piyook/llm-mock
`9e7a3ce`, mokksy/ai-mocks `fc70777`, MockServer `c892f58`. The homepage
table has an HTML comment on each new row with a short evidence note.

**Native model catalogs**
- aimock: Gemini `GET /v1beta/models` list and detail are native by
default. Anthropic `/v1/models/{id}` detail is native by default. The
Anthropic list on shared `/v1/models` needs `modelCatalog: "native"`.
See `/claude-messages#model-catalog` and `/gemini#model-catalog`.
- MSW: has no LLM endpoints. It is marked ✗, the same as the Embeddings
and Image rows.
- VidaiMock: `src/server.rs` registers `GET /v1/models`,
`/v1beta/models` and `/v1beta/openai/models` on `models_handler`. (Its
Anthropic `GET /v1/models/{model_action}` route is never registered,
because the Gemini `POST /v1/models/{model_action}` route claims the
path first.) In `src/handlers.rs`, `models_handler` always returns the
OpenAI list shape:
`{"object":"list","data":[{"id","object":"model","created","owned_by":"vidai"}]}`.
So its Gemini catalog route is not in native format, and there is no
Anthropic catalog.
- mock-llm: the README's default config ships one OpenAI-format
`/v1/models` rule (`{"object":"list","data":[{"id":"gpt-5.2",...}]}`).
There is no Anthropic or Gemini catalog.
- piyook/llm-mock: the README's "Available endpoints" lists only chat
completions, `/v1/messages` and `/v1/embeddings`. `src/api/` has only
`completions` and `embeddings`.
- mokksy/ai-mocks: `MockAnthropic` exposes only `messages()` (`POST
/v1/messages`), and `MockGemini` exposes only `generateContent` and
`generateContentStream`. The repo has no models route (code search for
`v1/models` finds nothing).
- MockServer: the `httpLlmResponse` supported-provider path table
(mock-server.com `llm_response_mocking.html`) has no models route.
`docs/code/llm-mocking.md` names `GET /v1/models` only as proxied
traffic that gets no telemetry.
- WireMock: has no LLM-specific docs (`wiremock.org/docs/solutions/ai/`
is about using AI to write stubs). It is ND, the same as WireMock's
other LLM-endpoint cells.

**Token-count endpoints**
- aimock: Anthropic `POST /v1/messages/count_tokens` and Gemini
`:countTokens` estimate tokens from the translated request. See
`/claude-messages#token-counts` and `/gemini#token-counts`.
- VidaiMock: `config/providers/gemini_count_tokens.yaml` matches
`^/v1(beta)?/models/[^:]+:countTokens$`. Its template
`config/templates/gemini/count_tokens.json.j2` always returns
`"totalTokens": 9`. There is no Anthropic `count_tokens` provider
config.
- mock-llm and mokksy/ai-mocks: GitHub code search for `count_tokens`
(mock-llm) and `countTokens` (mokksy) finds nothing. Neither README or
API has a count route.
- piyook/llm-mock: `src/api/` has only `completions` and `embeddings`,
and the endpoint list has no count route.
- MockServer: `docs/code/llm-mocking.md` names Anthropic
`/v1/messages/count_tokens` only as unaccounted proxied traffic. There
is no `httpLlmResponse` for it.
- MSW ✗ and WireMock ND, for the same reasons as the catalog row.

**Native 401 auth errors**
- aimock: opt-in `policy.authErrors: "native"` returns Anthropic
`authentication_error` and Gemini `UNAUTHENTICATED` bodies. See
`/claude-messages#authentication-errors` and
`/gemini#authentication-errors`.
- VidaiMock ✓: the README lists `X-Mock-Status` and `?chaos_status=`
overrides that return "real provider error envelopes".
`config/templates/anthropic/error.json.j2` maps 401 to
`authentication_error`. `config/templates/gemini/error.json.j2` maps 401
to `UNAUTHENTICATED`.
- piyook/llm-mock partial: chaos `status` accepts any status from 400 to
599. `src/utilities/chaos.ts` types every status except 429/529 (Claude)
or 429/503 (Gemini) as `api_error` or `INTERNAL`. So a 401 is not typed
`authentication_error` or `UNAUTHENTICATED`. The README also says
"LLMock has no authentication".
- mock-llm manual: the README example is a hand-written rule that
returns an OpenAI `invalid_api_key` 401.
- mokksy/ai-mocks manual: `AbstractBuildingStep.respondsError` lets you
set any status and body.
- MockServer partial: the LLM chaos `errorStatus` accepts 401. With no
`errorKind`, `LlmErrorBodies.kindForStatus`
(`mockserver-core/src/main/java/org/mockserver/llm/LlmErrorBodies.java`)
maps every status except 429 and 529 to `SERVER_ERROR`. So a 401 gets
the provider envelope typed `api_error` or `INTERNAL`, not
`authentication_error` or `UNAUTHENTICATED`. This is the same behavior
as piyook.
- MSW and WireMock manual: both are generic HTTP stubs with any status
and body.

### Weekly scanner compatibility

No `FEATURE_RULES` entry or migration variant names the new rows, so the
Monday scan never reads or writes them. The `GET /v1/models` rule stays
row-less. Its keyword (`/v1/models`) would match any OpenAI models
endpoint, which is not what the catalog row compares.

A new test, `hand-reviewed homepage rows with no scan rule` in
`competitive-matrix-homepage.test.ts`, checks four things:
- every new label is on the homepage;
- no rule or migration variant names a new label;
- when every competitor matches every rule, the homepage keeps every
new-row cell;
- when every rule matches, each scanned migration page keeps its new
rows.

I mutation-tested it: adding `"Token-count endpoints"` as a migration
variant fails 4 of its tests.

`docs-matrix-legend.test.ts`: the homepage ND count goes from 45 to 47,
because of the two WireMock ND cells.

Dry run (`npx tsx scripts/update-competitive-matrix.ts --dry-run`, with
a GitHub token), before and after the change:

```
before: Parsed 41 capability rows, 8 competitor columns.
after:  Parsed 44 capability rows, 8 competitor columns.
both:   No homepage matrix changes detected.
        1 migration page row(s) to check by hand: mock-llm / Azure OpenAI (combined-row)   [pre-existing]
        12 row-less detection(s) to check by hand                                        [identical list]
```

The only difference is the row count. No change is proposed to the new
rows or anywhere else.

## Checks

- `prettier --check .`, `eslint .` and `tsdown` build: pass.
- The docs and competitive-matrix tests (`docs-*`,
`competitive-matrix*`, `mcp-fakes-doc-samples`): 13 files, 530 tests,
all pass.
- Full `vitest run`: 17610 passed. One test failed: a timing assertion
in `chaos-fal-single-roll.test.ts`. That file only measures chaos
latency, and this PR does not change it. It passes when run alone (4/4).
- Local Playwright CLI screenshots, at 375x667, 430x932 and 1440x900:
  - What's New, on this branch and on main.
  - The homepage matrix, on this branch and on main.
  - The four edited migration pages.
- On phones, the homepage matrix scrolls sideways inside its wrapper, as
it does on main. The new rows add no width.
- What's New now fills two full grid rows. On main, the fifth card sat
alone in a second row.
- The "On this page" panel overlaps the quick-start card at 1440 wide.
Main does the same.
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