refactor(examples): per-story directory layout, self-verifying CI harness, and the start-here / capstone story set#2325
Conversation
|
@modelcontextprotocol/client
@modelcontextprotocol/codemod
@modelcontextprotocol/server
@modelcontextprotocol/server-legacy
@modelcontextprotocol/express
@modelcontextprotocol/fastify
@modelcontextprotocol/hono
@modelcontextprotocol/node
commit: |
a9e1263 to
0fbc1f3
Compare
0fbc1f3 to
e2f2ef1
Compare
…s-refs (#2325) - legacy-routing/server.ts: replace ensureSessionful() with a handleLegacy() that follows the standard sessionful pattern — unknown Mcp-Session-Id → 404 'Session not found', missing → 400. Restores the behavior the (now-removed) fix-session-status-codes changeset documented; the rework had regressed it by minting a fresh transport for any unknown sid. - docs/client.md: drop the dangling reference to the deleted streamableHttpWithSseFallbackClient.ts; the inline connect_sseFallback snippet is the complete pattern. - examples/README.md: generic two-terminal HTTP command now points at /mcp (bearer-auth/hono/oauth-client-credentials/standalone-get all mount there) with a note to check each story's package.json#example.path for the exact endpoint.
e2f2ef1 to
43c3152
Compare
0e5890a to
ebf896d
Compare
…s-refs (#2325) - legacy-routing/server.ts: replace ensureSessionful() with a handleLegacy() that follows the standard sessionful pattern — unknown Mcp-Session-Id → 404 'Session not found', missing → 400. Restores the behavior the (now-removed) fix-session-status-codes changeset documented; the rework had regressed it by minting a fresh transport for any unknown sid. - docs/client.md: drop the dangling reference to the deleted streamableHttpWithSseFallbackClient.ts; the inline connect_sseFallback snippet is the complete pattern. - examples/README.md: generic two-terminal HTTP command now points at /mcp (bearer-auth/hono/oauth-client-credentials/standalone-get all mount there) with a note to check each story's package.json#example.path for the exact endpoint.
…out repl/ playground
- delete elicitationUrl{Server,Client}.ts (URL-mode elicitation now lives in
examples/mrtr/; these had unescaped HTML interpolation of session/elicit ids)
- delete simpleClientCredentials.ts (superseded by examples/oauth-client-credentials/)
- delete simpleStreamableHttpServer.ts (quarried into the new repl/server.ts)
- escape the one query-derived value (`error`) interpolated into
simpleOAuthClient.ts's callback HTML via a small escHtml helper; sweep
confirmed no other HTML responses in the remaining oauth/ files
- move interactiveReplClient.ts -> examples/repl/client.ts and pair it with a
new fully-featured HTTP server (tools w/ input/output schemas + annotations,
prompts w/ completion, direct + templated resources, logging,
resources/list_changed published via handler.notify); excluded from the
harness (interactive REPL — run manually)
- update oauth/README + package.json to the trimmed contents; repoint the
docs/{client,server}.md cross-refs that named deleted files (#2325)
… call site One comment line above each harness helper call so a reader landing on any story file immediately sees what the helper abstracts and what they'd write in their own server/client (serveStdio/createMcpHandler; new Client + transport.connect). 14 server.ts call sites + 15 client.ts files. (#2325)
43c3152 to
08bef0f
Compare
…s-refs (#2325) - legacy-routing/server.ts: replace ensureSessionful() with a handleLegacy() that follows the standard sessionful pattern — unknown Mcp-Session-Id → 404 'Session not found', missing → 400. Restores the behavior the (now-removed) fix-session-status-codes changeset documented; the rework had regressed it by minting a fresh transport for any unknown sid. - docs/client.md: drop the dangling reference to the deleted streamableHttpWithSseFallbackClient.ts; the inline connect_sseFallback snippet is the complete pattern. - examples/README.md: generic two-terminal HTTP command now points at /mcp (bearer-auth/hono/oauth-client-credentials/standalone-get all mount there) with a note to check each story's package.json#example.path for the exact endpoint.
…out repl/ playground
- delete elicitationUrl{Server,Client}.ts (URL-mode elicitation now lives in
examples/mrtr/; these had unescaped HTML interpolation of session/elicit ids)
- delete simpleClientCredentials.ts (superseded by examples/oauth-client-credentials/)
- delete simpleStreamableHttpServer.ts (quarried into the new repl/server.ts)
- escape the one query-derived value (`error`) interpolated into
simpleOAuthClient.ts's callback HTML via a small escHtml helper; sweep
confirmed no other HTML responses in the remaining oauth/ files
- move interactiveReplClient.ts -> examples/repl/client.ts and pair it with a
new fully-featured HTTP server (tools w/ input/output schemas + annotations,
prompts w/ completion, direct + templated resources, logging,
resources/list_changed published via handler.notify); excluded from the
harness (interactive REPL — run manually)
- update oauth/README + package.json to the trimmed contents; repoint the
docs/{client,server}.md cross-refs that named deleted files (#2325)
… call site One comment line above each harness helper call so a reader landing on any story file immediately sees what the helper abstracts and what they'd write in their own server/client (serveStdio/createMcpHandler; new Client + transport.connect). 14 server.ts call sites + 15 client.ts files. (#2325)
6980724 to
f30a848
Compare
… package The two split packages become one `@modelcontextprotocol/examples` workspace member at `examples/`. Story directories live directly under it; each story imports the new dual-transport scaffold (`examples/harness.ts`), which selects `serveStdio(factory)` vs `createMcpHandler(factory)` from argv server-side and stdio-spawn vs Streamable HTTP client-side. ESLint enforces public-API-only imports in story files (no `@modelcontextprotocol/*/src/*`, no `packages/*` paths, no `core`, no test-helpers). Dead `scripts/cli.ts` / `prepack` scripts are dropped from `examples/shared`. Workspace topology change: `pnpm-workspace.yaml` adds `examples` as a root, `.changeset` config replaces the two old package names with the new one. The lockfile is updated for the topology change only — no new external dependencies.
`scripts/run-examples.ts` iterates `examples/*/` (skipping `shared`, `guides`, `oauth`, the quickstarts, and any directory whose `manifest.json` carries an `excluded` reason). For each story it runs the client over every transport the story supports (default: both): stdio runs the client alone (which spawns the sibling server); HTTP launches `server.ts --http --port <P>` on a per-story port, polls the port, runs `client.ts --http <url>`, checks exit 0 (and the optional `expects.stdout` substring), then kills the server. Aggregate exit is non-zero if any leg failed. A new `examples` CI job (`.github/workflows/examples.yml`) builds the workspace first — fixing the gap that killed an earlier examples smoke suite — then runs the script. `pnpm run:examples` is the local entry point.
dual-era (collapsed from dualEraStdio + dualEraStreamableHttp), mrtr, and
custom-methods are re-hosted on the dual-transport scaffold; each gets a
self-verifying client.ts (the dual-era client now asserts both eras over
the selected transport; the mrtr client asserts both auto-fulfil and manual
flows reach `deployed to …`; the custom-methods client spawns the server
from source via tsx instead of dist/).
sse-polling, standalone-get, the guide snippet collections, and the
interactive OAuth set are moved verbatim into their directories with a
manifest/README; sse-polling/standalone-get stay excluded for now (long-
running sessionful 2025), oauth/ stays excluded (browser flow).
`examples/{server,client}` (the old split packages) are removed.
`streamableHttpWithSseFallbackClient.ts` is retired (the 1.x transport-
fallback story is no longer distinct under v2; the snippet stays in the
client guide).
…teMcpHandler
`stateless-legacy/` is the minimal default-posture deployment (one factory,
2026 served per request, 2025 served stateless from the same factory) — the
one-liner replacement for the 1.x per-POST stateless idiom. `json-response/`
is `createMcpHandler({ responseMode: 'json' })` with a client that asserts a
2026-era request comes back as `application/json` and that the regular Client
works unchanged. `hono/` mounts `handler.fetch` on a `createMcpHonoApp()`
Hono app — the web-standard face for Workers/Deno/Bun/Node.
`tools/`, `prompts/`, `resources/` are the primitives a new author reads first (register, list, call/get/read, structured output, completion, templates). `streaming/` covers the in-flight channels — progress (`_meta.progressToken` → `onprogress`), logging (`notifications/message` sent as a request-tied notification), and cancellation (`ctx.mcpReq.signal`). `stickynotes/` is the real-app capstone: tools mutate a board, a resource per note, listChanged on add/remove, an elicitation-confirmed clear (cancel/unchecked/confirm proven). `caching/` declares cacheHints at three layers and the client reads the stamped `ttlMs`/`cacheScope` back (full client-side honouring is a follow-up).
Every server now has a real client.ts: `sampling/` (canned `sampling/createMessage` handler, stdio-only — push is 2025-era); `elicitation-form/` (auto-answers the form, accept + decline, stdio-only); `schema-validators/` (Zod/ArkType/Valibot input + outputSchema → structured output); `custom-version/` (asserts the configured supportedProtocolVersions list); `legacy-routing/` (`isLegacyRequest` in front of an existing sessionful 1.x deployment + a strict `legacy: 'reject'` entry on the same port — the documented v2 composition with a runnable example); `bearer-auth/` (`requireBearerAuth` in front of `createMcpHandler`; asserts 401 + WWW-Authenticate without a token, authInfo flow with one); `parallel-calls/` (multiple clients + parallel calls with attributed notifications, on a slim createMcpHandler server). `toolWithSampleServer`'s stdout-logging-into-the-protocol-stream bug is fixed in the move: every server log goes to stderr.
`docs/server.md` and `docs/client.md` source the guide snippets from
`examples/guides/{server,client}Guide.examples.ts`; prose links to the
moved examples are updated. The root README and typedoc `projectDocuments`
point at the new `examples/README.md` index.
… listen client One factory, both transports: over HTTP the example publishes via the handler's ServerNotifier (handler.notify.toolsChanged()) on the cross-request ServerEventBus; over stdio it toggles a RegisteredTool on the pinned instance and the entry's listen router fans the instance's tools/list_changed onto every open subscription. The client drives both the auto-opened stream (ClientOptions.listChanged) and a manual client.listen() / McpSubscription, calling a flip_tools tool to mutate on demand so the harness has no timer race.
…pports
The harness now runs each story over {stdio, http} × {modern, legacy}. The
shared connectFromArgs reads --legacy from argv (versionNegotiation
{mode:'legacy'} vs {mode:'auto'}); a per-story manifest.json era pin opts
era-specific stories out of the leg they cannot serve.
Dual-transport conversions:
- parallel-calls: one factory via runServerFromArgs / connectFromArgs per
client (was http-only).
- stickynotes: http leg exercises add/list/read/remove and skips the
push-elicitation-confirmed remove_all (no return path on per-request
HTTP); stdio leg keeps the full flow.
- subscriptions: dual per the previous commit.
Era pins:
- modern-only: mrtr, subscriptions, caching (2026-07-28 features); dual-era,
legacy-routing, stateless-legacy, json-response, hono, bearer-auth (drive
both eras themselves or do not use connectFromArgs).
- legacy-only: elicitation-form, sampling, stickynotes (push-style
server→client requests need a 2025 initialize handshake to advertise the
capability and a long-lived bidirectional connection); custom-methods,
custom-version (about the 2025 handshake / no envelope semantics).
elicitation-form and sampling stay stdio-only: createMcpHandler's
per-request/stateless posture has neither a durable client-capability record
nor a return path for the elicitation/sampling response.
Also names the Examples workflow.
…json#example Each `examples/<story>/` is now its own private `@mcp-examples/<story>` pnpm workspace package with `server`/`client` scripts and only the dependencies that story actually imports. The harness's per-story config moves from `manifest.json` (deleted) to a `package.json#example` field (`transports`, `era`, `path`, `excluded`, …) and the runner reads it from there. `examples/shared` is renamed `@mcp-examples/shared` and gains an `exports` entry so story packages can import it under tsx. The parent `@modelcontextprotocol/examples` package is kept (owns `harness.ts`, the typecheck/lint scripts, and `guides/`). Changeset `ignore` uses an `@mcp-examples/*` glob.
…o browser) A fully self-verifying `client_credentials` grant story closing the auth gap: `server.ts` hosts a minimal in-repo `client_credentials`-only Authorization Server (`createClientCredentialsAuthServer`, new in `@mcp-examples/shared`) on one port and a `createMcpHandler` resource server behind `requireBearerAuth` on another; `client.ts` asserts a bare request is 401, then connects with `ClientCredentialsProvider` so the SDK auth driver discovers the AS, exchanges id+secret for a Bearer token, and `ctx.authInfo` carries the granted clientId/scopes through the `whoami` tool. HTTP-only, modern-era. The better-auth/OIDC `setupAuthServer` only implements `authorization_code`, hence the new minimal AS. Its verifier explicitly models token expiry (and `requireBearerAuth` independently rejects on `expiresAt`), so the demo is not fail-open. The browser `authorization_code` flow stays under `examples/oauth/` (excluded).
…ed in the per-story restructure)
…s-refs (#2325) - legacy-routing/server.ts: replace ensureSessionful() with a handleLegacy() that follows the standard sessionful pattern — unknown Mcp-Session-Id → 404 'Session not found', missing → 400. Restores the behavior the (now-removed) fix-session-status-codes changeset documented; the rework had regressed it by minting a fresh transport for any unknown sid. - docs/client.md: drop the dangling reference to the deleted streamableHttpWithSseFallbackClient.ts; the inline connect_sseFallback snippet is the complete pattern. - examples/README.md: generic two-terminal HTTP command now points at /mcp (bearer-auth/hono/oauth-client-credentials/standalone-get all mount there) with a note to check each story's package.json#example.path for the exact endpoint.
…out repl/ playground
- delete elicitationUrl{Server,Client}.ts (URL-mode elicitation now lives in
examples/mrtr/; these had unescaped HTML interpolation of session/elicit ids)
- delete simpleClientCredentials.ts (superseded by examples/oauth-client-credentials/)
- delete simpleStreamableHttpServer.ts (quarried into the new repl/server.ts)
- escape the one query-derived value (`error`) interpolated into
simpleOAuthClient.ts's callback HTML via a small escHtml helper; sweep
confirmed no other HTML responses in the remaining oauth/ files
- move interactiveReplClient.ts -> examples/repl/client.ts and pair it with a
new fully-featured HTTP server (tools w/ input/output schemas + annotations,
prompts w/ completion, direct + templated resources, logging,
resources/list_changed published via handler.notify); excluded from the
harness (interactive REPL — run manually)
- update oauth/README + package.json to the trimmed contents; repoint the
docs/{client,server}.md cross-refs that named deleted files (#2325)
… call site One comment line above each harness helper call so a reader landing on any story file immediately sees what the helper abstracts and what they'd write in their own server/client (serveStdio/createMcpHandler; new Client + transport.connect). 14 server.ts call sites + 15 client.ts files. (#2325)
… --legacy run commands, multi-node section move
…E coverage fixes
- Rename elicitation-form/ -> elicitation/ and rewrite as the dual-era
elicitation story: form + URL mode on both protocol eras from one factory.
On 2025 (--legacy) the server uses the push-style elicitInput / throw
UrlElicitationRequiredError / createElicitationCompletionNotifier; on
2026-07-28 the same tools return inputRequired(...).elicit / .elicitUrl.
Adds enumNames to the form schema. stdio x dual-era (2 legs).
- repl/server.ts: add Implementation icons + websiteUrl.
- examples/README.md: rewrite Excluded as a table; rewrite the broken
Backwards-compatibility section (deleted-package commands -> guides
snippet pointer); update the elicitation row.
- sse-polling: README notes eventStore resumability is 2025-session-only;
fix stale "Run with:" header path.
- docs/{client,server}.md, oauth/README.md: re-point elicitation links.
…ted server, sessionful repl/, fix docs:check anchor - oauth/server.ts: in-repo authorization-code AS (setupAuthServer) + RS (createMcpHandler behind requireBearerAuth(demoTokenVerifier)) so simpleOAuthClient.ts has an in-repo target (M2). README run-manually section. - legacy-routing/: CORS exposedHeaders recipe (M8), explicit GET/DELETE routes for the sessionful arm (M9), README section on direct WebStandardStreamableHTTPServerTransport construction (M6). - repl/server.ts: re-hosted on sessionful NodeStreamableHTTPServerTransport with an InMemoryEventStore so the REPL client's reconnect/resumability commands work (M7). - elicitation/: plan_trip tool chains two form elicitations inside one tool call on both eras (M4). - oauth-client-credentials/README: PrivateKeyJwtProvider section pointing at the client guide snippet (M3). - examples/README: fix #backwards-compatibility -> #sse-fallback-for-legacy-servers (docs:check broken anchor); update oauth/ row. - CONTRIBUTING + root package.json: drop stale examples-server / oauth/ simpleStreamableHttpServer.ts references. - stickynotes/client.ts: header says 2025-era (matches era pin).
f30a848 to
be71bee
Compare
…t×era matrix (47→59 legs)
runServerFromArgs now hosts 2025-era HTTP traffic on a sessionful
NodeStreamableHTTPServerTransport (one transport+instance per session)
behind isLegacyRequest, with createMcpHandler({legacy:'reject'}) for the
modern arm — the documented composition. That removes the structural
reason elicitation/sampling/stickynotes couldn't run http/legacy.
Per-story matrix changes:
- elicitation, custom-methods, stickynotes: full stdio+http × dual
- sampling: stdio+http × legacy (deprecated 2026-07-28; no modern equiv)
- bearer-auth, hono, oauth-client-credentials: http × dual via
negotiationFromArgs() (era-agnostic HTTP-layer concerns)
- standalone-get: tool-triggered (add_resource) instead of 5s timer;
un-excluded, http × legacy
- sse-polling: --port/--http aware, self-verifying client, shorter
sleeps + retryInterval; un-excluded, http × legacy
- legacy-routing: add path:'/' so package.json#example.path is
discoverable per the README sentence
- json-response: stays modern-only (responseMode shapes the modern path
only); replace boilerplate '//' with the principled reason
- oauth: drop from NON_STORY, add stub client.ts so the SKIP reason
surfaces in the run-examples summary like repl's
README: add an Era column to the coverage tables; flip the
'different path' sentence to match the harness default (/); drop the
sse-polling/standalone-get row from Excluded.
run:examples: 23 run / 2 excluded; 59 legs passed / 0 failed (was 47).
…authorization-code client (59→61 legs)
The demo Authorization Server (`setupAuthServer`) gains an `autoConsent`
option: when set, a tiny middleware strips the OIDC `prompt` param from
`/api/auth/mcp/authorize` before it reaches better-auth, so the authorize
handler 302s straight back to `redirect_uri?code=...` — what clicking
Approve would do. Combined with the existing `/sign-in` auto-sign-in, the
whole authorization-code browser dance becomes a deterministic 302 chain.
`oauth/server.ts` now honours `--port` (AS on PORT+1, 127.0.0.1) and wires
`autoConsent` from `OAUTH_DEMO_AUTO_CONSENT=1`. `oauth/client.ts` is
rewritten as the CI-runnable headless flow: drives the SDK auth driver via
`InMemoryOAuthClientProvider`, captures the authorization URL it would
`open()`, follows it with `fetch({redirect:'manual'})` + a small cookie
jar, reads `?code` off the final Location, `transport.finishAuth(code)`,
reconnects, and asserts `ctx.authInfo` round-trips. `simpleOAuthClient.ts`
stays as the manual real-browser flow (`pnpm client:browser`).
`package.json#example`: excluded → http × dual, env
`OAUTH_DEMO_AUTO_CONSENT=1`. READMEs updated; oauth moves from Excluded to
Feature stories.
Also: `sse-polling` and `standalone-get` clients now pass
`versionNegotiation: { mode: 'legacy' }` explicitly (were era-blind,
reaching 2025 by fallback — make the leg honest).
…DME era/transport claims with package.json#example - sse-polling/server.ts: standard sessionful routing (unknown sid → 404 -32001, missing sid → 400, only build a transport on no-sid + isInitializeRequest); drop the orphan-transport path. Mirrors repl/, legacy-routing/, standalone-get/. - docs/server.md: drop the "logging" claim against legacy-routing/server.ts (it has sessions + CORS only); re-point the shutdown-handling sentence at repl/server.ts (which actually closes transports on SIGINT). - streaming/README.md: name ctx.mcpReq.notify (request-tied notifications/message) to match server.ts; note the per-request-HTTP caveat for ctx.mcpReq.log. - oauth-client-credentials/README.md: era is dual, not modern-only; oauth/ is now harness-run (headless via OAUTH_DEMO_AUTO_CONSENT), not excluded. - bearer-auth/README.md: same stale "oauth excluded from the harness" wording. Sweep of examples/*/README.md era/transport claims vs package.json#example found no other mismatches.
…ge; mount legacy-routing at /mcp; clarify 'dual (in-body)' notation (61→63 legs)
…r *.md); harness Buffer.concat for UTF-8 chunk-safe body decode; lift InMemoryEventStore into @mcp-examples/shared
…dexOf -1 → argv[0] bug); CLAUDE.md harness path; standalone-get/sse-polling README corrections
…dvertised PRM resource (127.0.0.1)
…ms with actual code
- docs/client.md: oauth-client-credentials link no longer claims env-var
switching between auth methods (the example only runs ClientCredentialsProvider;
private_key_jwt is README-note only)
- docs/client.md, docs/server.md: drop pre-restructure filenames from link
text (toolWithSampleServer.ts, ssePollingClient.ts, parallelToolCallsClient.ts,
multipleClientsParallel.ts, examples/server/) — files were renamed to the
per-story <story>/{server,client}.ts layout
- examples/caching/README.md: 'three layers' → 'two layers' (server.ts only
declares per-registration + server-level hints; handler-return precedence
noted but not exercised)
| }; | ||
| } | ||
| // 2026-07-28: two `inputRequired` rounds — the second carries the | ||
| // first answer back via `requestState` (an opaque server-minted | ||
| // string) so the chain survives the stateless retry. See ../mrtr/ | ||
| // for integrity-protecting `requestState` in production. | ||
| const dates = acceptedContent<{ departure: string; nights: number }>(ctx.mcpReq.inputResponses, 'dates'); | ||
| const destination = | ||
| ctx.mcpReq.requestState ?? acceptedContent<{ destination: string }>(ctx.mcpReq.inputResponses, 'dest')?.destination; | ||
| if (!destination) { | ||
| return inputRequired({ inputRequests: { dest: inputRequired.elicit({ message: 'Where to?', requestedSchema: DEST }) } }); | ||
| } | ||
| if (!dates) { | ||
| return inputRequired({ | ||
| requestState: destination, | ||
| inputRequests: { dates: inputRequired.elicit({ message: 'When?', requestedSchema: datesFor(destination) }) } | ||
| }); | ||
| } | ||
| return { content: [{ type: 'text', text: `trip planned: ${destination} on ${dates.departure} for ${dates.nights} nights` }] }; | ||
| } | ||
| ); |
There was a problem hiding this comment.
🔴 On the 2026-07-28 (inputRequired) path, plan_trip and link_account never terminate when the user declines or cancels: a non-accept response just re-issues the identical inputRequired round, so an auto-fulfilling client re-prompts until the round limit errors out — whereas the 2025-era branches of the same tools return a terminal "trip " / "link " result after one decline. register_user in this same file shows the correct pattern (presence-check inputResponses before re-issuing, terminal result on non-accept); mirroring it in both tools fixes the asymmetry.
Extended reasoning...
What the bug is. Two of the three tools in this canonical elicitation example handle a non-accept answer asymmetrically between protocol eras on the 2026-07-28 (inputRequired) path. link_account's modern branch checks only auth?.action !== 'accept' and re-issues the same inputRequired.elicitUrl round when the user declines or cancels. plan_trip's modern branch derives all of its state from acceptedContent(...), which (per packages/core/src/shared/inputRequired.ts:144-155) returns undefined whenever candidate.action !== 'accept' — so a declined dest or dates response is indistinguishable from "never asked" and the handler asks the same question again.
The code path that triggers it. A 2026-07-28 client calls plan_trip (or link_account); the tool returns inputRequired; the client's auto-fulfilment driver (packages/core/src/shared/inputRequiredDriver.ts) dispatches the embedded elicitation/create to the user, who clicks Decline or Cancel; the driver retries the call with inputResponses['dest'] = { action: 'decline' } (or ['auth']); the handler sees acceptedContent(...) === undefined / action !== 'accept' and returns the same inputRequired round again. The driver loops up to inputRequired.maxRounds (default 10) and then throws SdkErrorCode.InputRequiredRoundsExceeded — so the user is re-prompted repeatedly and the call ends in an error, instead of completing gracefully with a "declined" result.
Why this is an oversight, not a design choice. The 2025-era branches of the same two tools terminate after one decline (return { content: [{ type: 'text', text: \trip ${step1.action}` }] } / \link ${result.action}`), and register_user in this same file gets the modern path right: it first presence-checks ctx.mcpReq.inputResponses?.['form'] (so "asked but declined" is distinguishable from "not asked yet") and returns a terminal `registration ${response.action}` for non-accept. The README's framing — "the protocol carries it differently but the user experience is the same" — is violated only for these two tools.
Why CI doesn't catch it. examples/elicitation/client.ts only exercises the decline path for register_user (formAction = 'decline'); the elicitation handler always answers accept for plan_trip's and link_account's requests, so every harness leg stays green.
Step-by-step proof (plan_trip, modern leg).
- Client calls
plan_tripwith noinputResponses→destinationisundefined→ handler returnsinputRequired({ inputRequests: { dest: ... } }). - The auto-fulfilment driver shows the destination form; the user clicks Decline → driver retries with
inputResponses = { dest: { action: 'decline' } }. acceptedContent(inputResponses, 'dest')returnsundefined(action is not'accept'),requestStateis unset →destinationis stillundefined→ the handler returns the identicalinputRequiredround from step 1.- Steps 2–3 repeat until round 10, when the driver throws
InputRequiredRoundsExceededandcallToolrejects — versus the 2025 leg of the same tool, which would have returnedtrip declineafter step 2.link_accountfollows the same loop via itsauth?.action !== 'accept'check.
Impact. This is the documented reference implementation of the multi-round-trip elicitation pattern (linked from docs/server.md and examples/README.md), so the buggy "re-ask on decline" shape will be copy-pasted into real servers, where a user clicking Decline/Cancel gets an endless re-prompt (auto-fulfilling host) or a confusing rounds-exceeded error (manual host).
How to fix. Mirror register_user's presence check in both tools: for link_account, if inputResponses['auth'] exists and action !== 'accept', return `link ${action}` instead of re-issuing; for plan_trip, read inputResponses['dest'] / ['dates'] directly and return `trip ${action}` on a present non-accept response before falling through to the re-issue branches.
…ness, and the start-here / capstone story set (#2325)
Restructures
examples/from two flatsrc/directories into one self-verifying client/server pair per feature, each its own workspace package, and adds a CI job that builds the workspace and runs every pair end to end over every transport and protocol era it supports.Motivation and Context
Examples were typechecked and built but never executed in CI, so silent drift was possible. Every story now ships a
server.ts(what you would deploy) and aclient.ts(what a host would write — connects with the public client API, asserts results, exits non-zero on any mismatch). The client is the e2e test; the harness is pure orchestration. Each story is a standalone@mcp-examples/<story>package, so a user cancd examples/tools && pnpm install && pnpm run serverand it works.How Has This Been Tested?
scripts/run-examples.tsruns 21 stories across 46 transport×era legs, all passing locally. The full workspace gate suite (typecheck / lint / docs:check / unit / e2e / integration / conformance) is green.sync:snippets --checkpasses with the new guide snippet paths.Breaking Changes
None to published packages. The private
@modelcontextprotocol/examples-server/-clientpackages collapse into per-story@mcp-examples/*packages and a shared@mcp-examples/shared; documentation links to example files are updated. One stale changeset that referenced the removed package is dropped.Types of changes
Checklist
Additional context
Layout:
examples/<story>/{server.ts, client.ts, README.md, package.json}. The dual-transport / dual-era scaffold (connectFromArgs,runServerFromArgs) lives in@mcp-examples/shared; the harness reads each package's"example": { transports?, era?, excluded? }field for pins. By default each story runs four legs: stdio×modern, stdio×legacy, http×modern, http×legacy.New stories:
tools/prompts/resources(the "start here" primitives),streaming(progress / logging / cancellation),stickynotes(the capstone — tools, resources, list_changed and elicitation in one stateful server),caching(server-sidecacheHintsstamping),legacy-routing(isLegacyRequestin front of a strict modern entry),subscriptions(thesubscriptions/listenAPI end to end), andoauth-client-credentials(machine-to-machine grant — demo authorization server + acreateMcpHandlerresource server behindrequireBearerAuth; no browser).Stories that stay single-transport or single-era (each
README.mdsays why):bearer-auth,hono,json-response,legacy-routing,stateless-legacyare HTTP-only by nature;mrtrandsubscriptionsare modern-era-only (the methods don't exist on the 2025 protocol);elicitation-formandsamplingare stdio-only legacy (push-style server→client requests need a long-lived bidirectional connection — the modern equivalent is themrtrstory).Excluded (documented in each package):
examples/oauth/(browser authorization-code flow),sse-polling,standalone-get.