Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/examples.yml
Original file line number Diff line number Diff line change
Expand Up @@ -44,4 +44,4 @@ jobs:
- run: pnpm run build:all

- name: Run all example pairs (transport × era)
run: pnpm tsx scripts/run-examples.ts
run: pnpm tsx scripts/examples/run-examples.ts
4 changes: 2 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -128,8 +128,8 @@ asserts results, exits non-zero on mismatch). `pnpm run:examples` runs every sto
configured transport×era legs; the `examples (build + e2e)` CI job is part of the per-PR gate
basket. See `examples/README.md` for the full story matrix.

- `examples/harness.ts` — dual-transport scaffold (`connectFromArgs`, `runServerFromArgs`, `httpUrlFromArgs`, `runClient`)
- `examples/shared/` — `@mcp-examples/shared` package (demo OAuth provider, `InMemoryEventStore`)
- `examples/shared/` — `@mcp-examples/shared` package. Root export is args-only (`parseExampleArgs`, `check`, `siblingPath`); the demo OAuth provider and `InMemoryEventStore` live at the `@mcp-examples/shared/auth` subpath so non-auth stories don't eagerly evaluate better-auth/express/better-sqlite3. Stories import only this plumbing and inline the SDK transport setup themselves — see `examples/CONTRIBUTING.md`.
- `scripts/examples/` — runner (`run-examples.ts`)
- `examples/guides/` — typecheck-only snippet collections synced into `docs/{server,client}.md`

## Message Flow (Bidirectional Protocol)
Expand Down
94 changes: 94 additions & 0 deletions examples/CONTRIBUTING.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
# Contributing an example

Each `examples/<story>/` directory is a tiny `@mcp-examples/<story>` workspace
package containing a `server.ts` / `client.ts` pair. The pair is a
self-verifying e2e test: the client connects, asserts results, and exits
non-zero on any mismatch. `pnpm run:examples` runs every story over its
configured transport × era legs and is part of the per-PR CI gate.

## Typical shape

Examples are **compiled documentation**. Every story shows the SDK transport
setup **inline** — no helper hides `serveStdio`, `createMcpHandler`, `Client`,
or transport construction. The duplication is the feature: when the public API
changes, 25 compile errors flag 25 doc pages.

Only the part a reader is _not_ here to learn — argv parsing — is shared, via
`parseExampleArgs` / `check` / `siblingPath` from `@mcp-examples/shared` (a
workspace package, so it reads as scaffolding, not part of the example). The
demo OAuth provider and `InMemoryEventStore` live at the
`@mcp-examples/shared/auth` subpath so the args-only root barrel does not pull
better-auth/express/better-sqlite3 into every story.

Most stories follow the skeleton below; deviate freely when the story calls for
it (HTTP-only auth, sessionful transports, framework adapters, etc.).

### `server.ts`

```ts
import { createServer } from 'node:http';

import { parseExampleArgs } from '@mcp-examples/shared';
import { toNodeHandler } from '@modelcontextprotocol/node';
import { createMcpHandler, McpServer } from '@modelcontextprotocol/server';
import { serveStdio } from '@modelcontextprotocol/server/stdio';

function buildServer(): McpServer {
const server = new McpServer({ name: '<story>-example', version: '1.0.0' });
// … register tools / resources / prompts here …
return server;
}

const { transport, port } = parseExampleArgs();

if (transport === 'stdio') {
void serveStdio(buildServer);
console.error('[server] serving over stdio');
} else {
const handler = createMcpHandler(buildServer);
createServer(toNodeHandler(handler)).listen(port, () => {
console.error(`[server] listening on http://127.0.0.1:${port}/mcp`);
});
}
```

### `client.ts`

```ts
import { check, parseExampleArgs, siblingPath } from '@mcp-examples/shared';
import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client';
import { StdioClientTransport } from '@modelcontextprotocol/client/stdio';
Comment thread
claude[bot] marked this conversation as resolved.

const { transport, url, era } = parseExampleArgs();

const client = new Client({ name: '<story>-example-client', version: '1.0.0' }, { versionNegotiation: { mode: era === 'modern' ? 'auto' : 'legacy' } });

await client.connect(transport === 'stdio' ? new StdioClientTransport({ command: 'npx', args: ['-y', 'tsx', siblingPath(import.meta.url, 'server.ts')] }) : new StreamableHTTPClientTransport(new URL(url)));

// … example body — drive the server and assert with `check.*` …

await client.close();
```

The body uses top-level `await`. A `check.*` failure throws, Node prints the
error and exits 1; on success `client.close()` releases the last handle and
Node exits 0. `pnpm run:examples` reports PASS/FAIL from the exit code (a
timeout is reported as a hang — investigate it as a possible unclosed handle).

## Import rules (lint-enforced)

Stories may import from:

- `@modelcontextprotocol/{server,client,node,express,hono}` and their published
subpath exports (e.g. `@modelcontextprotocol/server/stdio`)
- `@mcp-examples/shared` (args/assert) and `@mcp-examples/shared/auth` (demo OAuth + `InMemoryEventStore`)
- third-party packages a consumer would `npm install`

Stories may **not** import from:

- `@modelcontextprotocol/core` or `@modelcontextprotocol/core/*` (internal barrel)
- `@modelcontextprotocol/*/src/*` or `@modelcontextprotocol/*/dist/*` (deep paths)
- `@modelcontextprotocol/test-helpers`
- any relative path that hides the SDK transport setup behind a shared helper

`@mcp-examples/shared` itself must never import from a story package (one-way).
16 changes: 8 additions & 8 deletions examples/README.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# MCP TypeScript SDK examples

One **story** per directory. Every story is a runnable, self-verifying client/server pair: `server.ts` is what you would deploy, `client.ts` is what a host would write — it connects, exercises the feature with the public client API, asserts results, and exits 0. CI runs every
pair over every transport it supports (`scripts/run-examples.ts`); a non-zero exit fails the build.
pair over every transport it supports (`scripts/examples/run-examples.ts`); a non-zero exit fails the build.
Comment thread
claude[bot] marked this conversation as resolved.

Each story is its own private workspace package (`@mcp-examples/<story>`). Run any pair from the repo root:

Expand Down Expand Up @@ -57,16 +57,16 @@ Add `-- --legacy` to the client command for the 2025-era handshake.
| [`sse-polling/`](./sse-polling/README.md) | SEP-1699 SSE polling/resumption (sessionful 2025) | http | legacy |
| [`standalone-get/`](./standalone-get/README.md) | Standalone GET stream + `listChanged` push (sessionful 2025) | http | legacy |

`dual (in-body)` = the client connects to both eras inside one harness run; the story demonstrates one server serving both side by side.
`dual (in-body)` = the client connects to both eras inside one runner invocation; the story demonstrates one server serving both side by side.

## Excluded

| Directory | What it is | Why not in CI |
| ------------------------------------------ | --------------------------------------------------------------------- | -------------------------------------------------------------------------- |
| [`repl/`](./repl/README.md) | Fully-featured HTTP playground server + readline client | Interactive — `client.ts` reads from stdin. Run manually in two terminals. |
| [`guides/`](./guides/README.md) | Snippet collections synced into `docs/server.md` and `docs/client.md` | Typecheck-only; not a runnable pair. |
| `server-quickstart/`, `client-quickstart/` | Website-tutorial sources | External network / API key; typecheck-only. |
| `shared/` | Demo OAuth provider helper library | Not a story — imported by the OAuth examples. |
| Directory | What it is | Why not in CI |
| ------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------- |
| [`repl/`](./repl/README.md) | Fully-featured HTTP playground server + readline client | Interactive — `client.ts` reads from stdin. Run manually in two terminals. |
| [`guides/`](./guides/README.md) | Snippet collections synced into `docs/server.md` and `docs/client.md` | Typecheck-only; not a runnable pair. |
| `server-quickstart/`, `client-quickstart/` | Website-tutorial sources | External network / API key; typecheck-only. |
| `shared/` | Argv/assert scaffold (`parseExampleArgs`/`check`/`siblingPath`); demo OAuth provider + `InMemoryEventStore` at the `./auth` subpath | Not a story — imported by every story as scaffolding. |

## Multi-node deployment patterns

Expand Down
2 changes: 1 addition & 1 deletion examples/bearer-auth/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,4 @@
Resource-server-only auth: `requireBearerAuth` + `mcpAuthMetadataRouter` from `@modelcontextprotocol/express` in front of `createMcpHandler`. The client asserts `401` + `WWW-Authenticate` without a token, and that the verified `authInfo` reaches the factory (`ctx.authInfo`) with
one.

**HTTP-only** by definition. The full interactive OAuth set lives under `../oauth/` (run headlessly by the harness via the demo AS's auto-consent mode).
**HTTP-only** by definition. The full interactive OAuth set lives under `../oauth/` (run headlessly in CI via the demo AS's auto-consent mode).
40 changes: 21 additions & 19 deletions examples/bearer-auth/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,27 +3,29 @@
* a request with `Authorization: Bearer demo-token` reaches the `whoami` tool
* with the verified `authInfo`.
*/
import { check, parseExampleArgs } from '@mcp-examples/shared';
import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client';

import { check, httpUrlFromArgs, negotiationFromArgs, runClient } from '../harness.js';
const { url, era } = parseExampleArgs();

const URL = httpUrlFromArgs('http://127.0.0.1:3000/mcp');
// Unauthenticated → 401 + WWW-Authenticate.
const unauth = await fetch(url, {
method: 'POST',
headers: { 'content-type': 'application/json', accept: 'application/json, text/event-stream' },
body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'ping' })
});
check.equal(unauth.status, 401);
check.match(unauth.headers.get('www-authenticate') ?? '', /Bearer/);

runClient('bearer-auth', async () => {
// Unauthenticated → 401 + WWW-Authenticate.
const unauth = await fetch(URL, {
method: 'POST',
headers: { 'content-type': 'application/json', accept: 'application/json, text/event-stream' },
body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'ping' })
});
check.equal(unauth.status, 401);
check.match(unauth.headers.get('www-authenticate') ?? '', /Bearer/);
// Authenticated → 200 and the tool sees the authInfo. Bearer auth is
// HTTP-layer and era-agnostic; the client honours `--legacy` via `era`.
const client = new Client(
{ name: 'bearer-auth-example-client', version: '1.0.0' },
{ versionNegotiation: { mode: era === 'modern' ? 'auto' : 'legacy' } }
);
await client.connect(new StreamableHTTPClientTransport(new URL(url), { authProvider: { token: async () => 'demo-token' } }));

// Authenticated → 200 and the tool sees the authInfo. Bearer auth is
// HTTP-layer and era-agnostic; `negotiationFromArgs()` honours `--legacy`.
const client = new Client({ name: 'bearer-auth-client', version: '1.0.0' }, { versionNegotiation: negotiationFromArgs() });
await client.connect(new StreamableHTTPClientTransport(new globalThis.URL(URL), { authProvider: { token: async () => 'demo-token' } }));
const result = await client.callTool({ name: 'whoami', arguments: {} });
check.equal(result.content?.[0]?.type === 'text' ? result.content[0].text : '', 'client=demo-client');
await client.close();
});
const result = await client.callTool({ name: 'whoami', arguments: {} });
check.equal(result.content?.[0]?.type === 'text' ? result.content[0].text : '', 'client=demo-client');

await client.close();
3 changes: 2 additions & 1 deletion examples/bearer-auth/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
"client": "tsx client.ts"
},
"dependencies": {
"@mcp-examples/shared": "workspace:*",
"@modelcontextprotocol/client": "workspace:*",
"@modelcontextprotocol/express": "workspace:*",
"@modelcontextprotocol/node": "workspace:*",
Expand All @@ -22,6 +23,6 @@
],
"era": "dual",
"path": "/mcp",
"//": "Bearer auth + 401/WWW-Authenticate is HTTP-layer and era-agnostic; the client honours --legacy via negotiationFromArgs."
"//": "Bearer auth + 401/WWW-Authenticate is HTTP-layer and era-agnostic; the client honours --legacy via the inline versionNegotiation branch."
}
}
32 changes: 18 additions & 14 deletions examples/bearer-auth/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
* endpoint is hosted on `createMcpHandler` with the verified `authInfo` passed
* through to the factory (`ctx.authInfo`). HTTP-only by definition.
*/
import { parseExampleArgs } from '@mcp-examples/shared';
import type { OAuthTokenVerifier } from '@modelcontextprotocol/express';
import {
createMcpExpressApp,
Expand All @@ -15,14 +16,20 @@ import {
requireBearerAuth
} from '@modelcontextprotocol/express';
import { toNodeHandler } from '@modelcontextprotocol/node';
import type { AuthInfo, OAuthMetadata } from '@modelcontextprotocol/server';
import type { AuthInfo, McpServerFactory, OAuthMetadata } from '@modelcontextprotocol/server';
import { createMcpHandler, McpServer, OAuthError, OAuthErrorCode } from '@modelcontextprotocol/server';
import * as z from 'zod/v4';

const argv = process.argv.slice(2);
const portIdx = argv.indexOf('--port');
const PORT = portIdx === -1 ? 3000 : Number(argv[portIdx + 1]);
const mcpServerUrl = new URL(`http://localhost:${PORT}/mcp`);
const buildServer: McpServerFactory = ctx => {
const server = new McpServer({ name: 'bearer-auth-example', version: '1.0.0' });
server.registerTool('whoami', { description: 'Returns the authenticated subject.', inputSchema: z.object({}) }, async () => ({
content: [{ type: 'text', text: `client=${ctx.authInfo?.clientId ?? 'anon'}` }]
}));
return server;
};

const { port } = parseExampleArgs();
const mcpServerUrl = new URL(`http://localhost:${port}/mcp`);

const oauthMetadata: OAuthMetadata = {
issuer: 'https://auth.example.com',
Expand All @@ -41,13 +48,10 @@ const staticTokenVerifier: OAuthTokenVerifier = {
}
};

const handler = createMcpHandler(ctx => {
const server = new McpServer({ name: 'bearer-auth-example', version: '1.0.0' });
server.registerTool('whoami', { description: 'Returns the authenticated subject.', inputSchema: z.object({}) }, async () => ({
content: [{ type: 'text', text: `client=${ctx.authInfo?.clientId ?? 'anon'}` }]
}));
return server;
});
// Bearer auth is HTTP-layer (no stdio arm). The MCP handler is the canonical
// `createMcpHandler(buildServer)`; the Express auth middleware in front of it
// is the point of this story.
const handler = createMcpHandler(buildServer);

const app = createMcpExpressApp();
app.use(
Expand All @@ -67,6 +71,6 @@ const auth = requireBearerAuth({
const node = toNodeHandler(handler);
app.all('/mcp', auth, (req, res) => void node(req, res, req.body));

app.listen(PORT, () => {
console.error(`bearer-auth example server on http://127.0.0.1:${PORT}/mcp`);
app.listen(port, () => {
console.error(`[server] listening on http://127.0.0.1:${port}/mcp`);
});
Loading
Loading