ScreenshotNeo

BlogAI agents

How to Build a Custom MCP Client

Build a production-ready MCP client: choose transports, negotiate protocol eras, discover tools, route model calls, secure data, and clean up reliably.

By the ScreenshotNeo team29 September 20267 min read

How to Build a Custom MCP Client

Direct answer

A custom MCP client is a connector inside a host application. It keeps one connection to one MCP server, negotiates a protocol version, discovers the server’s tools, resources and prompts, routes model-selected tool calls, returns results to the model, and closes the session cleanly. MCP uses JSON-RPC 2.0 messages between a host (the LLM application), its client connector, and a server that supplies context or actions.

The client discovers capabilities, routes tool calls, and returns results between the model host and server.
The client discovers capabilities, routes tool calls, and returns results between the model host and server.

The implementation sequence is: choose an SDK and protocol era; select stdio for a locally launched process or Streamable HTTP for a remote service; connect and inspect negotiated capabilities; list features; translate MCP tool schemas to your model API; route calls and results; handle tool and protocol errors; then tear down every process and session.

1. Choose an SDK and protocol target

The current TypeScript v2 client package is @modelcontextprotocol/client. The Python client is published as mcp. The TypeScript v2 line documents the 2026-07-28 specification. Older servers may implement revisions from 2024-10-07 through 2025-11-25, so record the era you support before writing a low-level client. Read the official client guide and protocol specification.

Transport follows deployment: stdio for a local process and Streamable HTTP for a remote service.
Transport follows deployment: stdio for a local process and Streamable HTTP for a remote service.
Decision Use Consequence
Local process stdio The client spawns and owns the server child process.
Remote deployment Streamable HTTP The client connects to an HTTP endpoint and may receive a session to terminate.
Old HTTP server Legacy SSE Use only for servers that predate Streamable HTTP.
Negotiation SDK auto mode Probe modern behavior and fall back when supported.

2. Build a TypeScript client over stdio

Install the client package. With stdio, do not launch the server separately: the transport owns it.

npm install @modelcontextprotocol/client
import { Client } from '@modelcontextprotocol/client';
import { StdioClientTransport } from '@modelcontextprotocol/client/stdio';

const client = new Client({ name: 'custom-host', version: '1.0.0' });
const transport = new StdioClientTransport({ command: 'node', args: ['server.js'] });

try {
  await client.connect(transport);
  console.log('capabilities', client.getServerCapabilities?.());
  console.log('instructions', client.getServerInstructions?.());
  const { tools } = await client.listTools();
  console.log(tools.map(({ name, description, inputSchema }) => ({ name, description, inputSchema })));
  const result = await client.callTool({ name: 'lookup_issue', arguments: { id: 'MCP-123' } });
  if (result.isError) throw new Error(`Tool returned an error: ${JSON.stringify(result)}`);
  console.log(result.content);
} finally {
  await client.close();
}

Metadata accessor names can vary by SDK release; check the installed version. The documented lifecycle is connection, discovery, calls and close. A Client plus one transport is a complete MCP client.

3. Connect to a remote server

import { Client } from '@modelcontextprotocol/client';
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/client/streamableHttp';

const client = new Client({ name: 'remote-host', version: '1.0.0' });
const transport = new StreamableHTTPClientTransport(new URL(process.env.MCP_ENDPOINT));
try {
  await client.connect(transport);
  const { tools } = await client.listTools();
  // Convert tools to your model provider's tool schema.
} finally {
  // Terminate an issued HTTP session according to the transport API.
  await client.close();
}

For an older HTTP+SSE server, use the SDK’s SSE transport as a compatibility path and a fresh client instance for that attempt. Do not mix an SSE fallback into a partially negotiated client.

4. Python client alternative

Python’s client is an asynchronous context manager. Entering performs connection and negotiation; after leaving, that connection is not reusable.

from mcp import ClientSession, StdioServerParameters
from mcp.client.stdio import stdio_client

server = StdioServerParameters(command='node', args=['server.js'])

async def main():
    async with stdio_client(server) as (read, write):
        async with ClientSession(read, write) as session:
            await session.initialize()
            tools = await session.list_tools()
            print([tool.name for tool in tools.tools])
            result = await session.call_tool('lookup_issue', {'id': 'MCP-123'})
            print(result)

Use the Python transport form matching your deployment: URL, stdio parameters, custom transport, or an in-process server for tests.

5. Bridge MCP to a model

MCP does not call an LLM for you. Your host orchestrates an MCP connection and a model API connection.

  1. Call listTools and retain each tool’s name, description and JSON input schema.
  2. Convert the schema to the model provider’s tool format. Preserve required fields and enum constraints.
  3. Send user messages and declarations to the model.
  4. Validate the selected name against the discovered set and validate arguments against the advertised schema.
  5. Call MCP with that name and arguments.
  6. Append returned typed content to the model conversation as the tool result. If isError is true, expose it as data so the model can recover.
  7. Repeat until the model returns a final answer.
const available = new Map(tools.map(tool => [tool.name, tool]));
async function dispatch(selection) {
  const definition = available.get(selection.name);
  if (!definition) throw new Error('Unregistered MCP tool');
  // Validate selection.arguments against definition.inputSchema.
  return client.callTool({ name: selection.name, arguments: selection.arguments });
}

6. Resources, prompts and notifications

Tools perform actions or computations. If the server advertises resources, list them and read a URI only when needed. If it advertises prompts, list and retrieve templates rather than assuming names exist. Capabilities are a contract: gate every request on advertised support.

Change notifications, including tool-list changes, are opt-in and require the corresponding server capability. Add them after the request/response path works. On a notification, refresh cached schemas before allowing new model calls.

7. Protocol-era compatibility

Legacy revisions use an initialize handshake. The 2026-07-28 era is documented as using server/discover and a _meta envelope on every request. SDK auto mode probes and falls back; pinning the modern revision does not. A hand-written client must implement negotiation for its declared target. Log the selected revision and reject an unexpected one instead of sending mixed-era messages.

  • Ask for consent before exposing user data or invoking a tool; show what data and action are involved.
  • Treat server tool descriptions, annotations and returned content as untrusted unless the server is trusted.
  • Allow authorization URLs only with HTTP or HTTPS. Use HTTP only for loopback development and require HTTPS in production.
  • Never invoke a shell to open a server-provided URL. Parse it and use an OS-supported non-shell opener.
  • If a proxy launches stdio on behalf of clients, allowlist commands and protect the proxy endpoint and credentials.
  • Keep secrets out of prompts and logs; redact authorization headers, cookies and tokens.

9. Errors, retries and teardown

Symptom Cause Fix
Process exits immediately Wrong command, arguments or directory Run the exact command manually, capture stderr and use an absolute path in services.
Handshake error Mixed protocol eras Use auto negotiation or implement one pinned revision consistently.
isError: true Schema rejection or handler failure Return the structured result to the model, correct arguments or show an actionable error.
Unknown tool failure Name was not returned by discovery Refresh tools and reject unregistered names before calling.
HTTP disconnect Expired session, proxy timeout or restart Reconnect and renegotiate; retry only idempotent calls.
Hanging shutdown Child process or session left open Put close() in finally and terminate issued HTTP sessions.

Use bounded backoff for connection setup. Do not blindly retry side-effecting tools: require an idempotency key or confirmation. Set deadlines around model and MCP calls, and include a correlation ID in logs.

10. Performance, reliability and cost

  • Reuse one negotiated connection per server session; repeated handshakes add startup work.
  • Cache tool schemas until a notification or reconnect, then refresh.
  • Use stdio for local tools and Streamable HTTP for independently deployed services.
  • Bound output size before returning content to a model; large resources consume context and increase model charges.
  • Use cancellation and deadlines so abandoned turns do not keep running.
  • Record request IDs, protocol revision, tool name, duration, result size and error class while redacting secrets.
  • MCP has no universal price. Costs come from model usage, hosting, network and downstream APIs.

Or skip the browser setup

If your MCP workflow needs screenshots, ScreenshotNeo provides an MCP server with take_screenshot, get_page_info and capture_pdf for Claude, Cursor and other MCP clients. You can use the custom-client pattern above or call its HTTP API directly.

See the ScreenshotNeo API docs for every option.

curl -G "https://api.screenshotneo.com/v1/shot" -d access_key=YOUR_API_KEY --data-urlencode url=https://stripe.com -o shot.webp
import requests
r = requests.get("https://api.screenshotneo.com/v1/shot", params={"access_key": "YOUR_API_KEY", "url": "https://stripe.com"}, timeout=90)
open("shot.webp", "wb").write(r.content)
const q = new URLSearchParams({ access_key: 'YOUR_API_KEY', url: 'https://stripe.com' });
const res = await fetch(`https://api.screenshotneo.com/v1/shot?${q}`);

ScreenshotNeo accepts cookie and consent banners before capture and removes 60+ known consent platforms, newsletter popups and chat widgets; each step can be disabled. Bot checks, blank pages, timeouts, failed loads and cache hits are not billed, and X-Page-Verdict and X-Billed identify the result. Options include full-page lazy-image capture, CSS-element capture, dark mode, 12 device presets or any viewport, retina scale, PDF paper and page controls, HTML/CSS rendering, custom CSS and JavaScript, clicks, selector waits, delays, network-idle waits, request blocking, headers, cookies, user agent, authorization, timezone, geolocation, transparent backgrounds, resizing, chosen-TTL caching, signed image links, async signed webhooks, bulk capture of 100 URLs, a usage API and an OpenAPI spec. There are 1,000 free screenshots each month without a card. Paid plans start at $5 for 3,000, with every feature on every plan. Create a free ScreenshotNeo account.

FAQ

Does a custom MCP client need an LLM?

No. It can discover and call servers by itself; the host may connect results to any model API.

Can one client connect to many servers?

Use one client and transport per server, with a server-qualified registry to avoid name collisions.

When should I use SSE?

Only when compatibility with a pre-Streamable-HTTP server requires it.

Are server tool descriptions trustworthy?

Only when you trust the server. Otherwise validate descriptions, URLs, arguments and returned content while preserving consent.