Skip to content

refactor(examples): per-story directory layout, self-verifying CI harness, and the start-here / capstone story set#2325

Merged
felixweinberger merged 27 commits into
v2-2026-07-28from
fweinberger/examples-rework
Jun 19, 2026
Merged

refactor(examples): per-story directory layout, self-verifying CI harness, and the start-here / capstone story set#2325
felixweinberger merged 27 commits into
v2-2026-07-28from
fweinberger/examples-rework

Conversation

@felixweinberger

@felixweinberger felixweinberger commented Jun 18, 2026

Copy link
Copy Markdown
Contributor

Restructures examples/ from two flat src/ 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 a client.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 can cd examples/tools && pnpm install && pnpm run server and it works.

How Has This Been Tested?

scripts/run-examples.ts runs 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 --check passes with the new guide snippet paths.

Breaking Changes

None to published packages. The private @modelcontextprotocol/examples-server / -client packages 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

  • Bug fix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to change)
  • Documentation update

Checklist

  • I have read the MCP Documentation
  • My code follows the repository's style guidelines
  • New and existing tests pass locally
  • I have added appropriate error handling
  • I have added or updated documentation as needed

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-side cacheHints stamping), legacy-routing (isLegacyRequest in front of a strict modern entry), subscriptions (the subscriptions/listen API end to end), and oauth-client-credentials (machine-to-machine grant — demo authorization server + a createMcpHandler resource server behind requireBearerAuth; no browser).

Stories that stay single-transport or single-era (each README.md says why): bearer-auth, hono, json-response, legacy-routing, stateless-legacy are HTTP-only by nature; mrtr and subscriptions are modern-era-only (the methods don't exist on the 2025 protocol); elicitation-form and sampling are stdio-only legacy (push-style server→client requests need a long-lived bidirectional connection — the modern equivalent is the mrtr story).

Excluded (documented in each package): examples/oauth/ (browser authorization-code flow), sse-polling, standalone-get.

@felixweinberger felixweinberger requested a review from a team as a code owner June 18, 2026 18:46
@changeset-bot

changeset-bot Bot commented Jun 18, 2026

Copy link
Copy Markdown

⚠️ No Changeset found

Latest commit: 9ab402d

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. If these changes should result in a version bump, you need to add a changeset.

This PR includes no changesets

When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

Click here to learn what changesets are, and how to add one.

Click here if you're a maintainer who wants to add a changeset to this PR

@pkg-pr-new

pkg-pr-new Bot commented Jun 18, 2026

Copy link
Copy Markdown

Open in StackBlitz

@modelcontextprotocol/client

npm i https://pkg.pr.new/modelcontextprotocol/typescript-sdk/@modelcontextprotocol/client@2325

@modelcontextprotocol/codemod

npm i https://pkg.pr.new/modelcontextprotocol/typescript-sdk/@modelcontextprotocol/codemod@2325

@modelcontextprotocol/server

npm i https://pkg.pr.new/modelcontextprotocol/typescript-sdk/@modelcontextprotocol/server@2325

@modelcontextprotocol/server-legacy

npm i https://pkg.pr.new/modelcontextprotocol/typescript-sdk/@modelcontextprotocol/server-legacy@2325

@modelcontextprotocol/express

npm i https://pkg.pr.new/modelcontextprotocol/typescript-sdk/@modelcontextprotocol/express@2325

@modelcontextprotocol/fastify

npm i https://pkg.pr.new/modelcontextprotocol/typescript-sdk/@modelcontextprotocol/fastify@2325

@modelcontextprotocol/hono

npm i https://pkg.pr.new/modelcontextprotocol/typescript-sdk/@modelcontextprotocol/hono@2325

@modelcontextprotocol/node

npm i https://pkg.pr.new/modelcontextprotocol/typescript-sdk/@modelcontextprotocol/node@2325

commit: 9ab402d

Comment thread docs/server.md Outdated
Comment thread examples/oauth/README.md Outdated
Comment thread examples/elicitation-form/README.md Outdated
Comment thread scripts/run-examples.ts
@felixweinberger felixweinberger force-pushed the fweinberger/examples-rework branch from a9e1263 to 0fbc1f3 Compare June 18, 2026 19:08
Comment thread examples/legacy-routing/server.ts Outdated
Comment thread docs/client.md Outdated
Comment thread examples/README.md
@felixweinberger felixweinberger force-pushed the fweinberger/examples-rework branch from 0fbc1f3 to e2f2ef1 Compare June 18, 2026 19:27
Comment thread examples/oauth/elicitationUrlServer.ts Outdated
Comment thread examples/custom-version/client.ts
Comment thread docs/client.md Outdated
Comment thread examples/README.md Outdated
felixweinberger added a commit that referenced this pull request Jun 18, 2026
…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.
@felixweinberger felixweinberger force-pushed the fweinberger/examples-rework branch from e2f2ef1 to 43c3152 Compare June 18, 2026 20:14
@felixweinberger felixweinberger force-pushed the fweinberger/listen-client branch from 0e5890a to ebf896d Compare June 18, 2026 20:26
felixweinberger added a commit that referenced this pull request Jun 18, 2026
…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.
felixweinberger added a commit that referenced this pull request Jun 18, 2026
…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)
felixweinberger added a commit that referenced this pull request Jun 18, 2026
… 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)
@felixweinberger felixweinberger force-pushed the fweinberger/examples-rework branch from 43c3152 to 08bef0f Compare June 18, 2026 20:33
Comment thread examples/README.md Outdated
Comment thread examples/stickynotes/client.ts
Comment thread package.json Outdated
felixweinberger added a commit that referenced this pull request Jun 18, 2026
…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.
felixweinberger added a commit that referenced this pull request Jun 18, 2026
…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)
felixweinberger added a commit that referenced this pull request Jun 18, 2026
… 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)
@felixweinberger felixweinberger force-pushed the fweinberger/examples-rework branch from 6980724 to f30a848 Compare June 18, 2026 22:16
Comment thread examples/README.md Outdated
Comment thread README.md
Comment thread examples/README.md
Base automatically changed from fweinberger/listen-client to v2-2026-07-28 June 19, 2026 09:57
… 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).
…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).
@felixweinberger felixweinberger force-pushed the fweinberger/examples-rework branch from f30a848 to be71bee Compare June 19, 2026 10:09
…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).
Comment thread examples/sse-polling/server.ts Outdated
Comment thread docs/server.md
Comment thread examples/streaming/README.md Outdated
Comment thread examples/oauth-client-credentials/README.md Outdated
…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.
Comment thread docs/client.md Outdated
Comment thread examples/harness.ts
Comment thread examples/bearer-auth/README.md Outdated
Comment thread examples/repl/server.ts Outdated
…ge; mount legacy-routing at /mcp; clarify 'dual (in-body)' notation (61→63 legs)
Comment thread examples/bearer-auth/client.ts Outdated
Comment thread CLAUDE.md Outdated
…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
Comment thread examples/oauth/server.ts
Comment thread docs/client.md Outdated
…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)
@felixweinberger felixweinberger merged commit 4a1ef36 into v2-2026-07-28 Jun 19, 2026
15 checks passed
@felixweinberger felixweinberger deleted the fweinberger/examples-rework branch June 19, 2026 13:31
Comment on lines +125 to +145
};
}
// 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` }] };
}
);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔴 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).

  1. Client calls plan_trip with no inputResponsesdestination is undefined → handler returns inputRequired({ inputRequests: { dest: ... } }).
  2. The auto-fulfilment driver shows the destination form; the user clicks Decline → driver retries with inputResponses = { dest: { action: 'decline' } }.
  3. acceptedContent(inputResponses, 'dest') returns undefined (action is not 'accept'), requestState is unset → destination is still undefined → the handler returns the identical inputRequired round from step 1.
  4. Steps 2–3 repeat until round 10, when the driver throws InputRequiredRoundsExceeded and callTool rejects — versus the 2025 leg of the same tool, which would have returned trip decline after step 2. link_account follows the same loop via its auth?.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.

felixweinberger added a commit that referenced this pull request Jun 24, 2026
…ness, and the start-here / capstone story set (#2325)
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