MCP Client skill

MCP Client is an agent skill for AI coding assistants (Claude Code, OpenClaw, Cursor, Codex). Consume MCP (Model Context Protocol) servers over stdio (local) or Streamable HTTP (remote): initialize handshake, call tools, read resources, get prompts, pagination/timeouts/errors, OAuth/bearer auth, plus Claude Desktop/Code, Cursor, OpenClaw config. Use when wiring an agent to an MCP server or debugging a transport/auth failure. Install with: npx skills-ws install mcp-client.

devv1.0.0Updated
copied ✓
openclawclaude-codecursorcodex
0 installsSecurity scan: cleanSource code

MCP Client — Consuming Model Context Protocol Servers

Transport policy (MCP spec): Use stdio for local subprocess servers and Streamable HTTP (StreamableHTTPClientTransport, endpoint usually /mcp) for remote servers. The old HTTP+SSE transport was deprecated in spec revision 2025-03-26 and superseded by Streamable HTTP; keep it only as a legacy fallback for old servers (endpoint usually /sse). WebSocket transport was removed. As of Jun 2026 the latest spec revision is 2025-11-25 — verify at https://modelcontextprotocol.io/specification.

This skill makes an agent expert at being an MCP client: discovering a server's capabilities, calling its tools, reading its resources, using its prompts, and doing so safely with auth, timeouts, retries, and cost control. It is provider-agnostic; one specific public server (mcp.skills.ws) appears only as an optional worked example at the end.

For building the server side, see the sibling skill mcp-server-builder. For agent orchestration around these tool calls, see ai-agent-building. For wallet/payment flows (x402), see wallet-integration and defi-integration.

What this skill covers

  1. Transports: stdio (local) vs Streamable HTTP (remote), with a Streamable-HTTP-then-SSE fallback for legacy servers.
  2. Connection lifecycle: initialize handshake, protocol-version negotiation, capability discovery, clean shutdown.
  3. Primitives: tools (tools/list, tools/call), resources (resources/list, resources/read, templates, subscriptions), prompts (prompts/list, prompts/get), and client-provided capabilities (roots, sampling, elicitation).
  4. Robustness: cursor pagination, progress notifications, cancellation, timeouts, JSON-RPC error codes, retries with backoff.
  5. Auth: OAuth 2.1 (the spec's standard for remote HTTP servers) plus provider-specific bearer/API-key headers; token storage and least privilege.
  6. Client configuration for Claude Desktop, Claude Code, Cursor, and OpenClaw (stdio + Streamable HTTP).
  7. Cost, caching, and security best practices.

1. Transports

MCP is JSON-RPC 2.0 messages over a transport. You pick the transport based on where the server runs.

TransportUse forEndpoint shapeSDK class (TS)SDK helper (Py)
stdioLocal subprocess (a CLI you spawn)command + argsStdioClientTransportstdio_client
Streamable HTTPRemote server over the network (preferred)https://host/mcpStreamableHTTPClientTransportstreamablehttp_client
HTTP+SSE (legacy)Old remote servers built pre-2025-03-26https://host/sseSSEClientTransportsse_client

stdio — the server is a process you launch; messages flow over stdin/stdout, framed as newline-delimited JSON-RPC. Most "install an MCP server" instructions (npx @scope/server, uvx some-server) are stdio. Logging must go to stderr — never stdout (stdout is the protocol channel).

Streamable HTTP — a single HTTP endpoint (commonly /mcp). The client POSTs JSON-RPC requests; the server may answer with a single application/json body or upgrade to an SSE stream (text/event-stream) for streaming/server-initiated messages. After the initialize response, the client must echo the negotiated version on every request via the MCP-Protocol-Version header, and persist any Mcp-Session-Id the server returns. This replaces the deprecated two-endpoint SSE transport.

Legacy SSE — two endpoints (a GET SSE stream + a POST channel). Only for servers that predate Streamable HTTP. Detect-and-fallback (see §2.3); don't build new clients on it.

Choosing and detecting

  • If you control the launch command → stdio.
  • If you have a URL → try Streamable HTTP first, fall back to SSE only if the server rejects it (HTTP 400/404/405 on the initialize POST).
  • Public server registries (the MCP registry server.json) list remotes[] entries typed streamable-http or sse; prefer the streamable-http entry.

2. Programmatic client (official SDK)

Install: npm i @modelcontextprotocol/sdk (TypeScript) or pip install mcp / uv add mcp (Python). The TypeScript SDK uses subpath imports under @modelcontextprotocol/sdk/.... (As of Jun 2026; check the current import paths and package name at https://github.com/modelcontextprotocol/typescript-sdk and https://github.com/modelcontextprotocol/python-sdk before pinning.)

2.1 Connect to a remote server (Streamable HTTP) — TypeScript

import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';

const client = new Client(
  { name: 'my-agent', version: '1.0.0' },
  // Advertise the client capabilities you actually implement (see §3.4).
  { capabilities: { /* roots: { listChanged: true }, sampling: {}, elicitation: {} */ } }
);

const transport = new StreamableHTTPClientTransport(
  new URL('https://api.example.com/mcp'),
  {
    // Provider-specific static headers (API key, etc.). For OAuth, prefer authProvider — see §5.
    requestInit: {
      headers: new Headers({ Authorization: `Bearer ${process.env.MCP_TOKEN}` }),
    },
  }
);

await client.connect(transport); // performs the initialize handshake for you
// ... use the client (see §3) ...
await client.close();

client.connect() runs the full initialize exchange: it sends the client's protocol version + capabilities + clientInfo, receives the server's negotiated version + capabilities + serverInfo, and sends the notifications/initialized ack. You don't hand-roll JSON-RPC.

2.2 Connect to a local server (stdio) — TypeScript

import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';

const transport = new StdioClientTransport({
  command: 'npx',
  args: ['-y', '@scope/some-mcp-server'],
  env: { ...process.env, SOME_API_KEY: process.env.SOME_API_KEY ?? '' }, // pass only what's needed
});

const client = new Client({ name: 'my-agent', version: '1.0.0' });
await client.connect(transport);

Secrets reach a local server via its environment, not the command line (args are visible in ps). Pass an explicit env allowlist rather than leaking your whole environment.

2.3 Streamable HTTP with SSE fallback (support old + new servers)

This is the canonical compatibility pattern from the SDK docs:

import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js';

async function connectRemote(url: string) {
  const baseUrl = new URL(url);
  try {
    // Preferred: modern Streamable HTTP
    const client = new Client({ name: 'my-agent', version: '1.0.0' });
    const transport = new StreamableHTTPClientTransport(baseUrl);
    await client.connect(transport);
    return { client, transport };
  } catch {
    // Legacy fallback: old HTTP+SSE servers (deprecated 2025-03-26)
    const client = new Client({ name: 'my-agent', version: '1.0.0' });
    const transport = new SSEClientTransport(baseUrl);
    await client.connect(transport);
    return { client, transport };
  }
}

2.4 Python client (Streamable HTTP, with stdio + legacy SSE shown)

import asyncio
from mcp import ClientSession, StdioServerParameters
from mcp.client.stdio import stdio_client
from mcp.client.streamable_http import streamablehttp_client  # preferred for remote
# from mcp.client.sse import sse_client                       # legacy fallback only

async def remote():
    # Provider-specific auth headers; for OAuth see the SDK auth helpers (§5).
    headers = {"Authorization": "Bearer <token>"}
    async with streamablehttp_client(
        "https://api.example.com/mcp", headers=headers, timeout=30
    ) as (read, write, _get_session_id):
        async with ClientSession(read, write) as session:
            await session.initialize()
            tools = await session.list_tools()
            print("tools:", [t.name for t in tools.tools])
            result = await session.call_tool("dns_lookup", {"domain": "example.com"})
            print(result.content)

async def local():
    params = StdioServerParameters(command="uvx", args=["some-mcp-server"], env=None)
    async with stdio_client(params) as (read, write):
        async with ClientSession(read, write) as session:
            await session.initialize()
            print((await session.list_tools()).tools)

asyncio.run(remote())

SDK note (Jun 2026): newer Python SDK releases also expose a streamable_http_client(url, http_client=httpx.AsyncClient(...)) form where you configure headers/timeout/auth on an httpx.AsyncClient (set follow_redirects=True). Use whichever your installed mcp version documents — python -c "import mcp; print(mcp.__version__)" then check that version's docs/.


3. Using server capabilities

After initialize, only call primitives the server actually advertised in its capabilities. Calling a method the server didn't declare returns a JSON-RPC error (-32601 Method not found).

3.1 Tools — discover and call

// List (paginate — never assume one page; see §3.5)
const { tools } = await client.listTools();
console.log(tools.map(t => `${t.name}: ${t.description}`));
// Each tool has a JSON Schema `inputSchema`; validate arguments against it before calling.

// Call
const result = await client.callTool({
  name: 'dns_lookup',
  arguments: { domain: 'example.com', type: 'MX' },
});

// Read structured + unstructured output. `content` is an array of typed blocks.
for (const block of result.content) {
  if (block.type === 'text') console.log(block.text);
  // other block types: 'image' (data+mimeType), 'resource', 'resource_link', 'audio'
}
// Tools signal failures via result.isError === true (NOT a transport/JSON-RPC error).
// Modern servers may also return `result.structuredContent` (typed JSON) — prefer it when present.
if (result.isError) {
  throw new Error('Tool reported an error: ' + JSON.stringify(result.content));
}

Two error channels — keep them straight:

  • Protocol errors (bad params, unknown method, transport down) → thrown as an McpError carrying a JSON-RPC code (v1 SDK; v2 splits this into local SdkError vs server-side ProtocolError).
  • Tool execution errors (the DNS lookup failed, the API 500'd) → returned successfully with result.isError === true and a human-readable message in content. The model is meant to see and react to these, so don't mask them.

3.2 Resources — list, read, templates, subscribe

// List concrete resources (paginate on nextCursor)
const { resources } = await client.listResources();
// Read one by URI
const { contents } = await client.readResource({ uri: 'config://app/settings' });
for (const item of contents) {
  // item.uri, item.mimeType, and either item.text or item.blob (base64)
  console.log(item.uri, item.mimeType);
}

// Resource templates (RFC 6570 URI templates) for parameterized reads
const { resourceTemplates } = await client.listResourceTemplates();
// e.g. uriTemplate: "github://repos/{owner}/{repo}/issues/{id}"

// If the server's resources capability advertises `subscribe: true`:
import { ResourceUpdatedNotificationSchema } from '@modelcontextprotocol/sdk/types.js';
await client.subscribeResource({ uri: 'log://app/today' });
client.setNotificationHandler(
  ResourceUpdatedNotificationSchema,
  (n) => console.log('resource changed:', n.params.uri)
);

Resources are app-controlled context (read-only data the host chooses to feed the model), distinct from tools (model-invoked actions). Don't treat a resources/read as a side-effecting call.

3.3 Prompts — list and fill

const { prompts } = await client.listPrompts(); // each has name + argument schema
const { messages } = await client.getPrompt({
  name: 'review-code',
  arguments: { code: 'console.log("hello")' },
});
// `messages` is a ready-to-send array of {role, content} you forward to the model.

Prompts are user-controlled templates (often surfaced as slash commands / menu items). Let the user pick them; don't auto-invoke silently.

3.4 Client-provided capabilities (the reverse direction)

A server can call back into your client if you advertised the capability in initialize:

  • roots — you expose filesystem roots the server may operate within (register a ListRootsRequest handler).
  • sampling — the server asks your model to generate text (sampling/createMessage). Gate this behind user approval and a token/cost budget; a malicious server could otherwise drive your LLM spend.
  • elicitation — the server asks the user for structured input mid-call (you render a form from a JSON Schema and return the answer). Never auto-fill secrets; show the user what's being requested.

Only advertise what you actually implement and intend to honor.

3.5 Pagination, progress, cancellation, timeouts

// Pagination: list endpoints return nextCursor; loop until it's undefined.
const allTools = [];
let cursor: string | undefined;
do {
  const page = await client.listTools({ cursor });
  allTools.push(...page.tools);
  cursor = page.nextCursor;
} while (cursor);

// Per-call timeout (default is 60s). On timeout the SDK sends a cancellation to the server.
// v1 SDK (npm i @modelcontextprotocol/sdk): McpError + ErrorCode from .../sdk/types.js.
// v2 renamed these — local errors become `SdkError`/`SdkErrorCode` and protocol errors
// `ProtocolError`/`ProtocolErrorCode`, both imported from `@modelcontextprotocol/client`.
// Check your installed major version and import accordingly.
import { McpError, ErrorCode } from '@modelcontextprotocol/sdk/types.js';
try {
  const r = await client.callTool(
    { name: 'slow-operation', arguments: {} },
    { timeout: 120_000 } // override default 60s
  );
} catch (e) {
  if (e instanceof McpError && e.code === ErrorCode.RequestTimeout) {
    console.error('timed out');
  } else { throw e; }
}

// Progress + long-running work: pass onprogress; reset the timeout as progress arrives,
// but cap total wall-clock with maxTotalTimeout so a stalled server can't hang forever.
await client.callTool(
  { name: 'long-operation', arguments: {} },
  {
    onprogress: ({ progress, total }) => console.log(`${progress}/${total ?? '?'}`),
    resetTimeoutOnProgress: true,
    maxTotalTimeout: 600_000,
  }
);

// Manual cancellation via AbortSignal:
const ac = new AbortController();
const p = client.callTool({ name: 'x', arguments: {} }, { signal: ac.signal });
// ac.abort();  // sends notifications/cancelled to the server

3.6 JSON-RPC / SDK error codes

CodeNameTypical causeClient action
-32700Parse errorMalformed JSON on the wireBug in transport/serialization; report
-32600Invalid requestBad JSON-RPC envelopeFix request shape
-32601Method not foundCalled a capability the server didn't advertiseCheck initialize capabilities first
-32602Invalid paramsArguments fail the tool's inputSchemaValidate args against the schema
-32603Internal errorServer-side exceptionRetry with backoff; if persistent, report
-32002Resource not foundBad/expired resource URIRe-list resources
-32001Request timeout (SDK)No response within timeoutBackoff/retry; raise timeout for slow tools

Distinguish these (protocol failures) from result.isError === true (the tool ran but failed). Retry only idempotent operations; never blindly retry a tool that may have side effects.


4. Configuring AI clients

Below: local (stdio) and remote (Streamable HTTP) for each client. Auth on remote servers is provider-specific — use OAuth (the client opens a browser flow) where the server supports it, or inject a bearer/API-key header. CLIs and config schemas change; verify against each tool's current docs (links inline).

4.1 Claude Desktop

Edit ~/Library/Application Support/Claude/claude_desktop_config.json (macOS) or %APPDATA%\Claude\claude_desktop_config.json (Windows). Claude Desktop primarily launches stdio servers:

{
  "mcpServers": {
    "filesystem": {
      "command": "npx",
      "args": ["-y", "@modelcontextprotocol/server-filesystem", "/Users/me/projects"]
    },
    "my-remote": {
      "command": "npx",
      "args": ["-y", "mcp-remote", "https://api.example.com/mcp", "--header", "Authorization: Bearer ${MCP_TOKEN}"],
      "env": { "MCP_TOKEN": "..." }
    }
  }
}

Native remote (Streamable HTTP / OAuth) support in Claude Desktop has shipped via Connectors/Settings rather than this JSON file; for a remote URL without native support, bridge it with the mcp-remote stdio adapter as above. Verify current options at https://modelcontextprotocol.io/docs/develop/connect-local-servers (as of Jun 2026).

4.2 Claude Code

# Local stdio server
claude mcp add filesystem -- npx -y @modelcontextprotocol/server-filesystem ~/projects

# Remote Streamable HTTP server (preferred for URLs)
claude mcp add --transport http my-remote https://api.example.com/mcp \
  --header "Authorization: Bearer ${MCP_TOKEN}"

# Legacy SSE server (only if it doesn't support Streamable HTTP)
claude mcp add --transport sse old-remote https://legacy.example.com/sse

# Manage
claude mcp list
claude mcp get my-remote
claude mcp remove my-remote

Equivalent .mcp.json (project-scoped, commit-safe if it contains no secrets):

{
  "mcpServers": {
    "my-remote": {
      "type": "http",
      "url": "https://api.example.com/mcp",
      "headers": { "Authorization": "Bearer ${MCP_TOKEN}" }
    },
    "old-remote": { "type": "sse", "url": "https://legacy.example.com/sse" }
  }
}

Use "type": "http" for Streamable HTTP. Keep "type": "sse" only for legacy servers. Reference: https://code.claude.com/docs/en/mcp (verify flags/keys for your version).

4.3 Cursor

~/.cursor/mcp.json (global) or .cursor/mcp.json (project):

{
  "mcpServers": {
    "filesystem": {
      "command": "npx",
      "args": ["-y", "@modelcontextprotocol/server-filesystem", "/Users/me/projects"]
    },
    "my-remote": {
      "url": "https://api.example.com/mcp",
      "headers": { "Authorization": "Bearer ${MCP_TOKEN}" }
    }
  }
}

A url entry is treated as remote (Streamable HTTP, SSE as fallback). Cursor also supports OAuth login for servers that advertise it. Docs: https://docs.cursor.com/context/mcp (verify, as of Jun 2026).

4.4 OpenClaw

openclaw.json:

{
  "mcp": {
    "servers": {
      "filesystem": {
        "transport": "stdio",
        "command": "npx",
        "args": ["-y", "@modelcontextprotocol/server-filesystem", "/Users/me/projects"]
      },
      "my-remote": {
        "transport": "http",
        "url": "https://api.example.com/mcp",
        "headers": { "Authorization": "Bearer ${MCP_TOKEN}" }
      },
      "old-remote": {
        "transport": "sse",
        "url": "https://legacy.example.com/sse"
      }
    }
  }
}

Prefer "transport": "http" (Streamable HTTP); reserve "sse" for legacy servers. Confirm exact keys against the OpenClaw version you run.

Secrets in configs: reference env vars (${MCP_TOKEN}) rather than pasting tokens; keep any file that contains a literal secret out of git.


5. Authentication

There are two worlds:

  1. OAuth 2.1 — the MCP spec's standard for remote HTTP servers. The server is an OAuth resource server; you obtain a token from its authorization server and send Authorization: Bearer <token>.
  2. Provider-specific headers — many real servers just want a static API-key header (Authorization: Bearer ..., X-Api-Key: ...). Simpler, but the key is long-lived — store and scope it carefully.

5.1 OAuth 2.1 discovery flow (spec)

  1. Client hits the MCP endpoint with no token → server returns 401 Unauthorized with a WWW-Authenticate: Bearer ... resource_metadata="https://server/.well-known/oauth-protected-resource" header. If the header is absent, fall back to the well-known PRM URIs (endpoint path first, then root).
  2. Client fetches that Protected Resource Metadata (PRM) doc to learn the authorization server(s).
  3. Client fetches the authorization server's metadata (try RFC 8414 /.well-known/oauth-authorization-server, then OpenID Connect Discovery /.well-known/openid-configuration; clients must support both), then runs an Authorization Code + PKCE flow using the S256 challenge method (refuse to proceed if the metadata lacks code_challenge_methods_supported). For client registration, prefer Client ID Metadata Documents (an HTTPS URL as client_id, advertised via client_id_metadata_document_supported); Dynamic Client Registration is an optional fallback kept for backwards compatibility. Include the resource parameter (RFC 8707, the MCP server's canonical URL) in both the authorization request and the token request so the token is audience-bound to this server, and get an access token (+ refresh token).
  4. Client retries with Authorization: Bearer <access_token>.
  5. On 401/expiry, refresh; on 403 you lack scope.

5.2 OAuth in the TypeScript SDK

For a token minted out-of-band, don't reach for authProvider; just send it as a static header (same pattern as §5.3):

import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';

const transport = new StreamableHTTPClientTransport(new URL('https://api.example.com/mcp'), {
  requestInit: { headers: new Headers({ Authorization: `Bearer ${process.env.MCP_TOKEN}` }) },
});

Reserve authProvider for full interactive OAuth (PKCE, redirect, token persistence): it must implement the complete OAuthClientProvider interface (redirect URL, client metadata, clientInformation(), tokens()/saveTokens(), redirectToAuthorization(), code-verifier storage), and you call transport.finishAuth(authorizationCode) after the redirect returns. Check the current interface at https://ts.sdk.modelcontextprotocol.io/ (v1 SDK docs; v2 docs under /v2/), the auth API evolves.

5.3 Provider-specific header auth

const transport = new StreamableHTTPClientTransport(new URL('https://api.example.com/mcp'), {
  requestInit: { headers: new Headers({ 'X-Api-Key': process.env.SERVICE_API_KEY! }) },
});

5.4 Token hygiene

  • Never hardcode tokens in source or commit them in config; read from env / a secret manager.
  • Never put secrets in URLs or stdio args (they leak to logs / ps); use headers or env.
  • Prefer short-lived access tokens + refresh; rotate long-lived API keys on a schedule.
  • Request least privilege scopes; use a distinct key per app/environment so one leak is contained.
  • Validate every authorization URL a server hands you before opening it: allow only http/https (http solely for loopback during development), reject javascript:, data:, file:, vbscript:, and never open the URL through a shell command (use a platform URL-opening API instead). A malicious server can otherwise turn the OAuth redirect into XSS or code execution.
  • Treat a server as untrusted: it can return prompt-injection-laden tool output. Don't auto-execute server-suggested shell/SQL; gate sampling/elicitation behind user approval.

6. Robustness patterns

6.1 Retry with backoff (transport-level)

async function withRetry<T>(fn: () => Promise<T>, max = 3): Promise<T> {
  for (let i = 0; i < max; i++) {
    try {
      return await fn();
    } catch (e: any) {
      const code = e?.code;                 // JSON-RPC / SDK error code
      const retriable = code === -32603      // internal error
        || code === -32001                   // timeout
        || e?.status === 429 || e?.status === 503;
      if (!retriable || i === max - 1) throw e;
      await new Promise(r => setTimeout(r, Math.min(1000 * 2 ** i, 30_000) + Math.random() * 250));
    }
  }
  throw new Error('unreachable');
}

// Only wrap idempotent calls. A non-idempotent tool (e.g. "send_email") must NOT be auto-retried.
const tools = await withRetry(() => client.listTools());

6.2 HTTP status handling (when calling a raw HTTP/REST endpoint, not via the SDK)

Some "MCP" providers also expose plain REST endpoints. For those, map status codes:

CodeMeaningAction
200OKProcess
400Bad requestFix params
401UnauthenticatedRefresh token / fix key
402Payment requiredSee §8 (handle the 402 challenge)
403ForbiddenInsufficient scope
429Rate limitedHonor Retry-After; backoff
5xxServer errorBackoff + retry (idempotent only)

6.3 Caching (avoid redundant calls)

const cache = new Map<string, { data: unknown; at: number }>();
const TTL: Record<string, number> = { dns: 300_000, whois: 86_400_000, ssl: 3_600_000 };
async function cached(tool: string, args: Record<string, unknown>, fn: () => Promise<unknown>) {
  const key = `${tool}:${JSON.stringify(args)}`;
  const hit = cache.get(key);
  if (hit && Date.now() - hit.at < (TTL[tool] ?? 60_000)) return hit.data;
  const data = await fn();
  cache.set(key, { data, at: Date.now() });
  return data;
}

Cache read-only/slow-changing results (DNS, WHOIS, SSL). Never cache anything user/auth-scoped under a shared key, and never cache side-effecting calls.

6.4 Safe local fallback (no command injection)

If you fall back to a local shell when an MCP call fails, never interpolate user input into a shell string. Resolve DNS with the runtime resolver, or use execFile with an argument array and validate input:

import { resolve4 } from 'node:dns/promises';

async function resilientDns(domain: string) {
  if (!/^[a-z0-9.-]{1,253}$/i.test(domain)) throw new Error('invalid domain');
  try {
    return await client.callTool({ name: 'dns', arguments: { domain, type: 'A' } });
  } catch {
    // Safe: no shell, argument is validated and passed to a resolver API (not a shell string).
    const records = await resolve4(domain);
    return { content: [{ type: 'text', text: JSON.stringify({ records }) }], isError: false };
  }
}

Anti-pattern: execSync(`dig +short ${domain} A`) — a domain of "x; rm -rf ~" executes arbitrary commands. Don't do this.


7. Cost control

  • Cache read-only results (§6.3) — biggest single lever.
  • Batch independent calls with Promise.all to cut latency (not necessarily cost).
  • Set timeouts so a hung server doesn't stall the whole agent (§3.5).
  • Cap LLM-side spend if you grant the server sampling — set a per-session token/dollar budget and require approval (§3.4).
  • Pick the pricing model that fits volume: most providers offer a free tier, a flat subscription, and/or pay-per-call. Subscriptions win above the break-even call volume; pay-per-call/x402 wins for spiky low volume. Confirm the current numbers on the provider's pricing page before optimizing — prices and quotas drift.

8. Pay-per-call (x402) — handle the challenge safely

Some HTTP MCP/API providers gate calls behind x402 (HTTP 402 Payment Required + onchain micropayment). x402 is provider-specific and still evolving (as of Jun 2026), so do not hardcode a price, token, chain, or receiver — read them from the server's 402 challenge each time.

Correct flow: call the endpoint → on 402, parse the challenge the server returns (it specifies accepts: scheme(s), network/chain-id, token contract, amount, payTo receiver, and a nonce/validity window) → pay exactly that, audience/chain-bound → resend the request with the payment proof header the scheme defines → the server (or its facilitator) verifies and serves the response.

// Pseudocode — adapt to the provider's documented x402 scheme; the server dictates the terms.
async function x402Fetch(url: string, opts: RequestInit = {}) {
  let res = await fetch(url, opts);
  if (res.status !== 402) return res;

  const challenge = await res.json(); // { accepts: [{ scheme, network, asset, amount, payTo, nonce, expiresAt }] }
  const terms = challenge.accepts[0];

  // ---- MANDATORY GUARDRAILS before spending money ----
  assertAllowlisted(terms.network, terms.asset, terms.payTo);   // chain/token/receiver allowlist
  assertWithinSpendLimit(terms.asset, terms.amount);            // per-call + rolling budget cap
  if (Date.now() > Date.parse(terms.expiresAt)) throw new Error('challenge expired');
  await confirmWithUser(terms);                                 // explicit human approval (skip only in dev)
  if (process.env.X402_MODE !== 'mainnet') terms.network = TESTNET_FOR(terms.network); // default to testnet

  const proof = await buildAndSignPayment(terms);  // sign per the scheme; bind to chain-id + nonce (replay-safe)
  return fetch(url, { ...opts, headers: { ...opts.headers, 'X-Payment': proof } });
}

Guardrails (non-negotiable for money-moving code):

  • Allowlist the receiver, chain-id, and token contract; reject anything the challenge proposes that isn't pre-approved (a compromised server could swap in its own receiver).
  • Spend limits: enforce a per-call max and a rolling session/day budget; abort on breach.
  • Explicit user confirmation for every payment outside an automated dev/testnet context.
  • Default to testnet; require an explicit mainnet opt-in env flag before real funds move.
  • Replay safety: bind the payment to the challenge's nonce + chain-id + expiry; never reuse a proof.
  • No hardcoded receiver/price. These come from the live challenge, not the skill.
  • This is unaudited financial automation — verify the provider's scheme and your wallet handling, and treat keys per wallet-integration / security-hardening.

KYC/jurisdiction/tax: moving stablecoins may have tax and regulatory implications in your jurisdiction — keep records and consult a professional.


9. Optional worked example — mcp.skills.ws

A public MCP/HTTP service for web-intelligence and onchain reads (screenshots, WHOIS, DNS, SSL, OCR, balances). Shown only to make the generic patterns above concrete. All prices, quotas, supported chains, and receiver addresses below are commercial facts that drift — treat them as illustrative and confirm live values from the service's own responses/pricing page before relying on them.

Connect (Streamable HTTP, preferred):

# Health
curl -s https://mcp.skills.ws/health
# Claude Code, as a remote Streamable HTTP MCP server
claude mcp add --transport http skills-ws https://mcp.skills.ws/mcp \
  --header "X-Api-Key: ${SKILLS_WS_KEY}"
// SDK
const transport = new StreamableHTTPClientTransport(
  new URL('https://mcp.skills.ws/mcp'),
  { requestInit: { headers: new Headers({ 'X-Api-Key': process.env.SKILLS_WS_KEY ?? '' }) } }
);
const client = new Client({ name: 'my-agent', version: '1.0.0' });
await client.connect(transport);
const { tools } = await client.listTools();
const r = await client.callTool({ name: 'dns', arguments: { domain: 'example.com', type: 'MX' } });

Auth tiers (illustrative — verify current values):

  • Free: small per-IP daily quota, no signup; watch X-RateLimit-Remaining and back off on 429.
  • API key (subscription): send X-Api-Key: <key>; obtain via the service's billing checkout. Read the current price from the upgrade prompt in a 402/429 body, not from this doc.
  • x402 pay-per-call: handle the 402 challenge per §8 — read the price/token/network/receiver from the live challenge; do not hardcode them.

Example tools (parameters as advertised by tools/list): screenshot (url,width,height,fullPage,format), whois (domain), dns (domain,type), ssl (domain), ocr (url), chain.balance/chain.erc20/chain.tx (address/token/hash + chain). Always trust the server's inputSchema from tools/list over any list here.

Worked multi-tool flow — website audit (via the SDK, parallel):

async function auditWebsite(client: Client, domain: string) {
  const [dns, ssl, whois] = await Promise.all([
    client.callTool({ name: 'dns', arguments: { domain, type: 'A' } }),
    client.callTool({ name: 'ssl', arguments: { domain } }),
    client.callTool({ name: 'whois', arguments: { domain } }),
  ]);
  for (const r of [dns, ssl, whois]) if (r.isError) console.warn('tool error', r.content);
  const shot = await client.callTool({ name: 'screenshot', arguments: { url: `https://${domain}`, fullPage: true } });
  return { dns, ssl, whois, shot }; // parse each result.structuredContent / content as needed
}

10. Troubleshooting

SymptomLikely causeFix
connect() hangs or 404/405 on remote URLServer is SSE-only (legacy) or wrong pathUse the Streamable-HTTP-then-SSE fallback (§2.3); try /mcp then /sse
-32601 Method not foundCalling a capability the server didn't advertiseInspect server capabilities from initialize; only call what's offered
-32602 Invalid paramsArgs don't match the tool's inputSchemaValidate args against the schema from tools/list
401 on every requestNo/expired/wrong-audience tokenRun the OAuth flow (§5.1) or fix the API-key header; ensure token is bound to this resource
403Token lacks scopeRe-consent with the needed scopes
Works once, fails after idleHTTP/SSE stream timed outReconnect with backoff; re-initialize; persist Mcp-Session-Id
Garbled stdio / server "won't start"Server logged to stdout (protocol channel)Ensure the server logs to stderr; check the launch command/args
result.isError === true but no exceptionTool ran but failedRead content; surface it to the model/user; retry only if idempotent
Calls hang foreverNo timeout setSet timeout + maxTotalTimeout (§3.5)
402 Payment RequiredPay-per-call gateHandle the challenge with guardrails (§8) — never hardcode the receiver/price

Quick reference

Decision tree

  • Local subprocess? → stdio (StdioClientTransport / stdio_client), secrets via env.
  • Remote URL? → Streamable HTTP (StreamableHTTPClientTransport / streamablehttp_client, endpoint /mcp); fall back to SSE only if it 400/404/405s.
  • Auth? → OAuth 2.1 (browser flow) where supported; else provider bearer/API-key header. Never commit tokens.
  • Slow tool? → timeout + onprogress + maxTotalTimeout.
  • Many results? → loop on nextCursor.
  • Tool failed? → check result.isError (not just exceptions).
  • Money (x402)? → handle the live 402 challenge with allowlist + spend cap + user confirm + testnet default; no hardcoded receiver.

Key facts (verify against current spec/SDK — Jun 2026)

  • Latest spec revision: 2025-11-25 (https://modelcontextprotocol.io/specification).
  • Streamable HTTP replaced HTTP+SSE in 2025-03-26; SSE is legacy-fallback only.
  • Send MCP-Protocol-Version: <negotiated> on every HTTP request after initialize; persist Mcp-Session-Id.
  • TS package @modelcontextprotocol/sdk (subpath imports); Python package mcp (pip install mcp).
  • Default per-call timeout: 60s (override per call).

Related skills: mcp-server-builder (build the server), ai-agent-building (orchestrate tool calls), wallet-integration / defi-integration (x402 payments), auth-implementation (OAuth flows), security-hardening (key handling, injection), onchain-analytics (consuming chain-data tools).