Skip to content

feat(sep-xxxx): Conformance suite for triggers and events - #521

Draft
panyam wants to merge 27 commits into
modelcontextprotocol:mainfrom
panyam:feat/events-conformance-suite
Draft

panyam wants to merge 27 commits into
modelcontextprotocol:mainfrom
panyam:feat/events-conformance-suite

Conversation

@panyam

@panyam panyam commented Sep 24, 2026 •

Copy link
Copy Markdown
Contributor

What changes

Adds a requirement-traceability yaml for MCP Events and the five server scenarios that score an implementation against it, plus 143 negative controls and one runner change. Everything here is extracted from the design sketch on main of modelcontextprotocol/experimental-ext-triggers-events, as amended by PR 7.

Scenario What it grades Rows
events-discovery the capability declaration, events/list, the descriptor fields, the error-code range, the -32601 fallback 14
events-poll poll delivery, the EventOccurrence shape, the cursor lifecycle, maxAgeMs, truncation 34
events-push a long-lived events/stream: confirmation, heartbeats, event frames, gaps, termination, cancellation 19
events-webhook events/subscribe, the subscription key, TTL negotiation and durability, events/unsubscribe 27
events-webhook-delivery what the server POSTs: verification, Standard Webhooks signing, retries, control envelopes, SSRF 29

122 of the 135 declared rows are emitted. The other 13 need a prerequisite nothing exposes yet, and each says which under Out of scope. Five scenarios is the whole plan; what remains is rows inside these files rather than more files.

Provenance

panyam offered an Events conformance suite in #triggers-events-wg on 2026-08-28 and @Poita accepted on 2026-08-31 ("No objections, that would be great!"). It follows the SEP-2640 suite (#330) in shape: extract the requirements into a traceability yaml first, then write scenarios that emit one check per row.

Per AGENTS.md this would normally start as an issue. The WG conversation is that discussion, and an extraction is easier to argue about as a diff than as prose, so it is here as a draft. Happy to split it into an issue if you would rather.

Prerequisite knowledge

  • Events design sketch — the document every row comes from. Cursor Lifecycle carries most of events-poll; Subscribing and Event Delivery is the densest normative section and is what the three later scenarios cover.
  • Standard Webhooks — the signing scheme the sketch adopts. sep-9999-delivery-signature-formula grades v1,<base64> over {id}.{timestamp}.{body}, keyed on the base64-decoded bytes after the whsec_ prefix, computed over the raw request bytes.
  • AGENTS.md § Scenario design and § Check conventions — why this is five scenarios with 122 checks rather than 122 scenarios, and why a missing prerequisite fails rather than skips (Standardize how scenarios report setup/execution failures #248). That rule shapes more of this diff than anything else.
  • src/traceability/ — how a declared row becomes tested or untested, which is what makes declaring all 135 rows up front the right move rather than an overreach.

The number question

The part I would most like a decision on, because it is the only thing blocking merge that this branch can act on.

SEP numbers are PR numbers in modelcontextprotocol/modelcontextprotocol, and Events has no PR there — the work lives in its own repository. Both traceability gates are numeric: the directory filter at src/traceability/index.ts:174 (/^sep-\d+\.yaml$/) and the check-id filter at line 41 (/^sep-\d+-/). A file named events.yaml is silently dropped from the manifest rather than rejected, and src/new-sep/index.ts:163 refuses any argument that is not a positive integer. So there is no honest name that works today.

Three ways out, and I have no strong preference between the first two:

  1. Reserve a number. Cleanest, and it makes this mergeable after a mechanical rename.
  2. Let the traceability layer carry pre-SEP extensions. Relax both regexes to accept a non-numeric key. Wider change, but Events will not be the last extension in this position.
  3. Keep it unnumbered and drop the yaml. Loses the traceability artifact, which is most of the value.

9999 unblocked the extraction and sits far from the live range. The rename touches the filename, the sep: field, every id in the yaml and the same ids under src/scenarios/server/events/ — mechanical, but wide enough to do once, in the right direction.

The second blocker is not mine to clear: six capability rows quote PR 7, which moves the declaration into Extension Negotiation and is unmerged. spec_source and spec_url name that PR's head rather than main, and a banner at the head of the yaml says so and says to re-point them on merge.

Reviewer's guide

A building inspector who cannot get onto the roof writes "could not access the roof" on the report. What they must not do is leave that line blank, because a blank line and a passed line look identical to whoever reads it next, and the owner then sells the house on a report that never mentioned the roof. Every awkward decision in this diff comes from that: a conformance run is a report somebody will act on, so the suite is built so silence is never mistaken for success. A check that cannot be exercised says which prerequisite was missing and counts as red (AGENTS.md #248, because SKIPPED is excluded from counts, exit codes and the expected-failures baseline, so a skipped row is one nobody can burn down). The capability gate asks before it skips, since a server that answers events/list while declaring nothing has an events surface no spec-following client can find. And where the inspector genuinely cannot reach — a retention gap, a revoked subscription, a callback the server will not POST to — the fixture grows a ladder, which is what the nine events_conformance_* controls are.

Read in this order. The first four are the argument; the rest is mechanism.

  1. src/seps/sep-9999.yaml — start here, header first. The placeholder, the PR 7 banner, how 144 keyword occurrences became 135 declared rows and 30 excluded ones, which rows each scenario emits, and what the 13 unemitted ones wait on. The rows are skimmable once the header lands.
  2. src/scenarios/server/events/discovery.ts — the off-menu gate at the top of checks(). Everything after it grades what the server actually serves, field by field, reporting the first offender per field so ids stay stable regardless of catalog size.
  3. src/scenarios/server/events/webhook-delivery.ts — the densest file and the one whose shape is least obvious. Two places to look: verificationChecks accepts consent by challenge or by a fetch of the receiver's /.well-known/mcp-webhook-receiver.json, because the document allows four consent paths and grading only the handshake fails a server that took another; and deliveryChecks grades the SSRF rows against the server as configured before asking it to permit this one origin, so no verdict about hardening is taken after the override.
  4. src/scenarios/server/events/webhook.ts — subscription management only. identityChecks is the interesting part: the key is (principal, delivery.url, name, arguments), a run holds one principal, and the fixture's subscribe_as control supplies the second so cross-tenant isolation is graded rather than assumed. Every subscription it creates is released before it returns.
  5. src/scenarios/server/events/push.ts — one 35s window serves every timing row, because the document permits a 30s heartbeat and a shorter window cannot tell a silent server from a slow one. It drives the error, gap and terminate controls where they exist, which is how four rows that no client can provoke get graded at all.
  6. src/scenarios/server/events/stream.ts and receiver.ts — why the scenarios cannot use the existing plumbing. conn.request() resolves on the response for its id, and a push stream withholds that until the subscription ends, so stream.ts opens the POST itself and hands back a live session. receiver.ts is the other half: the harness has to be the callback endpoint, because signatures, headers, the challenge and retry cadence are only visible from there, and it keeps raw body bytes because the signature covers those.
  7. src/scenarios/server/events/negative-fixture.ts and the four negative*.test.ts files — 143 controls over one fixture whose every option breaks exactly one rule. With two things wrong at once a flipped check does not say which one it caught. It also does the things no client can ask a real server for, so rows that report untestable against real implementations are graded here at least once.
  8. src/runner/summary.ts — the one change outside the events directory. The three runners each counted the same four numbers, so a fifth had to go in three places or none.
  9. src/scenarios/server/events/poll.ts, helpers.ts, src/scenarios/index.ts, src/types.ts — quietAdvanceChecks in poll.ts makes cursor advancement gradeable against a server where nothing is happening, which is the state a fixture is usually in. The rest is registration.

How it works

discovery:
  declared, value <- capabilities.extensions["io.modelcontextprotocol/events"]
  if not declared:
      probe <- events/list
      if probe is -32601:  the fallback rule is satisfied; skip the rest
      else:                fail the declaration check, keep grading
  grade events/list, then each descriptor field across all descriptors
  probe one unknown event name -> NotFound and the server-range rule

poll:
  pick the first descriptor advertising "poll"; none -> untestable, not skip
  poll(cursor=null)            # bootstrap: must replay nothing
  poll(cursor=<returned>)      # quiet period: a cursor must still come back
  poll(no cursor field)        # absent must be accepted as null
  poll(maxEvents=1)            # batch must be capped
  poll(maxAgeMs, with and without a cursor)
  poll forward from the bootstrap cursor to catch a real occurrence;
  grade the EventOccurrence rows only if one arrived

push:
  open events/stream, keep the POST open
  expect notifications/events/active within 3s
  settle 35s                   # outlasts the 30s heartbeat SHOULD
  drive yield_error / yield_gap / terminate where the fixture exposes them
  abort the request            # the Streamable HTTP cancel
  assert nothing more arrives; grade the final result; open two more streams

webhook:
  subscribe(https callback, fresh whsec_ secret, ttlMs) -> id, refreshBefore
  same key again               # idempotent upsert, same id
  vary url / name / arguments  # each addresses a different subscription
  ttlMs omitted / null / absurd; reject http://, bad secrets, unsupported mode
  restart the server, then ask which subscriptions survived
  unsubscribe by key, then a key never held -> NotFound

webhook-delivery:
  start a receiver; publish the well-known document over one path prefix
  subscribe with the guards on
    refused -> grade the two SSRF rows, then ask to permit this origin
  wait for the first POST, then a little longer for a second
  grade the handshake or the document fetch, the headers, the signature over
  the raw bytes, the envelopes, redirect refusal, the retry cadence, and the
  two non-retryable statuses

Decision log

  • All 135 rows are declared, though 122 are emitted. An undeclared requirement is invisible, and an undeclared-and-unemitted one is indistinguishable from a requirement nobody noticed. Declaring the full set makes the outstanding rows surface as untested, which is the manifest working rather than a gap in this file.
  • Pure MAY and OPTIONAL sentences get no row, and client, host and receiver obligations are excluded rather than declared. Roughly a fifth of the normative sentences address the client or the callback endpoint; declaring them would inflate the denominator with rows no server-side scenario could ever emit. The five that initially got a row were demoted to excluded: rather than deleted, so the keyword sweep stays auditable.
  • 88 of the 135 rows quote no RFC 2119 keyword. They come from the EventOccurrence field table, the error-code table and the descriptor fields, which the sketch states declaratively. Since "severity follows the keyword" is undefined for them, the yaml header states the rule used: a field the document marks required, or a wire fact a client cannot work without, is FAILURE; anything else drawn from an example payload is WARNING.
  • Five scenarios rather than one. A server may implement any non-empty subset of the three delivery modes, so poll-only and push-only servers are both conformant and grading them together would misreport each. Webhook splits again where the server stops answering requests and starts making them, because delivery needs a callback the server will accept.
  • The fixture controls gate the trigger, never the behaviour. Nothing a control does makes a server behave differently from how it would in production; it only gives the harness a way to ask. A server without them reports untestable and names the missing one, so the suite never requires them.
  • The SSRF verdict is taken before any override. A server that refuses the harness's loopback callback is doing the right thing, and that is graded first. Only then does the suite ask it to permit that one origin so the delivery rows can be reached, and the delivery-time revalidation row reports inapplicable afterwards, because a delivery to a deliberately-permitted origin is evidence of nothing.
  • One runner change, not a parallel path. unverified is a subset of failed and warnings, so no exit code moves and a clean run prints exactly what it printed before.

Risk / blast radius

  • Affects: additive apart from src/runner/summary.ts. Five new scenarios, one new yaml, one new entry in EXTENSION_IDS. No existing scenario, suite list or check id changes.
  • Not selected by default. source is { extensionId }, so matchesSpecVersion returns false for every --spec-version and these never join a dated run. Reaching them takes --scenario events-* or --suite all.
  • Tests: npm run build, npm run lint and npm test pass — 774 across 52 files, of which 143 are the events negative controls.
  • The runner change touches all three runners. They now share tallyChecks/formatTally. Verified against a Go server across all five scenarios per AGENTS.md on shared infrastructure, plus unit cases covering the subset rule, the prefix-only path, and that SKIPPED counts as neither.
  • Be paranoid about: the capability gate and the consent paths. The gate is the one place this suite departs from how every other extension scenario here behaves, and it is load-bearing for the headline finding. The consent logic is the other: if you think a server that publishes a well-known document should still be required to handshake, say so, because that choice decides whether a conformant server reads red.
  • src/seps/traceability.json will drift once these ids are emitted in a real run. Per AGENTS.md the traceability workflow refreshes it by PR and it is not a gate, so this leaves it alone.

Before / after

There was no Events suite before, so what matters is what an implementation looks like when scored, and what changed in the one being scored. Measured 2026-09-24 against mcpkit's examples/events/kitchen-sink at 0b84dbca, with the fixture's conformance controls enabled:

Scenario rows pass real failures unverified inapplicable
events-discovery 14 11 0 1 2
events-poll 34 29 0 4 1
events-push 19 19 0 0 0
events-webhook 27 24 0 3 0
events-webhook-delivery 29 22 0 6 1
123 105 0 14 4

Zero real failures is the end of a nine-day argument, not the starting point. The first full run, on 2026-09-21, was 8/11, 26/29, 11/15, 17/23 and 12/21, with fourteen divergences that were all real defects. Every one is now closed. They are worth reading as a list, because they are the case for the suite:

What the suite found
capabilities declared nothing while events/list answered a surface no spec-following client can discover
the HMAC keyed on the literal whsec_… string no Standard Webhooks receiver could verify a delivery
deliveries with no verification handshake an attacker could aim a subscription at a third party
http:// callbacks accepted at subscribe the secret and the payloads in cleartext
the stream correlation id in params.requestId a client holding two streams cannot route by what the document says to read
events/stream counted against the subscription cap the third concurrent stream refused, which the document exempts
unsubscribe answering success for a key never held a client cannot tell teardown from a typo
nextPollSeconds after the rename to nextPollMs plus maxAge still in seconds
the topology source with delivery: null and no inputSchema a descriptor contract the server did not keep
events/poll answering for a type advertising no poll and events omitted rather than [] when nothing happened

And one of them was not a defect in the implementation at all. sep-9999-capability-events-object failed against both implementations for different reasons: mcpkit declared nothing, while metronome-mcp.fly.dev — written by the sketch's author — declared under capabilities.extensions, where the document said top-level. Rather than soften the row or pick a side, I asked, on 2026-09-22. The document was the thing that was wrong, and PR 7 now moves it. Scoring two implementations against one unsoftened row found a defect in the specification, which is the strongest argument available for the shape of this suite.

Two suite bugs came out of the same exercise and are fixed here: minimalArguments gave up on any inputSchema with a required property, which scored metronome's poll at 1/34, and the occurrence rows were graded off a cursor: null poll that a conformant server answers empty. Neither was visible against a single target.

flowchart TD
    A[check runs] --> B{could it be exercised?}
    B -->|yes| C{requirement met?}
    C -->|yes| D[SUCCESS]
    C -->|no| E[FAILURE / WARNING by keyword]
    B -->|no, prerequisite missing| F[fails, naming the prerequisite]
    B -->|no, rule cannot apply here| G[SKIPPED]
    F --> H[counted as unverified in the summary]
Loading

Running them

node dist/index.js server --url <url> --scenario events-discovery
node dist/index.js server --url <url> --scenario events-poll
node dist/index.js server --url <url> --scenario events-webhook
node dist/index.js server --url <url> --scenario events-push --timeout 60000
node dist/index.js server --url <url> --scenario events-webhook-delivery --timeout 180000

Use a spec version of 2026-07-28 or later: extension negotiation did not exist before it, so nothing earlier can declare Events, and an older run reports the extension absent by design. The runner's default already lands there.

events-push needs the longer timeout for the reason above (EVENTS_PUSH_WATCH_MS overrides the window). EVENTS_WEBHOOK_CALLBACK_BASE points delivery at a public https callback and is the only way to grade those rows on a server that neither exposes the origin control nor accepts loopback. EVENTS_RECEIVER_WELL_KNOWN=0 withholds the receiver document to force the handshake path.

Out of scope

  • The 13 unemitted rows. Seven (schema-evolution-additive, breaking-change-new-name, list-changed-notification, four removal-*) need a server whose catalog can change mid-run, tracked as a control in the reference implementation. Three (error-forbidden, both authz-*) need a runner that can send or drop a credential, which is feat: Allow fixed request headers in server conformance tests #453. capability-list-changed-gated needs a server that declares listChanged: false and then sends the notification anyway. capability-client-declaration is a MAY about client behaviour that no server scenario can emit, and belongs in excluded: by this file's own rules. payload-minimality is a SHOULD about payload content that delivery-body-size measures the observable half of.
  • --header on the server runner (feat: Allow fixed request headers in server conformance tests #453). Four of the rows above want it, and scoring a second implementation currently needs a local proxy to inject a bearer token.
  • Renaming sep-9999-* once the number question is settled, and re-pointing spec_source at main when PR 7 merges. Both are merge blockers.

panyam and others added 27 commits September 15, 2026 01:06
… phase 1

Adds the requirement-traceability yaml for MCP Events plus the first two of
five server scenarios, scoring against the design sketch that merged on main
of modelcontextprotocol/experimental-ext-triggers-events on 2026-09-08.

The SEP number is a placeholder. Events has no PR in
modelcontextprotocol/modelcontextprotocol, and both traceability gates are
numeric (the filename regex at src/traceability/index.ts:174, the check-id
regex at line 41), so an unnumbered file is dropped from the manifest without
an error. 9999 is far from the live range; the rename is mechanical and the
yaml header names the rename as a merge blocker rather than a follow-up: a
merge under 9999 would publish it as a real SEP. The reservation question is
open with the WG.

src/seps/sep-9999.yaml declares 131 checks and 30 excluded rows against 144
RFC 2119 keyword occurrences. Pure MAY and OPTIONAL sentences get no check.
Client, host, receiver and SDK-guidance obligations are excluded rather than
declared, so the denominator holds only what a server-side run can observe.

events-discovery covers the capability, events/list, the descriptor fields and
the error-code contract. events-poll covers poll delivery, the EventOccurrence
shape and cursor lifecycle, driving a real two-poll quiet-period loop so
cursor advancement is gradeable against a server with no traffic. Together
they emit 45 rows; the other 86 report untested until the push and webhook
scenarios land.

The capability gate asks before it skips. An optional capability a server
never declared is not a defect, but a server that answers events/list while
declaring nothing has a surface no spec-following client would reach, and a
SKIP would report that as a clean run. mcpkit is in exactly that state.

Against examples/events/kitchen-sink at mcpkit main: discovery 8/11, poll
18/28. Six divergences, five of them server-side defects not previously
tracked, one confirming the known nextPollSeconds drift. The yaml header lists
each.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017kyEZdgh1zjdhURgdcZ4nL
…e occurrences off a poll that can carry them

Running the suite against a second implementation — Peter Alexander's
metronome-mcp.fly.dev, written by the author of the design sketch — found two
bugs that kitchen-sink alone could not surface.

minimalArguments gave up on any inputSchema with a required property. The
document's own examples are filters and transforms with nothing required, and
the helper took that for a rule. metronome.tick requires periodSeconds and
label, so every poll row reported untestable and the scenario scored 1/34. It
now derives a value per required property from the schema itself — default,
const, first enum or examples entry, then minimum for numbers or a fixed
string — and still declines to guess when a required property offers nothing.

The sep-9999-occurrence-* rows were graded off the bootstrap poll, which passes
cursor: null. Null means start from now, so a conformant server returns no
events there: the suite could only score those seven rows against a server that
violated sep-9999-cursor-null-starts-from-now. They now poll forward from the
returned cursor, waiting nextPollMs between attempts (clamped to 250ms-2s) up
to a 6s ceiling, and still report untestable against a quiet server.

Scores with the fixes. kitchen-sink at d2950655: discovery 8/11, poll 26/29,
up from 18/28 with no change to mcpkit. metronome: discovery 10/11, poll 27/29.

Two metronome divergences, in the yaml header. It answers -32603 rather than
-32602 for arguments that violate inputSchema. And it declares events under
capabilities.extensions["io.modelcontextprotocol/events"] where this document
puts it top-level, which is a question for the WG before the row moves either
way.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Adds the third of five events scenarios, covering the 17 sep-9999-stream-* rows.

The Connection abstraction cannot express events/stream: conn.request resolves
on the response for its id, and a push stream withholds that until the
subscription ends, so a scenario awaiting it would block for the life of the
stream and see none of the notifications it grades. stream.ts opens the POST
itself and hands back a session while the stream is still open. Aborting that
request is also the document's client-side cancel on Streamable HTTP, so the
cancel row falls out of the same mechanism.

The watch window is 35s by default, over the runner's 30s timeout, so the
scenario needs --timeout 60000. That is deliberate: the heartbeat is a MUST
with a 30s SHOULD on cadence, so a shorter window cannot tell a silent server
from a slow one. Under 30s the heartbeat rows report untestable rather than
failing a server that may be conformant. The interval check carries 2s of
tolerance, because kitchen-sink times its heartbeat at exactly 30s and measured
30005ms once scheduling had its say.

Rows needing something the client cannot ask for - an upstream failure, a
retention gap, a termination, a server-initiated close - report untestable with
the prerequisite named, per src/scenarios/untestable.ts.

kitchen-sink at d2950655: 11/15. metronome: 13/15, where both failures are
untestable rows rather than divergences.

One new divergence, not previously tracked: kitchen-sink puts the correlation
id in params.requestId, where the document requires
params._meta["io.modelcontextprotocol/subscriptionId"]. A client holding two
streams cannot route by what the document tells it to read.
…agement

The fourth of five scenarios: the 26 subscribe, TTL and unsubscribe rows.
Delivery itself - signatures, the verification handshake, control envelopes -
waits for events-webhook-delivery and a reachable callback.

Two things shape it. A run holds one principal, so the key components the
harness can vary (url, name, arguments) are graded and the principal half is
reported untestable rather than passing a cross-tenant row that was never
exercised across two tenants. And a server may cap concurrent subscriptions per
principal: kitchen-sink allows two per event type, so every throwaway
subscription is released as soon as its check is graded, including a probe the
server was supposed to reject but accepted. Without that the scenario grades
the cap error instead of the rule it was probing, which it did three times
while being written.

Every subscription is unsubscribed before the scenario returns, which matters
against a public server.

kitchen-sink at d2950655: 17/23, three divergences. It accepts an http://
callback URL where the document requires https and -32602 (two rows, one
probe), and unsubscribing a key it never held succeeds instead of answering
-32011 NotFound, so a client cannot tell teardown from a typo.

metronome: 20/23, no divergences. The three failures are untestable rows: the
unsupported-mode path (its only event type offers every mode), the auth
requirement, and cross-tenant isolation.
…nario

Grades what the server POSTs: the verification handshake, Standard Webhooks
signing, retries, control envelopes and the SSRF rules. receiver.ts runs a real
HTTP endpoint and keeps the raw body bytes, because the signature covers those
and re-serializing the JSON would change them.

The scenario needs a callback the server can reach, and
EVENTS_WEBHOOK_CALLBACK_BASE supplies one. Without it the receiver binds to
loopback, which is not a degraded mode but a different question: the document
requires a server to refuse a non-routable callback, so a loopback URL is the
SSRF probe. A server that refuses it passes the SSRF rows and reports delivery
untestable; one that POSTs to 127.0.0.1 fails them and hands over real
deliveries to grade everything else against.

kitchen-sink at d2950655: 12/21, four divergences, two of them security-shaped.

  It accepts and delivers to a loopback callback, so a caller can aim
  deliveries at anything the server can reach and the caller cannot.

  It signs with the literal whsec_ string as the HMAC key, where the document
  says the key is the base64-decoded bytes after that prefix. No Standard
  Webhooks receiver verifies these signatures; confirmed by hand against four
  candidate formulas before filing it here.

  It delivers without the verification handshake, so an unverified third-party
  URL receives POSTs.

metronome refuses the loopback callback outright, which passes the SSRF rows and
leaves the delivery rows untestable. Grading it needs a public callback.

One harness bug, found while reading the results: the retry row folded signature
validity into its freshness check, so a server with the wrong formula was
reported as reusing timestamps it had not reused. Freshness is now graded on
distinct timestamps and signatures alone.

Coverage is now 117 of the 131 declared rows across the five scenarios. The
remaining 14 need a server that changes at runtime (schema evolution,
list_changed, event-type removal) or error paths this run did not reach.
The SSRF row is fixture configuration, not a library defect. kitchen-sink
passes WithWebhookAllowPrivateNetworks(true) so the demos can reach a local
receiver, and that one flag disables both the subscribe-time and dial-time
guards; mcpkit's default path blocks loopback and the other non-routable
ranges. The row still fails, because the suite grades the server it was pointed
at, but it means the target runs with protection off rather than that mcpkit
lacks it.

Redirects are not followed either: webhook.go sets CheckRedirect to
ErrUseLastResponse, which is what the suite already scored.

Adds the root cause behind each of the other five, read out of mcpkit at
5cfb7c51. The signature bug is the widest: the whsec_ format arrived two days
after signing landed and key derivation was never revisited, so both verifiers,
the Go and Python clients, the whole-enchilada receiver and the telegram tests
all share it. Server and clients agree with each other and none agrees with the
document, which is the failure a conformance suite exists to catch.
webhook.ts listed 26 ids in ALL_IDS, the set it reports untestable when it
cannot get far enough to grade, where its gradeable path emits 27.
sep-9999-error-unsupported was the missing one, so against a server with no
webhook-capable event type the row did not report a missing prerequisite — it
vanished from the run. A suite that emits a different row set depending on the
server is a suite whose pass count cannot be compared across SDKs.

webhook-delivery.ts reported sep-9999-envelope-gap and -terminated untestable
unconditionally, on the reasoning that no client can ask a server for a
retention gap or a revocation. True, and beside the point: if the server sends
one anyway the envelope is sitting in what the receiver recorded, and nothing
looked. Both are now graded when they arrive and untestable when they do not,
which is how push.ts has always handled its gap row.

Also makes the settle window a knob. Three hard-coded 5s sleeps let retries and
the two non-retryable probes play out, which is most of that scenario's wall
time and pure waste against a fixture that answers in microseconds.
EVENTS_DELIVERY_SETTLE_MS overrides it; the default does not change.
A green run against kitchen-sink proves a check does not false-positive. It
proves nothing about whether the check catches anything, and only discovery and
poll had controls that did. These add 71 cases over the other three scenarios,
100 across all five, each pairing a conformant in-process server with one broken
in exactly one way.

One fixture serves all five. negative-fixture.ts holds the stateless events
server that negative.test.ts had inline, extracted unchanged, plus three things
the later scenarios need: events/stream as a real SSE response written on timers,
because the heartbeat cadence and the cancellation rows are about timing; a
subscription store keyed the way the document keys it, minus the principal a
single run cannot vary; and a delivery engine that POSTs signed Standard
Webhooks payloads to the callback and retries them, because the signature and
the retry cadence are only observable from the endpoint.

Every divergence the suite found in a real implementation has a case here, so a
red run can be read rather than re-derived: the correlation id in
params.requestId, streams counted against the subscription cap, an http://
callback accepted, unsubscribe answering success for a key it never held, the
HMAC keyed on the literal whsec_ string, and delivery with no verification
handshake.

The fixture also does the six things no client can ask a real server for — fail
upstream, lose its replay window, terminate a subscription, close a stream
itself, and send the gap and terminated control envelopes. Those rows report
untestable against both implementations, so these are the only evidence those
checks work at all.

Two notes for whoever writes the next one. vi.resetModules() is needed for the
scenarios that read their timing knobs at module load, and a reset registry hands
back a second copy of the connection module, so the run context has to be built
from the same fresh import or instanceof JsonRpcError is false across the two
graphs. And the delivery fixture spaces an out-of-order event 60ms ahead of the
verification POST, because the check compares millisecond arrival times and two
loopback POSTs land inside the same millisecond often enough to read as
in-order.

npm test: 725 passing across 51 files.
The two files that grade timing spent almost all of their wall time asleep: the
delivery controls waited out five settle windows per case and took 180s, the push
controls 80s. Both together took the suite from 163s to 425s, which every
contributor pays on pre-push.

The cases are independent — each builds its own fixture on its own port and
touches no shared state — so the only thing keeping them sequential was
`vi.resetModules()` per case, needed because both scenarios read their timing
knobs when the module is first evaluated. Importing once per file in `beforeAll`
removes that, and the cases then run concurrently: delivery 45s, push 43s, suite
254s.

One graph means one set of timings, and one push case has to outlast the 30s
heartbeat cadence the document permits, because under 30s a silent server and a
slow one are indistinguishable. It takes its own graph in a sequential block at
the foot of the file, after the concurrent cases are done with theirs; resetting
the registry while they were still running would pull it out from under them.

725 passing, unchanged.
The design sketch put it top-level under `capabilities.events`. metronome, the
sketch author's own server, used `capabilities.extensions`. Raised with him
2026-09-22 rather than resolved by picking a side, and the document turned out
to be the thing that was wrong: upstream PR 7 moves the sketch into Extension
Negotiation, keyed by `io.modelcontextprotocol/events`.

`declaredEventsCapability` and the four scenarios that probe the declaration
now read the extensions map. `EVENTS_CAPABILITY` is gone; `EVENTS_EXTENSION_ID`
absorbed its job, having previously carried a comment saying in as many words
not to read the capability at that key.

Two rows are restated from PR 7 and four are new: the empty settings object,
the optional client declaration, `listChanged` gating whether the server may
send `notifications/events/list_changed`, and the `-32601` fallback for a
server that does not offer the extension. The four are declared and not yet
emitted, so they report untested.

The header's record of both metronome divergences becomes a record of how they
resolved. He fixed the `-32603`-instead-of-`-32602` one the same day.

These rows track an unmerged PR, which the head of this file now says.

Refs modelcontextprotocol#504
Six push and webhook rows describe what a server does when something goes
wrong upstream, and a healthy server does none of it during a run. The
scenarios watched a window, saw nothing, and reported untestable, which under
src/scenarios/untestable.ts is red and stays red.

A fixture MAY now expose the conditions as ordinary tools. This calls the two
that are safe to fire mid-stream — a transient upstream failure and a
retention gap — neither of which ends the subscription, so the rows that grade
heartbeats and deliveries still see what they need. Termination is left alone
deliberately: it is terminal for the source and these scenarios share one
fixture process, so firing it here would poison whatever runs next.

The controls are called over their own connection rather than the streaming
one. That is the more faithful simulation, since an upstream failure does not
arrive as a request from the subscriber watching for it, and the signals fan
out to every live subscriber regardless.

Discovery happens on the connection run() already holds, not around the
stream. The first version probed later and cost a connect plus two tools/list
round trips inside the observation window, which was enough to miss a
termination arriving at 250ms and turned negative-push.test.ts red. A server
without the controls now pays nothing for the question and opens no second
connection.

Servers without controls are unaffected and keep reporting untestable, with
the reason naming the specific tool that would make the row gradeable rather
than describing the gap in the abstract.

Refs modelcontextprotocol#504
sep-9999-stream-terminated-ends-subscription and both
sep-9999-stream-final-result-* could not be graded on the main session, and not
for want of a control: the harness cancels that stream to test
stream-cancel-stops-delivery, and on Streamable HTTP a client-side abort is
terminal, so no final frame is ever sent. A server-ended stream is a different
stream.

terminateChecks opens its own, fires the terminate control, and grades all
three from it. It refuses to terminate the event type the rest of the run
depends on, because termination is one-shot for the source and the four events
scenarios share one fixture process; a fixture offering a single push-capable
type gets untestable with that reason rather than a poisoned suite.

The main session keeps grading these opportunistically. A server that closes
the stream itself has already answered them, and dedupe is first-wins, so the
control path never runs for ids the observation window already produced. That
ordering is what the negative fixture exercises, and getting it wrong turned
negative-push.test.ts red twice: once by removing the opportunistic path
entirely, once by flattening three rows to one severity when terminating is a
MUST and the two final-result rows are SHOULDs.

Also fixes a bug in the final-result shape check that mcpkit surfaced. It
counted `resultType` as information the frame carries, but `resultType` is a
base-protocol field on the common Result interface that servers MUST include,
which sep-2640.yaml already records. Every conformant server failed that row.
`_meta` was already excluded; `resultType` now is too.

Against mcpkit: events-push 14/16 to 17/18, zero warnings, the remaining
failure being a real missing subscriptionId.

Refs modelcontextprotocol#504
…ontrols

The rule is that two tenants subscribing to the same `(name, arguments)` with
the same callback get distinct subscriptions, because the principal is part of
the key. A run authenticates as one principal for its lifetime, so the harness
could only ever supply one side of that comparison and the row reported
untestable.

A fixture MAY now register on another principal's behalf and answer whether a
given principal's subscription still exists. With both, the check does the
comparison the rule is about: same event, same callback, principal the only
thing that differs.

Two things are graded, and the second is the one that bites. Distinct ids show
the principal reached the key at all. A surviving subscription after the other
tenant unsubscribes shows the two are genuinely independent, which is what a
tenant would notice if it were not true — one tenant able to cancel another's
subscription.

askControl joins fireControl in helpers for the controls that answer with text
rather than just succeeding.

Servers without the controls are unaffected and keep reporting untestable, now
naming the two tools that would make the row gradeable.

Against mcpkit: events-webhook 17/23 to 18/23.

Refs modelcontextprotocol#504
…iled one

Once a server verifies inside events/subscribe, which is how the document
reads ("returned synchronously from events/subscribe"), three things in the
webhook-delivery scenario stopped measuring what they claim to.

Every probe callback answered every POST with its failure status, the
challenge included, so a verifying server could not subscribe the 410, 413,
retry or redirect probes and all four rows went untestable. The receiver
now answers the challenge on every path before the path's behaviour
applies; wrong-challenge is the one path that still fails it. The 410/413
and retry graders also waited for the first POST on their path, which is
now the challenge, answered 200 and never retried, so they wait for the
first non-verification POST instead. The redirect grader waits for a POST
it actually answered with 302.

verification-failure-error was untestable on the assumption that the
handshake is asynchronous. It now subscribes a wrong-challenge callback:
-32015 with reason challenge_failed passes; any other error, or an accepted
subscription that is then delivered to, fails; an accepted subscription with
no delivery is a WARNING, since the endpoint is safe but the subscriber was
never told.

delivery-retry-regenerates-signature failed on any two retries inside one
second, because webhook-timestamp is whole seconds and a regenerated stamp
is indistinguishable from a reused one there. It flapped against mcpkit,
whose first retry is 500ms out. Only a stamp repeated across attempts a
second or more apart now fails; retries all inside one second are a WARNING.

The fixture gains synchronousVerification (and ignoreFailedEcho for the
negative case) and retryGapMs. Against mcpkit kitchen-sink --serve the
scenario goes from 12 to 18 SUCCESS; with verification disabled in the
library the verification rows go red again.
ttl-long-grant-retained, ttl-no-expiry-persisted and ttl-no-expiry-gc-terminated
were untestable because they need a restart the harness cannot ask for over
the wire. A fixture exposing three controls now makes them gradeable:

- events_conformance_restart rebuilds the server over the same subscription
  store and answers the generation it is moving to
- events_conformance_generation answers the current one
- events_conformance_subscription_state answers active, suspended or absent
  for a derived id, which the exists control cannot, since it hides
  suspended subscriptions

The generation pair is what makes the restart detectable on both kinds of
server: a stateful one ends the old session, so the scenario reconnects; a
stateless one keeps answering on the same connection, first from the old
build and then the new.

The scenario subscribes once with ttlMs:null and once with a one-day grant,
restarts, and reads both back. A no-expiry grant that is not honoured leaves
its two rows untestable at WARNING rather than failing, since granting one is
optional. The GC row watches the no-expiry subscription, whose placeholder
callback fails every delivery, for EVENTS_GC_WAIT_MS: dropped passes, still
there is a WARNING because dropping is a MAY, and already lost across the
restart is untestable. The terminated envelope goes to the placeholder origin
and cannot be observed, which the check says.

The durability checks run last, since a restart ends the scenario's
connection. Wait limits are read at call time so the negative controls can
shorten them. Against mcpkit kitchen-sink --conformance-events the scenario
goes from 22/23 to 25/26; a build whose restart loses the store fails the
two retention rows.
The document gives a server four ways to confirm a callback wants deliveries:
the challenge handshake, a configured allowlist, prior out-of-band verification,
or a receiver-published /.well-known/mcp-webhook-receiver.json. The suite only
knew the first, so it reported a server that read the document and delivered
without challenging as one that "delivered to a callback that was never asked to
prove intent". mcpkit 1449 landed that path today, which would have turned the
row red for a server satisfying it.

The receiver now publishes the document and counts fetches, and consent counts
as established by a challenge or by a fetch that precedes the first delivery.
It is declared over one path prefix and no other, so the main delivery path
exercises the document route while every probe path still takes the handshake:
one run covers both rather than whichever the server prefers.
EVENTS_RECEIVER_WELL_KNOWN=0 withholds it. The document is honoured only on an
https origin, so against a loopback receiver the handshake decides everything,
the same way the SSRF rows only mean something over a tunnel.

Two rows come out of that. sep-9999-verification-no-raw-endpoint-responses was
an unconditional SUCCESS whose details read "no endpoint response body was
echoed back in any server error observed during this run" — nothing had observed
anything, so it could not have caught a leak. It now grades the error from the
wrong-challenge probe, where the receiver echoes a distinctive string, and
reports untestable on a run where no endpoint failed.
sep-9999-error-callback-endpoint-error, declared since the first commit and never
emitted, is claimed off that same error: the -32015 code and whether data.reason
is one of the documented lastError categories.

Why the failure branch stays a failure when nothing is observable: the allowlist
and out-of-band paths cannot apply to these callbacks, because the path is minted
fresh for the run and no operator has ever seen it.

7 new controls, 31 in negative-delivery.test.ts. Against the previous scenario
the well-known case fails with "expected 'FAILURE' to be 'SUCCESS'", which is
the regression this prevents.
Four declared rows emitted nothing while the request that would grade each was
already being sent. Claiming them costs no new round trips.

sep-9999-fallback-method-not-found — what a server that does not offer the
extension answers. discovery.ts already probes events/list on a server that
declares nothing and keys its SKIP off -32601, so the one run that ever meets
such a server now grades the rule instead of folding it into skipAll. A server
answering some other code fails it: a client cannot tell "no such extension"
from a real failure. Everything else still skips, because every other events
rule is genuinely inapplicable there.

sep-9999-capability-empty-settings — `{}` declares support with no list-change
notifications. Graded off the declared value, and inapplicable rather than failed
when the server declared settings of its own, since the rule says what an empty
object means and nothing about a populated one.

sep-9999-error-invalid-params and -resource-exhausted — the error-table rows for
-32602 and -32013. The first rides the poll invalid-arguments probe, the way
sep-9999-subscribe-url-https-required already rides its enforcement probe, with
details.gradedBy naming where the verdict came from. The second grades the three
concurrent streams in push.ts: a refusal for a quota must name the quota in
data.limit, and a server that reaches no limit reports untestable rather than
passing.

117 of 135 rows emitted before this and the commit before it; 122 now. Of the 13
left, seven wait on a control that can change a catalog mid-run, two on a second
credential, and sep-9999-error-forbidden on a principal the harness can be
refused as.

5 new controls. One existing assertion changes: the "skips the suite" case now
asserts the fallback row passes and everything else skips, which adds an
assertion rather than relaxing one. No assertion weakened.
… track

The extensions-map change added four rows and restated two, and the header's
arithmetic did not follow: it still said 131 declared, 117 of 131 emitted, and
"the 14 rows nothing emits yet". It is 135, 122 and 13.

It also said "see the tracking note at the head of this file" and there was no
such note. There is now, as a banner: the six rows that quote text only PR 7
has, why the suite is ahead of the document, and to re-point spec_source at main
when it merges. spec_source and spec_url named `main`, where a reader who fetched
it found text contradicting those rows; both now name PR 7's head.

Seven strings still told implementers the capability lives at
`capabilities.events`, including the description attached to
sep-9999-capability-events-object itself, so one check carried two contradictory
statements and the one a reader of a failing run sees was the wrong one. The
survivors were exactly the strings no control asserts on, which is also where to
add an assertion. The note in helpers.ts keeps the old spelling deliberately,
because it is explaining the history.

declaredEventsCapability now calls extensionsOf rather than inlining the same
lookup, which is what that helper's doc comment promises.
The comment introducing poll's error-table row named the webhook precedent by its
exact slug, which makes `grep sep-9999-subscribe-url-https-required` report two
scenarios emitting it when only one does. It now names the rule in prose. The
header's count for poll.ts followed the same miscount and read 35 where the file
emits 34; the suite total of 122 was right either way.
Measuring against kitchen-sink at mcpkit d7624d68 found two rows going missing
rather than reporting a missing prerequisite, both in paths I had just added to.

sep-9999-fallback-method-not-found was only emitted when the server declared
nothing, so against every real events server it vanished. It is now SKIPPED there
with the reason: a server that declares the extension is not a server that does
not offer it, so the rule cannot apply.

events-webhook-delivery emitted 27 rows when it refused a loopback callback and
29 when it delivered. The refusal answers two of the four SSRF rows by itself;
the other two need a delivery to revalidate and a redirect to refuse, and were
simply absent. Both now report untestable there.

This is the defect the suite's own untestable policy exists to prevent: a row
that appears on one path and not another makes two servers' pass counts
incomparable, which is the whole point of a fixed denominator. Controls for both,
asserting the row is present and inapplicable rather than absent.
… a time

events-webhook-delivery grades 29 rows and reaches 2 of them against a server
with its SSRF guards on. Not a defect on either side: the harness must be the
receiver, because the signature, the headers, the handshake and the retry cadence
are only visible from the endpoint being POSTed to, so it listens on loopback —
and a hardened server refuses a loopback callback, which is what two of those
rows grade. Both cannot hold for one subscription.

The way out is a fixture control, the same shape as the other eight:
events_conformance_allow_callback_origin permits one named origin past the
callback guards for the rest of the process. The order is what keeps the SSRF
verdict honest. Subscribe with the guards on, grade the two SSRF rows against the
server as configured, then lift for this receiver's origin and subscribe again,
so signing, headers, retries, envelopes and verification grade off real
deliveries. Nothing about hardening is concluded after the override.

sep-9999-ssrf-validate-at-delivery-time now reports SKIPPED when the origin was
permitted on purpose, because a delivery to a deliberately-allowed origin is
evidence of nothing; proving revalidation still needs a hostname whose DNS answer
changes between subscribe and delivery.

The control does not exist yet: mcpkit 1457 has the contract, and this side sits
behind hasControl, so a server without it reports untestable exactly as before
and names both ways out, the tunnel and the control. 26 of the 27 rows become
reachable when it lands.

Two controls cover both paths. Writing them turned up the fixture refusing a
permitted origin anyway, because its https check ran first — which also showed
the "harden" flag I had added was redundant, since a fixture without
acceptHttpUrl already refuses the harness's loopback callback. Gone, and the
permitted set now bypasses that check the way the real control will.

npm test: 751 passing.
A missing prerequisite reports as a failure on purpose: SKIPPED is excluded from
counts, exit codes and the expected-failures baseline, so a skipped row is one
nobody can burn down. The cost is that a run reads as broken when it is mostly
unchecked. events-webhook-delivery against a hardened server printed

  Passed: 2/29, 27 failed, 0 warnings

where nothing was violated and 27 rows had no reachable callback to grade
against. A reader cannot tell that from a server with 27 real defects.

The distinction was already in the data. notTestable() writes a stable prefix and
untestableCheck() sets details.untestable, both so consumers can separate
"violated" from "could not be verified" without a new status. The runners just
did not print it:

  Passed: 21/21, 0 failed, 8 warnings (8 of those unverified, not violated)

The clause appears only when there is something to say, so a clean run prints
exactly what it printed before.

The four numbers were computed by three copies of the same four filters in
server.ts, client.ts and authorization-server.ts, so a fifth number had to go in
three places or in none. They now share tallyChecks/formatTally. No verdict
changes: unverified counts rows already inside failed and warnings, and every
exit code still keys off those, so a run that failed before fails now.

Verified against a Go server, per AGENTS.md on shared infrastructure: mcpkit's
kitchen-sink at eec13596 across all five events scenarios, 101 of 122 rows
passing and 0 real failures, plus 10 unit cases covering the subset rule, the
prefix-only path and that SKIPPED counts as neither.
…not refuse

Both SSRF rows were graded from one refusal of the harness's
http://127.0.0.1 receiver. The https rule refuses that URL on its own, so
a server that never checks routability passed reject-non-routable anyway.
mcpkit's conformance build was in exactly that state until
panyam/mcpkit#1462 and passed the row throughout:

  -32602 delivery.url rejected: delivery.url must use https (got http)
  SUCCESS sep-9999-ssrf-reject-non-routable

The row now has its own probe, run before anything can lift a guard: an
https://127.0.0.1 callback aimed at a bare TCP listener that records
whether the server connected. The scheme is valid, so only routability
can refuse it, and the listener separates a refusal from a dial that
failed afterwards. A server that skips the check and then fails its own
verification POST answers -32015, which reads as a refusal from the
subscribe alone.

  refused, never dialled   SUCCESS
  dialled, either way      FAILURE
  accepted, never dialled  WARNING (may be delivery-time validation)

Over a tunnel the server may be on another host, where its dial to
127.0.0.1 never reaches the listener, so there only -32602 counts as a
refusal. The probe also runs in that mode, where the row used to be
untestable.

validate-callback-url keeps grading from the http refusal, since either
rule refusing it is validation.

The negative fixture gains rejectNonRoutableUrl. The existing "refusing
the loopback callback passes the SSRF rows" case used a fixture that
enforced https alone and asserted this row SUCCESS, which certified the
bug; it now uses a fixture that refuses non-routable hosts, and still
asserts SUCCESS. Four new cases cover the table above, all red on the
old scenario.

Refs panyam/mcpkit#1460
error-resource-exhausted was graded only inside the concurrency probe,
which opens three streams on one event type and needs all three to stay
open for stream-exempt-from-concurrency-cap. A server capped on that type
passes the error row and fails the MUST beside it, so on one type the two
cannot both pass, and a server that caps anything else never has the row
provoked at all. mcpkit reports it untestable for exactly that reason
(panyam/mcpkit#1461), though it answers -32013 with data.limit on every
subscribe path.

New read-only control, events_conformance_quota, answering
{"name": "<event type>", "max": <n>}. When present, the scenario opens
streams on that type one at a time, up to max+1, and grades the first
refusal:

  -32013 with data.limit     SUCCESS
  -32013 without it          WARNING
  another code               FAILURE
  max+1 opened, no refusal   untestable, naming the cap that never bit

A refusal before max+1 counts, since other scenarios may hold
subscriptions under the same principal. The probe's row is pushed ahead
of the concurrency probe's, so dedupe keeps it; without the control the
concurrency probe's opportunistic grading stands as before, and its
untestable message now names the control.

The negative fixture gains a per-type quota with the control, and four
cases cover the table and the absent control, all red on the old
scenario. The existing maxConcurrent cases are unchanged.

Refs panyam/mcpkit#1461
…o's own subscription

envelope-gap and envelope-terminated reported untestable against every
server, because nothing in a run produces either. The push scenario solved
the same problem with yield_gap and terminate, but those cannot carry over:
in mcpkit a source's gap and terminal signals reach push streams only, so
no webhook envelope results, and terminate ends a whole event type, which
for a webhook-capable type ends it for every scenario after this one.

Two per-subscription controls instead, taking { id }:
events_conformance_webhook_gap and events_conformance_webhook_terminate.
The scenario fires them after the retry and redirect probes, terminate
last since it ends the main subscription, and waits up to 5s for each
envelope.

  envelope arrived, well formed     SUCCESS
  arrived without cursor / error    WARNING
  acknowledged, nothing arrived     gap WARNING, terminated FAILURE
  control absent or declined        untestable, naming the control

The envelope rows now grade after the probes, from what arrived, so the
signalled envelopes also count toward the discriminator, signing and
webhook-id rows.

The negative fixture gains both controls, reusing its envelope sender with
the event and handshake switched off. Four cases, all red on the old
scenario; the malformed-envelope case asserts on the graded body as well as
the status, since an untestable row is also a WARNING.

Refs panyam/mcpkit#1463
Only the poll scenario graded this row, and only against its own target,
so a server whose replay-capable types it polls reported it SKIPPED
while its push streams broke the rule. mcpkit did exactly that
(panyam/mcpkit#1468): a gap on a cursorless type sent
notifications/events/active {cursor: null, truncated: true}.

The push scenario now finds a type without replay by opening each
push-capable type until one confirms its stream with cursor: null, the
push analogue of poll reading cursor: null off its result, since
events/list declares nothing about replay. It asks for a gap there with
yield_gap and watches 1.5s:

  no truncated:true active     SUCCESS
  truncated:true active        WARNING (SHOULD), with the frame
  every type replays           SKIPPED, as on poll
  no yield_gap control         untestable, naming it

run() fills the row in on every early return, so runs stay comparable.

The negative fixture gains one no-replay type and the yield_gap control,
which sends the conformant fresh active for any other type. Five cases,
all red on the old scenario.

Refs panyam/mcpkit#1468
@pkg-pr-new

pkg-pr-new Bot commented Sep 24, 2026

Copy link
Copy Markdown

Open in StackBlitz

npx https://pkg.pr.new/@modelcontextprotocol/conformance@521

commit: 2323122

@panyam panyam changed the title feat(sep-xxxx): events server conformance against the design sketch — five scenarios, 122 of 135 rows feat(sep-xxxx): Conformance suite for triggers and events Sep 24, 2026

This branch has not been deployed

No deployments
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