Deuz SDK 2.0 is out — stores, guardrails, handoffs, and zero-config MCP. What is new in 2.0
Deuz SDK

MCP

Model Context Protocol — zero-config servers in the loop, OAuth 2.0, sampling, roots, reconnect, and a connection pool.

The MCP module connects to a Model Context Protocol server, lists its tools, and maps them into a canonical ToolSet. Tool execution proxies transparently to the server — when the model calls an MCP tool, the SDK invokes it over the live connection and feeds the result back into the loop.

In 2.0 you usually do not build a client at all:

zero-config.ts
import { generateText } from '@deuz-sdk/core';

const { text } = await generateText({
  model,
  messages,
  maxSteps: 6,
  mcp: [{ url: 'https://mcp.example.com/mcp' }],
});

The loop connects, lists the tools, merges them into tools, hot-refreshes on a tools/list_changed notification, and closes what it opened when the run ends.

Beyond tools, a client exposes the server's resources and prompts, answers server-initiated elicitation and sampling requests, and advertises roots (all per the 2025-11-25 MCP revision).

@modelcontextprotocol/sdk is a lazy optional peer (^1.29.0): imported with a dynamic import() only when you actually connect, so the edge bundle never pulls it in unless you use MCP. Install it yourself:

npm i @modelcontextprotocol/sdk

If it is missing at runtime, the call rejects with an InvalidRequestError telling you to install it.

Which path do I want?

Four ways to get MCP tools into a run. They differ in who owns the connection, which is the only decision that actually matters.

SituationUseConnection lifetime
A script, a job, one request — nothing to keep warmmcp: [{ url }]Opened and closed by the run. One handshake per call.
A server process handling many requests against the same few serversmcp: [{ url }] + deps.mcpPoolOpened once by the pool, reused across requests, closed at shutdown.
You need listResources / getPrompt / setRoots, or one connection has to outlive many runscreateMcpClient(), then mcp: [{ client }] (or spread await client.listTools() into tools)Yours. You call close().
A local server launched as a child processmcp: [{ command }] (Node) or createStdioMcpClient()Same split: config = the run's, a live client = yours.

Two situations where the answer is "none of the above":

  • generateObject / streamObject reject mcp. They are single-shot by design; connecting servers only to drop their tools would pay a full handshake for nothing. Passing it is a caller error, not a silent no-op.
  • A run that only sets mcp, with no tools of its own, still routes through the agentic loop — that is where the tool round-trip lives.

Start at the top of the table and move down only when a row's limitation actually bites you. A pool is not an optimization you need before you have concurrency; a hand-built client is not required before you need a method the loop does not call.

Transports

SubpathFunctionTransportRuntime
@deuz-sdk/core/mcpcreateMcpClienthttp (Streamable HTTP) or sse (legacy)Edge-safe (fetch-only, no Node builtins)
@deuz-sdk/core/mcp/stdiocreateStdioMcpClientstdio (spawns a child process)Node-only
@deuz-sdk/core/mcp/nodecreateFileTokenStore, createLoopbackRedirect— (OAuth helpers)Node-only

Use http for remote servers — it is the current Streamable HTTP transport. sse is the legacy fallback some servers (e.g. Firecrawl's /v2/sse) still expose. The stdio transport spawns a local command (npx -y firecrawl-mcp) and talks over stdin/stdout, so it only works in Node and lives on its own subpath to keep the edge core free of Node builtins.

There is no WebSocket transport. A WebSocket server needs a custom McpClient implementation.


Zero-config MCP

CommonCallOptions.mcp takes an array of entries. The MCP code is reached through a dynamic import, so a call without this option pulls none of it into the bundle.

Entry kinds

type McpLoopEntry =
  | McpHttpLoopConfig     // { url, type?, headers?, auth?, namespace?, onElicitationRequest? }
  | McpStdioLoopConfig    // { command, args?, env?, namespace? }
  | McpClientLoopEntry    // { client, namespace? }
  | McpClient;            // a bare, already-connected client
mixed-entries.ts
await generateText({
  model, messages, maxSteps: 8,
  mcp: [
    { url: 'https://search.example.com/mcp', headers: { Authorization: `Bearer ${token}` } },
    { command: 'npx', args: ['-y', 'firecrawl-mcp'], env: { FIRECRAWL_API_KEY: key } },
    { client: longLivedClient, namespace: 'internal' },
  ],
  tools: { now },   // your own tools ALWAYS win a name collision
});

sampling and roots are not loop-entry fields. A config entry accepts auth and onElicitationRequest; everything else a server can ask you for is declared at construction, so it needs createMcpClient() and a { client } entry.

Ownership: who closes what

The single most common MCP bug is closing something you borrowed, or leaking something you opened. The rule is mechanical:

EntryOpened byClosed byWhat you write
{ url } / { command } configthe runthe run, at every exit — including a failed connect and a thrown toolnothing
{ url } / { command } with deps.mcpPoolthe poolthe pool — never the run, not even on failurepool.close() on shutdown
a live McpClient (bare or { client })youyouawait client.close() in a finally
a client you built and never handed to mcpyouyousame

Closing a long-lived connector because one request finished is the bug this rule exists to prevent — so closeOwned() filters on ownership rather than on "everything the run touched". The mirror image is just as real: a createMcpClient() you forget to close() keeps an HTTP session (or, on stdio, a child process) alive for the life of the host process.

Namespacing

Tool names travel to providers that accept [A-Za-z0-9_-] only, so a prefix has to be sanitized. The rule, in order:

  1. an explicit namespace always wins;
  2. a single entry gets no prefix at allmcp: [{ url }] should read like tools: {…};
  3. otherwise the server's own handshake name (serverInfo().name), with runs of anything else collapsed to _ and leading/trailing _ trimmed;
  4. and mcp{index} when the server sent no name, or when its name sanitizes to nothing.

Prefixed names are `${namespace}_${tool}`. Two servers exporting the same final name is not fatal: the later entry wins and the collision is logged — failing a run over a name clash would be worse than an over-specific tool.

Rule 2 is a trap when the entry list grows

Adding a second server silently renames every tool of the first one, because rule 2 stops applying. A model prompted with search_query now sees github_search_query, and any activeTools list or hasToolCall('…') stop condition written against the old name stops matching. Set an explicit namespace on day one if the list might ever grow.

Fail fast

A server that cannot be reached rejects the whole call rather than quietly running with a smaller tool set — a half-connected agent produces confidently wrong answers, which is worse than an actionable error. Whatever the call already opened is closed before the rejection propagates, so a failure leaks no connections.

generateText surfaces that as the call's rejection; streamChat resolves it inside the pump, so it becomes an error part and the never-throw contract is preserved.

Hot refresh

Every client — borrowed ones included — is subscribed to tools/list_changed. At the next step boundary the loop re-reads the catalogs and rebuilds the wire tool lists. A failed tools/list re-arms the dirty flag and keeps the previous catalog: one failed refresh must not end a healthy run.

Config-based connections default to reconnect: true, because an agentic run outlives a single request and a dropped session in the middle of step 4 would otherwise fail every remaining tool call. A client you built keeps whatever lifecycle you chose.

deps.mcpPool — for server processes

Without a pool, every request that passes mcp: [{ url }] pays a fresh handshake and then closes it. createMcpPool() is a process-lifetime cache:

pool.ts
import { createMcpPool } from '@deuz-sdk/core/mcp';

const mcpPool = createMcpPool();               // once, at startup

export async function handler(req: Request) {
  return generateText({
    model, messages, maxSteps: 6,
    mcp: [{ url: 'https://mcp.example.com/mcp' }],
    deps: { mcpPool },
  });
}

process.on('SIGTERM', () => mcpPool.close());  // the pool's owner closes it

Keyed by the config's structure, so the same { url } handed to a hundred requests handshakes once. namespace is deliberately excluded from the key — it is a presentation choice about tool names, not a property of the connection — while functions and class instances (an elicitation handler, a DeuzOAuthProvider, a token store) get identity-based keys, because two configs identical except for their handler are not the same connection.

The in-flight promise is what is cached, so concurrent requests for a cold server share one connect instead of racing several. A failed connect is evicted (the next call retries rather than replaying a dead rejection), and so is an entry whose session has since gone closed / error — a pool that hands back a corpse is worse than no pool.

Identity-based keys are why a new object per request is a leak: build the value once, at module scope. A config carrying a fresh elicitation handler on every call mints a fresh connection every call. The pool says so once, through createMcpPool({ logger }).

It bounds that rather than trusting it: maxSize defaults to 32 entries and evicts the least recently acquired one, closing it. The trade is real — the pool cannot see which clients a run is still using, so a session evicted mid-run takes that run's remaining MCP tool calls down with it. Keep the ceiling comfortably above the number of distinct servers in flight.


createMcpClient

import { createMcpClient } from '@deuz-sdk/core/mcp';

const client = await createMcpClient(options);

async: it loads the SDK, builds the transport and connects before resolving.

OptionTypeDefaultNotes
transport.type'http' | 'sse'http = Streamable HTTP; sse = legacy fallback.
transport.urlstringThe server endpoint.
transport.headersRecord<string, string>Sent on every request.
name / versionstring'deuz' / '0.0.0'Advertised to the server in the handshake.
onElicitationRequestMcpElicitationHandlerElicitation. Declares the capability.
authMcpOAuthOptions | DeuzOAuthProviderOAuth 2.0.
authorizationCodestringThe ?code= from the redirect — exchanged before connecting. Requires auth.
samplingMcpSamplingOptionsSampling. Declares the capability.
rootsMcpRootsOptionRoots. Declares the capability.
reconnectboolean | McpReconnectPolicyoffReconnect.
onStatusChange(status, info?) => voidObserve every transition. A throwing callback is swallowed.
keepAliveMsnumberoffChained ping() heartbeat. A rejected ping counts as a drop.
clockClockhost clockTime source for backoff + keepalive (edge-safety: never ambient).

createStdioMcpClient (@deuz-sdk/core/mcp/stdio) takes the same lifecycle options plus command / args / env, and supports onElicitationRequest, sampling and roots identically. A reconnect respawns the child process — the old one died with the pipe.

Capabilities are declared at construction, so handlers register before connect

onElicitationRequest, sampling and roots each declare a client capability in the constructor and register their handler before connect(). Declaring a capability without a handler would lie to servers, so there is no way to attach one afterwards on a managed connection — a reconnect builds a new client, and only what the factory registers comes back with it.

Presence is tested with !== undefined everywhere: roots: [] is a value ("no roots"), not an absent option.

The McpClient

interface McpClient {
  listTools(namespace?: string): Promise<ToolSet>;
  callTool(name: string, args: unknown): Promise<unknown>;
  listResources(): Promise<McpResource[]>;
  readResource(uri: string): Promise<McpResourceContent[]>;
  listPrompts(): Promise<McpPrompt[]>;
  getPrompt(name: string, args?: Record<string, string>): Promise<McpGetPromptResult>;
  status(): McpConnectionStatus;
  serverInfo?(): { name: string; version?: string } | undefined;
  onToolListChanged(cb: () => void): () => void;
  close(): Promise<void>;
}

Both factories return McpRootsClient — the above plus setRoots(roots: string[]).

MethodNotes
listTools(namespace?)Maps the server's tools into canonical Tools. Served from a cache that only invalidation clears.
callTool(name, args)Direct invocation. The loop never needs it — each mapped tool's execute calls it.
listResources() / listPrompts()Auto-paginated (cursor handled for you, capped at 100 pages against endless cursors).
readResource(uri)Contents array: entries carry text or base64 blob plus mimeType.
getPrompt(name, args?)Messages come back in MCP's own shape, not canonical Message — map them yourself.
status()'connecting' | 'connected' | 'reconnecting' | 'closed' | 'error'. Always 'connected' for an unmanaged client.
serverInfo?()The handshake Implementation block. Read through the current session, so a reconnect onto a redeployed server reports the new name. Optional, so a hand-written client stays valid.
onToolListChanged(cb)Fires on tools/list_changed or a reconnect landing on a possibly-different server. Returns an unsubscribe.
close()Closes the connection / terminates the child process. Always call it when you own the client.

The resource/prompt methods need SDK ^1.29.0 — an older installed SDK rejects with an actionable upgrade error.

How tools map

  • The MCP inputSchema is a JSON Schema, so it goes straight onto Tool.parameters. An outputSchema, when present, is carried onto Tool.outputSchema as metadata.
  • Each tool's execute proxies to the server via callTool. With a managed connection the call always targets the current client, so tools built before a reconnect keep working after it.
  • Structured results win: when the server returns structuredContent, execute returns that object verbatim (per spec, the text blocks are a redundant serialization). Otherwise the text blocks are joined into a string.
  • If the server marks the result as an error (isError), execute throws — which the tool loop catches and feeds back as an is_error tool result, so the model can self-heal rather than the call crashing.

OAuth 2.0

An MCP server that wants OAuth answers the first connect with a 401. The SDK implements the whole protocol — RFC 8414 metadata discovery, RFC 7591 dynamic client registration, PKCE (S256) and refresh — behind one seam whose only jobs are to say where the user comes back to and to persist four things. So this module writes a provider, never a flow: there is no token-endpoint call, no code-challenge derivation and no refresh timer in it.

The two-step flow

Connecting is two createMcpClient calls with a human in between. Nothing is retried in place, because the second call may well happen in a different process (a web request finishing a redirect, a CLI resuming after a browser round-trip):

① createMcpClient({ transport, auth })

   ├── tokens already in the store? ──────────────────────────────► connected ✓

   └── no tokens
         │  the SDK runs discovery (RFC 8414) → registration (RFC 7591) → PKCE,
         │  and writes `code-verifier:<serverUrl>` into your TokenStore

         ├── auth.onRedirect?.(url)          ← a push copy of the URL, if you set one
         └── throws McpAuthorizationRequiredError { serverUrl, authorizationUrl }

② your host shows `authorizationUrl` to the user
   │  (a web app 302s to it · a CLI prints it · a desktop shell launches it)
   │  the library NEVER opens a browser

   the user consents; the authorization server redirects to your `redirectUri?code=…`

③ createMcpClient({ transport, auth, authorizationCode: code })
   │  transport.finishAuth(code) exchanges it BEFORE connect, using the stored
   │  verifier, and saves `tokens:<serverUrl>`
   └──────────────────────────────────────────────────────────────► connected ✓
oauth-two-step.ts
import { createMcpClient } from '@deuz-sdk/core/mcp';
import { McpAuthorizationRequiredError } from '@deuz-sdk/core';

const transport = { type: 'http', url: 'https://mcp.example.com/mcp' } as const;
const auth = { redirectUri: 'https://app.example.com/oauth/callback', store };

let client;
try {
  // 1st attempt — tokens already in the store? straight through.
  client = await createMcpClient({ transport, auth });
} catch (err) {
  if (!(err instanceof McpAuthorizationRequiredError)) throw err;

  // The SDK already ran discovery + registration, so the URL exists.
  redirect(err.authorizationUrl!);            // your host decides HOW
  const authorizationCode = await waitForCallbackCode();

  // 2nd attempt — the code is exchanged BEFORE connecting.
  client = await createMcpClient({ transport, auth, authorizationCode });
}

McpAuthorizationRequiredError (from @deuz-sdk/core) carries serverUrl and, when a provider was configured, authorizationUrl. It is a plain serializable error, which matters: the process that shows the URL to a user is often not the process that connects.

Facts that decide whether your flow works:

  • The authorization code is single-use, so it is spent on the first attempt only. A reconnect re-reads whatever tokens it bought.
  • The PKCE verifier lives in your TokenStore. Steps ① and ③ must therefore see the same store. inMemoryTokenStore() is fine within one process and cannot possibly work across two.
  • Passing auth as plain options mints a fresh provider per call. That is safe precisely because all the state is in the store — but it also means provider.authorizationUrl() is not something you can read back later; take the URL off the error.
  • onRedirect does not replace the throw. It is a notification, delivered the moment the SDK produces the URL. The connect still rejects with McpAuthorizationRequiredError (carrying the same URL string), so a catch is required either way.

Or: completeAuth(code)

When you build the provider yourself you can finish the exchange without re-entering createMcpClient:

complete-auth.ts
import { createOAuthProvider, createMcpClient } from '@deuz-sdk/core/mcp';

const provider = createOAuthProvider({
  redirectUri: 'https://app.example.com/oauth/callback',
  store,
  onRedirect: (url) => sendToUser(url),   // pushed as soon as it exists
});

try {
  client = await createMcpClient({ transport, auth: provider });
} catch (err) {
  const code = await waitForCallbackCode();
  await provider.completeAuth(code);      // exchange + persist
  client = await createMcpClient({ transport, auth: provider });
}

One provider can serve many servers: every stored key is namespaced by server URL, so sharing a provider across mcp: [{ url: a, auth }, { url: b, auth }] never crosses tokens.

interface DeuzOAuthProvider {
  readonly provider: unknown;              // the SDK-side OAuthClientProvider — pass it through, do not inspect it
  authorizationUrl(): URL | undefined;
  completeAuth(code: string): Promise<void>;
  tokens(): Promise<Record<string, unknown> | undefined>;
  invalidate(): Promise<void>;             // drop tokens → next connect re-authorizes
}

completeAuth needs to know which server it is finishing an exchange for. createMcpClient binds that for you on the first attempt (and persists it as server-url); a provider that has never been handed to a client rejects with an actionable error instead of guessing.

invalidate() drops the tokens and the verifier but deliberately keeps the dynamic client registration — it is not a credential the server rejected, and re-registering on every expiry would litter the authorization server with dead clients.

createOAuthProvider options

OptionTypeNotes
redirectUristringRequired. Must match the registered client.
clientIdstringPre-registered client. Omit to let dynamic registration (RFC 7591) mint one — a configured clientId short-circuits registration entirely.
clientSecretstringConfidential clients. Omit for public (PKCE) clients.
scopestringSpace-separated scopes.
clientMetadataRecord<string, unknown>Extra fields for the registration body; your fields win over the defaults.
storeTokenStoreDefault: inMemoryTokenStore() — tokens die with the process.
onRedirect(url: URL) => void | Promise<void>Called with the authorization URL as soon as the SDK produces it. A notification, not a substitute for catching McpAuthorizationRequiredError.

The TokenStore seam

Deliberately the smallest possible surface — a scoped key/value map — so a Map, localStorage, a Supabase row or a KV namespace all satisfy it without an adapter. Every method may be sync or async.

interface TokenStore {
  get(key: string): Promise<string | undefined> | string | undefined;
  set(key: string, value: string): Promise<void> | void;
  delete(key: string): Promise<void> | void;
}

Four namespaced keys per server, so one store backs many:

tokens:<serverUrl>         the access/refresh token pair (SECRET)
client-info:<serverUrl>    what dynamic registration minted
code-verifier:<serverUrl>  the in-flight PKCE verifier (short-lived)
server-url                 the server the last authorization was started for

server-url survives every invalidation: it is a binding, not a credential, and the process that carries the ?code= back needs it to know which exchange it is completing.

These values are live refresh tokens

A file-backed store must create the file 0600. A browser-storage-backed one is only appropriate for a server the user alone controls. inMemoryTokenStore() is the safe default precisely because it forgets everything on exit.

Node CLI: a loopback redirect and a file store

@deuz-sdk/core/mcp/node ships the two things a CLI or desktop flow needs and a browser-safe runtime cannot provide.

cli-oauth.ts
import { homedir } from 'node:os';
import { createFileTokenStore, createLoopbackRedirect } from '@deuz-sdk/core/mcp/node';
import { createMcpClient } from '@deuz-sdk/core/mcp';
import { McpAuthorizationRequiredError } from '@deuz-sdk/core';

const loopback = await createLoopbackRedirect();          // 127.0.0.1:<free port>/callback
const transport = { type: 'http', url } as const;
const auth = {
  redirectUri: loopback.redirectUri,
  store: createFileTokenStore({ path: `${homedir()}/.deuz/mcp-tokens.json` }),
};

let client;
try {
  client = await createMcpClient({ transport, auth });
} catch (err) {
  if (!(err instanceof McpAuthorizationRequiredError)) throw err;

  // REQUIRED: the listener only accepts a redirect that echoes ITS state back.
  const authorize = new URL(err.authorizationUrl!);
  if (loopback.state) authorize.searchParams.set('state', loopback.state);

  console.log('Open this URL to authorize:\n' + authorize.toString());
  const authorizationCode = await loopback.waitForCode();
  client = await createMcpClient({ transport, auth, authorizationCode });
} finally {
  await loopback.close();
}

Forwarding `loopback.state` is not optional

createLoopbackRedirect() mints a fresh 256-bit state and refuses any redirect that does not echo it (400, compared in constant time, and the wait is left running so a forgery cannot cancel a real redirect that is still in flight). Any process on the machine — or any page open in the user's browser — can reach a loopback port, so without the echo the listener would accept an authorization code of someone else's choosing (RFC 6819 §4.4.1.5 code injection).

Hand it a value you already minted with state: '…', or turn the check off with state: false only when the authorization request genuinely cannot carry state.

createLoopbackRedirect({ port?, path?, timeoutMs?, state? }) is a single-shot listener bound to 127.0.0.1 — not localhost (which can resolve to ::1 first, leaving an IPv4 redirect with nothing listening) and never 0.0.0.0 (a wildcard would take an authorization code off the LAN). The timeout defaults to 5 minutes, measured from creation rather than from waitForCode(), because the redirect URI is live the moment it resolves. close() is idempotent and safe in a finally; a bind failure on an explicit port rejects instead of hanging.

createFileTokenStore writes one JSON file, created 0600, through a temp file and a rename — a crash mid-write can never tear the existing tokens. Every operation is chained onto the previous one, because set is a read-modify-write and the SDK's token save and verifier save race routinely. A file that is missing, unreadable or invalid JSON reads as empty rather than throwing: re-authorizing is recoverable, crashing a run on a half-written file is not.

Edge: a KV store scoped per user

On an edge runtime there is no loopback and no filesystem: the redirect is an ordinary route in your app, and the store is three lines over whatever KV you have.

kv-token-store.ts
import type { TokenStore } from '@deuz-sdk/core/mcp';

// Cloudflare KV, Vercel KV, Upstash, a Supabase table — anything with get/put/delete.
export const kvTokenStore = (kv: KVNamespace, userId: string): TokenStore => ({
  get: (key) => kv.get(`mcp:${userId}:${key}`).then((v) => v ?? undefined),
  set: (key, value) => kv.put(`mcp:${userId}:${key}`, value),
  delete: (key) => kv.delete(`mcp:${userId}:${key}`),
});
edge-oauth-route.ts
import { generateText, McpAuthorizationRequiredError } from '@deuz-sdk/core';
import { createOAuthProvider } from '@deuz-sdk/core/mcp';

const MCP_URL = 'https://mcp.example.com/mcp';
const authOptions = (env: Env, userId: string) => ({
  redirectUri: `${env.APP_URL}/oauth/callback`,
  store: kvTokenStore(env.KV, userId),
});

// POST /chat — run normally, or bounce the user into consent.
export async function chat(req: Request, env: Env, userId: string) {
  try {
    const { text } = await generateText({
      model, messages, maxSteps: 6,
      mcp: [{ url: MCP_URL, auth: authOptions(env, userId) }],
    });
    return Response.json({ text });
  } catch (err) {
    if (!(err instanceof McpAuthorizationRequiredError)) throw err;
    // Step ② — the client cannot show a URL to a user; your app can.
    return Response.json({ authorizeUrl: err.authorizationUrl }, { status: 428 });
  }
}

// GET /oauth/callback?code=… — step ③, in a completely different request.
export async function callback(req: Request, env: Env, userId: string) {
  const code = new URL(req.url).searchParams.get('code')!;
  // The KV already holds this user's `code-verifier:` and `server-url`, so a
  // fresh provider can finish the exchange without reconnecting to anything.
  await createOAuthProvider(authOptions(env, userId)).completeAuth(code);
  return Response.redirect(`${env.APP_URL}/chat`, 302);
}

Scoping the KV namespace by user is the whole trick: the provider already namespaces by server, so one KV holds every user × every server without collisions. Encrypt at rest if your KV does not. Two caveats this recipe leans on:

  • completeAuth resolves the server from server-url, which records the last server bound for that store. With more than one MCP server per user, finish the exchange with createMcpClient({ transport, auth, authorizationCode }) instead — that binds the server explicitly before exchanging.
  • Add your own CSRF state to the authorization URL before handing it to the browser, and check it on the callback route. An edge redirect route is exactly as reachable as a loopback port, and nothing mints a state for you here the way createLoopbackRedirect() does.

In a zero-config entry

McpHttpLoopConfig.auth takes the same union, so the loop can run the flow for you:

mcp: [{ url: 'https://mcp.example.com/mcp', auth: { redirectUri, store, onRedirect } }]

A first run with no stored tokens rejects the call with McpAuthorizationRequiredError. Sharing one DeuzOAuthProvider across several entries shares its store and its registration — and, with a pool, keeps them on one connection per provider identity, which is why the provider must be per user and not per request.


Reconnect, status and keepalive

All of it is additive and off by default: leaving these options unset reproduces the 1.x connect-once behaviour exactly, where a dropped session stays dropped.

const client = await createMcpClient({
  transport: { type: 'http', url },
  reconnect: { maxAttempts: 5, initialDelayMs: 500, maxDelayMs: 30_000, factor: 2, jitter: 0.25 },
  keepAliveMs: 30_000,
  onStatusChange: (status, info) => log(status, info?.attempt, info?.error),
});
StatusMeaning
connectingThe first handshake. Emitted even though it is the initial value, so a listener sees the whole trace.
connectedLive.
reconnectingA backoff window after an unexpected drop; info.attempt is 1-based.
errorA drop whose attempts are exhausted.
closedAn explicit close() — or any drop when reconnect is off.

reconnect: true takes the defaults above. The initial connect is never retried: a server that is wrong or unreachable right now should reject createMcpClient() the way it always did. Reconnect covers the other half — a session that was healthy and died under you.

This is session-level recovery: every attempt builds a new transport and a new client through the same factory, so every handler is re-registered and nothing is silently lost. It composes with StreamableHTTPClientTransport's own reconnectionOptions, which resume the GET event stream inside a session that is still alive — that never fires this state machine, because the session never actually died.

Backoff is min(maxDelayMs, initialDelayMs × factor ** (attempt − 1)) spread by ±jitter. The spread is derived from the injected clock (FNV-1a), so core never reaches for Math.random() and a fake clock keeps delays reproducible; jitter: 0 makes them exact.

How a dead HTTP session is detected

This is the part that changed in 2.0, and it is worth reading before you tune anything.

StreamableHTTPClientTransport never calls onclose when its socket dies. It reports through onerror and quietly keeps retrying its own GET event stream. So the onclose recovery path never saw a dead HTTP session: status() answered connected and reconnect did nothing — unless a keepAliveMs heartbeat happened to be configured to notice.

Wiring onerror straight into the drop path would over-correct. The same callback fires for an unparseable SSE frame, one failed event-stream retry, and a message the transport could not decode — none of which end the session, and tearing a healthy connection down over those would be a worse bug than the one it fixes.

So a transport error only asks the question, and a single ping() answers it:

EventVerdict
onerror on a live session, ping() succeedsNot a drop. The session is kept.
onerror on a live session, ping() failsA drop. Recovery starts, reporting the original transport error (it says what actually happened, not what the ping found).
onerror, but the SDK/client has no ping()The 1.x behaviour stands: stay quiet rather than kill a live session on a transient error.
onerror before the handshake completesRecorded, not acted on. A failing connect() unwinds through its own caller, which already owns the retry.
keepAliveMs heartbeat rejectsA drop. The same path.
onclose (stdio: the child process died)A drop.

Two latches keep that honest: one probe in flight per generation, and one drop per session — the generation counter alone is not enough, because a heartbeat ping and an onerror probe both in flight when the server goes down would otherwise each start their own reconnect ladder, and maxAttempts would bound neither.

So: reconnect now stands on its own over HTTP. keepAliveMs remains the way to catch a session that dies silently, with no error at all — a load balancer dropping an idle connection, a server restarting between requests. The heartbeat is chained, not periodic, so a slow server can never stack pings.

A successful reconnect marks the tool list dirty: the server you came back to may not be the one you left.


Sampling

The server writes a prompt; your model answers it and you pay for the tokens. Treat it as capability delegation, not a callback.

sampling.ts
const client = await createMcpClient({
  transport: { type: 'http', url },
  sampling: {
    model: anthropic('claude-haiku-5'),
    maxTokens: 2048,                    // a CEILING — a request can only lower it
    approve: async (req) => {
      // req.messages are already canonical, so this reviews exactly what the model will receive
      return ui.confirm(req.systemPrompt, req.messages);
    },
  },
});
interface McpSamplingRequest {
  messages: Message[];        // canonical, already mapped from the MCP wire shape
  systemPrompt?: string;
  maxTokens: number;          // the server's ask, clamped by your ceiling
  temperature?: number;
  stopSequences?: string[];
  modelPreferences?: Record<string, unknown>;  // MCP's hints, forwarded unread
}

Sampling drives your model and spends your money

Configuring sampling hands a remote server the ability to run inference on your key, with your credits, inside your rate limits — and the prompt is written entirely by that server. approve and the maxTokens ceiling exist for exactly that reason: they are the only two things standing between a compromised or greedy server and your bill. A server you do not fully control should have both.

The blast radius is bounded but not zero: the sample runs as a plain single-turn generateText with no tools, so a server cannot reach your tool set through this door — but it can read whatever your systemPrompt and messages contain, because it wrote them.

Facts worth pinning:

  • The capability is declared only when you configure sampling. A server cannot ask a client that never advertised it.
  • approve is the HITL gate and it runs first, so a refused request never reaches the provider. Returning false — or throwing — refuses it; the SDK turns either into a JSON-RPC error back to the server, so a refusal costs nothing and is visible upstream. A throw propagates verbatim, because its message is more useful to the server than one we would invent.
  • The request is reviewed in canonical shape. An approval gate that had to re-parse the MCP wire shape would be one more place to get the mapping wrong.
  • Your model always wins. modelPreferences is forwarded into the request object for your approve to read; it never selects a model.
  • maxTokens is a clamp, never a raise. A server that omits it gets a 1024-token default — never sample unbounded.
  • It runs through our generateText, so deps, keys, middleware, retries and observation all behave exactly as they do everywhere else.
  • The answer goes back as MCP's single text block with a mapped stopReason (lengthmaxTokens, stop_sequencestopSequence, everything else → endTurn, the honest floor since MCP has no vocabulary for tool calls or content filters).

Roots

Directories the server may operate on (roots/list).

const client = await createMcpClient({
  transport: { type: 'http', url },
  roots: ['/srv/app', 'file:///var/data'],   // or a function, re-read on every request
});

await client.setRoots(['/srv/app/v2']);      // swaps the list AND notifies the server

file:// only — and one bad entry poisons the whole list

MCP's RootSchema pins uri to z.string().startsWith('file://'), and roots/list answers with an array the server validates as a whole. So a single https:// entry makes the server discard every root you sent — and the failure lands as a Zod error on the far side of the wire, where nothing can act on it.

The SDK therefore refuses a non-file:// URI where you supplied it, with an error naming the offender. A bare filesystem path is not a URI at all, so it is promoted for you: backslashes flipped, file:// prepended (C:\workfile://C:/work). A scheme of at least two characters is what distinguishes a URI from a Windows drive letter.

Notes:

  • roots: [] is a value — an explicit "no roots" — and declares the capability. Absence does not.
  • A function form is re-read on every roots/list request, so a dynamic list needs no re-registration. Its entries are validated at read time (the array form is validated at construction, so a bad literal rejects createMcpClient() outright).
  • setRoots() throws when the client was created without a roots option: the capability is declared at construction, and announcing a change to something never advertised would lie to the server. It validates before swapping, so a refused call leaves the server's view untouched.
  • With a managed connection, setRoots notifies the current session, so it survives a reconnect.
  • Roots are a declaration, not a sandbox. The server decides what to do with them; nothing here confines a server that ignores the list.

Elicitation

Servers can pause mid-operation and ask the user for input (elicitation/create). The request is a two-mode union:

const mcp = await createMcpClient({
  transport: { type: 'http', url: 'https://mcp.example.com/mcp' },
  onElicitationRequest: async (req) => {
    if (req.mode === 'form') {
      // req.requestedSchema: flat object, primitive props — render a form.
      const content = await showForm(req.message, req.requestedSchema);
      return content ? { action: 'accept', content } : { action: 'decline' };
    }
    // req.mode === 'url': show req.url to the user and ask for consent.
    // NEVER auto-open or prefetch it (spec requirement). accept = consent
    // only — the interaction completes out-of-band on the server's side.
    const consented = await confirmOpenUrl(req.message, req.url);
    return { action: consented ? 'accept' : 'decline' };
  },
});

Return { action: 'accept', content? }, { action: 'decline' }, or { action: 'cancel' }. Form-mode content must match requestedSchema. URL mode exists for sensitive flows (OAuth, payment, API keys) that must not pass through the client — treat the URL as untrusted, display the full host, and let the user decide.

Requests without a mode are form mode (spec back-compat). The lower-level registerElicitation(client, handler) is exported for attaching a handler to an already-connected raw MCP client; it throws on a peer older than ^1.29.0.


Building a client by hand

Still supported, and the right choice when one connection serves many requests, or when you need a method the loop never calls.

mcp-http.ts
import { generateText, tool } from '@deuz-sdk/core';
import { createMcpClient } from '@deuz-sdk/core/mcp';
import { z } from 'zod';

const search = await createMcpClient({
  transport: {
    type: 'http',
    url: 'https://search.example.com/mcp',
    headers: { Authorization: `Bearer ${process.env.MCP_TOKEN!}` },
  },
  reconnect: true,
  keepAliveMs: 30_000,
});
const docs = await createMcpClient({
  transport: { type: 'sse', url: 'https://docs.example.com/v2/sse' },
});

try {
  // A ToolSet is a plain record, so spread them. Namespace per server.
  const tools = {
    ...(await search.listTools('search')),   // search_query, …
    ...(await docs.listTools('docs')),       // docs_fetch, …
    now: tool({
      description: 'Return the current ISO timestamp.',
      parameters: z.object({}),
      execute: async () => ({ now: new Date().toISOString() }),
    }),
  };

  const { text, steps } = await generateText({
    model,
    messages: [{ role: 'user', content: 'What time is it, and what does example.com sell?' }],
    maxSteps: 6,
    tools,
  });
} finally {
  await Promise.all([search.close(), docs.close()]);
}

Or hand the live clients to the loop and let it namespace them — mcp: [{ client: search, namespace: 'search' }, { client: docs, namespace: 'docs' }]. They are borrowed, so close() stays yours, but you get the hot refresh and the merged tool set for free.

stdio (Node)

mcp-stdio.ts
import { createStdioMcpClient } from '@deuz-sdk/core/mcp/stdio';

const mcp = await createStdioMcpClient({
  command: 'npx',
  args: ['-y', 'firecrawl-mcp'],
  env: { FIRECRAWL_API_KEY: process.env.FIRECRAWL_API_KEY! },
  reconnect: true,   // a reconnect RESPAWNS the child process
});

Calling a tool directly

const result = await mcp.callTool('scrape', { url: 'https://example.com' });

If the server returns an error result, callTool throws — mirroring execute.

Common mistakes

SymptomCauseFix
InvalidRequestError: MCP support needs the optional peer …@modelcontextprotocol/sdk is not installed. It is a peer, never bundled.npm i @modelcontextprotocol/sdk
An edge/browser build fails resolving node:child_processSomething imported @deuz-sdk/core/mcp/stdio or /mcp/node. Both are Node-only by construction.Use { url } entries on edge; keep stdio behind a Node route.
Tools silently renamed after adding a second serverNamespace rule 2 ("a single entry gets no prefix") stopped applying.Set an explicit namespace per entry.
mcp: duplicate tool name '…' in the logTwo servers export the same final name; the later entry won.Give at least one of them a namespace.
The pool opens a connection per requestThe config's auth / onElicitationRequest is a fresh object each call, so the key falls back to identity.Hoist the provider/handler to module scope (per user, not per request). Pass createMcpPool({ logger }) to hear it once.
A CLI OAuth redirect shows "Authorization rejected"The authorization URL did not carry loopback.state.Forward it — see the Node CLI recipe.
No PKCE code verifier is stored …Steps ① and ③ used different stores, or an in-memory store across two processes.Use one persistent TokenStore for the whole flow.
A stdio server keeps running after the requestThe client was built by hand and never closed.await client.close() in a finally — or hand a { command } config to mcp and let the run own it.
generateObject throws about ignored loop optionsmcp (and guardrails) are refused there by design.Use generateText / streamChat, or drop the option.
A tool call fails mid-run with a closed sessionA pooled entry was evicted by maxSize while the run still held it.Raise maxSize above the number of distinct servers in flight.

Two more that produce no error at all:

  • Never cache the raw SDK client object. Its identity changes across a reconnect; the McpClient wrapper follows the session for you, and so does every ToolSet it produced.
  • close() is idempotent and raises the closed flag first, silencing reconnect and keepalive before anything awaits — so calling it from a finally on an already-closed client is free.

See also

On this page