docs: rebuild the v2 documentation around a task-based page tree (#2397)
This commit is contained in:
parent
708d5455d9
commit
b153505428
120 changed files with 11884 additions and 4971 deletions
53
.github/ISSUE_TEMPLATE/v2-feedback.yml
vendored
Normal file
53
.github/ISSUE_TEMPLATE/v2-feedback.yml
vendored
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
name: v2 feedback
|
||||
description: Bugs, API friction, or docs gaps in v2 of the SDK
|
||||
title: '[v2] '
|
||||
labels: ['v2']
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Thanks for trying v2. Anything that broke, surprised you, or slowed you down is useful — API feedback is explicitly welcome while v2 is in beta.
|
||||
|
||||
Docs: https://ts.sdk.modelcontextprotocol.io/v2/ · Migration from v1: https://ts.sdk.modelcontextprotocol.io/v2/migration/
|
||||
- type: textarea
|
||||
id: what
|
||||
attributes:
|
||||
label: What happened?
|
||||
description: What did you do, and what went wrong (or felt wrong)? Paste error output verbatim if there is any.
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: expected
|
||||
attributes:
|
||||
label: What did you expect?
|
||||
validations:
|
||||
required: false
|
||||
- type: textarea
|
||||
id: repro
|
||||
attributes:
|
||||
label: Code to reproduce
|
||||
description: The smallest snippet or repository that shows it. For docs feedback, link the page instead.
|
||||
render: TypeScript
|
||||
validations:
|
||||
required: false
|
||||
- type: input
|
||||
id: version
|
||||
attributes:
|
||||
label: SDK version
|
||||
description: The published version (`npm ls @modelcontextprotocol/server @modelcontextprotocol/client`) or commit.
|
||||
validations:
|
||||
required: false
|
||||
- type: dropdown
|
||||
id: area
|
||||
attributes:
|
||||
label: Area
|
||||
options:
|
||||
- Server
|
||||
- Client
|
||||
- Transports
|
||||
- Auth
|
||||
- Documentation
|
||||
- Migration / codemod
|
||||
- Other
|
||||
validations:
|
||||
required: false
|
||||
6
.github/workflows/examples.yml
vendored
6
.github/workflows/examples.yml
vendored
|
|
@ -45,3 +45,9 @@ jobs:
|
|||
|
||||
- name: Run all example pairs (transport × era)
|
||||
run: pnpm tsx scripts/examples/run-examples.ts
|
||||
|
||||
# Every docs-page companion under examples/guides/ is a real program
|
||||
# (the page's quoted output comes from it). Run each one; a file whose
|
||||
# first line is "// docs: typecheck-only" is skipped.
|
||||
- name: Run guide examples
|
||||
run: pnpm docs:examples
|
||||
|
|
|
|||
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -51,7 +51,6 @@ dist/
|
|||
.worktrees/
|
||||
|
||||
# Generated docs-site artifacts
|
||||
docs/index.md
|
||||
docs/api/
|
||||
docs/.vitepress/cache/
|
||||
docs/.vitepress/dist/
|
||||
|
|
|
|||
|
|
@ -131,7 +131,7 @@ basket. See `examples/README.md` for the full story matrix.
|
|||
|
||||
- `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`
|
||||
- `examples/guides/` — per-page snippet companions for the `docs/` guide pages (one `<section>/<page>.examples.ts` per page); fences sync via `pnpm sync:snippets`, and the runnable ones are executed in CI by `pnpm docs:examples`
|
||||
|
||||
## Message Flow (Bidirectional Protocol)
|
||||
|
||||
|
|
|
|||
28
README.md
28
README.md
|
|
@ -2,17 +2,17 @@
|
|||
|
||||
<!-- prettier-ignore -->
|
||||
> [!IMPORTANT]
|
||||
> **This is the `main` branch which contains v2 of the SDK (currently in development, pre-alpha).**
|
||||
> **This is the `main` branch — v2 of the SDK, now in beta** (`@modelcontextprotocol/server`, `@modelcontextprotocol/client`), implementing the [2026-07-28 MCP spec](https://blog.modelcontextprotocol.io/posts/2026-07-28-release-candidate/).
|
||||
>
|
||||
> We anticipate a stable v2 release in Q3 2026 along with the [updated MCP spec](https://blog.modelcontextprotocol.io/posts/2026-07-28-release-candidate/). Until then, **v1.x remains the recommended version** for production use. v1.x will continue to receive bug fixes and security updates for at least 6 months after v2 ships to give people time to upgrade.
|
||||
> **Have feedback? Please [open a v2 issue](https://github.com/modelcontextprotocol/typescript-sdk/issues/new?template=v2-feedback.yml)** — it is the most useful thing you can do for the SDK right now. The [v2 documentation](https://ts.sdk.modelcontextprotocol.io/v2/) starts with a ten-minute server tutorial.
|
||||
>
|
||||
> For v1 documentation, see the [V1 API docs](https://ts.sdk.modelcontextprotocol.io/). For v2 API docs, see [`/v2/`](https://ts.sdk.modelcontextprotocol.io/v2/).
|
||||
> We expect a stable release alongside the full release of the 2026-07-28 spec on July 28, 2026. Until then, **v1.x remains the supported release for production**; it keeps receiving bug fixes and security updates for at least 6 months after v2 ships. v1 documentation: [ts.sdk.modelcontextprotocol.io](https://ts.sdk.modelcontextprotocol.io/) · v2: [`/v2/`](https://ts.sdk.modelcontextprotocol.io/v2/).
|
||||
|
||||
<!-- prettier-ignore -->
|
||||
> [!WARNING]
|
||||
> **We're temporarily restricting PRs to contributors only to manage reviewer capacity while implementation work for the [new spec](https://blog.modelcontextprotocol.io/posts/2026-07-28-release-candidate/) is ongoing**
|
||||
> **We're limiting pull requests to contributors while we land the [2026-07-28 spec](https://blog.modelcontextprotocol.io/posts/2026-07-28-release-candidate/) implementation.**
|
||||
>
|
||||
> Please continue to submit issues as your main source of feedback. We anticipate reopening once we have a stable release for the new spec, currently slated to launch on July 28, 2026.
|
||||
> [Issues](https://github.com/modelcontextprotocol/typescript-sdk/issues/new?template=v2-feedback.yml) are the most useful feedback right now — we'll reopen PRs as v2 stabilizes.
|
||||
|
||||
[](https://www.npmjs.com/package/@modelcontextprotocol/server)
|
||||
[](https://www.npmjs.com/package/@modelcontextprotocol/client) 
|
||||
|
|
@ -129,22 +129,22 @@ async function main() {
|
|||
main();
|
||||
```
|
||||
|
||||
Ready to build something real? Follow the step-by-step quickstart tutorials:
|
||||
Ready to build something real? Follow the step-by-step tutorials:
|
||||
|
||||
- [Build a weather server](docs/server-quickstart.md) — server quickstart
|
||||
- [Build an LLM-powered chatbot](docs/client-quickstart.md) — client quickstart
|
||||
- [Build your first server](docs/get-started/first-server.md) — a stdio weather-alert server, from `npm init` to a tool call
|
||||
- [Build your first client](docs/get-started/first-client.md) — connect to that server, list its tools, and call them
|
||||
|
||||
The complete code for each tutorial is in [`examples/server-quickstart/`](https://github.com/modelcontextprotocol/typescript-sdk/tree/main/examples/server-quickstart/) and
|
||||
[`examples/client-quickstart/`](https://github.com/modelcontextprotocol/typescript-sdk/tree/main/examples/client-quickstart/). For more advanced runnable examples, see:
|
||||
For runnable, end-to-end examples beyond the tutorials, see:
|
||||
|
||||
- [`examples/README.md`](examples/README.md) — runnable, self-verifying client/server example pairs (one story per directory)
|
||||
|
||||
## Documentation
|
||||
|
||||
- [Server Guide](docs/server.md) — building MCP servers: transports, tools, resources, prompts, server-initiated requests, and deployment
|
||||
- [Client Guide](docs/client.md) — building MCP clients: connecting, tools, resources, prompts, server-initiated requests, and error handling
|
||||
- [FAQ](docs/faq.md) — frequently asked questions and troubleshooting
|
||||
- [API docs](https://modelcontextprotocol.github.io/typescript-sdk/)
|
||||
- [Build a server](docs/get-started/first-server.md) — your first MCP server, step by step
|
||||
- [Build a client](docs/get-started/first-client.md) — your first MCP client, step by step
|
||||
- [Documentation site](https://ts.sdk.modelcontextprotocol.io/v2/) — the full guides: tools, resources, prompts, serving over HTTP and stdio, clients, OAuth, and migration
|
||||
- [Troubleshooting](docs/troubleshooting.md) — common errors and their fixes
|
||||
- [API reference](https://ts.sdk.modelcontextprotocol.io/v2/api/)
|
||||
- [MCP documentation](https://modelcontextprotocol.io/docs)
|
||||
- [MCP specification](https://modelcontextprotocol.io/specification/latest)
|
||||
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@ export default defineConfig({
|
|||
title: 'MCP TypeScript SDK',
|
||||
description: 'The TypeScript SDK implementation of the Model Context Protocol specification.',
|
||||
base: '/v2/',
|
||||
srcExclude: ['v1/**', 'behavior-surface-pins.md'],
|
||||
srcExclude: ['v1/**', '_meta/**', 'behavior-surface-pins.md'],
|
||||
sitemap: { hostname: 'https://ts.sdk.modelcontextprotocol.io/v2/' },
|
||||
markdown: {
|
||||
config(md) {
|
||||
|
|
@ -44,26 +44,82 @@ export default defineConfig({
|
|||
},
|
||||
themeConfig: {
|
||||
nav: [
|
||||
{ text: 'Guide', link: '/server-quickstart', activeMatch: '^/(server|client|faq)' },
|
||||
{ text: 'Get started', link: '/get-started/first-server', activeMatch: '^/get-started/' },
|
||||
{ text: 'Servers', link: '/servers/tools', activeMatch: '^/(servers|serving)/' },
|
||||
{ text: 'Clients', link: '/clients/connect', activeMatch: '^/clients/' },
|
||||
{ text: 'Migration', link: '/migration/', activeMatch: '^/migration/' },
|
||||
{ text: 'API Reference', link: '/api/', activeMatch: '^/api/' },
|
||||
{ text: 'V1 Docs', link: 'https://ts.sdk.modelcontextprotocol.io/' }
|
||||
],
|
||||
sidebar: [
|
||||
{
|
||||
text: 'Getting started',
|
||||
text: 'Get started',
|
||||
items: [
|
||||
{ text: 'Server Quickstart', link: '/server-quickstart' },
|
||||
{ text: 'Client Quickstart', link: '/client-quickstart' }
|
||||
{ text: 'Build a server', link: '/get-started/first-server' },
|
||||
{ text: 'Plug into a real host', link: '/get-started/real-host' },
|
||||
{ text: 'Build a client', link: '/get-started/first-client' },
|
||||
{ text: 'Packages', link: '/get-started/packages' },
|
||||
{ text: 'Examples', link: '/get-started/examples' }
|
||||
]
|
||||
},
|
||||
{
|
||||
text: 'Guides',
|
||||
text: 'Servers',
|
||||
items: [
|
||||
{ text: 'Server', link: '/server' },
|
||||
{ text: 'Client', link: '/client' }
|
||||
{ text: 'Tools', link: '/servers/tools' },
|
||||
{ text: 'Resources', link: '/servers/resources' },
|
||||
{ text: 'Prompts', link: '/servers/prompts' },
|
||||
{ text: 'Completion', link: '/servers/completion' },
|
||||
{ text: 'Logging, progress, cancellation', link: '/servers/logging-progress-cancellation' },
|
||||
{ text: 'Elicitation', link: '/servers/elicitation' },
|
||||
{ text: 'Sampling (sunset)', link: '/servers/sampling' },
|
||||
{ text: 'Input required', link: '/servers/input-required' },
|
||||
{ text: 'Notifications', link: '/servers/notifications' },
|
||||
{ text: 'Errors', link: '/servers/errors' }
|
||||
]
|
||||
},
|
||||
{
|
||||
text: 'Serving',
|
||||
items: [
|
||||
{ text: 'stdio', link: '/serving/stdio' },
|
||||
{ text: 'HTTP', link: '/serving/http' },
|
||||
{ text: 'Express', link: '/serving/express' },
|
||||
{ text: 'Hono', link: '/serving/hono' },
|
||||
{ text: 'Fastify', link: '/serving/fastify' },
|
||||
{ text: 'Web-standard runtimes', link: '/serving/web-standard' },
|
||||
{ text: 'Sessions, state, scaling', link: '/serving/sessions-state-scaling' },
|
||||
{ text: 'Authorization', link: '/serving/authorization' },
|
||||
{ text: 'Legacy clients', link: '/serving/legacy-clients' }
|
||||
]
|
||||
},
|
||||
{
|
||||
text: 'Clients',
|
||||
items: [
|
||||
{ text: 'Connect', link: '/clients/connect' },
|
||||
{ text: 'Calling', link: '/clients/calling' },
|
||||
{ text: 'Handle server requests', link: '/clients/server-requests' },
|
||||
{ text: 'Roots (sunset)', link: '/clients/roots' },
|
||||
{ text: 'Subscriptions', link: '/clients/subscriptions' },
|
||||
{ text: 'OAuth', link: '/clients/oauth' },
|
||||
{ text: 'Machine auth', link: '/clients/machine-auth' },
|
||||
{ text: 'Middleware', link: '/clients/middleware' },
|
||||
{ text: 'Caching', link: '/clients/caching' }
|
||||
]
|
||||
},
|
||||
{ text: 'Protocol versions', link: '/protocol-versions' },
|
||||
{
|
||||
text: 'Advanced',
|
||||
collapsed: true,
|
||||
items: [
|
||||
{ text: 'Low-level server', link: '/advanced/low-level-server' },
|
||||
{ text: 'Custom methods', link: '/advanced/custom-methods' },
|
||||
{ text: 'Schema libraries', link: '/advanced/schema-libraries' },
|
||||
{ text: 'Custom transports', link: '/advanced/custom-transports' },
|
||||
{ text: 'Wire schemas', link: '/advanced/wire-schemas' },
|
||||
{ text: 'Gateway', link: '/advanced/gateway' }
|
||||
]
|
||||
},
|
||||
{ text: 'Testing', link: '/testing' },
|
||||
{ text: 'Troubleshooting', link: '/troubleshooting' },
|
||||
{
|
||||
text: 'Migration',
|
||||
items: [
|
||||
|
|
@ -72,10 +128,6 @@ export default defineConfig({
|
|||
{ text: '2026-07-28 protocol support', link: '/migration/support-2026-07-28' }
|
||||
]
|
||||
},
|
||||
{
|
||||
text: 'FAQ',
|
||||
items: [{ text: 'FAQ', link: '/faq' }]
|
||||
},
|
||||
{
|
||||
text: 'API Reference',
|
||||
collapsed: true,
|
||||
|
|
|
|||
|
|
@ -6,6 +6,24 @@
|
|||
* light, white on slate dark. We map that onto VitePress brand variables.
|
||||
*/
|
||||
|
||||
/* ------------------------------------------------------------------ layout */
|
||||
|
||||
/*
|
||||
* The default theme caps the doc column at 688px (and the page at 1440px),
|
||||
* which forces horizontal scrolling on most of our ~100-column code blocks.
|
||||
* Let the layout breathe and the column grow so typical snippets fit;
|
||||
* genuinely long lines still scroll inside their own block.
|
||||
*/
|
||||
:root {
|
||||
--vp-layout-max-width: 1680px;
|
||||
}
|
||||
|
||||
/* !important: the default rule is a scoped component style ([data-v-…]), which
|
||||
otherwise out-specifies any override written here. */
|
||||
.VPDoc.has-aside .content-container {
|
||||
max-width: 960px !important;
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------- branding */
|
||||
|
||||
:root {
|
||||
|
|
|
|||
249
docs/_meta/CONVENTIONS.md
Normal file
249
docs/_meta/CONVENTIONS.md
Normal file
|
|
@ -0,0 +1,249 @@
|
|||
# Docs page conventions
|
||||
|
||||
Single source of truth for form. Three blocks: REGISTER (voice), SCAFFOLD FORMAT
|
||||
(page shape), WIRING (snippet mechanics, verified in this worktree). Every writer
|
||||
agent reads this file before touching a page.
|
||||
|
||||
---
|
||||
|
||||
## REGISTER
|
||||
|
||||
THE REGISTER — "restrained tiangolo". Felix chose this against live samples. Every fully
|
||||
written page must pass EVERY rule; the voice reviewer fails pages per-rule.
|
||||
R1. First screenful: a code block, or one lead-in sentence then a code block. Zero preamble.
|
||||
Carve-out (Felix, 2026-06-30): the LANDING PAGE (index.md) leads with a short "what is
|
||||
MCP" orientation paragraph + a spec link BEFORE the code. Every other page: code first.
|
||||
R2. No main-flow paragraph exceeds 3 sentences; most are 1-2.
|
||||
R3. Second person, imperative, present tense. Never "we", never "the user", never "one can".
|
||||
R4. Every code block has a one-line lead-in naming the ONE thing it adds or changes.
|
||||
Never two code blocks adjacent.
|
||||
R5. After a capability lands, state the observable result; when there is output, quote it
|
||||
verbatim in its own fenced block — and it must be REAL (produced by the companion example).
|
||||
R6. The schema-payoff sentence appears at most ONCE per page, as a plain sentence
|
||||
("From that one schema the SDK derives the JSON Schema the model sees, validates arguments
|
||||
before your handler runs, and infers the handler's argument types."). Never a refrain,
|
||||
never a bullet ceremony.
|
||||
R7. Asides leave the main column: tips, version notes, "coming from X" -> ::: tip / ::: info /
|
||||
::: warning containers (VitePress syntax). The page read WITHOUT its asides is still a
|
||||
complete working path.
|
||||
R8. Era caveats are ONE line linking /protocol-versions — never inline era prose. Deprecation
|
||||
(SEP-2577: sampling, roots) is the opposite: a loud ::: warning banner at the TOP of that
|
||||
page, migration target named first.
|
||||
R9. Headings on doing-pages are imperative micro-steps ("Add a tool", "Validate the input"),
|
||||
not noun labels.
|
||||
R10. Bold a key term once on first introduction. Backtick every identifier. Never {@linkcode}.
|
||||
Define terms inline; link for depth, never instead of defining.
|
||||
R11. End with "## Recap": 3-6 flat bullets, each one claim already made on the page, zero code,
|
||||
zero new information. No "Next up" handoff line after it.
|
||||
R12. BANNED: theatrical stingers ("Look at what you passed in", "You didn't write any of them",
|
||||
"That's it.", "That's the whole API."); exclamation marks; rhetorical questions; "Let's";
|
||||
"we will"; "In this section"; "simply"; "basically"; "It is important to note"; emoji;
|
||||
passive voice for SDK behavior (write "the SDK generates", not "is generated by").
|
||||
R13. No option/parameter tables on narrative pages. Two carve-outs only: the error-code table
|
||||
at the bottom of servers/errors.md, and the one-line install command at the top of every
|
||||
serving/ recipe page.
|
||||
R14. No hedging ("you might want to", "consider"). State what to do.
|
||||
R15. Never write DEFENSIVELY. No prose that justifies a choice to an imagined reviewer:
|
||||
no "this page is structured this way because", no "we use X rather than Y since",
|
||||
no pre-empting objections, no apologizing for an API. Teach what the reader does;
|
||||
rationale belongs in PR descriptions and ADRs, never on the page. If a sentence
|
||||
answers "why did the AUTHOR do it this way" instead of "what does the READER do
|
||||
next", delete it. (Source: Felix's #2390 review pattern, 2026-06-29 — both of his
|
||||
flags that round were defensive prose; the fix each time was contract + the one
|
||||
sharp edge + minimal usage, justification moved out.)
|
||||
The target feel — confident, plain, code-first, not performed. Calibration sample (Felix-approved):
|
||||
|
||||
## Add a tool
|
||||
|
||||
registerTool takes a name, a config, and a handler.
|
||||
inputSchema is a Zod schema — the only schema you write.
|
||||
|
||||
server.registerTool('search', {
|
||||
description: 'Search the product catalog',
|
||||
inputSchema: z.object({
|
||||
query: z.string(),
|
||||
limit: z.number().int().max(50).optional(),
|
||||
}),
|
||||
}, async ({ query, limit }) => { ... })
|
||||
|
||||
From that one schema the SDK derives the JSON Schema the
|
||||
model sees, validates arguments before your handler runs,
|
||||
and infers the handler's argument types.
|
||||
|
||||
::: tip
|
||||
Call the tool with limit: 999 and the SDK rejects it
|
||||
before your function runs.
|
||||
:::
|
||||
|
||||
## Recap
|
||||
|
||||
* registerTool(name, config, handler) registers a tool.
|
||||
* One Zod schema: wire schema, validation, handler types.
|
||||
|
||||
---
|
||||
|
||||
## SCAFFOLD FORMAT
|
||||
|
||||
SCAFFOLD FORMAT — every non-calibration page is a SKELETON, not prose. Exact shape:
|
||||
---
|
||||
status: scaffold
|
||||
shape: <tutorial|how-to|explanation|reference>
|
||||
---
|
||||
# <Title>
|
||||
|
||||
<!-- SCAFFOLD - structure only; prose comes in a later tranche.
|
||||
scope: <the one-line scope from the approved tree, verbatim>
|
||||
teaches: <symbolA>, <symbolB>, ...
|
||||
source: mined from docs/<file>.md "<heading>" | net-new
|
||||
-->
|
||||
|
||||
## <Imperative micro-step heading>
|
||||
<!-- teaches: X | salvage: docs/servers/tools.md "<heading>" -->
|
||||
<fenced ts block: the page's ONE defining lead code block, REAL verified API, with a
|
||||
first-line comment: // draft - API verified against packages/<pkg>/src/<file>.ts>
|
||||
<!-- result: one line, what the reader observes -->
|
||||
|
||||
## <Imperative micro-step heading>
|
||||
<!-- teaches: Y -->
|
||||
<!-- code: one line describing the block that goes here -->
|
||||
|
||||
... (4-9 H2 sections total; each one job)
|
||||
|
||||
## Recap
|
||||
<!-- the 3-6 claims this page will prove -->
|
||||
|
||||
Only the FIRST code block is real (and source-verified). Later blocks are comment placeholders.
|
||||
H2 count and ordering ARE the deliverable — they get structure-reviewed.
|
||||
Special cases: sampling.md and roots.md open with a ::: warning sunset banner placeholder
|
||||
(SEP-2577; name the migration target). Each serving/ recipe page's first body line is the
|
||||
install one-liner. real-host.md: VS Code leads the main flow; Claude Code and Cursor are
|
||||
labeled H3 subsections after it (Felix ruling).
|
||||
|
||||
---
|
||||
|
||||
## WIRING
|
||||
|
||||
How a code fence in a docs page is wired to a typechecked example file. All of this
|
||||
was verified live in this worktree on 2026-06-29 (see steps below); the mechanics are
|
||||
implemented by `scripts/sync-snippets.ts`.
|
||||
|
||||
### 1. Region syntax (in `examples/guides/**/*.examples.ts`)
|
||||
|
||||
A region is a named block delimited by line comments. The name MUST be identical on the
|
||||
open and close marker:
|
||||
|
||||
```
|
||||
//#region instructions_basic
|
||||
const server = new McpServer(
|
||||
{ name: 'db-server', version: '1.0.0' },
|
||||
{ instructions: '...' }
|
||||
);
|
||||
//#endregion instructions_basic
|
||||
```
|
||||
|
||||
Regions live at the top level of the companion file, or nested inside a helper function
|
||||
when the snippet needs surrounding setup (see `examples/guides/serving/stdio.examples.ts`
|
||||
for the former and `examples/guides/clients/machine-auth.examples.ts` for the latter).
|
||||
The sync script dedents the region body to the indentation of the `//#region` line, so
|
||||
any wrapper indentation is stripped in the rendered fence. Region extraction only works
|
||||
for `.ts` files. Region names follow `exportedName_variant` (e.g. `registerTool_search`).
|
||||
|
||||
### 2. Fence attribute syntax (in the `.md` page)
|
||||
|
||||
The opening fence carries a `source="<relative-path>#<region>"` attribute. The body
|
||||
between the fences is OWNED by the sync script — it is overwritten on every mutating
|
||||
run, and `--check` reports drift if it does not already match the region byte-for-byte.
|
||||
|
||||
Real, working example (this exact fence is live in this file and is verified by
|
||||
`pnpm sync:snippets --check`; this file lives in `_meta/`, one level deeper than a
|
||||
top-level page, hence the extra `../` — a page at `docs/<page>.md` uses one `../`):
|
||||
|
||||
````md
|
||||
```ts source="../../examples/guides/serving/stdio.examples.ts#serveStdio_basic"
|
||||
import { McpServer } from '@modelcontextprotocol/server';
|
||||
import { serveStdio } from '@modelcontextprotocol/server/stdio';
|
||||
|
||||
const handle = serveStdio(() => {
|
||||
const server = new McpServer({ name: 'notes', version: '1.0.0' });
|
||||
// server.registerTool(...) — one factory builds the instance that serves the connection
|
||||
return server;
|
||||
});
|
||||
```
|
||||
````
|
||||
|
||||
Notes on the exact form (regex `MARKDOWN_LABELED_FENCE_PATTERN` in sync-snippets.ts):
|
||||
- The opening fence must start at column 0: `` ```ts source="<path>#<region>" ``.
|
||||
- An optional display filename may appear BEFORE `source=`:
|
||||
`` ```ts my-app.ts source="./x.examples.ts#foo" ``.
|
||||
- Omit `#<region>` to inline an entire file (any file type, e.g. `json`, `sh`).
|
||||
- The closing fence must be a bare ```` ``` ```` on its own line.
|
||||
|
||||
### 3. Path resolution rule
|
||||
|
||||
The path in `source="..."` is resolved RELATIVE TO THE MARKDOWN FILE's own directory
|
||||
(`resolve(dirname(mdFile), examplePath)` in the script). It is NOT relative to the repo
|
||||
root and NOT relative to `docs/`.
|
||||
|
||||
From the standard locations, the prefixes are:
|
||||
|
||||
| page location | companion example location | `source=` prefix |
|
||||
|--------------------------------|------------------------------------------|------------------|
|
||||
| `docs/<page>.md` | `examples/guides/<file>.examples.ts` | `../examples/guides/` |
|
||||
| `docs/<section>/<page>.md` | `examples/guides/<section>/<file>.examples.ts` | `../../examples/guides/<section>/` |
|
||||
|
||||
So a page at `docs/get-started/first-server.md` whose companion is
|
||||
`examples/guides/get-started/firstServer.examples.ts` uses:
|
||||
|
||||
```
|
||||
source="../../examples/guides/get-started/firstServer.examples.ts#<region>"
|
||||
```
|
||||
|
||||
The sync script scans `docs/**/*.md`.
|
||||
|
||||
### 4. How example files are typechecked
|
||||
|
||||
`examples/guides/` has NO package.json or tsconfig of its own. It is part of the
|
||||
`@modelcontextprotocol/examples` workspace package (`examples/package.json`), whose
|
||||
`examples/tsconfig.json` includes `"./"` recursively — so new files under
|
||||
`examples/guides/<section>/*.examples.ts` are picked up with ZERO config changes.
|
||||
|
||||
The typecheck command (verified passing in this worktree):
|
||||
|
||||
```sh
|
||||
pnpm --filter @modelcontextprotocol/examples typecheck
|
||||
```
|
||||
|
||||
(it runs `tsgo -p tsconfig.json --noEmit` inside `examples/`.)
|
||||
|
||||
Import rules for example files (enforced by that tsconfig's `paths` mapping):
|
||||
- `import { McpServer } from '@modelcontextprotocol/server'`
|
||||
- `import { serveStdio, StdioServerTransport } from '@modelcontextprotocol/server/stdio'`
|
||||
- `import * as z from 'zod/v4'` ← the ONLY zod import form used in this repo.
|
||||
- Never import from `@modelcontextprotocol/core-internal` in a guide example.
|
||||
|
||||
### 5. The sync command (HARD RULE)
|
||||
|
||||
Writers run ONLY the read-only check, never the mutating form:
|
||||
|
||||
```sh
|
||||
pnpm sync:snippets --check
|
||||
```
|
||||
|
||||
NEVER run bare `pnpm sync:snippets` — it rewrites every fenced block in `docs/` and
|
||||
`docs/` in place, and concurrent mutating runs from parallel agents race and clobber
|
||||
each other. Hand-write the fence body to match the region exactly; `--check` confirms.
|
||||
|
||||
Verified outputs from prep (2026-06-29):
|
||||
- With a stale `source=` fence present in `docs/`, `--check` exits 1 with
|
||||
`1 file(s) out of sync: .../docs/_THROWAWAY-proof.md (1 snippet(s))`.
|
||||
- With no drift, `--check` exits 0 with `All snippets are up to date`.
|
||||
|
||||
### 6. Other verified ground truth
|
||||
|
||||
- `registerTool` lives on `McpServer` (`packages/server/src/server/mcp.ts`). The
|
||||
raw-shape `inputSchema` overload is deprecated — ALWAYS pass `z.object({...})`.
|
||||
- Blessed entry points: `serveStdio` (`@modelcontextprotocol/server/stdio`) and
|
||||
`createMcpHandler` (`@modelcontextprotocol/server`).
|
||||
- Verify every API you reference against `packages/*/src` at this commit before writing
|
||||
it into a lead code block; annotate the block's first line with
|
||||
`// draft - API verified against packages/<pkg>/src/<file>.ts`.
|
||||
131
docs/advanced/custom-methods.md
Normal file
131
docs/advanced/custom-methods.md
Normal file
|
|
@ -0,0 +1,131 @@
|
|||
---
|
||||
shape: how-to
|
||||
---
|
||||
|
||||
# Custom methods
|
||||
|
||||
A **custom method** is a JSON-RPC method outside the MCP specification. Prefix it with a vendor namespace — `acme/search`, never a bare `search` — so it can never collide with a spec method.
|
||||
|
||||
## Handle a vendor-prefixed method on the server
|
||||
|
||||
`setRequestHandler` lives on the low-level [`Server`](./low-level-server.md), reached from an `McpServer` as `mcp.server`. A non-spec method needs schemas: pass `{ params, result }` as the second argument and the handler receives the parsed `params` object directly.
|
||||
|
||||
```ts source="../../examples/guides/advanced/custom-methods.examples.ts#setRequestHandler_custom"
|
||||
import { McpServer } from '@modelcontextprotocol/server';
|
||||
import * as z from 'zod/v4';
|
||||
|
||||
const SearchParams = z.object({ query: z.string(), limit: z.number().int().default(10) });
|
||||
const SearchResult = z.object({ items: z.array(z.string()) });
|
||||
|
||||
const mcp = new McpServer({ name: 'acme-search', version: '1.0.0' });
|
||||
|
||||
mcp.server.setRequestHandler('acme/search', { params: SearchParams, result: SearchResult }, async ({ query, limit }) => {
|
||||
return { items: Array.from({ length: limit }, (_, index) => `${query}-${index}`) };
|
||||
});
|
||||
```
|
||||
|
||||
The SDK validates incoming `params` against `SearchParams` before the handler runs; `result` types the handler's return value. A spec method never takes the schema bundle — `setRequestHandler('tools/call', handler)` resolves its schemas from the method name.
|
||||
|
||||
::: tip
|
||||
Send `acme/search` with `query: 42` and the request fails before your handler runs — the caller gets back an `Invalid params` JSON-RPC error:
|
||||
|
||||
```
|
||||
Invalid params for acme/search: query: Invalid input: expected string, received number
|
||||
```
|
||||
|
||||
:::
|
||||
|
||||
## Call it from the client
|
||||
|
||||
Every call on this page comes from an in-memory `Client` connected to the server above — [Test a server](../testing.md) shows that wiring. For a non-spec method, `client.request` takes the request and a result schema; the SDK validates the response against it before the promise resolves and infers the return type from it.
|
||||
|
||||
```ts source="../../examples/guides/advanced/custom-methods.examples.ts#request_custom"
|
||||
const result = await client.request({ method: 'acme/search', params: { query: 'mcp', limit: 3 } }, SearchResult);
|
||||
console.log(result);
|
||||
```
|
||||
|
||||
The handler's return value comes back validated and typed:
|
||||
|
||||
```
|
||||
{ items: [ 'mcp-0', 'mcp-1', 'mcp-2' ] }
|
||||
```
|
||||
|
||||
::: info
|
||||
For spec methods, `client.request({ method: 'tools/list' })` takes no schema — the SDK resolves it from the method name, exactly as `setRequestHandler` does on the server.
|
||||
:::
|
||||
|
||||
## Send a custom notification from the handler
|
||||
|
||||
A custom **notification** is the one-way mirror of a custom request: vendor-prefixed, no result. Registering a method again replaces its handler — replace `acme/search` with one that reports progress through `ctx.mcpReq.notify`.
|
||||
|
||||
```ts source="../../examples/guides/advanced/custom-methods.examples.ts#setRequestHandler_notify"
|
||||
mcp.server.setRequestHandler('acme/search', { params: SearchParams, result: SearchResult }, async ({ query, limit }, ctx) => {
|
||||
await ctx.mcpReq.notify({ method: 'acme/searchProgress', params: { stage: 'start', pct: 0 } });
|
||||
const items = Array.from({ length: limit }, (_, index) => `${query}-${index}`);
|
||||
await ctx.mcpReq.notify({ method: 'acme/searchProgress', params: { stage: 'done', pct: 1 } });
|
||||
return { items };
|
||||
});
|
||||
```
|
||||
|
||||
`ctx.mcpReq.notify` sends each notification to the peer whose request is being handled, on the same connection.
|
||||
|
||||
## Receive it on the client
|
||||
|
||||
`setNotificationHandler` follows the same rule as `setRequestHandler`: a non-spec notification method takes a `{ params }` schema, and the handler receives the parsed params.
|
||||
|
||||
```ts source="../../examples/guides/advanced/custom-methods.examples.ts#setNotificationHandler_custom"
|
||||
const SearchProgressParams = z.object({ stage: z.string(), pct: z.number() });
|
||||
|
||||
client.setNotificationHandler('acme/searchProgress', { params: SearchProgressParams }, params => {
|
||||
console.log(params);
|
||||
});
|
||||
|
||||
await client.request({ method: 'acme/search', params: { query: 'mcp', limit: 1 } }, SearchResult);
|
||||
```
|
||||
|
||||
That one call logs both stages:
|
||||
|
||||
```
|
||||
{ stage: 'start', pct: 0 }
|
||||
{ stage: 'done', pct: 1 }
|
||||
```
|
||||
|
||||
## Declare an extension capability
|
||||
|
||||
An **extension capability** advertises a vendor feature during capability negotiation: `capabilities.extensions` maps a prefix-qualified extension identifier to that extension's settings object. Declare entries with `registerCapabilities` before connecting.
|
||||
|
||||
```ts source="../../examples/guides/advanced/custom-methods.examples.ts#registerCapabilities_extensions"
|
||||
mcp.server.registerCapabilities({
|
||||
extensions: { 'com.example/feature-flags': { flags: ['dark-mode', 'beta-search'] } }
|
||||
});
|
||||
```
|
||||
|
||||
Every client that connects sees the entry. The settings value is free-form JSON; `{}` means supported with no settings.
|
||||
|
||||
## Read the negotiated extensions on the client
|
||||
|
||||
After connecting, the advertised map is on `client.getServerCapabilities()`.
|
||||
|
||||
```ts source="../../examples/guides/advanced/custom-methods.examples.ts#getServerCapabilities_extensions"
|
||||
const extensions = client.getServerCapabilities()?.extensions ?? {};
|
||||
console.log(extensions);
|
||||
```
|
||||
|
||||
The map arrives exactly as the server declared it:
|
||||
|
||||
```
|
||||
{
|
||||
'com.example/feature-flags': { flags: [ 'dark-mode', 'beta-search' ] }
|
||||
}
|
||||
```
|
||||
|
||||
Legacy connections advertise it in the `initialize` result and 2026-07-28 connections in `server/discover` — see [Protocol versions](../protocol-versions.md).
|
||||
|
||||
## Recap
|
||||
|
||||
- `setRequestHandler(method, { params, result }, handler)` handles a non-spec method; spec methods never take the schema bundle.
|
||||
- The SDK validates incoming `params` before the handler runs and rejects what fails with an `Invalid params` error; `result` types the handler's return value.
|
||||
- `client.request(request, ResultSchema)` is the calling side; the SDK validates the response against the schema.
|
||||
- Custom notifications mirror custom requests: `ctx.mcpReq.notify` on one side, `setNotificationHandler` with `{ params }` on the other.
|
||||
- `capabilities.extensions` advertises a vendor feature before connecting; the client reads the negotiated map after.
|
||||
- Method names and extension identifiers are prefix-qualified (`acme/search`, `com.example/feature-flags`) — never bare words.
|
||||
203
docs/advanced/custom-transports.md
Normal file
203
docs/advanced/custom-transports.md
Normal file
|
|
@ -0,0 +1,203 @@
|
|||
---
|
||||
shape: how-to
|
||||
---
|
||||
# Custom transports
|
||||
|
||||
A **transport** moves `JSONRPCMessage` values in both directions over a channel the SDK knows nothing about. Implement the `Transport` interface and `connect()` accepts it like a built-in one.
|
||||
|
||||
## Implement the `Transport` interface
|
||||
|
||||
Three methods — `start`, `send`, `close` — and three callbacks the SDK installs: `onmessage`, `onerror`, `onclose`. This loopback delivers each message straight to a linked peer in the same process.
|
||||
|
||||
```ts source="../../examples/guides/advanced/custom-transports.examples.ts#transport_loopback"
|
||||
import type { JSONRPCMessage, Transport } from '@modelcontextprotocol/server';
|
||||
|
||||
export class LoopbackTransport implements Transport {
|
||||
onclose?: () => void;
|
||||
onerror?: (error: Error) => void;
|
||||
onmessage?: (message: JSONRPCMessage) => void;
|
||||
|
||||
private peer?: LoopbackTransport;
|
||||
|
||||
/** Cross-wire two ends: whatever one end sends, the other receives. */
|
||||
static link(a: LoopbackTransport, b: LoopbackTransport): void {
|
||||
a.peer = b;
|
||||
b.peer = a;
|
||||
}
|
||||
|
||||
async start(): Promise<void> {
|
||||
// Open your channel here. The loopback has nothing to open.
|
||||
}
|
||||
|
||||
async send(message: JSONRPCMessage): Promise<void> {
|
||||
const peer = this.peer;
|
||||
if (!peer) throw new Error('Loopback peer is gone');
|
||||
queueMicrotask(() => peer.onmessage?.(message));
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
this.peer = undefined;
|
||||
this.onclose?.();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The SDK never looks inside your channel: it calls `send` for every outbound message and expects every inbound one on `onmessage`. Both `@modelcontextprotocol/server` and `@modelcontextprotocol/client` export `Transport`, `TransportSendOptions`, and `JSONRPCMessage`, so one implementation serves either side.
|
||||
|
||||
## Honor the callback contract
|
||||
|
||||
The interface carries three rules that no type checker enforces.
|
||||
|
||||
- Never call `start()` on a transport you hand to a `Client` or `Server`: `connect()` installs the three callbacks and then calls `start()` itself. A transport that starts reading before the callbacks exist drops messages.
|
||||
- `close()` must end by firing your own `onclose` — the protocol layer tears down its side of the connection from that callback, however the channel ended.
|
||||
- `onerror` reports out-of-band conditions (a malformed frame, a dropped socket) and is not necessarily fatal. For a failure the sender must see, throw from `send` instead.
|
||||
|
||||
## Connect it like a built-in transport
|
||||
|
||||
`Client.connect()` and `McpServer.connect()` take any `Transport`. Link two loopback ends, hand one to each side, and call a tool.
|
||||
|
||||
```ts source="../../examples/guides/advanced/custom-transports.examples.ts#connect_loopback"
|
||||
import { Client } from '@modelcontextprotocol/client';
|
||||
import { McpServer } from '@modelcontextprotocol/server';
|
||||
|
||||
const server = new McpServer({ name: 'loopback-demo', version: '1.0.0' });
|
||||
server.registerTool('ping', { description: 'Reply with pong' }, async () => ({
|
||||
content: [{ type: 'text', text: 'pong' }]
|
||||
}));
|
||||
|
||||
const client = new Client({ name: 'loopback-client', version: '1.0.0' });
|
||||
|
||||
const serverEnd = new LoopbackTransport();
|
||||
const clientEnd = new LoopbackTransport();
|
||||
LoopbackTransport.link(serverEnd, clientEnd);
|
||||
|
||||
await server.connect(serverEnd);
|
||||
await client.connect(clientEnd);
|
||||
|
||||
const result = await client.callTool({ name: 'ping' });
|
||||
console.log(result.content);
|
||||
```
|
||||
|
||||
The whole MCP handshake and the tool call run over the loopback; the handler's `content` comes back unchanged:
|
||||
|
||||
```
|
||||
[ { type: 'text', text: 'pong' } ]
|
||||
```
|
||||
|
||||
## Frame messages over a byte stream
|
||||
|
||||
The loopback hands its peer a parsed object. A socket hands you bytes — frame them with the same helpers the stdio transports use: `ReadBuffer` buffers chunks and yields one parsed message per newline-delimited line, `serializeMessage` writes one, and `deserializeMessage` parses a single line you already hold.
|
||||
|
||||
```ts source="../../examples/guides/advanced/custom-transports.examples.ts#transport_socket"
|
||||
import { ReadBuffer, serializeMessage } from '@modelcontextprotocol/server';
|
||||
import type { Socket } from 'node:net';
|
||||
|
||||
export class SocketTransport implements Transport {
|
||||
onclose?: () => void;
|
||||
onerror?: (error: Error) => void;
|
||||
onmessage?: (message: JSONRPCMessage) => void;
|
||||
|
||||
private readonly readBuffer = new ReadBuffer();
|
||||
|
||||
constructor(private readonly socket: Socket) {}
|
||||
|
||||
async start(): Promise<void> {
|
||||
this.socket.on('data', chunk => {
|
||||
try {
|
||||
this.readBuffer.append(chunk);
|
||||
let message = this.readBuffer.readMessage();
|
||||
while (message !== null) {
|
||||
this.onmessage?.(message);
|
||||
message = this.readBuffer.readMessage();
|
||||
}
|
||||
} catch (error) {
|
||||
this.onerror?.(error as Error);
|
||||
}
|
||||
});
|
||||
this.socket.on('error', error => this.onerror?.(error));
|
||||
this.socket.on('close', () => this.onclose?.());
|
||||
}
|
||||
|
||||
async send(message: JSONRPCMessage): Promise<void> {
|
||||
this.socket.write(serializeMessage(message));
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
this.socket.end();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`readMessage()` returns `null` until a complete line has arrived and skips non-JSON lines, so partial chunks, coalesced writes, and stray debug output all come out as whole `JSONRPCMessage` values or not at all.
|
||||
|
||||
::: tip
|
||||
`ReadBuffer` throws once its buffer exceeds 10 MB (`STDIO_DEFAULT_MAX_BUFFER_SIZE`). Pass `new ReadBuffer({ maxBufferSize })` to raise the cap.
|
||||
:::
|
||||
|
||||
## Report a session ID and the negotiated version
|
||||
|
||||
Three optional members let the protocol layer talk back to your transport; the SDK uses each one only when it is present. Set `sessionId` when your channel has one, and declare `setProtocolVersion` to receive the protocol version the two sides negotiated during `initialize`.
|
||||
|
||||
```ts source="../../examples/guides/advanced/custom-transports.examples.ts#transport_session"
|
||||
export class SessionLoopbackTransport extends LoopbackTransport {
|
||||
sessionId?: string;
|
||||
|
||||
protocolVersion?: string;
|
||||
|
||||
setProtocolVersion(version: string): void {
|
||||
this.protocolVersion = version;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Connect a client and a server over a linked pair of these and both ends end up holding the same version; logging the client end's `protocolVersion` after `connect()` prints:
|
||||
|
||||
```
|
||||
2025-11-25
|
||||
```
|
||||
|
||||
Which version you see depends on the connection's protocol era — see [Protocol versions](../protocol-versions.md).
|
||||
|
||||
::: info
|
||||
`setSupportedProtocolVersions` is the third optional member: `connect()` passes the local side's accepted versions into it, which is how the HTTP server transports know what to allow in the `MCP-Protocol-Version` header.
|
||||
:::
|
||||
|
||||
## Opt into per-request cancellation
|
||||
|
||||
Declare `hasPerRequestStream` only on a transport that opens one underlying request per outbound JSON-RPC request, and forward `requestSignal` to that request.
|
||||
|
||||
```ts source="../../examples/guides/advanced/custom-transports.examples.ts#transport_perRequest"
|
||||
readonly hasPerRequestStream = true;
|
||||
|
||||
async send(message: JSONRPCMessage, options?: TransportSendOptions): Promise<void> {
|
||||
const response = await fetch(this.endpoint, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json', ...options?.headers },
|
||||
body: JSON.stringify(message),
|
||||
signal: options?.requestSignal
|
||||
});
|
||||
this.onmessage?.(parseJSONRPCMessage(await response.json()));
|
||||
}
|
||||
```
|
||||
|
||||
On a 2026-07-28 connection the protocol layer cancels an in-flight request by aborting that request's `requestSignal` instead of sending `notifications/cancelled` — see [Protocol versions](../protocol-versions.md). Single-channel transports — stdio, the loopback above — leave the flag undefined and ignore `requestSignal`; cancellation stays a notification for them.
|
||||
|
||||
## Test it against the in-memory pair
|
||||
|
||||
`InMemoryTransport` is the reference implementation: the smallest `Transport` the SDK ships, and a known-good baseline for the client and server you drive your own transport with.
|
||||
|
||||
```ts source="../../examples/guides/advanced/custom-transports.examples.ts#inMemory_pair"
|
||||
import { InMemoryTransport } from '@modelcontextprotocol/client';
|
||||
|
||||
const [inMemoryClientEnd, inMemoryServerEnd] = InMemoryTransport.createLinkedPair();
|
||||
```
|
||||
|
||||
Run the same client and server over both pairs: the `ping` call above returns the same `content` over `InMemoryTransport` as over the loopback, and anything that differs is a bug in your transport. [Test a server](../testing.md) builds its whole harness on this pair.
|
||||
|
||||
## Recap
|
||||
|
||||
- A transport is `start`, `send`, `close` plus `onmessage`, `onerror`, `onclose` — nothing else is required.
|
||||
- `connect()` installs the callbacks, then calls `start()` for you; `close()` must fire `onclose`.
|
||||
- `ReadBuffer`, `serializeMessage`, and `deserializeMessage` give you the newline-delimited framing the stdio transports use.
|
||||
- `sessionId`, `setProtocolVersion`, `setSupportedProtocolVersions`, and `hasPerRequestStream` are optional members the SDK uses only when they are present.
|
||||
- `InMemoryTransport.createLinkedPair()` is the reference implementation and the baseline to test your transport against.
|
||||
160
docs/advanced/gateway.md
Normal file
160
docs/advanced/gateway.md
Normal file
|
|
@ -0,0 +1,160 @@
|
|||
---
|
||||
shape: how-to
|
||||
---
|
||||
# Gateways and worker fleets
|
||||
|
||||
A **gateway** — a proxy, a worker pool, any process that fronts one MCP server with many short-lived clients — probes the server once and reuses the answer for every connection after it.
|
||||
|
||||
## Connect with a prior discover result
|
||||
|
||||
`connect()` takes an optional `prior`: a persisted `DiscoverResult` from an earlier probe. With it, `connect()` adopts the server's advertisement directly and sends nothing on the wire.
|
||||
|
||||
```ts source="../../examples/guides/advanced/gateway.examples.ts#connect_prior"
|
||||
import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client';
|
||||
|
||||
const url = new URL('http://localhost:3000/mcp');
|
||||
|
||||
// Probe once …
|
||||
const bootstrap = new Client({ name: 'gateway', version: '1.0.0' }, { versionNegotiation: { mode: 'auto' } });
|
||||
await bootstrap.connect(new StreamableHTTPClientTransport(url));
|
||||
const persisted = JSON.stringify(bootstrap.getDiscoverResult());
|
||||
|
||||
// … then every other client connects with zero round trips.
|
||||
const worker = new Client({ name: 'worker', version: '1.0.0' });
|
||||
await worker.connect(new StreamableHTTPClientTransport(url), { prior: JSON.parse(persisted) });
|
||||
```
|
||||
|
||||
`worker` is connected: `callTool` works immediately, and the server has not heard from it yet.
|
||||
|
||||
`connect({ prior })` is 2026-07-28+ only — see [Protocol versions](../protocol-versions.md).
|
||||
|
||||
## Probe once at bootstrap
|
||||
|
||||
An `'auto'`-mode (or pinned) connect sends `server/discover` and records the answer; `getDiscoverResult()` reads it back.
|
||||
|
||||
```ts source="../../examples/guides/advanced/gateway.examples.ts#bootstrap_probe"
|
||||
console.log(bootstrap.getDiscoverResult());
|
||||
```
|
||||
|
||||
The recorded value is the server's whole advertisement — supported versions, capabilities, identity, instructions:
|
||||
|
||||
```
|
||||
{
|
||||
ttlMs: 0,
|
||||
cacheScope: 'private',
|
||||
supportedVersions: [ '2026-07-28' ],
|
||||
capabilities: { tools: { listChanged: true } },
|
||||
serverInfo: { name: 'gateway-target', version: '1.0.0' },
|
||||
resultType: 'complete'
|
||||
}
|
||||
```
|
||||
|
||||
::: tip
|
||||
An already-connected client can re-probe at any time: `await client.discover()` sends `server/discover` and updates `getDiscoverResult()`. A default-mode connect never probes, so its `getDiscoverResult()` is `undefined` — [Protocol versions](../protocol-versions.md#pin-an-era) lists the negotiation modes.
|
||||
:::
|
||||
|
||||
## Persist the advertisement
|
||||
|
||||
The value is plain JSON. Write the string to Redis, a config map, or a process-local cache; parse it back wherever a client needs it.
|
||||
|
||||
```ts source="../../examples/guides/advanced/gateway.examples.ts#persist_advertisement"
|
||||
import type { DiscoverResult } from '@modelcontextprotocol/client';
|
||||
|
||||
const prior = JSON.parse(persisted) as DiscoverResult;
|
||||
```
|
||||
|
||||
Nothing about `prior` is tied to the process that probed: any client that can reach the same URL can adopt it.
|
||||
|
||||
## Fan out to workers
|
||||
|
||||
Build every replica from the same blob; the `request_count` call after them is the proof.
|
||||
|
||||
```ts source="../../examples/guides/advanced/gateway.examples.ts#fan_out"
|
||||
const fleet = await Promise.all(
|
||||
['worker-a', 'worker-b', 'worker-c'].map(async name => {
|
||||
const replica = new Client({ name, version: '1.0.0' });
|
||||
await replica.connect(new StreamableHTTPClientTransport(url), { prior });
|
||||
return replica;
|
||||
})
|
||||
);
|
||||
|
||||
const proof = await worker.callTool({ name: 'request_count' });
|
||||
console.log(proof.structuredContent);
|
||||
```
|
||||
|
||||
`request_count` is a tool on this page's example server that returns how many MCP requests reached the process. Five clients are connected by now — `bootstrap`, `worker`, three replicas — and the server has answered two requests:
|
||||
|
||||
```
|
||||
{ requests: 2 }
|
||||
```
|
||||
|
||||
The bootstrap probe was the first request and the `request_count` call itself the second. The four `connect({ prior })` calls sent nothing.
|
||||
|
||||
## Reuse only within one authorization context
|
||||
|
||||
The advertisement is what the server returned to the credential that probed.
|
||||
|
||||
::: warning
|
||||
Never share a persisted `DiscoverResult` across principals — key the blob on the authorization context that obtained it (a credential hash works). The server still authorizes every request, so a wider `prior` grants nothing, but it misleads client-side capability gating.
|
||||
:::
|
||||
|
||||
## Open a listen stream when a worker needs notifications
|
||||
|
||||
`connect({ prior })` never auto-opens a `subscriptions/listen` stream — prior-connected clients are request-only until you open one yourself.
|
||||
|
||||
```ts source="../../examples/guides/advanced/gateway.examples.ts#listen_worker"
|
||||
const subscription = await worker.listen({ toolsListChanged: true });
|
||||
console.log(subscription.honoredFilter);
|
||||
```
|
||||
|
||||
The server acknowledges the filter it agreed to honor:
|
||||
|
||||
```
|
||||
{ toolsListChanged: true }
|
||||
```
|
||||
|
||||
From here the stream behaves like any other subscription — [Subscriptions](../clients/subscriptions.md) covers the notification handlers and the close semantics.
|
||||
|
||||
::: info
|
||||
A `listChanged` option configured on a prior-connected client registers its handlers but stays silent: no stream opens until you call `listen()`.
|
||||
:::
|
||||
|
||||
## Handle a stale or incompatible advertisement
|
||||
|
||||
A `prior` that shares no 2026-07-28+ revision with the client rejects with `SdkError(EraNegotiationFailed)` before anything reaches the transport.
|
||||
|
||||
```ts source="../../examples/guides/advanced/gateway.examples.ts#prior_stale"
|
||||
import { SdkError, SdkErrorCode } from '@modelcontextprotocol/client';
|
||||
|
||||
const stale: DiscoverResult = { ...prior, supportedVersions: ['2025-06-18'] };
|
||||
|
||||
const late = new Client({ name: 'worker-d', version: '1.0.0' }, { versionNegotiation: { mode: 'auto' } });
|
||||
try {
|
||||
await late.connect(new StreamableHTTPClientTransport(url), { prior: stale });
|
||||
} catch (error) {
|
||||
if (!(error instanceof SdkError) || error.code !== SdkErrorCode.EraNegotiationFailed) throw error;
|
||||
console.log(error.code);
|
||||
|
||||
// Fall back to a fresh probe, then re-persist getDiscoverResult().
|
||||
await late.connect(new StreamableHTTPClientTransport(url));
|
||||
console.log('re-probed:', late.getNegotiatedProtocolVersion());
|
||||
}
|
||||
```
|
||||
|
||||
The rejection happens before the transport starts, so the same `Client` connects again on the fallback path:
|
||||
|
||||
```
|
||||
ERA_NEGOTIATION_FAILED
|
||||
re-probed: 2026-07-28
|
||||
```
|
||||
|
||||
Replace the persisted blob with the fresh `getDiscoverResult()` and the rest of the fleet recovers on its next read.
|
||||
|
||||
## Recap
|
||||
|
||||
- `connect(transport, { prior })` adopts a persisted `DiscoverResult` with zero round trips.
|
||||
- The advertisement comes from one `'auto'`-mode or pinned probe — or an explicit `client.discover()` — and `getDiscoverResult()` reads it back.
|
||||
- The value is plain JSON: stringify it into a shared cache, parse it in any process that fronts the same server.
|
||||
- Reuse a `DiscoverResult` only across clients that present the same authorization context.
|
||||
- Prior-connected clients are request-only; call `listen()` on the one that needs notifications.
|
||||
- An incompatible `prior` rejects with `SdkError(EraNegotiationFailed)`; fall back to a fresh probe and re-persist.
|
||||
161
docs/advanced/low-level-server.md
Normal file
161
docs/advanced/low-level-server.md
Normal file
|
|
@ -0,0 +1,161 @@
|
|||
---
|
||||
shape: explanation
|
||||
---
|
||||
|
||||
# Low-level Server
|
||||
|
||||
`Server` is the **protocol layer** under `McpServer`: it routes each JSON-RPC request to the handler you register for that method string, and nothing more. Rebuild the `search` tool from [Tools](../servers/tools.md) on it to see what `registerTool` adds.
|
||||
|
||||
## Build the server and list your tools by hand
|
||||
|
||||
Declare the `tools` capability in the constructor and answer `tools/list` yourself. `inputSchema` is the raw JSON Schema the client and the model see.
|
||||
|
||||
```ts source="../../examples/guides/advanced/low-level-server.examples.ts#lowLevel_listTools"
|
||||
import { Server } from '@modelcontextprotocol/server';
|
||||
|
||||
const catalog = [
|
||||
{ name: 'Espresso cup', price: 12 },
|
||||
{ name: 'Travel mug', price: 24 },
|
||||
{ name: 'Mug rack', price: 36 }
|
||||
];
|
||||
|
||||
const server = new Server({ name: 'catalog', version: '1.0.0' }, { capabilities: { tools: {} } });
|
||||
|
||||
server.setRequestHandler('tools/list', async () => ({
|
||||
tools: [
|
||||
{
|
||||
name: 'search',
|
||||
description: 'Search the product catalog',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: { query: { type: 'string', description: 'Substring to match against product names' } },
|
||||
required: ['query']
|
||||
}
|
||||
}
|
||||
]
|
||||
}));
|
||||
```
|
||||
|
||||
A client's `tools/list` returns exactly the array you wrote — the SDK derived none of it.
|
||||
|
||||
::: tip
|
||||
Drop `capabilities: { tools: {} }` and `setRequestHandler('tools/list', …)` throws. `Server` never infers a capability from a handler, the way `registerTool` registers the `tools` capability for you.
|
||||
:::
|
||||
|
||||
## Handle `tools/call` yourself
|
||||
|
||||
`tools/call` is one handler for every tool. Dispatch on `request.params.name` and read `request.params.arguments` yourself.
|
||||
|
||||
```ts source="../../examples/guides/advanced/low-level-server.examples.ts#lowLevel_callTool"
|
||||
server.setRequestHandler('tools/call', async request => {
|
||||
if (request.params.name !== 'search') {
|
||||
return { content: [{ type: 'text', text: `Unknown tool: ${request.params.name}` }], isError: true };
|
||||
}
|
||||
const { query } = request.params.arguments as { query: string };
|
||||
const hits = catalog.filter(product => product.name.toLowerCase().includes(query.toLowerCase()));
|
||||
return { content: [{ type: 'text', text: hits.map(product => product.name).join('\n') }] };
|
||||
});
|
||||
```
|
||||
|
||||
An in-memory `Client` connected to this server — [Test a server](../testing.md) shows that wiring — calls `search` with `{ query: 'mug' }` and the handler's `content` comes back unchanged:
|
||||
|
||||
```
|
||||
[ { type: 'text', text: 'Travel mug\nMug rack' } ]
|
||||
```
|
||||
|
||||
Now call it with `{ query: 42 }`. The protocol layer checks only that `arguments` is an object, so the value reaches the handler and the handler crashes:
|
||||
|
||||
```
|
||||
ProtocolError -32603: query.toLowerCase is not a function
|
||||
```
|
||||
|
||||
`callTool` rejected with a protocol error instead of resolving to an `isError: true` tool result — [Errors](../servers/errors.md) covers the difference.
|
||||
|
||||
## Validate arguments yourself
|
||||
|
||||
From one Zod `inputSchema` the SDK derives the JSON Schema the model sees, validates arguments before your handler runs, and infers the handler's argument types. Here you wrote the JSON Schema by hand, the cast went unchecked, and nothing tied the two together.
|
||||
|
||||
`fromJsonSchema` — exported from `@modelcontextprotocol/server` — wraps a JSON Schema object as a validator you run yourself. Registering `tools/call` again replaces the handler; this one rejects before it touches the arguments.
|
||||
|
||||
```ts source="../../examples/guides/advanced/low-level-server.examples.ts#lowLevel_validate"
|
||||
const SearchArguments = fromJsonSchema<{ query: string }>({
|
||||
type: 'object',
|
||||
properties: { query: { type: 'string' } },
|
||||
required: ['query']
|
||||
});
|
||||
|
||||
server.setRequestHandler('tools/call', async request => {
|
||||
if (request.params.name !== 'search') {
|
||||
return { content: [{ type: 'text', text: `Unknown tool: ${request.params.name}` }], isError: true };
|
||||
}
|
||||
const parsed = await SearchArguments['~standard'].validate(request.params.arguments ?? {});
|
||||
if (parsed.issues) {
|
||||
return { content: [{ type: 'text', text: parsed.issues.map(issue => issue.message).join('; ') }], isError: true };
|
||||
}
|
||||
const hits = catalog.filter(product => product.name.toLowerCase().includes(parsed.value.query.toLowerCase()));
|
||||
return { content: [{ type: 'text', text: hits.map(product => product.name).join('\n') }] };
|
||||
});
|
||||
```
|
||||
|
||||
The same `{ query: 42 }` call now comes back as an ordinary tool result the model can read and retry:
|
||||
|
||||
```
|
||||
{
|
||||
content: [ { type: 'text', text: 'data/query must be string' } ],
|
||||
isError: true
|
||||
}
|
||||
```
|
||||
|
||||
Keeping the schema you advertise in `tools/list` identical to the one you validate with is still on you — `registerTool` derives both from the same object.
|
||||
|
||||
## Serve it with the same entry points
|
||||
|
||||
`serveStdio` — from `@modelcontextprotocol/server/stdio` — and `createMcpHandler` each take an `McpServerFactory`, and the factory returns either an `McpServer` or a `Server`.
|
||||
|
||||
```ts source="../../examples/guides/advanced/low-level-server.examples.ts#lowLevel_serve"
|
||||
serveStdio(() => server);
|
||||
createMcpHandler(() => server);
|
||||
```
|
||||
|
||||
Every serving recipe — [stdio](../serving/stdio.md), [HTTP](../serving/http.md) — applies to this server unchanged.
|
||||
|
||||
## Reach the low level from `McpServer`
|
||||
|
||||
Every `McpServer` owns its `Server` as `mcp.server`, so drop down per method, never per program. Declare the extra capability in the constructor, keep `registerTool` for the tools, and hand-register the one method `McpServer` has no API for.
|
||||
|
||||
```ts source="../../examples/guides/advanced/low-level-server.examples.ts#lowLevel_escapeHatch"
|
||||
const mcp = new McpServer({ name: 'catalog', version: '1.0.0' }, { capabilities: { resources: { subscribe: true } } });
|
||||
|
||||
mcp.registerTool(
|
||||
'search',
|
||||
{ description: 'Search the product catalog', inputSchema: z.object({ query: z.string() }) },
|
||||
async ({ query }) => {
|
||||
const names = catalog.filter(product => product.name.includes(query)).map(product => product.name);
|
||||
return { content: [{ type: 'text', text: names.join('\n') }] };
|
||||
}
|
||||
);
|
||||
|
||||
const subscriptions = new Set<string>();
|
||||
mcp.server.setRequestHandler('resources/subscribe', async request => {
|
||||
subscriptions.add(request.params.uri);
|
||||
return {};
|
||||
});
|
||||
```
|
||||
|
||||
`registerTool` still answers `tools/list` and `tools/call`; `resources/subscribe` reaches the handler you wrote. On the 2026-07-28 revision resource subscriptions arrive on a `subscriptions/listen` stream the serving entries answer for you — see [Protocol versions](../protocol-versions.md).
|
||||
|
||||
## Decide which layer to build on
|
||||
|
||||
Default to `McpServer`. `registerTool`, `registerResource`, and `registerPrompt` cover everything this page rebuilt — schema derivation, argument validation, typed handler arguments — plus the bookkeeping it skipped: `listChanged` notifications, [completions](../servers/completion.md), and the list/read/get dispatch for every registry.
|
||||
|
||||
Build on `Server` when you own dispatch: a [gateway](./gateway.md) that forwards whatever method arrives, a tool set computed per request from an external registry, or [custom methods](./custom-methods.md) outside the spec.
|
||||
|
||||
You never choose once for the whole program. Start on `McpServer` and take over individual methods through `mcp.server` as they need it.
|
||||
|
||||
## Recap
|
||||
|
||||
- `Server` is the protocol layer: `setRequestHandler(method, handler)` per spec method, and nothing derived on top.
|
||||
- On `Server` you write the JSON Schema in `tools/list` and the argument validation in `tools/call`; `registerTool` derives both from one Zod schema.
|
||||
- A handler exception on `Server` reaches the client as a protocol error, not as an `isError: true` tool result.
|
||||
- `serveStdio` and `createMcpHandler` accept a factory that returns a `Server` unchanged.
|
||||
- `mcp.server` is the per-method escape hatch; default to `McpServer` and drop to `Server` only where you own dispatch.
|
||||
164
docs/advanced/schema-libraries.md
Normal file
164
docs/advanced/schema-libraries.md
Normal file
|
|
@ -0,0 +1,164 @@
|
|||
---
|
||||
shape: how-to
|
||||
---
|
||||
# Schema libraries
|
||||
|
||||
## Register a tool with an ArkType schema
|
||||
|
||||
`inputSchema` accepts any **Standard Schema** that can produce JSON Schema — ArkType works as-is, no wrapper, exactly like the Zod schemas in [Tools](../servers/tools.md).
|
||||
|
||||
```ts source="../../examples/guides/advanced/schema-libraries.examples.ts#registerTool_arktype"
|
||||
import { McpServer } from '@modelcontextprotocol/server';
|
||||
import { type } from 'arktype';
|
||||
|
||||
const server = new McpServer({ name: 'schema-zoo', version: '1.0.0' });
|
||||
|
||||
server.registerTool(
|
||||
'greet',
|
||||
{
|
||||
description: 'Greet someone by name',
|
||||
inputSchema: type({ name: 'string', 'times?': '1 <= number.integer <= 5' })
|
||||
},
|
||||
async ({ name, times }) => ({
|
||||
content: [{ type: 'text', text: Array.from({ length: times ?? 1 }, () => `Hello, ${name}`).join('\n') }]
|
||||
})
|
||||
);
|
||||
```
|
||||
|
||||
From that one schema the SDK derives the JSON Schema the model sees, validates arguments before your handler runs, and infers the handler's argument types — `name` is `string`, `times` is `number | undefined`.
|
||||
|
||||
Every call on this page comes from an in-memory `Client` connected to this server — [Test a server](../testing.md) shows that wiring. Call `greet` with `times: 99` and the SDK rejects the call with ArkType's own message; the handler never runs:
|
||||
|
||||
```
|
||||
{
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: 'Input validation error: Invalid arguments for tool greet: times: times must be at most 5 (was 99)'
|
||||
}
|
||||
],
|
||||
isError: true
|
||||
}
|
||||
```
|
||||
|
||||
::: info Coming from v1?
|
||||
Raw shapes (`inputSchema: { name: z.string() }`) are deprecated — pass a schema object. See the [upgrade guide](../migration/upgrade-to-v2.md).
|
||||
:::
|
||||
|
||||
## Register a tool with a Valibot schema
|
||||
|
||||
Valibot does not expose JSON Schema conversion on the schema itself — wrap it with `toStandardJsonSchema` from `@valibot/to-json-schema`.
|
||||
|
||||
```ts source="../../examples/guides/advanced/schema-libraries.examples.ts#registerTool_valibot"
|
||||
import { toStandardJsonSchema } from '@valibot/to-json-schema';
|
||||
import * as v from 'valibot';
|
||||
|
||||
server.registerTool(
|
||||
'shout',
|
||||
{ description: 'Greet someone, loudly', inputSchema: toStandardJsonSchema(v.object({ name: v.string() })) },
|
||||
async ({ name }) => ({ content: [{ type: 'text', text: `HELLO, ${name.toUpperCase()}` }] })
|
||||
);
|
||||
```
|
||||
|
||||
`tools/list` now advertises `shout` with the JSON Schema the wrapper derives, and Valibot parses every call that reaches the handler.
|
||||
|
||||
## Start from JSON Schema you already have
|
||||
|
||||
`fromJsonSchema` (exported from `@modelcontextprotocol/server`) wraps a plain JSON Schema document so you can register it without a schema library. The generic parameter types the handler's arguments; omit it and they are `unknown`.
|
||||
|
||||
```ts source="../../examples/guides/advanced/schema-libraries.examples.ts#registerTool_fromJsonSchema"
|
||||
import { fromJsonSchema } from '@modelcontextprotocol/server';
|
||||
|
||||
server.registerTool(
|
||||
'farewell',
|
||||
{
|
||||
description: 'Say goodbye',
|
||||
inputSchema: fromJsonSchema<{ name: string }>({
|
||||
type: 'object',
|
||||
properties: { name: { type: 'string' } },
|
||||
required: ['name']
|
||||
})
|
||||
},
|
||||
async ({ name }) => ({ content: [{ type: 'text', text: `Goodbye, ${name}` }] })
|
||||
);
|
||||
```
|
||||
|
||||
`tools/list` advertises the document you passed, unchanged:
|
||||
|
||||
```
|
||||
{
|
||||
type: 'object',
|
||||
properties: { name: { type: 'string' } },
|
||||
required: [ 'name' ]
|
||||
}
|
||||
```
|
||||
|
||||
The SDK checks every call against it with a real **JSON Schema validator** — the last two sections pick which one.
|
||||
|
||||
## Validate structured output with any library
|
||||
|
||||
`outputSchema` — and a prompt's `argsSchema` — follow the same Standard Schema rule. Return the matching value as `structuredContent`, next to the human-readable `content`.
|
||||
|
||||
```ts source="../../examples/guides/advanced/schema-libraries.examples.ts#registerTool_outputSchema"
|
||||
server.registerTool(
|
||||
'measure',
|
||||
{
|
||||
description: 'Measure the length of a name',
|
||||
inputSchema: type({ name: 'string' }),
|
||||
outputSchema: type({ name: 'string', length: 'number' })
|
||||
},
|
||||
async ({ name }) => {
|
||||
const output = { name, length: name.length };
|
||||
return { content: [{ type: 'text', text: JSON.stringify(output) }], structuredContent: output };
|
||||
}
|
||||
);
|
||||
```
|
||||
|
||||
The SDK validates `structuredContent` against the ArkType schema before the result leaves your server. Calling `measure` with `{ name: 'Ada' }` returns both renderings:
|
||||
|
||||
```
|
||||
{
|
||||
content: [ { type: 'text', text: '{"name":"Ada","length":3}' } ],
|
||||
structuredContent: { name: 'Ada', length: 3 }
|
||||
}
|
||||
```
|
||||
|
||||
## Swap the JSON Schema validator
|
||||
|
||||
The server runs a JSON Schema validator in two places: a `fromJsonSchema` schema, and [elicitation](../servers/elicitation.md) form responses. Build one from the `validators/ajv` subpath, which re-exports the SDK's bundled `Ajv` and `addFormats`.
|
||||
|
||||
```ts source="../../examples/guides/advanced/schema-libraries.examples.ts#jsonSchemaValidator_ajv"
|
||||
import { addFormats, Ajv, AjvJsonSchemaValidator } from '@modelcontextprotocol/server/validators/ajv';
|
||||
|
||||
const ajv = new Ajv({ strict: true, allErrors: true });
|
||||
addFormats(ajv);
|
||||
const validator = new AjvJsonSchemaValidator(ajv);
|
||||
|
||||
const strict = new McpServer({ name: 'schema-zoo', version: '1.0.0' }, { jsonSchemaValidator: validator });
|
||||
```
|
||||
|
||||
`strict` now checks elicitation form responses with your `Ajv` instance.
|
||||
|
||||
::: warning
|
||||
`jsonSchemaValidator` covers elicitation form responses only. A `fromJsonSchema` schema binds its validator at creation — pass yours as the second argument: `fromJsonSchema(document, validator)`.
|
||||
:::
|
||||
|
||||
## Pick the validator for your runtime
|
||||
|
||||
Leave `jsonSchemaValidator` unset and the SDK selects by runtime: AJV on Node.js, `@cfworker/json-schema` on workerd and in browsers. Import from the `validators/cf-worker` subpath to pin the lightweight one anywhere.
|
||||
|
||||
```ts source="../../examples/guides/advanced/schema-libraries.examples.ts#jsonSchemaValidator_cfWorker"
|
||||
import { CfWorkerJsonSchemaValidator } from '@modelcontextprotocol/server/validators/cf-worker';
|
||||
|
||||
const edge = new McpServer({ name: 'schema-zoo', version: '1.0.0' }, { jsonSchemaValidator: new CfWorkerJsonSchemaValidator() });
|
||||
```
|
||||
|
||||
`edge` runs the same two validation paths through `@cfworker/json-schema` instead of AJV, on any runtime.
|
||||
|
||||
## Recap
|
||||
|
||||
- `inputSchema`, `outputSchema`, and a prompt's `argsSchema` accept any Standard Schema that exposes JSON Schema — Zod and ArkType as-is, Valibot through `@valibot/to-json-schema`.
|
||||
- The raw-shape overload (`inputSchema: { name: z.string() }`) is deprecated; pass a schema object.
|
||||
- `fromJsonSchema(document)` registers a JSON Schema you already have; the generic parameter types the handler's arguments.
|
||||
- `jsonSchemaValidator` on the server options swaps the validator for elicitation form responses; `fromJsonSchema` takes its own as a second argument.
|
||||
- The default validator is runtime-selected — AJV on Node.js, `@cfworker/json-schema` on workerd and browsers — and the `validators/ajv` and `validators/cf-worker` subpaths force either one.
|
||||
158
docs/advanced/wire-schemas.md
Normal file
158
docs/advanced/wire-schemas.md
Normal file
|
|
@ -0,0 +1,158 @@
|
|||
---
|
||||
shape: how-to
|
||||
---
|
||||
# Wire schemas
|
||||
|
||||
`@modelcontextprotocol/core` exports the **wire schemas** — the exact Zod constants the SDK validates protocol and OAuth payloads against — for code that holds raw JSON instead of SDK objects.
|
||||
|
||||
## Validate a wire payload
|
||||
|
||||
`CallToolResultSchema.safeParse` validates an upstream body before you relay it.
|
||||
|
||||
```ts source="../../examples/guides/advanced/wire-schemas.examples.ts#wireSchemas_validateResult"
|
||||
import { CallToolResultSchema } from '@modelcontextprotocol/core';
|
||||
|
||||
// The body an upstream server returned for a tools/call you forwarded.
|
||||
const body: unknown = JSON.parse('{"content":[{"type":"text","text":"Travel mug"}]}');
|
||||
|
||||
const parsed = CallToolResultSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
throw new Error(`upstream returned an invalid tools/call result: ${parsed.error.message}`);
|
||||
}
|
||||
console.log(parsed.data.content);
|
||||
```
|
||||
|
||||
`parsed.data` is the typed result:
|
||||
|
||||
```
|
||||
[ { type: 'text', text: 'Travel mug' } ]
|
||||
```
|
||||
|
||||
Hand the same schema a malformed body and `safeParse` returns the failure instead of throwing.
|
||||
|
||||
```ts source="../../examples/guides/advanced/wire-schemas.examples.ts#wireSchemas_validateResult_invalid"
|
||||
const malformed = CallToolResultSchema.safeParse({ content: 'Travel mug' });
|
||||
console.log(malformed.error?.issues);
|
||||
```
|
||||
|
||||
The error names the field that broke the contract:
|
||||
|
||||
```
|
||||
[
|
||||
{
|
||||
expected: 'array',
|
||||
code: 'invalid_type',
|
||||
path: [ 'content' ],
|
||||
message: 'Invalid input: expected array, received string'
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
::: info Coming from v1?
|
||||
These are the `*Schema` constants v1 exported from `@modelcontextprotocol/sdk/types.js`. The codemod rewrites the import path — see the [upgrade guide](../migration/upgrade-to-v2.md).
|
||||
:::
|
||||
|
||||
## Decide whether you need this package at all
|
||||
|
||||
If you build with `McpServer` or `Client`, skip this package: [tools](../servers/tools.md) arrive in your handler already validated, and [tool calls](../clients/calling.md) come back as typed results. Reach for `@modelcontextprotocol/core` when nothing stands between you and the JSON — gateways, proxies, test harnesses, [worker fleets](./gateway.md).
|
||||
|
||||
Install it separately (`npm install @modelcontextprotocol/core`) — `@modelcontextprotocol/server` and `@modelcontextprotocol/client` keep a Zod-free public surface and never depend on it. The package is runtime-neutral; `zod` is its only dependency.
|
||||
|
||||
## Pick the schema for the message you hold
|
||||
|
||||
Every named type in the spec has a matching constant, `<SpecType>Schema`. When you do not yet know which one you hold, `JSONRPCMessageSchema` validates the undecoded envelope.
|
||||
|
||||
```ts source="../../examples/guides/advanced/wire-schemas.examples.ts#wireSchemas_envelope"
|
||||
import { JSONRPCMessageSchema } from '@modelcontextprotocol/core';
|
||||
|
||||
const frame = '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"search","arguments":{"query":"mug"}}}';
|
||||
const message = JSONRPCMessageSchema.parse(JSON.parse(frame));
|
||||
```
|
||||
|
||||
`message` narrows to one of the four JSON-RPC shapes — request, notification, result response, error response — and an invalid frame throws a `ZodError`.
|
||||
|
||||
The constants come in the same families as the spec: requests (`CallToolRequestSchema`), results (`ListToolsResultSchema`), notifications (`ProgressNotificationSchema`), and `*ParamsSchema` for when you hold only the `params` object (`CallToolRequestParamsSchema`).
|
||||
|
||||
## Route raw JSON-RPC in a proxy
|
||||
|
||||
Parse the envelope once, branch on `method`, then validate with the per-method request schema before forwarding.
|
||||
|
||||
```ts source="../../examples/guides/advanced/wire-schemas.examples.ts#wireSchemas_route"
|
||||
import { CallToolRequestSchema } from '@modelcontextprotocol/core';
|
||||
|
||||
if ('method' in message) {
|
||||
switch (message.method) {
|
||||
case 'tools/call': {
|
||||
const call = CallToolRequestSchema.parse(message);
|
||||
console.log(`forward tools/call for ${call.params.name} upstream`);
|
||||
break;
|
||||
}
|
||||
default:
|
||||
console.log(`forward ${message.method} unchanged`);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`call.params.name` is a typed `string`, with no `Client` or `Server` anywhere in the path:
|
||||
|
||||
```
|
||||
forward tools/call for search upstream
|
||||
```
|
||||
|
||||
For everything beyond validation — sessions, capability negotiation, request correlation — build on the SDK instead: see the [low-level server](./low-level-server.md).
|
||||
|
||||
## Validate OAuth and discovery metadata
|
||||
|
||||
The second export group covers OAuth and OpenID discovery. `OAuthMetadataSchema` validates an authorization server's metadata document.
|
||||
|
||||
```ts source="../../examples/guides/advanced/wire-schemas.examples.ts#wireSchemas_oauthMetadata"
|
||||
import { OAuthMetadataSchema } from '@modelcontextprotocol/core';
|
||||
|
||||
// In production this body comes from GET <issuer>/.well-known/oauth-authorization-server.
|
||||
const response = new Response(
|
||||
JSON.stringify({
|
||||
issuer: 'https://auth.example.com',
|
||||
authorization_endpoint: 'https://auth.example.com/authorize',
|
||||
token_endpoint: 'https://auth.example.com/token',
|
||||
response_types_supported: ['code']
|
||||
})
|
||||
);
|
||||
|
||||
const metadata = OAuthMetadataSchema.parse(await response.json());
|
||||
console.log(metadata.token_endpoint);
|
||||
```
|
||||
|
||||
A document missing a required endpoint fails the parse; a valid one comes back typed:
|
||||
|
||||
```
|
||||
https://auth.example.com/token
|
||||
```
|
||||
|
||||
The group follows the same naming convention: `OAuthTokensSchema` for token responses, `OAuthProtectedResourceMetadataSchema` for protected-resource metadata, `OpenIdProviderDiscoveryMetadataSchema` for OpenID provider discovery.
|
||||
|
||||
## Get the TypeScript types, guards and errors from the SDK packages
|
||||
|
||||
`@modelcontextprotocol/core` exports Zod values and nothing else. The spec types, the `isJSONRPCRequest`-style guards, and the error classes are public API of `@modelcontextprotocol/server` and `@modelcontextprotocol/client` — import them from whichever package you already depend on.
|
||||
|
||||
```ts source="../../examples/guides/advanced/wire-schemas.examples.ts#wireSchemas_types"
|
||||
import type { CallToolResult } from '@modelcontextprotocol/client';
|
||||
import * as z from 'zod/v4';
|
||||
|
||||
// The SDK's spec type and the schema's own inferred output describe the same value.
|
||||
const relayed: CallToolResult = parsed.data;
|
||||
type CallToolResultFromCore = z.infer<typeof CallToolResultSchema>;
|
||||
```
|
||||
|
||||
The assignment typechecks: what a core schema parses is what the SDK packages type. A package that depends only on core derives the same types with `z.infer`.
|
||||
|
||||
::: tip
|
||||
To check a value's shape without taking a Zod dependency at all, use the `isSpecType` guards exported from `@modelcontextprotocol/client` and `@modelcontextprotocol/server`: `isSpecType.CallToolResult(value)`.
|
||||
:::
|
||||
|
||||
## Recap
|
||||
|
||||
- `@modelcontextprotocol/core` exports the SDK's own spec and OAuth/OpenID Zod schemas, and nothing else.
|
||||
- Its audience is code that holds raw JSON — gateways, proxies, test harnesses — not `Client` or `Server` users.
|
||||
- Every spec type has a `<Name>Schema` constant; `JSONRPCMessageSchema` validates the undecoded envelope.
|
||||
- Types, guards and error classes are not in core — import them from `@modelcontextprotocol/server` or `@modelcontextprotocol/client`.
|
||||
- The package is runtime-neutral; `zod` is its only dependency.
|
||||
|
|
@ -1,442 +0,0 @@
|
|||
---
|
||||
title: Client Quickstart
|
||||
---
|
||||
|
||||
# Quickstart: Build an LLM-powered chatbot
|
||||
|
||||
In this tutorial, we'll build an LLM-powered chatbot that connects to an MCP server, discovers its tools, and uses Claude to call them.
|
||||
|
||||
Before you begin, it helps to have gone through the [server quickstart](./server-quickstart.md) so you understand how clients and servers communicate.
|
||||
|
||||
[You can find the complete code for this tutorial here.](https://github.com/modelcontextprotocol/typescript-sdk/tree/main/examples/client-quickstart)
|
||||
|
||||
## Prerequisites
|
||||
|
||||
This quickstart assumes you have familiarity with:
|
||||
|
||||
- TypeScript
|
||||
- LLMs like Claude
|
||||
|
||||
Before starting, ensure your system meets these requirements:
|
||||
|
||||
- Node.js 20 or higher installed (or **Bun** / **Deno** — the SDK supports all three runtimes)
|
||||
- Latest version of `npm` installed
|
||||
- An Anthropic API key from the [Anthropic Console](https://console.anthropic.com/settings/keys)
|
||||
|
||||
> [!TIP]
|
||||
> This tutorial uses Node.js and npm, but you can substitute `bun` or `deno` commands where appropriate. For example, use `bun add` instead of `npm install`, or run the client with `bun run` / `deno run`.
|
||||
|
||||
## Set up your environment
|
||||
|
||||
First, let's create and set up our project:
|
||||
|
||||
**macOS/Linux:**
|
||||
|
||||
```bash
|
||||
# Create project directory
|
||||
mkdir mcp-client
|
||||
cd mcp-client
|
||||
|
||||
# Initialize npm project
|
||||
npm init -y
|
||||
|
||||
# Install dependencies
|
||||
npm install @anthropic-ai/sdk @modelcontextprotocol/client
|
||||
|
||||
# Install dev dependencies
|
||||
npm install -D @types/node typescript
|
||||
|
||||
# Create source file
|
||||
mkdir src
|
||||
touch src/index.ts
|
||||
```
|
||||
|
||||
**Windows:**
|
||||
|
||||
```powershell
|
||||
# Create project directory
|
||||
md mcp-client
|
||||
cd mcp-client
|
||||
|
||||
# Initialize npm project
|
||||
npm init -y
|
||||
|
||||
# Install dependencies
|
||||
npm install @anthropic-ai/sdk @modelcontextprotocol/client
|
||||
|
||||
# Install dev dependencies
|
||||
npm install -D @types/node typescript
|
||||
|
||||
# Create source file
|
||||
md src
|
||||
new-item src\index.ts
|
||||
```
|
||||
|
||||
Update your `package.json` to set `type: "module"` and a build script:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "tsc"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Create a `tsconfig.json` in the root of your project:
|
||||
|
||||
```json
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ESNext",
|
||||
"lib": ["ESNext"],
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"outDir": "./build",
|
||||
"rootDir": "./src",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
```
|
||||
|
||||
## Creating the client
|
||||
|
||||
### Basic client structure
|
||||
|
||||
First, let's set up our imports and create the basic client class in `src/index.ts`:
|
||||
|
||||
```ts source="../examples/client-quickstart/src/index.ts#prelude"
|
||||
import Anthropic from '@anthropic-ai/sdk';
|
||||
import { Client } from '@modelcontextprotocol/client';
|
||||
import { StdioClientTransport } from '@modelcontextprotocol/client/stdio';
|
||||
import readline from 'readline/promises';
|
||||
|
||||
const ANTHROPIC_MODEL = 'claude-sonnet-4-6';
|
||||
|
||||
class MCPClient {
|
||||
private mcp: Client;
|
||||
private _anthropic: Anthropic | null = null;
|
||||
private tools: Anthropic.Tool[] = [];
|
||||
|
||||
constructor() {
|
||||
// Initialize MCP client
|
||||
this.mcp = new Client({ name: 'mcp-client-cli', version: '1.0.0' });
|
||||
}
|
||||
|
||||
private get anthropic(): Anthropic {
|
||||
// Lazy-initialize Anthropic client when needed
|
||||
return this._anthropic ??= new Anthropic({ apiKey: process.env.ANTHROPIC_API_KEY });
|
||||
}
|
||||
```
|
||||
|
||||
### Server connection management
|
||||
|
||||
Next, we'll implement the method to connect to an MCP server:
|
||||
|
||||
```ts source="../examples/client-quickstart/src/index.ts#connectToServer"
|
||||
async connectToServer(serverScriptPath: string) {
|
||||
try {
|
||||
// Determine script type and appropriate command
|
||||
const isJs = serverScriptPath.endsWith('.js');
|
||||
const isPy = serverScriptPath.endsWith('.py');
|
||||
if (!isJs && !isPy) {
|
||||
throw new Error('Server script must be a .js or .py file');
|
||||
}
|
||||
const command = isPy
|
||||
? (process.platform === 'win32' ? 'python' : 'python3')
|
||||
: process.execPath;
|
||||
|
||||
// Initialize transport and connect to server
|
||||
const transport = new StdioClientTransport({ command, args: [serverScriptPath] });
|
||||
await this.mcp.connect(transport);
|
||||
|
||||
// List available tools
|
||||
const toolsResult = await this.mcp.listTools();
|
||||
this.tools = toolsResult.tools.map((tool) => ({
|
||||
name: tool.name,
|
||||
description: tool.description ?? '',
|
||||
input_schema: tool.inputSchema as Anthropic.Tool.InputSchema,
|
||||
}));
|
||||
console.log('Connected to server with tools:', this.tools.map(({ name }) => name));
|
||||
} catch (e) {
|
||||
console.log('Failed to connect to MCP server: ', e);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Query processing logic
|
||||
|
||||
Now let's add the core functionality for processing queries and handling tool calls:
|
||||
|
||||
```ts source="../examples/client-quickstart/src/index.ts#processQuery"
|
||||
async processQuery(query: string) {
|
||||
const messages: Anthropic.MessageParam[] = [
|
||||
{
|
||||
role: 'user',
|
||||
content: query,
|
||||
},
|
||||
];
|
||||
|
||||
// Initial Claude API call
|
||||
let response = await this.anthropic.messages.create({
|
||||
model: ANTHROPIC_MODEL,
|
||||
max_tokens: 1000,
|
||||
messages,
|
||||
tools: this.tools,
|
||||
});
|
||||
|
||||
// Process responses, executing tool calls until Claude stops requesting them
|
||||
const finalText = [];
|
||||
|
||||
while (true) {
|
||||
const toolUses: Anthropic.ToolUseBlock[] = [];
|
||||
for (const content of response.content) {
|
||||
if (content.type === 'text') {
|
||||
finalText.push(content.text);
|
||||
} else if (content.type === 'tool_use') {
|
||||
toolUses.push(content);
|
||||
}
|
||||
}
|
||||
|
||||
if (toolUses.length === 0) {
|
||||
break;
|
||||
}
|
||||
|
||||
// Execute every requested tool call and collect the results
|
||||
const toolResults: Anthropic.ToolResultBlockParam[] = [];
|
||||
for (const toolUse of toolUses) {
|
||||
const toolArgs = toolUse.input as Record<string, unknown>;
|
||||
const result = await this.mcp.callTool({
|
||||
name: toolUse.name,
|
||||
arguments: toolArgs,
|
||||
});
|
||||
|
||||
finalText.push(`[Calling tool ${toolUse.name} with args ${JSON.stringify(toolArgs)}]`);
|
||||
|
||||
// Extract text from tool result content blocks
|
||||
const toolResultText = result.content
|
||||
.filter((block) => block.type === 'text')
|
||||
.map((block) => block.text)
|
||||
.join('\n');
|
||||
|
||||
toolResults.push({
|
||||
type: 'tool_result',
|
||||
tool_use_id: toolUse.id,
|
||||
content: toolResultText,
|
||||
// Tell Claude when the tool call failed
|
||||
...(result.isError ? { is_error: true } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
// Continue the conversation: the assistant turn, then ALL tool
|
||||
// results together in a single user turn
|
||||
messages.push({
|
||||
role: 'assistant',
|
||||
content: response.content,
|
||||
});
|
||||
messages.push({
|
||||
role: 'user',
|
||||
content: toolResults,
|
||||
});
|
||||
|
||||
// Get next response from Claude
|
||||
response = await this.anthropic.messages.create({
|
||||
model: ANTHROPIC_MODEL,
|
||||
max_tokens: 1000,
|
||||
messages,
|
||||
tools: this.tools,
|
||||
});
|
||||
}
|
||||
|
||||
return finalText.join('\n');
|
||||
}
|
||||
```
|
||||
|
||||
### Interactive chat interface
|
||||
|
||||
Now we'll add the chat loop and cleanup functionality:
|
||||
|
||||
```ts source="../examples/client-quickstart/src/index.ts#chatLoop"
|
||||
async chatLoop() {
|
||||
const rl = readline.createInterface({
|
||||
input: process.stdin,
|
||||
output: process.stdout,
|
||||
});
|
||||
|
||||
try {
|
||||
console.log('\nMCP Client Started!');
|
||||
console.log('Type your queries or "quit" to exit.');
|
||||
|
||||
while (true) {
|
||||
const message = await rl.question('\nQuery: ');
|
||||
if (message.toLowerCase() === 'quit') {
|
||||
break;
|
||||
}
|
||||
const response = await this.processQuery(message);
|
||||
console.log('\n' + response);
|
||||
}
|
||||
} finally {
|
||||
rl.close();
|
||||
}
|
||||
}
|
||||
|
||||
async cleanup() {
|
||||
await this.mcp.close();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Main entry point
|
||||
|
||||
Finally, we'll add the main execution logic:
|
||||
|
||||
```ts source="../examples/client-quickstart/src/index.ts#main"
|
||||
async function main() {
|
||||
const serverScriptPath = process.argv[2];
|
||||
if (!serverScriptPath) {
|
||||
console.log('Usage: node build/index.js <path_to_server_script>');
|
||||
return;
|
||||
}
|
||||
const mcpClient = new MCPClient();
|
||||
try {
|
||||
await mcpClient.connectToServer(serverScriptPath);
|
||||
|
||||
// Check if we have a valid API key to continue
|
||||
const apiKey = process.env.ANTHROPIC_API_KEY;
|
||||
if (!apiKey) {
|
||||
console.log(
|
||||
'\nNo ANTHROPIC_API_KEY found. To query these tools with Claude, set your API key:'
|
||||
+ '\n export ANTHROPIC_API_KEY=your-api-key-here'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
await mcpClient.chatLoop();
|
||||
} catch (e) {
|
||||
console.error('Error:', e);
|
||||
process.exit(1);
|
||||
} finally {
|
||||
await mcpClient.cleanup();
|
||||
process.exit(0);
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
```
|
||||
|
||||
## Running the client
|
||||
|
||||
To run your client with any MCP server:
|
||||
|
||||
**macOS/Linux:**
|
||||
|
||||
```bash
|
||||
# Build TypeScript
|
||||
npm run build
|
||||
|
||||
# Run the client with a Node.js MCP server
|
||||
ANTHROPIC_API_KEY=your-key-here node build/index.js path/to/server/build/index.js
|
||||
|
||||
# Example: connect to the weather server from the server quickstart
|
||||
ANTHROPIC_API_KEY=your-key-here node build/index.js /absolute/path/to/weather/build/index.js
|
||||
```
|
||||
|
||||
**Windows:**
|
||||
|
||||
```powershell
|
||||
# Build TypeScript
|
||||
npm run build
|
||||
|
||||
# Run the client with a Node.js MCP server
|
||||
$env:ANTHROPIC_API_KEY="your-key-here"; node build/index.js path\to\server\build\index.js
|
||||
```
|
||||
|
||||
**The client will:**
|
||||
|
||||
1. Connect to the specified server
|
||||
2. List available tools
|
||||
3. Start an interactive chat session where you can:
|
||||
- Enter queries
|
||||
- See tool executions
|
||||
- Get responses from Claude
|
||||
|
||||
## What's happening under the hood
|
||||
|
||||
When you submit a query:
|
||||
|
||||
1. Your query is sent to Claude along with the tool descriptions discovered during connection
|
||||
2. Claude decides which tools (if any) to use
|
||||
3. The client executes any requested tool calls through the server
|
||||
4. Results are sent back to Claude
|
||||
5. Claude provides a natural language response
|
||||
6. The response is displayed to you
|
||||
|
||||
> [!NOTE]
|
||||
> By default, the client uses the legacy 2025-era `initialize` handshake, so it works with any 2025-era server, including the weather server from the server quickstart. To opt into the 2026-07-28 draft revision, see [Protocol version negotiation](./client.md#protocol-version-negotiation-2026-07-28-revision).
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Server Path Issues
|
||||
|
||||
- Double-check the path to your server script is correct
|
||||
- Use the absolute path if the relative path isn't working
|
||||
- On Windows, both backslashes (`\`) and forward slashes (`/`) work as path separators
|
||||
- Verify the server file has the correct extension (`.js` for Node.js or `.py` for Python)
|
||||
|
||||
Example of correct path usage:
|
||||
|
||||
**macOS/Linux:**
|
||||
|
||||
```bash
|
||||
# Relative path
|
||||
node build/index.js ./server/build/index.js
|
||||
|
||||
# Absolute path
|
||||
node build/index.js /Users/username/projects/mcp-server/build/index.js
|
||||
```
|
||||
|
||||
**Windows:**
|
||||
|
||||
```powershell
|
||||
# Relative path
|
||||
node build/index.js .\server\build\index.js
|
||||
|
||||
# Absolute path (either format works)
|
||||
node build/index.js C:\projects\mcp-server\build\index.js
|
||||
node build/index.js C:/projects/mcp-server/build/index.js
|
||||
```
|
||||
|
||||
### Response Timing
|
||||
|
||||
- The first response might take up to 30 seconds to return
|
||||
- This is normal and happens while:
|
||||
- The server initializes
|
||||
- Claude processes the query
|
||||
- Tools are being executed
|
||||
- Subsequent responses are typically faster
|
||||
- Don't interrupt the process during this initial waiting period
|
||||
|
||||
### Common Error Messages
|
||||
|
||||
If you see:
|
||||
|
||||
- `Error: Cannot find module`: Check your build folder and ensure TypeScript compilation succeeded
|
||||
- `Error: spawn ... ENOENT` or an immediate exit: check the server script path and that `node` / `python3` can run it (the client spawns the server, so don't start it yourself)
|
||||
- `Tool execution failed`: Verify the tool's required environment variables are set
|
||||
- `ANTHROPIC_API_KEY is not set`: Check your environment variables (e.g., `export ANTHROPIC_API_KEY=...`)
|
||||
- `TypeError`: Ensure you're using the correct types for tool arguments
|
||||
- `BadRequestError`: Ensure you have enough credits to access the Anthropic API
|
||||
|
||||
## Next steps
|
||||
|
||||
Now that you have a working client, here are some ways to go further:
|
||||
|
||||
- [**Client guide**](./client.md) — Add OAuth, middleware, sampling, and more to your client.
|
||||
- [**Example clients**](https://github.com/modelcontextprotocol/typescript-sdk/tree/main/examples) — Browse runnable client examples.
|
||||
- [**FAQ**](./faq.md) — Troubleshoot common errors.
|
||||
1065
docs/client.md
1065
docs/client.md
File diff suppressed because it is too large
Load diff
104
docs/clients/caching.md
Normal file
104
docs/clients/caching.md
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
---
|
||||
shape: how-to
|
||||
---
|
||||
# Cache responses
|
||||
|
||||
Caching is one feature with two halves: the server marks a result with a freshness hint, and the client's **response cache** serves it locally while it stays fresh.
|
||||
|
||||
## Let the cache work
|
||||
|
||||
The cacheable verbs check the cache before they send. A still-fresh entry comes back without a round trip; `cacheMode` overrides the disposition per call.
|
||||
|
||||
```ts source="../../examples/guides/clients/caching.examples.ts#responseCache_use"
|
||||
const tools = await client.listTools(); // network, then cached for the server's ttlMs
|
||||
const again = await client.listTools(); // served from cache while still fresh
|
||||
|
||||
await client.listTools(undefined, { cacheMode: 'refresh' }); // always refetch and re-store
|
||||
await client.readResource({ uri: 'config://app' }, { cacheMode: 'bypass' }); // no cache read or write
|
||||
```
|
||||
|
||||
`client` is connected to the server in the next section — served in-process by `createMcpHandler`, the wiring [Test a server](../testing.md) shows — and the harness counts every request that reaches it. After all four calls, only the first `listTools()` and the `'refresh'` crossed the wire:
|
||||
|
||||
```
|
||||
tools/list requests that reached the server: 2
|
||||
resources/read requests that reached the server: 1
|
||||
```
|
||||
|
||||
Nothing on the client opts in: every `Client` holds a response cache, and the server's hint decides what it may serve.
|
||||
|
||||
## Have the server send the hint
|
||||
|
||||
`ServerOptions.cacheHints` attaches a `ttlMs` and a `cacheScope` to each cacheable result it names (SEP-2549) — without one, the SDK emits `ttlMs: 0` and no client ever serves that result from cache.
|
||||
|
||||
```ts source="../../examples/guides/clients/caching.examples.ts#cacheHints_server"
|
||||
const server = new McpServer(
|
||||
{ name: 'catalog', version: '1.0.0' },
|
||||
{
|
||||
cacheHints: {
|
||||
'tools/list': { ttlMs: 60_000, cacheScope: 'public' },
|
||||
'resources/read': { ttlMs: 5_000, cacheScope: 'private' }
|
||||
}
|
||||
}
|
||||
);
|
||||
```
|
||||
|
||||
`registerResource` also takes a per-resource `cacheHint`; it wins, field by field, over the `resources/read` entry here for that resource's read results. Mark a result `cacheScope: 'public'` only when it is identical for every caller — anything derived from the caller's authorization context stays `'private'`, the default.
|
||||
|
||||
::: tip
|
||||
A server cannot pin an entry forever: the client caps any `ttlMs` at 24 hours (`MAX_CACHE_TTL_MS`).
|
||||
:::
|
||||
|
||||
## Choose a cache mode per call
|
||||
|
||||
`cacheMode` on `listTools()`, `listPrompts()`, `listResources()`, `listResourceTemplates()`, and `readResource()` — the cacheable verbs — takes one of three values. `'use'`, the default, serves a still-fresh entry and otherwise fetches and stores. `'refresh'` always fetches and stores the fresh result.
|
||||
|
||||
`'bypass'` fetches without reading or writing: it leaves the cache byte-untouched, including the `tools/list` entry the SDK itself reads for output validation when you [call tools](./calling.md).
|
||||
|
||||
## Bring your own store
|
||||
|
||||
`responseCacheStore` swaps the backing store; the default is a fresh `InMemoryResponseCacheStore` per client, holding at most 512 `resources/read` entries.
|
||||
|
||||
```ts source="../../examples/guides/clients/caching.examples.ts#responseCacheStore_shared"
|
||||
const store = new InMemoryResponseCacheStore({ maxEntries: 2048 });
|
||||
|
||||
const client = new Client({ name: 'my-client', version: '1.0.0' }, { responseCacheStore: store });
|
||||
```
|
||||
|
||||
Every method on the `ResponseCacheStore` interface may return a promise, so a Redis-style store implements the same five methods. Entries are keyed by connected-server identity, so one store can back many clients: connections to different servers never collide.
|
||||
|
||||
## Partition the store per user
|
||||
|
||||
When one shared store serves several principals, set `cachePartition` to a stable identity of the authorization context — the auth subject, for example.
|
||||
|
||||
```ts source="../../examples/guides/clients/caching.examples.ts#cachePartition_perUser"
|
||||
const client = new Client({ name: 'gateway', version: '1.0.0' }, { responseCacheStore: sharedStore, cachePartition: userId });
|
||||
```
|
||||
|
||||
`'private'`-scoped entries are stored under that partition and never read across it; `'public'`-scoped entries stay shared within the server's namespace.
|
||||
|
||||
::: warning
|
||||
A shared store without `cachePartition` can serve one user's `'private'`-scoped resource bodies to another. Set it whenever the store outlives a single principal.
|
||||
:::
|
||||
|
||||
## Cache against servers that send no hints
|
||||
|
||||
`defaultCacheTtlMs` is the TTL applied when a cacheable result arrives without a `ttlMs`. The default is `0`: a result with no hint is never served from cache.
|
||||
|
||||
```ts source="../../examples/guides/clients/caching.examples.ts#defaultCacheTtlMs_optIn"
|
||||
const client = new Client({ name: 'my-client', version: '1.0.0' }, { defaultCacheTtlMs: 60_000 });
|
||||
```
|
||||
|
||||
Fresh or not, the cache also evicts itself when the server signals a change: a `list_changed` notification drops the matching list entries, and `notifications/resources/updated` drops the cached body for that URI — see [Subscriptions](./subscriptions.md).
|
||||
|
||||
::: info
|
||||
Cache hints are a 2026-07-28 surface — see [Protocol versions](../protocol-versions.md). Against a 2025-era server, `defaultCacheTtlMs` is the only lever.
|
||||
:::
|
||||
|
||||
## Recap
|
||||
|
||||
- Caching is one feature with two halves: the server attaches `ttlMs` / `cacheScope`, the client honours them — by default neither half does anything alone.
|
||||
- `listTools()`, `listPrompts()`, `listResources()`, `listResourceTemplates()`, and `readResource()` serve a still-fresh result without a round trip; `cacheMode` overrides per call.
|
||||
- A result without a hint carries `ttlMs: 0` and is never served from cache; the client caps every `ttlMs` at 24 hours.
|
||||
- `responseCacheStore` swaps the backing store; `cachePartition` is mandatory when that store serves several principals.
|
||||
- `defaultCacheTtlMs` opts in to caching against servers that send no hints.
|
||||
- `list_changed` and `notifications/resources/updated` evict matching entries automatically.
|
||||
178
docs/clients/calling.md
Normal file
178
docs/clients/calling.md
Normal file
|
|
@ -0,0 +1,178 @@
|
|||
---
|
||||
shape: how-to
|
||||
---
|
||||
# Call tools, read resources, get prompts
|
||||
|
||||
Every block on this page runs on a connected `Client` — [Connect to a server](./connect.md) shows the wiring — here paired in memory with an `orders` server that registers three tools, a resource, and a prompt.
|
||||
|
||||
## List the tools and call one
|
||||
|
||||
`listTools` returns the tools the server advertises; `callTool` invokes one by name with a plain `arguments` object.
|
||||
|
||||
```ts source="../../examples/guides/clients/calling.examples.ts#listTools_callTool"
|
||||
const { tools } = await client.listTools();
|
||||
console.log(tools.map(tool => tool.name));
|
||||
|
||||
const result = await client.callTool({ name: 'lookup-order', arguments: { id: 'A-1041' } });
|
||||
console.log(result.content);
|
||||
```
|
||||
|
||||
`result.content` is the content array the tool handler returned, unchanged:
|
||||
|
||||
```
|
||||
[ 'lookup-order', 'order-total', 'export-orders' ]
|
||||
[ { type: 'text', text: 'A-1041: 3 items, shipped' } ]
|
||||
```
|
||||
|
||||
::: tip
|
||||
A failed tool call is still a result: check `isError` on it before trusting `content`. Arguments the input schema rejects come back the same way. Only protocol-level failures — unknown tool, timeout — throw.
|
||||
:::
|
||||
|
||||
## Let the SDK walk the pages
|
||||
|
||||
That `listTools()` already walked every page: when a server splits its list, the SDK follows `nextCursor` page by page and returns one aggregated list with `nextCursor: undefined`. `listPrompts()`, `listResources()`, and `listResourceTemplates()` aggregate the same way.
|
||||
|
||||
Pass a `cursor` — a page's `nextCursor` your application held on to — and `listTools` returns exactly that page, raw.
|
||||
|
||||
```ts source="../../examples/guides/clients/calling.examples.ts#listTools_onePage"
|
||||
const page = await client.listTools({ cursor: heldCursor });
|
||||
console.log(
|
||||
page.tools.map(tool => tool.name),
|
||||
page.nextCursor
|
||||
);
|
||||
```
|
||||
|
||||
The `orders` server hands out its three tools two per page, and `heldCursor` names the second page — one tool, nothing left to follow:
|
||||
|
||||
```
|
||||
[ 'export-orders' ] undefined
|
||||
```
|
||||
|
||||
::: warning
|
||||
`ClientOptions.listMaxPages` (default 64) caps the aggregate walk; a server whose pagination never terminates rejects the call with an `SdkError` whose code is `LIST_PAGINATION_EXCEEDED`. `listMaxPages: 0` removes the cap. Explicit-`cursor` calls are never capped.
|
||||
:::
|
||||
|
||||
## Read structured output
|
||||
|
||||
A tool that declares an `outputSchema` returns `structuredContent` next to `content`. It is typed `unknown` — check that it is present and narrow it before use.
|
||||
|
||||
```ts source="../../examples/guides/clients/calling.examples.ts#callTool_structured"
|
||||
const details = await client.callTool({ name: 'order-total', arguments: { id: 'A-1041' } });
|
||||
|
||||
const total: unknown = details.structuredContent;
|
||||
if (typeof total === 'object' && total !== null && 'currency' in total) {
|
||||
console.log(total);
|
||||
}
|
||||
```
|
||||
|
||||
`order-total` declares `{ id, total, currency }`, and that is what comes back:
|
||||
|
||||
```
|
||||
{ id: 'A-1041', total: 61.5, currency: 'EUR' }
|
||||
```
|
||||
|
||||
When an earlier `listTools()` gave the client the tool's `outputSchema`, `callTool` validates `structuredContent` against it and rejects a result that does not match.
|
||||
|
||||
The wire encoding of structured results differs by protocol era — see [Protocol versions](../protocol-versions.md).
|
||||
|
||||
## Read a resource
|
||||
|
||||
`listResources` names what the server exposes; `readResource` fetches one URI.
|
||||
|
||||
```ts source="../../examples/guides/clients/calling.examples.ts#readResource_basic"
|
||||
const { resources } = await client.listResources();
|
||||
console.log(resources.map(resource => resource.uri));
|
||||
|
||||
const { contents } = await client.readResource({ uri: 'orders://recent' });
|
||||
console.log(contents[0]);
|
||||
```
|
||||
|
||||
Each item in `contents` carries the `uri`, a `mimeType`, and either `text` or a base64 `blob`:
|
||||
|
||||
```
|
||||
[ 'orders://recent' ]
|
||||
{
|
||||
uri: 'orders://recent',
|
||||
mimeType: 'application/json',
|
||||
text: '["A-1041","A-1042"]'
|
||||
}
|
||||
```
|
||||
|
||||
For parameterized URIs, `listResourceTemplates()` returns the server's URI templates — expand one and pass the resulting URI to `readResource`. To react when a resource changes instead of re-reading it on a timer, see [Subscriptions](./subscriptions.md).
|
||||
|
||||
## Get a prompt
|
||||
|
||||
`listPrompts` advertises each prompt with its arguments; `getPrompt` fills them in and returns `messages` ready to send to a model.
|
||||
|
||||
```ts source="../../examples/guides/clients/calling.examples.ts#getPrompt_basic"
|
||||
const { prompts } = await client.listPrompts();
|
||||
console.log(prompts.map(prompt => prompt.name));
|
||||
|
||||
const prompt = await client.getPrompt({ name: 'summarize-order', arguments: { id: 'A-1041', tone: 'terse' } });
|
||||
console.log(prompt.messages);
|
||||
```
|
||||
|
||||
The server's template comes back with both arguments substituted:
|
||||
|
||||
```
|
||||
[ 'summarize-order' ]
|
||||
[
|
||||
{
|
||||
role: 'user',
|
||||
content: {
|
||||
type: 'text',
|
||||
text: 'Write a terse status update for order A-1041.'
|
||||
}
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
## Autocomplete an argument
|
||||
|
||||
`complete` asks the server for suggestions while the user types an argument: `ref` names the prompt (or resource template) and `argument` carries the partial value.
|
||||
|
||||
```ts source="../../examples/guides/clients/calling.examples.ts#complete_tone"
|
||||
const { completion } = await client.complete({
|
||||
ref: { type: 'ref/prompt', name: 'summarize-order' },
|
||||
argument: { name: 'tone', value: 'f' }
|
||||
});
|
||||
console.log(completion.values);
|
||||
```
|
||||
|
||||
The server matches `f` against the values it accepts for `tone`:
|
||||
|
||||
```
|
||||
[ 'formal', 'friendly' ]
|
||||
```
|
||||
|
||||
## Track progress on a long call
|
||||
|
||||
Every verb takes request options as a second argument. `onprogress` receives each `notifications/progress` the server emits for this call; `resetTimeoutOnProgress` restarts the request timeout on every update and `maxTotalTimeout` is the absolute cap.
|
||||
|
||||
```ts source="../../examples/guides/clients/calling.examples.ts#callTool_progress"
|
||||
const exported = await client.callTool(
|
||||
{ name: 'export-orders', arguments: { format: 'csv' } },
|
||||
{
|
||||
onprogress: update => console.log(update),
|
||||
resetTimeoutOnProgress: true,
|
||||
maxTotalTimeout: 600_000
|
||||
}
|
||||
);
|
||||
console.log(exported.content);
|
||||
```
|
||||
|
||||
The updates stream in while the call is still pending; the return type does not change:
|
||||
|
||||
```
|
||||
{ progress: 1, total: 2, message: 'exported A-1041' }
|
||||
{ progress: 2, total: 2, message: 'exported A-1042' }
|
||||
[ { type: 'text', text: '2 orders exported as csv' } ]
|
||||
```
|
||||
|
||||
## Recap
|
||||
|
||||
- `listTools`, `listResources`, `listResourceTemplates`, and `listPrompts` aggregate every page; `{ cursor }` fetches a single raw page and `listMaxPages` caps the walk.
|
||||
- `callTool` returns `content` for the model and, when the tool declares an `outputSchema`, `structuredContent` for your application.
|
||||
- `readResource({ uri })` and `getPrompt({ name, arguments })` follow the same list-then-fetch shape as tools.
|
||||
- `complete()` returns the server's suggestions for a prompt or resource-template argument.
|
||||
- `onprogress` in the request options streams progress updates without changing the call's return type.
|
||||
106
docs/clients/connect.md
Normal file
106
docs/clients/connect.md
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
---
|
||||
shape: how-to
|
||||
---
|
||||
# Connect to a server
|
||||
|
||||
A **client** holds one connection to one server: construct a `Client`, pick a **transport**, and `connect()`.
|
||||
|
||||
## Create a client and connect over HTTP
|
||||
|
||||
`Client` takes a name and a version; `StreamableHTTPClientTransport` takes the server's MCP endpoint URL.
|
||||
|
||||
```ts source="../../examples/guides/clients/connect.examples.ts#connect_streamableHttp"
|
||||
import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client';
|
||||
|
||||
const client = new Client({ name: 'my-client', version: '1.0.0' });
|
||||
|
||||
const transport = new StreamableHTTPClientTransport(new URL('http://localhost:3000/mcp'));
|
||||
|
||||
await client.connect(transport);
|
||||
```
|
||||
|
||||
`connect()` runs the `initialize` handshake and resolves once it completes. The client now holds the negotiated protocol version, the server's capabilities, and its instructions.
|
||||
|
||||
::: info Coming from v1?
|
||||
`Client` and the transport classes keep their names — only the import paths moved, to `@modelcontextprotocol/client` and its `/stdio` subpath. Run the codemod, then see the [upgrade guide](../migration/upgrade-to-v2.md).
|
||||
:::
|
||||
|
||||
## Connect to a local process over stdio
|
||||
|
||||
For a server you run as a child process, change only the transport: `StdioClientTransport`, imported from `@modelcontextprotocol/client/stdio`, spawns the command and speaks JSON-RPC over its stdin and stdout.
|
||||
|
||||
```ts source="../../examples/guides/clients/connect.examples.ts#connect_stdio"
|
||||
const client = new Client({ name: 'my-client', version: '1.0.0' });
|
||||
|
||||
const transport = new StdioClientTransport({ command: 'node', args: ['server.js'] });
|
||||
|
||||
await client.connect(transport);
|
||||
```
|
||||
|
||||
`server.js` runs as a child of your process. `close()` shuts it down in order: close stdin, then `SIGTERM`, then `SIGKILL`.
|
||||
|
||||
::: tip
|
||||
`InMemoryTransport.createLinkedPair()` is the third transport: it links a `Client` and an `McpServer` inside one process, no network and no child process. [Test a server](../testing.md) builds on it.
|
||||
:::
|
||||
|
||||
## Fall back to SSE for servers that predate Streamable HTTP
|
||||
|
||||
An SSE-only server speaks the older HTTP+SSE transport instead of Streamable HTTP. Try `StreamableHTTPClientTransport` first; when it fails, retry with `SSEClientTransport` on a fresh `Client`.
|
||||
|
||||
```ts source="../../examples/guides/clients/connect.examples.ts#connect_sseFallback"
|
||||
try {
|
||||
const client = new Client({ name: 'my-client', version: '1.0.0' });
|
||||
await client.connect(new StreamableHTTPClientTransport(new URL(url)));
|
||||
return client;
|
||||
} catch {
|
||||
const client = new Client({ name: 'my-client', version: '1.0.0' });
|
||||
await client.connect(new SSEClientTransport(new URL(url)));
|
||||
return client;
|
||||
}
|
||||
```
|
||||
|
||||
Whichever branch returns, the `Client` behaves the same from here on — nothing downstream depends on the transport.
|
||||
|
||||
::: info
|
||||
`versionNegotiation` in `ClientOptions` controls which protocol revision `connect()` negotiates — see [Protocol versions](../protocol-versions.md).
|
||||
:::
|
||||
|
||||
## Read what the server told you at connect time
|
||||
|
||||
Three accessors return what the server declared during the handshake; all of them return `undefined` until `connect()` resolves.
|
||||
|
||||
```ts source="../../examples/guides/clients/connect.examples.ts#connect_introspect"
|
||||
console.log(client.getServerVersion());
|
||||
console.log(client.getServerCapabilities());
|
||||
console.log(client.getInstructions());
|
||||
```
|
||||
|
||||
Connected to a server named `travel` that registered one tool and set `instructions`, that prints:
|
||||
|
||||
```
|
||||
{ name: 'travel', version: '2.1.0' }
|
||||
{ tools: { listChanged: true } }
|
||||
Call list-trips before book-trip. Dates are ISO 8601.
|
||||
```
|
||||
|
||||
The capability object gates every verb on [the next page](./calling.md): only ask for what the server advertised. `getInstructions()` is the server's usage guide for the model — put it in the system prompt.
|
||||
|
||||
## Disconnect cleanly
|
||||
|
||||
Over Streamable HTTP, terminate the server-side session, then close the client.
|
||||
|
||||
```ts source="../../examples/guides/clients/connect.examples.ts#connect_close"
|
||||
await transport.terminateSession();
|
||||
await client.close();
|
||||
```
|
||||
|
||||
`close()` tears down the transport and rejects every request still in flight with a `CONNECTION_CLOSED` error. `terminateSession()` returns without sending anything when the server never issued a session ID. On the other transports, `close()` alone is the whole teardown.
|
||||
|
||||
## Recap
|
||||
|
||||
- `new Client({ name, version })`, a transport, and `connect()` are the whole setup; `connect()` runs the `initialize` handshake.
|
||||
- `StreamableHTTPClientTransport` connects to remote servers; `StdioClientTransport`, from `@modelcontextprotocol/client/stdio`, spawns local ones; `SSEClientTransport` is the fallback for SSE-only servers.
|
||||
- `InMemoryTransport.createLinkedPair()` links a client and a server in one process.
|
||||
- After `connect()`, `getServerVersion()`, `getServerCapabilities()`, and `getInstructions()` return what the server declared.
|
||||
- `close()` tears down the transport and rejects in-flight requests.
|
||||
- Protocol-revision differences live on the protocol versions page, not here.
|
||||
106
docs/clients/machine-auth.md
Normal file
106
docs/clients/machine-auth.md
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
---
|
||||
shape: how-to
|
||||
---
|
||||
# Authenticate without a user
|
||||
|
||||
Protecting a server you run → [Require authorization](../serving/authorization.md). Authenticating an end user → [OAuth](./oauth.md). No user — a job, a backend, a service account → this page.
|
||||
|
||||
## Authenticate with client credentials
|
||||
|
||||
`ClientCredentialsProvider` runs the OAuth `client_credentials` grant from a `client_id` and `client_secret`. Pass it as the transport's `authProvider` — every flow on this page plugs into that same option.
|
||||
|
||||
```ts source="../../examples/guides/clients/machine-auth.examples.ts#clientCredentials_connect"
|
||||
import { Client, ClientCredentialsProvider, StreamableHTTPClientTransport } from '@modelcontextprotocol/client';
|
||||
|
||||
const authProvider = new ClientCredentialsProvider({
|
||||
clientId: 'reporting-job',
|
||||
clientSecret: 'reporting-job-secret'
|
||||
});
|
||||
|
||||
const client = new Client({ name: 'reporting-job', version: '1.0.0' });
|
||||
const transport = new StreamableHTTPClientTransport(new URL('https://api.example.com/mcp'), { authProvider });
|
||||
|
||||
await client.connect(transport);
|
||||
```
|
||||
|
||||
`connect` discovers the server's authorization server, posts the grant to its token endpoint, and attaches the access token to every request. On a 401 the provider refreshes the token and the transport retries once. No browser, no end user.
|
||||
|
||||
::: tip
|
||||
Pass `expectedIssuer` to pin the credential to the authorization server it was registered with. If discovery resolves a different issuer, the SDK throws `AuthorizationServerMismatchError` instead of sending the secret.
|
||||
:::
|
||||
|
||||
## Bring your own bearer token
|
||||
|
||||
When something outside the SDK already owns the token — an API key, a gateway, a platform secret store — implement `AuthProvider` with only `token()`.
|
||||
|
||||
```ts source="../../examples/guides/clients/machine-auth.examples.ts#bearerToken_provider"
|
||||
const authProvider: AuthProvider = { token: async () => getStoredToken() };
|
||||
|
||||
const transport = new StreamableHTTPClientTransport(new URL('https://api.example.com/mcp'), { authProvider });
|
||||
```
|
||||
|
||||
The transport calls `token()` before every request and sets the `Authorization` header from whatever it returns. Without `onUnauthorized`, a 401 throws `UnauthorizedError`. Add `onUnauthorized(ctx)` to refresh the credential and the transport retries the request once.
|
||||
|
||||
## Sign with a private key instead of a secret
|
||||
|
||||
`PrivateKeyJwtProvider` runs the same `client_credentials` grant, but authenticates the token request with a signed JWT assertion (`private_key_jwt`, RFC 7523) in place of a shared secret.
|
||||
|
||||
```ts source="../../examples/guides/clients/machine-auth.examples.ts#privateKeyJwt_provider"
|
||||
const authProvider = new PrivateKeyJwtProvider({
|
||||
clientId: 'reporting-job',
|
||||
privateKey: pemEncodedKey,
|
||||
algorithm: 'RS256'
|
||||
});
|
||||
|
||||
const transport = new StreamableHTTPClientTransport(new URL('https://api.example.com/mcp'), { authProvider });
|
||||
```
|
||||
|
||||
`privateKey` accepts a PEM string, a `Uint8Array`, or a JWK object. The provider signs a fresh assertion for every token request; `jwtLifetimeSeconds` overrides the 300-second default, and `claims` merges extra claims into the assertion.
|
||||
|
||||
## Act for an enterprise user with cross-app access
|
||||
|
||||
**Cross-app access** (Enterprise Managed Authorization, SEP-990) lets a service reach an MCP server for a user who already authenticated with the enterprise IdP, with no second consent screen. Two exchanges get it there: the IdP ID Token becomes a **JWT Authorization Grant** (RFC 8693), and that grant becomes an MCP access token (RFC 7523).
|
||||
|
||||
`CrossAppAccessProvider` runs the second exchange. Your `assertion` callback supplies the grant — here `discoverAndRequestJwtAuthGrant` performs the first exchange against the IdP.
|
||||
|
||||
```ts source="../../examples/guides/clients/machine-auth.examples.ts#crossAppAccess_provider"
|
||||
const authProvider = new CrossAppAccessProvider({
|
||||
assertion: async ctx => {
|
||||
const grant = await discoverAndRequestJwtAuthGrant({
|
||||
idpUrl: 'https://idp.example.com',
|
||||
audience: ctx.authorizationServerUrl,
|
||||
resource: ctx.resourceUrl,
|
||||
idToken: await getIdToken(),
|
||||
clientId: 'idp-exchange-client',
|
||||
clientSecret: 'idp-exchange-secret',
|
||||
scope: ctx.scope,
|
||||
fetchFn: ctx.fetchFn
|
||||
});
|
||||
return grant.jwtAuthGrant;
|
||||
},
|
||||
clientId: 'reporting-job',
|
||||
clientSecret: 'reporting-job-secret'
|
||||
});
|
||||
|
||||
const transport = new StreamableHTTPClientTransport(new URL('https://api.example.com/mcp'), { authProvider });
|
||||
```
|
||||
|
||||
The SDK discovers the MCP server's authorization server and resource URL (RFC 9728) before it calls `assertion`, then hands them in on `ctx` together with the negotiated `scope` and the transport's `fetchFn`. Pass them through so the IdP issues a grant bound to the right audience and resource.
|
||||
|
||||
## Drop to the token-exchange utilities
|
||||
|
||||
Both exchanges behind `CrossAppAccessProvider` are exported as standalone functions for flows the provider does not cover — caching grants across transports, a non-standard IdP step, your own token store.
|
||||
|
||||
- `requestJwtAuthorizationGrant` exchanges an ID Token for a JWT Authorization Grant at a known IdP token endpoint (RFC 8693).
|
||||
- `discoverAndRequestJwtAuthGrant` performs the same exchange, discovering the IdP's token endpoint from `idpUrl` first.
|
||||
- `exchangeJwtAuthGrant` exchanges a JWT Authorization Grant for an access token at the MCP server's authorization server (RFC 7523).
|
||||
|
||||
All three live in [`client/crossAppAccess`](../api/@modelcontextprotocol/client/client/crossAppAccess.md) in the API reference.
|
||||
|
||||
## Recap
|
||||
|
||||
- Every flow on this page plugs in through the same `authProvider` option on `StreamableHTTPClientTransport`.
|
||||
- `ClientCredentialsProvider` runs the `client_credentials` grant with a shared secret; `PrivateKeyJwtProvider` runs the same grant with a signed JWT assertion in its place.
|
||||
- An `AuthProvider` with only `token()` is enough when something outside the SDK owns the token; without `onUnauthorized`, a 401 throws `UnauthorizedError`.
|
||||
- `CrossAppAccessProvider` chains an enterprise IdP token through a JWT Authorization Grant to an MCP access token (SEP-990), and both exchanges are exported standalone.
|
||||
- Authenticating an end user belongs on [OAuth](./oauth.md).
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue