Profiles·Public

@modelcontextprotocol/sdk

semver^1.0.0postconditions62functions39last verified2026-06-24coverage score91%

Postconditions: what we check

  • connect · connect-success
    info
    Whenwhen connection is established successfully
    Returnsvoid (connection is ready for use)
    Required handlingNo action required — use the returned value as needed.
    costmediumin prodimmediate exceptionusers seeservice unavailablevisibilityvisible
    Sources[1]
  • connect · connect-throws-on-failure
    error
    Whenwhen transport fails, spawn fails (stdio), protocol negotiation fails, or transport already connected
    ThrowsError — transport error, spawn error, or protocol mismatch
    Required handlingCaller MUST wrap connect() in a try-catch block. Connection failures are unrecoverable and must be surfaced to the caller. Log the error and stop further MCP operations on this client/server. For CLI tools, a top-level process.catch() on main() is acceptable. For HTTP-embedded servers, missing error handling crashes the request handler.
    costmediumin prodimmediate exceptionusers seeservice unavailablevisibilityvisible
    Sources[1][2]
  • callTool · call-tool-success
    info
    Whenwhen the tool executes successfully
    ReturnsCallToolResult with content array and isError: false
    Required handlingNo action required — use the returned value as needed.
    costmediumin prodimmediate exceptionusers seeservice unavailablevisibilityvisible
    Sources[3]
  • callTool · call-tool-throws-on-protocol-error
    error
    Whenwhen the MCP protocol fails (tool not found, transport disconnected, timeout)
    ThrowsMcpError or Error — protocol-level failure
    Required handlingCaller MUST wrap client.callTool() in a try-catch block. Protocol errors are thrown (not returned in-band) when the transport is broken or the tool does not exist on the server. These are distinct from in-band tool execution errors (result.isError). Both error paths must be handled.
    costmediumin prodimmediate exceptionusers seeservice unavailablevisibilityvisible
    Sources[1][4]
  • listTools · list-tools-success
    info
    Whenwhen request succeeds
    ReturnsObject with tools array listing available tools
    Required handlingNo action required — use the returned value as needed.
    costmediumin prodimmediate exceptionusers seeservice unavailablevisibilityvisible
    Sources[3]
  • listTools · list-tools-throws-on-failure
    error
    Whenwhen transport fails or server is disconnected
    ThrowsMcpError or Error — network or protocol failure
    Required handlingCaller MUST wrap client.listTools() in a try-catch block. Failures indicate the MCP server is unreachable or the connection was lost. Handle by reconnecting or disabling MCP features gracefully.
    costmediumin prodimmediate exceptionusers seeservice unavailablevisibilityvisible
    Sources[1]
  • close · close-discards-in-flight-requests
    warning
    Whenclose() is called while there are pending request() calls awaiting a response. In-flight requests receive McpError(ErrorCode.ConnectionClosed, 'Connection closed') instead of a response. This is confirmed from dist/cjs/shared/protocol.js: "const error = McpError.fromError(ErrorCode.ConnectionClosed, 'Connection closed')".
    ThrowsMcpError with code ErrorCode.ConnectionClosed (-32000) thrown into any pending request() awaiting a response. The close() call itself does not throw.
    Required handlingCallers MUST handle McpError from in-flight requests when the connection is closed. In teardown sequences (shutdown handlers, test cleanup), ensure all request promises are settled BEFORE calling close(). Common pattern: await Promise.allSettled([pendingRequests]); await client.close(); Do NOT fire-and-forget close() if there are active request workflows that need results.
    costlowin proddegraded serviceusers seedegraded performancevisibilitysilent
    Sources[2]
  • close · close-missing-on-server-shutdown
    error
    WhenMcpServer.connect() is called in a long-running process but close() is never called when the HTTP request ends (stateless Streamable HTTP) or when the user disconnects (stdio session). Transport resources (SSE connections, subprocess handles, timers) accumulate.
    ThrowsNo error thrown — resource leak is silent
    Required handlingCallers MUST call close() in all exit paths: HTTP response finalizers, process SIGTERM handlers, test afterEach/afterAll hooks. For Express/Next.js MCP servers using StreamableHTTPServerTransport, call close() at the end of each request handler. For stdio MCP servers, register process.on('SIGINT', ...) and process.on('SIGTERM', ...) handlers that call server.close().
    costmediumin proddegraded serviceusers seeservice unavailablevisibilitysilent
    Sources[2]
  • readResource · read-resource-missing-try-catch
    error
    Whenasync function calls client.readResource() without try-catch. The server may throw McpError for: resource URI not found (MethodNotFound -32601), server-side I/O failure (InternalError -32603), or protocol-level failures (timeout RequestTimeout -32001, connection closed ConnectionClosed -32000).
    ThrowsMcpError — base class for protocol errors. Key codes: - ErrorCode.MethodNotFound (-32601): resource URI does not exist on server - ErrorCode.InternalError (-32603): server-side error reading the resource - ErrorCode.RequestTimeout (-32001): no response within timeout (default 60s) - ErrorCode.ConnectionClosed (-32000): transport disconnected during request
    Required handlingCaller MUST wrap client.readResource() in try-catch and handle McpError. Resource URIs can disappear (files deleted, DB rows removed) between listResources() and readResource(). TOCTOU pattern is common — always handle MethodNotFound. Minimum: try { const result = await client.readResource({ uri: 'file:///data.json' }); return result.contents; } catch (error) { if (error instanceof McpError && error.code === ErrorCode.MethodNotFound) { return null; // Resource was removed } throw error; }
    costmediumin prodimmediate exceptionusers seeservice unavailablevisibilityvisible
    Sources[2]
  • readResource · read-resource-capability-not-advertised
    error
    Whenclient.readResource() is called when the connected server has NOT advertised the resources capability in its initialization response. The client SDK throws synchronously before sending the request: "Server does not support resources (required for resources/read)".
    ThrowsError: "Server does not support resources (required for resources/read)" Thrown synchronously from assertCapabilityForMethod() in client/index.js.
    Required handlingCallers MUST check server capabilities before calling readResource(). Use client.getServerCapabilities() after connect() to verify resources support: const caps = client.getServerCapabilities(); if (!caps?.resources) { throw new Error('This MCP server does not support resources'); } Do NOT assume all MCP servers expose resources — it is an optional capability.
    costmediumin prodimmediate exceptionusers seeservice unavailablevisibilityvisible
    Sources[2]
  • getPrompt · get-prompt-missing-try-catch
    error
    Whenasync function calls client.getPrompt() without try-catch. Throws McpError when the prompt name does not exist on the server (MethodNotFound -32601), arguments fail server-side validation (InvalidParams -32602), transport fails (ConnectionClosed -32000), or request times out (RequestTimeout -32001).
    ThrowsMcpError — key codes: - ErrorCode.MethodNotFound (-32601): prompt name not found on server - ErrorCode.InvalidParams (-32602): invalid or missing required prompt arguments - ErrorCode.RequestTimeout (-32001): no response within timeout (default 60s) - ErrorCode.ConnectionClosed (-32000): transport disconnected
    Required handlingCaller MUST wrap client.getPrompt() in try-catch. Prompt names and arguments are often user-controlled or configured at runtime — validate before calling, and handle MethodNotFound gracefully (the prompt may have been removed after listing): try { const prompt = await client.getPrompt({ name: 'analyze-code', arguments: { lang: 'ts' } }); return prompt.messages; } catch (error) { if (error instanceof McpError && error.code === ErrorCode.MethodNotFound) { // Prompt was removed, fall back to default return getDefaultPrompt(); } throw error; }
    costmediumin prodimmediate exceptionusers seeservice unavailablevisibilityvisible
    Sources[2]
  • getPrompt · get-prompt-capability-not-advertised
    error
    Whenclient.getPrompt() is called when the connected server has NOT advertised the prompts capability. SDK throws synchronously: "Server does not support prompts (required for prompts/get)"
    ThrowsError: "Server does not support prompts (required for prompts/get)"
    Required handlingCallers MUST verify prompts capability via client.getServerCapabilities() after connect(). Prompts is an optional server capability — not all MCP servers expose prompt templates.
    costmediumin prodimmediate exceptionusers seeservice unavailablevisibilityvisible
    Sources[2]
  • createMessage · create-message-missing-try-catch
    error
    Whenasync function calls server.createMessage() without try-catch. The client may reject the sampling request (capability not supported, rate limit, model error) via McpError. Also throws on transport failure (ConnectionClosed -32000) or timeout (RequestTimeout -32001).
    ThrowsMcpError — key codes: - ErrorCode.InvalidParams (-32602): malformed message or invalid model parameters - ErrorCode.RequestTimeout (-32001): LLM call timed out (client's LLM API is slow) - ErrorCode.ConnectionClosed (-32000): client disconnected before responding Additionally: Error "Client does not support sampling (required for sampling/createMessage)" when client has no sampling capability.
    Required handlingCaller MUST wrap server.createMessage() in try-catch. LLM API calls are slow (5-60s) and can fail at any point. The server's request handler should not crash on sampling failure: try { const result = await server.createMessage({ messages: [{ role: 'user', content: { type: 'text', text: prompt } }], maxTokens: 1024 }); return result.content; } catch (error) { if (error instanceof McpError && error.code === ErrorCode.RequestTimeout) { throw new McpError(ErrorCode.InternalError, 'LLM sampling timed out'); } throw error; }
    costhighin prodimmediate exceptionusers seeservice unavailablevisibilityvisible
    Sources[2]
  • createMessage · create-message-capability-not-advertised
    error
    Whenserver.createMessage() is called when the connected client has NOT advertised the sampling capability. SDK throws synchronously: "Client does not support sampling (required for sampling/createMessage)" This is thrown from assertCapabilityForMethod() before any network call.
    ThrowsError: "Client does not support sampling (required for sampling/createMessage)"
    Required handlingServer implementations MUST check client capabilities via server.getClientCapabilities() before calling createMessage(). Not all MCP clients support sampling. If sampling is not available, fall back to static responses or inform the caller via McpError: const caps = server.getClientCapabilities(); if (!caps?.sampling) { throw new McpError(ErrorCode.MethodNotFound, 'This operation requires client-side AI sampling, which is not supported'); }
    costmediumin prodimmediate exceptionusers seeservice unavailablevisibilityvisible
    Sources[2]
  • elicitInput · elicit-input-missing-try-catch
    error
    Whenasync function calls server.elicitInput() without try-catch. The client may reject the elicitation (user cancels, capability not supported, response validation failure) via McpError. Also throws on timeout (RequestTimeout -32001) if the user never responds or connection closes (ConnectionClosed -32000).
    ThrowsMcpError — key codes: - ErrorCode.InvalidParams (-32602): client's elicitation response does not match the requested JSON Schema ("Elicitation response content does not match requested schema") - ErrorCode.RequestTimeout (-32001): user did not respond within timeout - ErrorCode.ConnectionClosed (-32000): client disconnected before submitting form Additionally: Error "Client does not support elicitation (required for elicitation/create)" or Error "Client does not support URL elicitation" for mode mismatches.
    Required handlingCaller MUST wrap server.elicitInput() in try-catch. Users can cancel or time out, and clients may not support elicitation. Handle cancellation gracefully: try { const result = await server.elicitInput({ requestedSchema: { type: 'object', properties: { confirm: { type: 'boolean' } } }, message: 'Confirm deletion of all records?' }); if (result.action === 'cancel') { return { cancelled: true }; } return processUserInput(result.content); } catch (error) { if (error instanceof McpError && error.code === ErrorCode.RequestTimeout) { return { timedOut: true }; } throw error; }
    costmediumin prodimmediate exceptionusers seeservice unavailablevisibilityvisible
    Sources[2]
  • elicitInput · elicit-input-capability-not-advertised
    error
    Whenserver.elicitInput() is called when the connected client has NOT advertised the elicitation capability. SDK throws synchronously: "Client does not support elicitation (required for elicitation/create)" For URL-mode elicitation specifically: "Client does not support URL elicitation (required for ...)"
    ThrowsError: "Client does not support elicitation (required for elicitation/create)" Error: "Client does not support URL elicitation" (for URL-mode params)
    Required handlingServer implementations MUST check client capabilities before calling elicitInput(). Elicitation is an optional client capability introduced in MCP protocol 2025-06-18. Older clients (Claude Desktop pre-2025, most CLI clients) do not support it: const caps = server.getClientCapabilities(); if (!caps?.elicitation) { // Fall back to including data in the tool's input schema throw new McpError(ErrorCode.InvalidRequest, 'Client does not support interactive input — provide all parameters upfront'); }
    costmediumin prodimmediate exceptionusers seeservice unavailablevisibilityvisible
    Sources[2]
  • listResources · list-resources-missing-try-catch
    error
    Whenasync function calls client.listResources() without try-catch. Throws McpError when the transport fails (ConnectionClosed -32000), request times out (RequestTimeout -32001), or server returns an error response (InternalError -32603 for server-side listing failure).
    ThrowsMcpError — key codes: - ErrorCode.RequestTimeout (-32001): server did not respond within timeout - ErrorCode.ConnectionClosed (-32000): transport disconnected - ErrorCode.InternalError (-32603): server-side resource enumeration failure
    Required handlingCaller MUST wrap client.listResources() in try-catch. Server-side resource enumeration can fail (DB unavailable, filesystem permission denied). Pagination cursors expire — handle InvalidParams when a stale cursor is used.
    costmediumin prodimmediate exceptionusers seeservice unavailablevisibilityvisible
    Sources[2]
  • listResources · list-resources-capability-not-advertised
    error
    Whenclient.listResources() is called when the connected server has NOT advertised the resources capability. SDK throws synchronously: "Server does not support resources (required for resources/list)"
    ThrowsError: "Server does not support resources (required for resources/list)"
    Required handlingCheck server capabilities via client.getServerCapabilities() after connect() before calling listResources(). Resources is an optional capability — not all MCP servers expose data sources.
    costmediumin prodimmediate exceptionusers seeservice unavailablevisibilityvisible
    Sources[2]
  • listPrompts · list-prompts-missing-try-catch
    error
    Whenasync function calls client.listPrompts() without try-catch. Throws McpError on transport failure (ConnectionClosed -32000) or timeout (RequestTimeout -32001).
    ThrowsMcpError — key codes: - ErrorCode.RequestTimeout (-32001): server did not respond - ErrorCode.ConnectionClosed (-32000): transport disconnected
    Required handlingCaller MUST wrap client.listPrompts() in try-catch. Network-connected MCP servers can fail or disconnect. Handle gracefully by disabling prompt features.
    costmediumin prodimmediate exceptionusers seeservice unavailablevisibilityvisible
    Sources[2]
  • listPrompts · list-prompts-capability-not-advertised
    error
    Whenclient.listPrompts() is called when the connected server has NOT advertised the prompts capability. SDK throws synchronously: "Server does not support prompts (required for prompts/list)"
    ThrowsError: "Server does not support prompts (required for prompts/list)"
    Required handlingCheck server capabilities via client.getServerCapabilities() before calling listPrompts().
    costlowin prodimmediate exceptionusers seeservice unavailablevisibilityvisible
    Sources[2]
  • subscribeResource · subscribe-resource-missing-try-catch
    error
    Whenasync function calls client.subscribeResource() without try-catch. Throws McpError when: URI not found on server (MethodNotFound -32601), subscriptions not supported (InvalidRequest -32600 or capability Error), transport fails (ConnectionClosed -32000), or request times out (RequestTimeout -32001).
    ThrowsMcpError — key codes: - ErrorCode.MethodNotFound (-32601): the resource URI does not exist - ErrorCode.RequestTimeout (-32001): no response within timeout - ErrorCode.ConnectionClosed (-32000): transport disconnected Additionally: Error "Server does not support resource subscriptions (required for resources/subscribe)"
    Required handlingCaller MUST wrap client.subscribeResource() in try-catch. Subscriptions are typically set up at session start — a failure here means the client will miss resource change events silently if not handled: try { await client.subscribeResource({ uri: 'file:///config.json' }); } catch (error) { console.error('Resource subscription failed:', error.message); // Fall back to polling or static read }
    costlowin proddegraded serviceusers seedegraded performancevisibilitysilent
    Sources[2]
  • subscribeResource · subscribe-resource-no-notification-handler
    warning
    Whenclient.subscribeResource() is called but no handler is registered for notifications/resources/updated notifications via client.setNotificationHandler(). The subscription succeeds but change notifications are silently dropped.
    ThrowsNo error thrown — notification is silently discarded
    Required handlingCallers MUST register a notification handler BEFORE subscribing to resources. Register with: client.setNotificationHandler(ResourceUpdatedNotificationSchema, (notification) => { const updatedUri = notification.params.uri; // Re-read the resource client.readResource({ uri: updatedUri }).then(handleUpdatedResource); }); The handler must be registered before connect() or at minimum before subscribeResource() to avoid a race condition where the first update notification arrives before the handler.
    costlowin proddegraded serviceusers seedegraded performancevisibilitysilent
    Sources[2]
  • experimental.tasks.callToolStream · call-tool-stream-error-message-unhandled
    error
    Whenasync function iterates client.experimental.tasks.callToolStream() without checking for message.type === 'error' messages. The generator yields an error message object (not a thrown exception) when: the tool's outputSchema validation fails, the tool returns no structuredContent when one is required, or the server returns a protocol error. Callers who only handle 'result' silently swallow all error conditions.
    ThrowsDoes NOT throw — errors are yielded as { type: 'error', error: McpError } messages. McpError codes seen in source: - ErrorCode.InvalidRequest (-32600): tool has outputSchema but returned no structuredContent - ErrorCode.InvalidParams (-32602): structuredContent does not match tool's outputSchema - ErrorCode.ConnectionClosed (-32000): transport disconnected mid-stream - ErrorCode.RequestTimeout (-32001): stream timed out
    Required handlingCaller MUST handle message.type === 'error' in the for-await loop: for await (const message of stream) { if (message.type === 'error') { console.error('Tool stream error:', message.error.message); break; } if (message.type === 'result') { processResult(message.result); } } Not handling 'error' messages means tool failures are silently ignored — the stream ends without returning any result and the caller never knows.
    costmediumin proddegraded serviceusers seedegraded performancevisibilitysilent
    Sources[2]
  • experimental.tasks.callToolStream · call-tool-stream-missing-try-catch
    error
    WhenThe async generator itself (not just the yielded messages) can throw McpError if the underlying protocol transport fails before the stream starts. The outer for-await call must be wrapped in try-catch to handle transport-level failures.
    ThrowsMcpError — thrown (not yielded) when: - ErrorCode.ConnectionClosed (-32000): transport closed before stream could start - ErrorCode.RequestTimeout (-32001): initial request timed out
    Required handlingWrap the entire for-await in try-catch AND check for error messages: try { for await (const message of client.experimental.tasks.callToolStream(params)) { if (message.type === 'error') { /* handle */ break; } if (message.type === 'result') { /* use result */ } } } catch (e) { // McpError from transport failure }
    costmediumin prodimmediate exceptionusers seeservice unavailablevisibilityvisible
    Sources[2]
  • experimental.tasks.cancelTask · cancel-task-missing-try-catch
    error
    Whenasync function calls client.experimental.tasks.cancelTask(taskId) without try-catch. Throws McpError when the task does not exist or has already completed/failed/cancelled. Cancellation in cleanup paths (finally blocks, error handlers, shutdown hooks) without try-catch causes the cleanup path itself to throw, masking the original error.
    ThrowsMcpError — key codes confirmed from source: - ErrorCode.InvalidParams (-32602): "Task not found: <taskId>" — task was cleaned up - ErrorCode.InvalidParams (-32602): "Cannot cancel task in terminal status: completed|failed|cancelled" - ErrorCode.InvalidRequest (-32600): "Failed to cancel task: <message>" — server-side failure - ErrorCode.ConnectionClosed (-32000): transport disconnected - ErrorCode.RequestTimeout (-32001): server did not respond
    Required handlingCaller MUST wrap cancelTask() in try-catch, especially in finally blocks: async function cleanup(taskId: string) { try { await client.experimental.tasks.cancelTask(taskId); } catch (e) { // Expected: task may have already completed or been cleaned up if (e instanceof McpError && e.code === ErrorCode.InvalidParams) { return; // Task already in terminal state — no action needed } throw e; // Unexpected error — re-throw } } Tasks complete asynchronously — a task that was running when cancelTask() is called may have already finished. This is a normal condition, not an error.
    costmediumin prodimmediate exceptionusers seeservice unavailablevisibilityvisible
    Sources[2]
  • experimental.tasks.getTask · get-task-missing-try-catch
    error
    Whenasync function calls client.experimental.tasks.getTask(taskId) without try-catch. Throws McpError when the task is not found — which is a normal terminal condition for tasks that have been cleaned up after completion. Polling loops without try-catch will crash when a task expires from the server's task store.
    ThrowsMcpError — key codes confirmed from source: - ErrorCode.InvalidParams (-32602): "Failed to retrieve task: Task not found" — task was cleaned up (normal for expired/old tasks) - ErrorCode.ConnectionClosed (-32000): transport disconnected - ErrorCode.RequestTimeout (-32001): server did not respond in time
    Required handlingCaller MUST wrap getTask() in try-catch, especially in polling loops: async function pollTask(taskId: string) { while (true) { try { const taskStatus = await client.experimental.tasks.getTask(taskId); if (taskStatus.status === 'completed') return taskStatus; if (taskStatus.status === 'failed') throw new Error('Task failed'); await sleep(1000); // Poll interval } catch (e) { if (e instanceof McpError && e.code === ErrorCode.InvalidParams) { // Task was cleaned up — treat as completion or re-fetch return null; } throw e; } } } Servers may clean up task records after some retention period. A 'Task not found' error does not mean the task never existed — it may have already completed and been garbage-collected from the task store.
    costmediumin prodimmediate exceptionusers seeservice unavailablevisibilityvisible
    Sources[2]
  • auth · auth-throws-oauth-error-on-server-rejection
    error
    Whenasync function calls auth() without try-catch. The OAuth server may reject the client with OAuthError subtypes: InvalidClientError (invalid credentials), InvalidGrantError (expired/revoked code), UnauthorizedClientError (client not permitted this grant type), ServerError (auth server internal error). auth() automatically retries once on InvalidClientError and InvalidGrantError by invalidating credentials, but rethrows all other OAuthError subtypes immediately.
    ThrowsOAuthError subclasses from '@modelcontextprotocol/sdk/server/auth/errors': - InvalidClientError: client credentials invalid or revoked (auto-retried once) - UnauthorizedClientError: client not authorized for this grant type (retried once) - InvalidGrantError: authorization code expired/reused or refresh token revoked (retried once) - InvalidScopeError: requested scope not permitted by server - AccessDeniedError: user denied the authorization request - InvalidRequestError: malformed OAuth request parameters - ServerError / OAuthError: generic auth server failure Additionally: Error when provider.clientInformation() returns undefined and authorizationCode is also provided simultaneously.
    Required handlingCaller MUST wrap auth() in try-catch and handle OAuthError subtypes: import { auth } from '@modelcontextprotocol/sdk/client/auth'; import { OAuthError, InvalidGrantError } from '@modelcontextprotocol/sdk/server/auth/errors'; try { const result = await auth(provider, { serverUrl }); if (result === 'REDIRECT') { return; // User is being redirected — do not proceed with client } // result === 'AUTHORIZED' — proceed with MCP client } catch (error) { if (error instanceof InvalidGrantError) { // Refresh token revoked — force full re-auth await provider.invalidateCredentials?.('all'); redirectToLogin(); } else if (error instanceof OAuthError) { showAuthError(error.message); } else { throw error; } }
    costhighin prodimmediate exceptionusers seeservice unavailablevisibilityvisible
    Sources[5]
  • auth · auth-redirect-result-not-checked
    error
    Whenasync function calls auth() and proceeds to use the MCP client without checking whether the return value is 'REDIRECT'. When auth() returns 'REDIRECT', it has already called provider.redirectToAuthorization() but no tokens are stored yet. Code that ignores the return value and immediately calls client.connect() will fail because no access token is available.
    ThrowsDoes NOT throw — returns 'REDIRECT' string. The caller silently proceeds without tokens, causing connect() to fail with HTTP 401 Unauthorized from the server.
    Required handlingCallers MUST check the auth() return value before proceeding: const result = await auth(provider, { serverUrl }); if (result === 'REDIRECT') { return; // Abort — user being redirected to authorize in browser } // Only reach here when result === 'AUTHORIZED' await client.connect(transport);
    costmediumin prodimmediate exceptionusers seeservice unavailablevisibilityvisible
    Sources[5]
  • exchangeAuthorization · exchange-authorization-throws-on-invalid-code
    error
    Whenasync function calls exchangeAuthorization() without try-catch. The token endpoint returns HTTP 400 with error='invalid_grant' when the authorization code is expired, already used, or the code_verifier does not match the code_challenge. The SDK parses this into InvalidGrantError and throws it. Authorization codes are typically single-use and expire in 60-600 seconds.
    ThrowsOAuthError subclasses parsed from the token endpoint error response: - InvalidGrantError: authorization code expired, already redeemed, or code_verifier mismatch (PKCE failure) - InvalidClientError: client credentials invalid (wrong client_secret) - InvalidRequestError: missing required parameters (code, redirect_uri, code_verifier) - OAuthError (base): any other RFC 6749 error from the token endpoint Additionally: network errors (TypeError) if the token endpoint is unreachable
    Required handlingCaller MUST wrap exchangeAuthorization() in try-catch: try { const tokens = await exchangeAuthorization(authServerUrl, { metadata, clientInformation, authorizationCode: code, codeVerifier, redirectUri }); await provider.saveTokens(tokens); } catch (error) { if (error instanceof InvalidGrantError) { // Code expired or already used — restart auth flow await provider.invalidateCredentials?.('all'); return auth(provider, { serverUrl }); } throw error; }
    costmediumin prodimmediate exceptionusers seeservice unavailablevisibilityvisible
    Sources[5]
  • refreshAuthorization · refresh-authorization-throws-on-revoked-token
    error
    Whenasync function calls refreshAuthorization() without try-catch. The token endpoint returns HTTP 400 with error='invalid_grant' when the refresh token is expired, revoked, or bound to a terminated session (password change, account deactivation, admin revocation). Background token refresh jobs that silently swallow this error leave the app in a perpetually unauthenticated state.
    ThrowsOAuthError subclasses: - InvalidGrantError: refresh token expired, revoked, or session terminated (requires full re-authentication — cannot recover by retrying) - InvalidClientError: client credentials rejected by the auth server - InvalidRequestError: malformed token refresh request - OAuthError (base): any other RFC 6749 error from the token endpoint Additionally: network errors (TypeError) if the token endpoint is unreachable
    Required handlingCaller MUST wrap refreshAuthorization() in try-catch and handle InvalidGrantError specifically — this is the permanent failure case: try { const newTokens = await refreshAuthorization(authServerUrl, { metadata, clientInformation, refreshToken: storedTokens.refresh_token }); await provider.saveTokens(newTokens); } catch (error) { if (error instanceof InvalidGrantError) { // Refresh token revoked — must re-authenticate from scratch await provider.invalidateCredentials?.('all'); scheduleReauth(); // Trigger full auth() flow on next request } else { logger.error('Token refresh failed:', error.message); } }
    costhighin proddegraded serviceusers seeservice unavailablevisibilitysilent
    Sources[5]
  • registerClient · register-client-throws-on-invalid-metadata
    error
    Whenasync function calls registerClient() without try-catch. The registration endpoint rejects client registration when the client_metadata contains invalid redirect_uris (non-HTTPS, non-localhost), unsupported grant_types, or malformed metadata. Registration failure is fatal — the client cannot proceed without a client_id.
    ThrowsOAuthError subclasses: - InvalidClientMetadataError: invalid redirect_uri, unsupported grant_type, or malformed client metadata (HTTP 400) - InvalidRequestError: missing required fields in client metadata - OAuthError (base): registration disallowed by server policy or rate limited Additionally: Error('OAuth client information must be saveable for dynamic registration') when provider.saveClientInformation is not implemented
    Required handlingCaller MUST wrap registerClient() in try-catch: try { const clientInfo = await registerClient(authServerUrl, { metadata, clientMetadata: provider.clientMetadata }); await provider.saveClientInformation(clientInfo); } catch (error) { if (error instanceof InvalidClientMetadataError) { logger.error('Invalid client registration metadata:', error.message); } throw error; // Registration failure is fatal — cannot proceed }
    costmediumin prodimmediate exceptionusers seeservice unavailablevisibilityvisible
    Sources[5]
  • StreamableHTTPServerTransport.handleRequest · handle-request-throws-on-stateless-reuse
    error
    Whenasync function calls transport.handleRequest() on a stateless StreamableHTTPServerTransport (sessionIdGenerator: undefined) after the first request has already completed. Stateless transports are single-use — each HTTP request requires a new transport instance. Reusing the same instance throws.
    ThrowsError: 'Stateless transport cannot be reused across requests. Create a new transport per request.' — thrown synchronously at the start of handleRequest().
    Required handlingFor stateless Streamable HTTP (serverless/edge functions), create a new transport instance per request: app.post('/mcp', async (req, res) => { const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined // stateless mode }); const server = new McpServer(serverInfo); await server.connect(transport); await transport.handleRequest(req, res, req.body); await server.close(); }); For stateful deployments with long-lived sessions, use a session ID generator and store transports in a session map keyed by Mcp-Session-Id header.
    costmediumin prodimmediate exceptionusers seeservice unavailablevisibilityvisible
    Sources[2]
  • StreamableHTTPServerTransport.handleRequest · handle-request-missing-session-routing
    error
    WhenMCP server uses stateful StreamableHTTPServerTransport (with session IDs) but routes all POST requests to a single transport instance instead of routing by Mcp-Session-Id header. The transport returns HTTP 404 with 'Session not found' for any request whose session ID does not match the transport's session. This is the most common mistake when migrating from SSE transport to Streamable HTTP.
    ThrowsDoes NOT throw — returns HTTP 404 JSON response silently: { jsonrpc: '2.0', error: { code: -32001, message: 'Session not found' } } All client requests fail with 404 until session routing is implemented.
    Required handlingFor stateful deployments, maintain a session map and route by Mcp-Session-Id: const sessions = new Map(); app.post('/mcp', async (req, res) => { const sessionId = req.headers['mcp-session-id']; if (sessionId && sessions.has(sessionId)) { await sessions.get(sessionId).handleRequest(req, res, req.body); } else { const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: () => randomUUID() }); transport.onclose = () => sessions.delete(transport.sessionId); sessions.set(transport.sessionId, transport); const server = new McpServer(serverInfo); await server.connect(transport); await transport.handleRequest(req, res, req.body); } });
    costmediumin proddegraded serviceusers seeservice unavailablevisibilityvisible
    Sources[2]
  • StdioClientTransport.start · stdio-start-throws-on-spawn-failure
    error
    Whenasync function constructs StdioClientTransport and calls start() (or calls Client.connect() with a StdioClientTransport) without try-catch. The spawn fails when: (1) command is not found on PATH — throws ENOENT; (2) command exists but is not executable — throws EACCES; (3) transport is already started — throws Error('StdioClientTransport already started!'). These failures reject the start() promise, propagating through Client.connect() as an uncaught rejection.
    ThrowsError from Node.js child_process spawn: - ENOENT: command not found on PATH (e.g. 'npx mcp-server-xyz' where package is not installed) - EACCES: command found but not executable (permission denied) - Error('StdioClientTransport already started!'): transport reuse — call close() first
    Required handlingWrap Client.connect() (or transport.start()) in try-catch and handle spawn errors: import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio'; import { Client } from '@modelcontextprotocol/sdk/client'; const transport = new StdioClientTransport({ command: 'npx', args: ['-y', '@modelcontextprotocol/server-filesystem'], }); try { await client.connect(transport); } catch (error) { if (error.code === 'ENOENT') { console.error('MCP server binary not found — ensure it is installed'); } else if (error.code === 'EACCES') { console.error('MCP server binary not executable — check file permissions'); } else { throw error; } }
    costmediumin prodimmediate exceptionusers seeservice unavailablevisibilityvisible
    Sources[6]
  • StdioClientTransport.start · stdio-start-throws-already-started
    error
    WhenCode creates a StdioClientTransport and calls connect() (or start()) twice on the same instance without calling close() between connections. This is a common mistake when implementing reconnection logic — the old transport instance must be discarded and a new one created after each disconnect, not restarted.
    ThrowsError('StdioClientTransport already started! If using Client class, note that connect() calls start() automatically.')
    Required handlingFor reconnection, create a new StdioClientTransport instance rather than reusing the old one. Attach onerror/onclose handlers to detect disconnect: transport.onclose = async () => { // Create a fresh transport — do NOT reuse the old one const newTransport = new StdioClientTransport({ command, args }); await client.connect(newTransport); };
    costlowin prodimmediate exceptionusers seeservice unavailablevisibilityvisible
    Sources[6]
  • StreamableHTTPClientTransport.finishAuth · finish-auth-throws-no-provider
    error
    Whenasync function calls transport.finishAuth(code) on a StreamableHTTPClientTransport that was constructed without an authProvider option. The transport cannot complete the OAuth flow because no provider is configured to store tokens or perform the exchange.
    ThrowsUnauthorizedError('No auth provider') from '@modelcontextprotocol/sdk/client/auth'
    Required handlingOnly call finishAuth() on a transport that was constructed with an authProvider: const transport = new StreamableHTTPClientTransport(serverUrl, { authProvider: myOAuthProvider, // REQUIRED for finishAuth() to work }); // After redirect: const code = new URL(window.location.href).searchParams.get('code'); try { await transport.finishAuth(code); } catch (error) { if (error instanceof UnauthorizedError) { // Re-show login flow } }
    costlowin prodimmediate exceptionusers seeservice unavailablevisibilityvisible
    Sources[5]
  • StreamableHTTPClientTransport.finishAuth · finish-auth-throws-authorization-failed
    error
    Whenasync function calls transport.finishAuth(code) without try-catch. The auth() helper returns a non-'AUTHORIZED' result (e.g. 'REDIRECT') when token exchange fails — the code may be expired, already used, or the PKCE verifier may be stale (e.g. user reloaded the redirect page). The transport throws UnauthorizedError('Failed to authorize') in this case.
    ThrowsUnauthorizedError('Failed to authorize') — token exchange completed but did not produce a valid access token. PKCE mismatch, expired code, or re-used code.
    Required handlingCatch UnauthorizedError and restart the full OAuth flow from the beginning: try { await transport.finishAuth(authorizationCode); await client.connect(transport); } catch (error) { if (error instanceof UnauthorizedError) { // Code is stale or already used — restart from authorization URL const freshTransport = new StreamableHTTPClientTransport(serverUrl, { authProvider }); await client.connect(freshTransport); // triggers new redirect } }
    costlowin prodimmediate exceptionusers seeservice unavailablevisibilityvisible
    Sources[5]
  • StreamableHTTPClientTransport.terminateSession · terminate-session-throws-on-server-error
    warning
    Whenasync function calls transport.terminateSession() without try-catch. The server returns a non-2xx, non-405 HTTP response (e.g. 500 Internal Server Error, 503 Service Unavailable, or 403 Forbidden). StreamableHTTPError is thrown with the HTTP status code and statusText. Note that HTTP 405 Method Not Allowed is explicitly handled as success per the MCP spec — only unexpected failures throw.
    ThrowsStreamableHTTPError(statusCode, 'Failed to terminate session: <statusText>') from '@modelcontextprotocol/sdk/client/streamableHttp'. HTTP 405 is NOT thrown — it indicates the server doesn't support explicit termination.
    Required handlingWrap terminateSession() in try-catch. Failure to terminate is non-fatal — the session will eventually expire server-side. Treat errors as best-effort cleanup: try { await transport.terminateSession(); } catch (error) { if (error instanceof StreamableHTTPError) { // Non-fatal — session will expire naturally console.warn('Session termination failed:', error.message); } else { throw error; // Re-throw unexpected errors (network failure, etc.) } }
    costlowin proddegraded serviceusers seedegraded performancevisibilitysilent
    Sources[7]
  • WebStandardStreamableHTTPServerTransport.handleRequest · web-transport-stateless-reuse-throws
    error
    WhenCloudflare Worker or similar serverless handler creates one WebStandardStreamableHTTPServerTransport instance (e.g. at module scope or in a singleton) in stateless mode (no sessionIdGenerator) and reuses it across multiple requests. The second call to handleRequest() throws because stateless transport is single-use — message ID counters from the first request would collide with the second.
    ThrowsError('Stateless transport cannot be reused across requests. Create a new transport per request.')
    Required handlingIn stateless mode, create a new transport per request: // Cloudflare Worker — CORRECT export default { async fetch(request: Request): Promise<Response> { const transport = new WebStandardStreamableHTTPServerTransport(); // new per request const server = new McpServer(serverInfo); await server.connect(transport); return transport.handleRequest(request); } }; // WRONG — singleton transport in stateless mode const transport = new WebStandardStreamableHTTPServerTransport(); // reused! export default { async fetch(request) { return transport.handleRequest(request); // throws on 2nd request } };
    costmediumin prodimmediate exceptionusers seeservice unavailablevisibilityvisible
    Sources[8]
  • WebStandardStreamableHTTPServerTransport.handleRequest · web-transport-missing-session-routing
    error
    WhenMCP server uses stateful WebStandardStreamableHTTPServerTransport (with sessionIdGenerator) but routes all requests to a single transport instance. The transport returns a JSON error response with HTTP 404 for any request whose session ID does not match. This mirrors the same mistake as with StreamableHTTPServerTransport but in web-standards environments (Hono, Deno, Cloudflare Workers with Durable Objects).
    ThrowsDoes NOT throw — returns Response with HTTP 404 and JSON body: { jsonrpc: '2.0', error: { code: -32001, message: 'Session not found' } }
    Required handlingFor stateful deployments, maintain a session map and route by Mcp-Session-Id header: const sessions = new Map(); // Hono.js example app.all('/mcp', async (c) => { const sessionId = c.req.header('mcp-session-id'); let transport = sessionId ? sessions.get(sessionId) : undefined; if (!transport) { transport = new WebStandardStreamableHTTPServerTransport({ sessionIdGenerator: () => crypto.randomUUID(), onsessionclosed: (id) => sessions.delete(id), }); const server = new McpServer(serverInfo); await server.connect(transport); } const response = await transport.handleRequest(c.req.raw); if (transport.sessionId) sessions.set(transport.sessionId, transport); return response; });
    costmediumin proddegraded serviceusers seeservice unavailablevisibilityvisible
    Sources[7]
  • WebSocketClientTransport.start · websocket-transport-already-started
    error
    Whenasync function calls transport.start() when the transport was already started (i.e. start() called twice, or called manually when Client.connect() was already used). The _socket field is already set, so start() throws synchronously (before any await), rejecting the returned Promise.
    ThrowsError('WebSocketClientTransport already started! If using Client class, note that connect() calls start() automatically.')
    Required handlingNever call transport.start() manually when using the Client class — Client.connect() calls it. For manual transport use, ensure start() is called exactly once: const transport = new WebSocketClientTransport(new URL('wss://mcp.example.com')); // Let Client manage lifecycle: const client = new Client(clientInfo); await client.connect(transport); // calls start() internally // Do NOT also call: // await transport.start(); // throws AlreadyStarted
    costlowin prodimmediate exceptionusers seeservice unavailablevisibilityvisible
    Sources[9]
  • WebSocketClientTransport.start · websocket-transport-connection-failed
    error
    Whenasync function calls transport.start() (or client.connect()) without try-catch. The WebSocket connection fails — the server is unreachable, the URL is wrong, TLS verification fails, the server rejects the 'mcp' subprotocol, or the network is unavailable. The WebSocket onerror event fires and the Promise rejects with the underlying connection error.
    ThrowsError from WebSocket onerror — the exact message depends on the runtime and network error. Common: ECONNREFUSED, ENOTFOUND, TLS certificate errors, or 'WebSocket error: ...' with the event JSON.
    Required handlingWrap connect() in try-catch and handle connection failures as unrecoverable: try { await client.connect(transport); } catch (error) { // WebSocket connection failed — server unreachable, bad URL, or TLS error console.error('MCP connection failed:', error.message); process.exit(1); // or surface error to user }
    costmediumin prodimmediate exceptionusers seeservice unavailablevisibilityvisible
    Sources[9]
  • SSEClientTransport.start · sse-transport-no-auth-provider
    error
    Whenasync function calls client.connect(sseTransport) without try-catch, and the MCP server returns HTTP 401 Unauthorized to the initial SSE GET request. The transport attempts OAuth auth flow but no authProvider is configured in SSEClientTransportOptions. UnauthorizedError is thrown.
    ThrowsUnauthorizedError('No auth provider') from '@modelcontextprotocol/sdk/client/auth'. Thrown when the server requires authentication but the client transport was constructed without an authProvider.
    Required handlingConfigure an authProvider when connecting to OAuth-protected MCP servers, or catch UnauthorizedError to detect auth requirements: try { const transport = new SSEClientTransport(serverUrl, { authProvider }); await client.connect(transport); } catch (error) { if (error instanceof UnauthorizedError) { // Server requires OAuth — configure an authProvider or redirect user console.error('Server requires authentication'); } throw error; }
    costlowin prodimmediate exceptionusers seeservice unavailablevisibilityvisible
    Sources[5]
  • SSEClientTransport.start · sse-transport-connection-error
    error
    Whenasync function calls client.connect(sseTransport) without try-catch, and the SSE connection to the server fails. The EventSource connection fires an error event — server unreachable, HTTP error status (4xx/5xx), or network failure. The transport rejects with SseError containing the HTTP status code and message.
    ThrowsSseError(code: number | undefined, message: string, event: ErrorEvent) from '@modelcontextprotocol/sdk/client/sse'. The code field is the HTTP status code if available (e.g., 404, 500), or undefined for network errors.
    Required handlingWrap connect() in try-catch. For migration from SSE to StreamableHTTP, catch SseError to trigger SSE fallback after StreamableHTTP fails: try { await client.connect(streamableTransport); } catch { // Fall back to legacy SSE transport try { const sseTransport = new SSEClientTransport(serverUrl); await client.connect(sseTransport); } catch (error) { if (error instanceof SseError) { console.error(`SSE connection failed (HTTP ${error.code}): ${error.message}`); } throw error; } }
    costmediumin prodimmediate exceptionusers seeservice unavailablevisibilityvisible
    Sources[10]
  • SSEServerTransport.handlePostMessage · sse-handle-post-content-type-error
    error
    WhenThe MCP client sends a POST request to the SSE endpoint with a Content-Type other than 'application/json' (e.g., 'text/plain', 'multipart/form-data', or missing Content-Type). The transport throws an error indicating the unsupported content type — this unhandled error can crash a raw Node.js HTTP server or bubble as an unhandled rejection.
    ThrowsError('Unsupported content-type: <type>') — where <type> is the actual Content-Type header value received. Thrown inside handlePostMessage() before any JSON parsing is attempted.
    Required handlingWrap handlePostMessage() in a try-catch inside your POST route handler, and return an appropriate HTTP error response: app.post('/messages', async (req, res) => { try { await transport.handlePostMessage(req, res); } catch (error) { console.error('POST handler error:', error.message); if (!res.headersSent) { res.status(400).json({ error: error.message }); } } });
    costlowin prodimmediate exceptionusers seeservice unavailablevisibilityvisible
    Sources[11]
  • SSEServerTransport.handlePostMessage · sse-handle-post-dns-rebinding-blocked
    warning
    WhenDNS rebinding protection is enabled (enableDnsRebindingProtection: true) on the SSEServerTransport and an incoming POST request has a Host header that does not match any entry in allowedHosts. The transport writes HTTP 403 to the response and fires the onerror callback — but does NOT throw from handlePostMessage(). Code that relies on the throw to detect rejections will silently lose requests.
    ThrowsDoes NOT throw — instead writes HTTP 403 response and calls this.onerror?.(new Error(validationError)). The onerror callback receives an Error with the DNS rebinding rejection message.
    Required handlingAttach an onerror handler to the transport to log DNS rebinding rejections, and do NOT rely on try-catch alone for security-rejection detection: transport.onerror = (err) => { console.error('Transport security rejection:', err.message); // Increment security metrics / alert on repeated violations };
    costlowin proddegraded serviceusers seeservice unavailablevisibilitysilent
    Sources[11]
  • StdioServerTransport.start · stdio-server-transport-already-started
    error
    Whenasync function calls server.connect(transport) (or transport.start()) when the StdioServerTransport has already been started. This happens when connect() is called twice on the same server instance, or when start() is called manually after Server.connect() was already used.
    ThrowsError('StdioServerTransport already started! If using Server class, note that connect() calls start() automatically.')
    Required handlingEnsure Server.connect() is called exactly once. For subprocess MCP servers, the canonical pattern is a single connect() at startup: const server = new McpServer({ name: 'my-server', version: '1.0.0' }); const transport = new StdioServerTransport(); // At server startup — called once await server.connect(transport); // Do NOT call again: // await server.connect(transport); // throws AlreadyStarted
    costlowin prodimmediate exceptionusers seeservice unavailablevisibilityvisible
    Sources[12]
  • ProxyOAuthServerProvider.exchangeAuthorizationCode · proxy-exchange-code-upstream-error
    error
    Whenasync function calls provider.exchangeAuthorizationCode() (called internally by the MCP auth token handler) without handling ServerError. The upstream authorization server returns a non-2xx HTTP response (e.g., 400 invalid_grant, 401 invalid_client, 500 server error). ServerError is thrown with the HTTP status code in the message.
    ThrowsServerError('Token exchange failed: <status>') from '@modelcontextprotocol/sdk/server/auth/errors'. ServerError extends OAuthError with errorCode 'server_error'.
    Required handlingWhen implementing custom token handling around ProxyOAuthServerProvider, catch ServerError and return an appropriate OAuth error response: try { const tokens = await provider.exchangeAuthorizationCode( client, authorizationCode, codeVerifier, redirectUri ); return tokens; } catch (error) { if (error instanceof ServerError) { // Upstream token endpoint failed — return 502 or OAuth error response throw new InvalidGrantError('Upstream authorization failed'); } throw error; } Note: the mcpAuthRouter() handles this automatically — only custom token handlers need to catch ServerError manually.
    costmediumin prodimmediate exceptionusers seeservice unavailablevisibilityvisible
    Sources[5]
  • ProxyOAuthServerProvider.exchangeRefreshToken · proxy-refresh-token-upstream-error
    error
    Whenasync function calls provider.exchangeRefreshToken() (called by the MCP auth token handler when grant_type is 'refresh_token') without handling ServerError. The upstream AS returns non-2xx — the refresh token is expired, revoked, or the upstream server is temporarily unavailable (503).
    ThrowsServerError('Token refresh failed: <status>') from '@modelcontextprotocol/sdk/server/auth/errors'.
    Required handlingWhen the upstream returns a token refresh error, the MCP client's session is effectively expired. Surface this as an authentication failure so the client restarts the OAuth flow: try { const tokens = await provider.exchangeRefreshToken( client, refreshToken, scopes ); return tokens; } catch (error) { if (error instanceof ServerError) { // Upstream refresh failed — client must re-authorize from scratch throw new InvalidGrantError('Refresh token expired or revoked'); } throw error; }
    costmediumin prodimmediate exceptionusers seeservice unavailablevisibilityvisible
    Sources[5]
  • McpServer.sendLoggingMessage · send-logging-message-capability-not-set
    warning
    WhenMcpServer is created WITHOUT capabilities.logging enabled, and sendLoggingMessage() is called. The method silently returns undefined — the log message is never delivered to the client. No error is thrown and no warning is emitted. Callers have no way to detect the failure without inspecting server capabilities at runtime. Evidence: dist/cjs/server/index.js — logging guard is falsy, method returns undefined.
    ThrowsDoes NOT throw — returns undefined silently. Messages are silently dropped.
    Required handlingDeclare the logging capability at construction time to ensure messages are delivered: // ✅ Declare logging capability at construction time const server = new McpServer( { name: 'my-server', version: '1.0.0' }, { capabilities: { logging: {} } } ); // Now sendLoggingMessage() will deliver the notification to the client await server.sendLoggingMessage({ level: 'error', data: 'critical failure' });
    costmediumin prodsilent failureusers seedegraded performancevisibilitysilent
    Sources[13]
  • McpServer.sendLoggingMessage · send-logging-message-not-connected
    error
    WhensendLoggingMessage() is called before the server is connected to a transport (before connect() completes or after close()). The underlying notification() method throws Error('Not connected') because this._transport is null. Evidence: dist/cjs/shared/protocol.js — notification() checks this._transport.
    ThrowsError('Not connected') — thrown by the underlying notification() method when this._transport is null.
    Required handlingOnly call sendLoggingMessage() after server.connect() has completed: const server = new McpServer( { name: 'my-server', version: '1.0.0' }, { capabilities: { logging: {} } } ); const transport = new StdioServerTransport(); await server.connect(transport); // connect first // ✅ Safe to call after connect() await server.sendLoggingMessage({ level: 'info', data: 'server ready' });
    costlowin prodimmediate exceptionusers seeservice unavailablevisibilityvisible
    Sources[13]
  • fetchToken · fetch-token-missing-params
    error
    WhenfetchToken() is called with a provider that does NOT implement prepareTokenRequest() AND no authorizationCode option is supplied. The function cannot construct a token request and throws synchronously before any network call.
    ThrowsError('Either provider.prepareTokenRequest() or authorizationCode is required')
    Required handlingCaller MUST wrap in try-catch and ensure exactly one of: (a) provider implements prepareTokenRequest(scope) returning URLSearchParams of grant-specific token request body, OR (b) authorizationCode is supplied (and provider.redirectUrl is set). Pattern: try { const tokens = await fetchToken(provider, authServerUrl, { metadata, authorizationCode: code, }); } catch (err) { if (err.message.includes('prepareTokenRequest')) { // misconfigured provider — log + alert ops } throw err; } Without this check, misconfigured OAuth providers fail at first use rather than at startup, leading to runtime auth failures only surfaced when users attempt to sign in.
    costmediumin prodimmediate exceptionusers seeservice unavailablevisibilityvisible
    Sources[14]
  • fetchToken · fetch-token-oauth-error-not-handled
    error
    WhenfetchToken() is called and the authorization server returns a non-2xx response (invalid_grant, invalid_client, unauthorized_client, invalid_scope, etc. per RFC 6749 section 5.2). executeTokenRequest() awaits parseErrorResponse(response) and throws the parsed OAuthError subclass (InvalidGrantError, InvalidClientError, etc.). Code that treats fetchToken() as infallible crashes on every token refresh when the refresh token has been revoked.
    ThrowsOAuthError subclass: InvalidGrantError (401), InvalidClientError (401), UnauthorizedClientError (400), InvalidScopeError (400), ServerError (5xx), or generic OAuthError on unrecognized error codes. Each subclass exposes errorCode, errorDescription, and errorUri properties for actionable handling.
    Required handlingCaller MUST catch and inspect the OAuthError subclass to distinguish recoverable failures (e.g. invalid_grant on refresh → re-authenticate) from terminal misconfigurations (invalid_client → check credentials): try { const tokens = await fetchToken(provider, authServerUrl, { metadata, }); await provider.saveTokens(tokens); } catch (err) { if (err instanceof InvalidGrantError) { // Refresh token revoked — clear stored tokens, re-auth await provider.saveTokens(null); throw new SessionExpiredError(); } if (err instanceof InvalidClientError) { // Client misconfigured — alert ops, do NOT retry throw new ConfigError('OAuth client credentials invalid'); } throw err; } The most common bug is treating fetchToken() as "always succeeds if I have valid credentials" and not handling the refresh-token revocation path, causing all users to be silently logged out at the next refresh.
    costhighin prodimmediate exceptionusers seeservice unavailablevisibilityvisible
    Sources[14][15]
  • discoverOAuthServerInfo · discover-oauth-server-info-fetch-error
    error
    WhendiscoverOAuthServerInfo() is awaited but the authorization-server metadata fetch fails. This can happen when (a) the network is down, (b) the auth server is misconfigured (returns 5xx or non-JSON), (c) the legacy fallback URL (server URL itself) is not an OAuth authorization server. The protected-resource-metadata error is silently swallowed by the inner try/catch, but the discoverAuthorizationServerMetadata error propagates.
    ThrowsError from discoverAuthorizationServerMetadata: network error, 'Failed to discover OAuth metadata' error, or fetch TypeError. The thrown error does NOT distinguish between "no auth server configured" and "auth server temporarily unreachable" — both surface as discovery failures.
    Required handlingCaller MUST wrap in try-catch and distinguish "discovery permanently fails (legacy server, no OAuth)" from "discovery temporarily fails": try { const { authorizationServerUrl, authorizationServerMetadata } = await discoverOAuthServerInfo(serverUrl, { fetchFn }); cache.set(serverUrl, { authorizationServerUrl, authorizationServerMetadata }); } catch (err) { if (err instanceof TypeError) { // Network error — retry with exponential backoff throw new TransientError('OAuth discovery network error', { cause: err }); } // Permanent failure — server doesn't expose OAuth metadata throw new ConfigError('OAuth not configured at ' + serverUrl); } Failing to handle the transient-vs-permanent distinction either causes (a) infinite retry loops on a permanently misconfigured server, or (b) immediate failure on the first network glitch during startup, blocking the entire client.
    costmediumin prodimmediate exceptionusers seeservice unavailablevisibilityvisible
  • experimental.tasks.createMessageStream · create-message-stream-error-message-not-handled
    error
    WhenThe AsyncGenerator returned by createMessageStream() yields a ResponseMessage with type === 'error' but the consuming for-await loop does not branch on message.type. The 'error' message contains the actual MCP error (CapabilityMismatchError, ToolUseValidationError, or upstream model provider error) but the loop continues iterating and the error is silently dropped. Worse, if the consumer relies only on the 'result' message, the loop exits without processing a result and downstream code receives undefined.
    ThrowsDoes NOT throw automatically. The 'error' ResponseMessage carries the actual Error in its .error field but the for-await loop must explicitly throw it. Using shared/responseMessage.js takeResult() helper auto-throws — without it, errors are silently lost.
    Required handlingCaller MUST either (a) use the takeResult() helper which throws on 'error' messages, OR (b) explicitly branch on message.type and throw on 'error': // Option A — use takeResult() helper: import { takeResult } from '@modelcontextprotocol/sdk/shared/responseMessage.js'; try { const result = await takeResult( server.experimental.tasks.createMessageStream(params) ); } catch (err) { logger.error('Sampling failed', err); throw err; } // Option B — manual handling: try { for await (const message of stream) { switch (message.type) { case 'taskCreated': break; case 'taskStatus': break; case 'result': finalResult = message.result; break; case 'error': throw message.error; // critical } } } catch (err) { // ... } // BUG — silently drops errors: for await (const message of stream) { if (message.type === 'result') finalResult = message.result; // 'error' messages silently ignored }
    costhighin prodsilent failureusers seelost datavisibilitysilent
    Sources[18][19]
  • experimental.tasks.createMessageStream · create-message-stream-tool-capability-not-checked
    error
    WhencreateMessageStream() is called with params.tools or params.toolChoice but the connected client did NOT advertise the sampling.tools capability during initialization. The synchronous capability check throws immediately before yielding any stream message.
    ThrowsError('Client does not support sampling tools capability.') — thrown synchronously when entering the generator, before any ResponseMessage is yielded.
    Required handlingCaller MUST either (a) gate the call on server.getClientCapabilities()?.sampling?.tools at call-site, OR (b) wrap the iteration in try-catch and surface a UI-friendly error telling the user their client is too old: const caps = server.getClientCapabilities(); if ((params.tools || params.toolChoice) && !caps?.sampling?.tools) { throw new UnsupportedClientError( 'Update your MCP client to use tools in sampling' ); } const stream = server.experimental.tasks.createMessageStream(params); const result = await takeResult(stream); Without this check, every sampling request from older clients fails with a confusing generic Error.
    costmediumin prodimmediate exceptionusers seeservice unavailablevisibilityvisible
    Sources[18]
  • experimental.tasks.elicitInputStream · elicit-input-stream-error-message-not-handled
    error
    WhenThe AsyncGenerator returned by elicitInputStream() yields a ResponseMessage with type === 'error' but the consuming for-await loop does not branch on message.type. The 'error' message contains the actual elicitation failure (user cancellation, timeout, or client rejection) but the loop continues iterating and the error is silently dropped. Downstream code receives undefined and treats it as "user declined" instead of surfacing the actual error.
    ThrowsDoes NOT throw automatically. The 'error' ResponseMessage carries the actual Error in its .error field. The for-await loop must explicitly check message.type and throw, or use takeResult().
    Required handlingCaller MUST use takeResult() OR explicitly branch on message.type. For URL-mode elicitations (long-running OAuth-style flows), the taskStatus messages are observability only — the actual result or error arrives at terminal messages: import { takeResult } from '@modelcontextprotocol/sdk/shared/responseMessage.js'; try { const result = await takeResult( server.experimental.tasks.elicitInputStream({ mode: 'url', message: 'Please authenticate', elicitationId: 'auth-123', url: 'https://example.com/auth', }, { task: { ttl: 300000 } }) ); if (result.action === 'accept') { // user completed elicitation } else if (result.action === 'decline') { // user explicitly declined } } catch (err) { logger.error('Elicitation failed', err); throw err; }
    costhighin prodsilent failureusers seelost datavisibilitysilent
    Sources[18][19]
  • takeResult · take-result-missing-error-handling
    error
    WhenA call to takeResult(stream) is awaited without try/catch and without an upstream catch handler. takeResult MUST throw whenever the source generator yields an 'error' ResponseMessage or terminates without a 'result'. The error type is the embedded McpError (carrying ErrorCode and message from the server) OR a plain Error('No result in stream.') when the stream ended prematurely (transport closed mid-task, server crashed, network drop). Uncaught, this becomes an unhandled promise rejection that crashes the worker / Express handler / event loop.
    ThrowsMcpError (from the 'error' ResponseMessage payload, code + data from server) OR Error('No result in stream.') when the generator terminates without yielding a result. Iteration-level errors from the source stream propagate unchanged.
    Required handlingAlways wrap takeResult() in try/catch when consuming a tool/sampling stream. The 'No result in stream.' case is real: when the transport drops mid-iteration (StreamableHTTP session timeout, Stdio pipe close), the generator returns without throwing a network error but also without yielding 'result', and takeResult surfaces the truncation with that exact message: import { takeResult } from '@modelcontextprotocol/sdk/shared/responseMessage.js'; try { const result = await takeResult( client.experimental.tasks.callToolStream({ name: 'search', arguments: {} }) ); return result.content; } catch (err) { if (err instanceof McpError) { logger.error('Tool returned error', { code: err.code }); } else if (err.message === 'No result in stream.') { logger.error('Stream closed without result — likely transport drop'); } throw err; }
    costhighin prodimmediate exceptionusers seeservice unavailablevisibilityvisible
    Sources[19]
  • toArrayAsync · to-array-async-missing-error-handling
    error
    WhenA call to toArrayAsync(stream) is awaited without try/catch. While toArrayAsync does NOT throw on 'error' ResponseMessages in the stream (those land in the returned array), it DOES propagate any iteration error from the underlying AsyncGenerator — transport closure mid-task, parse errors on malformed server messages, McpError thrown during stream setup. Uncaught, this becomes an unhandled promise rejection. Additionally: callers using toArrayAsync MUST inspect the returned array for 'error' messages before treating it as success.
    ThrowsPropagates errors from the source AsyncGenerator (transport drop, parse errors, McpError on setup). Does NOT throw on 'error' messages within the stream — those land in the returned array.
    Required handlingWrap toArrayAsync() in try/catch AND inspect the returned array for 'error' entries before treating the result as success: import { toArrayAsync } from '@modelcontextprotocol/sdk/shared/responseMessage.js'; try { const messages = await toArrayAsync( client.experimental.tasks.callToolStream({ name: 'search', arguments: {} }) ); const errorMsg = messages.find(m => m.type === 'error'); if (errorMsg) { throw errorMsg.error; } const resultMsg = messages.find(m => m.type === 'result'); if (!resultMsg) { throw new Error('Stream produced no result'); } return resultMsg.result; } catch (err) { logger.error('Tool stream collection failed', err); throw err; }
    costmediumin prodsilent failureusers seelost datavisibilitysilent
    Sources[19]
  • experimental.tasks.requestStream · request-stream-error-message-not-handled
    error
    WhenThe AsyncGenerator returned by requestStream() yields a ResponseMessage with type === 'error' but the consuming for-await loop does not branch on message.type. The 'error' message contains the actual MCP error (McpError with ErrorCode from the server, schema validation failure from the resultSchema, or timeout from RequestOptions.timeout) but the loop continues iterating. Generic custom-method consumers often write a for-await loop that only checks message.type === 'result', dropping all errors silently. Downstream code receives undefined and behaves as if the request succeeded with empty data.
    ThrowsDoes NOT throw automatically from the for-await loop. The 'error' ResponseMessage carries the actual Error in its .error field. The consumer must explicitly branch on message.type or use takeResult() which auto-throws. Iteration errors from the underlying transport propagate normally.
    Required handlingUse takeResult() OR explicitly branch on message.type === 'error': import { takeResult } from '@modelcontextprotocol/sdk/shared/responseMessage.js'; try { const result = await takeResult( client.experimental.tasks.requestStream( { method: 'custom/operation', params: {...} }, CustomResultSchema, { task: { ttl: 60000 } } ) ); return result; } catch (err) { if (err instanceof McpError) { logger.error('Custom request failed', { code: err.code }); } throw err; }
    costhighin prodsilent failureusers seelost datavisibilitysilent
    Sources[20][19]
  • OAuthServerProvider.verifyAccessToken · verify-access-token-must-throw-on-invalid
    error
    WhenA custom OAuthServerProvider.verifyAccessToken(token) implementation does NOT throw InvalidTokenError on tokens it cannot validate. Instead, it returns dummy AuthInfo, returns from a try/catch that swallows the underlying validation error, or resolves with unverified data from a JWT decode without signature check. requireBearerAuth() calls await verifier.verifyAccessToken(token) with no defensive validity gate — it only checks expiresAt and scopes on the returned object. An attacker presenting a crafted or expired token receives a 200 response with full access. This is an authentication-bypass class bug, not a hardening defect.
    ThrowsThe contract: verifyAccessToken MUST throw InvalidTokenError (from @modelcontextprotocol/sdk/server/auth/errors.js) for any token that is malformed, expired (if the provider validates this itself), revoked, unknown, or signed by an untrusted key. May also throw InsufficientScopeError, ServerError (upstream auth server unreachable), or any OAuthError subclass — these are handled by the middleware with appropriate HTTP status codes. Throwing a plain Error (not OAuthError) coerces to 500 — also acceptable from a security standpoint (fail-closed).
    Required handlingCustom OAuthServerProvider / OAuthTokenVerifier implementations must throw InvalidTokenError on every code path where the token cannot be verified. JWT signature failures, database lookups returning no row, upstream introspection endpoint returning inactive=true — all of these must result in a throw, never a silent return: import { InvalidTokenError } from '@modelcontextprotocol/sdk/server/auth/errors.js'; class MyTokenVerifier implements OAuthTokenVerifier { async verifyAccessToken(token: string): Promise<AuthInfo> { try { const decoded = await this.verifyJwt(token); if (!decoded) { throw new InvalidTokenError('Token verification failed'); } if (decoded.revoked) { throw new InvalidTokenError('Token has been revoked'); } return { token, clientId: decoded.client_id, scopes: decoded.scope?.split(' ') ?? [], expiresAt: decoded.exp, }; } catch (err) { if (err instanceof InvalidTokenError) throw err; // Never swallow — re-throw as InvalidTokenError throw new InvalidTokenError( `Token verification failed: ${(err as Error).message}` ); } } } The middleware translates InvalidTokenError into a 401 response with a WWW-Authenticate header. Returning dummy AuthInfo to "fail open" so logs can capture the request is a critical mistake — the request reaches the protected handler with req.auth populated.
    costcriticalin prodsilent failureusers seesecurity breachvisibilitysilent
  • withOAuth · with-oauth-enhanced-fetch-must-handle-unauthorized
    error
    WhenA fetch pipeline built with withOAuth (typically via applyMiddlewares(withOAuth(provider), ...)) is awaited without try/catch around the resulting enhanced fetch call. The enhanced fetch throws UnauthorizedError on three paths: (a) provider.auth() returns 'REDIRECT' meaning the user must complete interactive authorization, (b) auth() returns any non-'AUTHORIZED' status, (c) the retried request after re-auth still returns 401. Each of these is a genuine auth failure that the caller MUST handle — the enhanced fetch deliberately surfaces these as exceptions instead of returning the 401 Response. Without try/catch, this becomes an unhandled rejection that crashes the worker / Express handler / event loop, and the user receives no actionable error message about the auth failure.
    ThrowsUnauthorizedError (from @modelcontextprotocol/sdk/client/auth.js) with one of three messages: "Authentication requires user authorization - redirect initiated", "Authentication failed with result: <status>", or "Authentication failed for <url>". The original 401 Response is NOT returned — it is discarded. Network-level errors propagate from the underlying fetch unchanged (TypeError on DNS failure, AbortError on timeout).
    Required handlingAlways wrap the enhanced fetch call in try/catch and branch on UnauthorizedError. For (a) REDIRECT, surface a UI affordance for the user to complete authorization. For (b)/(c), treat as a hard failure — refresh tokens / re-discover endpoints / surface to operator: import { withOAuth, applyMiddlewares } from '@modelcontextprotocol/sdk/client/middleware.js'; import { UnauthorizedError } from '@modelcontextprotocol/sdk/client/auth.js'; const enhanced = applyMiddlewares( withOAuth(provider, 'https://api.example.com') )(fetch); try { const response = await enhanced('https://api.example.com/data'); return await response.json(); } catch (err) { if (err instanceof UnauthorizedError) { if (err.message.includes('redirect initiated')) { redirectToAuthorizationUI(); return null; } logger.error('OAuth re-auth failed', { url, err }); throw new ApiAuthError('Authentication failed', { cause: err }); } throw err; } Note: MCP transports (SSE, StreamableHTTP) handle OAuth internally — withOAuth is for plain fetch calls in application code adjacent to MCP, not for the MCP transport itself.
    costmediumin prodimmediate exceptionusers seeauthentication failurevisibilityvisible

Sources

Every postcondition cites at least one of these. Grouped by source type; numbered to match the footnotes above.

Official documentation
  • [2]
    modelcontextprotocol.io/docs/concepts/transports
    Transports
  • [3]
    spec.modelcontextprotocol.io/specification/server/tools
    Tools
  • [4]
    spec.modelcontextprotocol.io/specification/server/tools
    Tools
  • [5]
    modelcontextprotocol.io/specification/2025-03-26/basic
    Authorization
  • [6]
    modelcontextprotocol.io/specification/2025-03-26/basic
    Transports
  • [7]
    modelcontextprotocol.io/specification/2025-03-26/basic
    Transports
  • [8]
    modelcontextprotocol.io/specification/2025-03-26/basic
    Transports
  • [10]
    modelcontextprotocol.io/docs/concepts/transports
    Transports
  • [13]
    modelcontextprotocol.io/specification/2025-03-26/server
    Logging
  • [15]
    datatracker.ietf.org/doc/html/rfc6749
    Rfc6749
  • [16]
    datatracker.ietf.org/doc/html/rfc9728
    Rfc9728
  • [17]
    datatracker.ietf.org/doc/html/rfc8414
    Rfc8414
Source code
Issues & pull requests

Research notes

Curator notes from SOURCES.md captured when the profile was written so you can verify the reasoning, not just the rules.

Sources: @modelcontextprotocol/sdk

Official Documentation

URLDescription
https://modelcontextprotocol.io/introductionMCP introduction and overview
https://spec.modelcontextprotocol.io/Full MCP specification
https://spec.modelcontextprotocol.io/specification/server/tools/Tool specification including error handling
https://spec.modelcontextprotocol.io/specification/server/tools/#error-handlingIn-band error handling for callTool
https://modelcontextprotocol.io/docs/concepts/transportsTransport documentation
https://github.com/modelcontextprotocol/typescript-sdkTypeScript SDK GitHub repository
https://github.com/modelcontextprotocol/typescript-sdk/blob/main/packages/client/src/client/client.tsClient implementation source
https://github.com/modelcontextprotocol/typescript-sdk/blob/main/packages/server/src/server/mcp.tsMcpServer implementation source
https://github.com/modelcontextprotocol/typescript-sdk/blob/main/packages/server/src/server/server.tsServer base class source

Real-World Evidence

Correct error handling (witsy — 22k+ stars)

Correct error handling (n8n — 50k+ stars)

Missing error handling (nocodb — 49k+ stars)

CLI tool with top-level catch (groq-compound-mcp)

  • Pattern: server.connect() inside main(), with main().catch(...) — acceptable for CLI
Need a different package?
Request a profile