DEV Community

Royal Simpson Pinto
Royal Simpson Pinto

Posted on

Connect to Any MCP Server in TypeScript Over stdio and HTTP, Then List Its Tools

Most people meet the Model Context Protocol (MCP) through a client like Claude Desktop, where a config file quietly wires up servers and you never see the handshake. But the protocol is just a client and a server talking over a transport, and the official TypeScript SDK gives you every piece you need to do that yourself. In this tutorial I want to show you how to connect to an MCP server programmatically, over both stdio and HTTP, and then enumerate its tools, resources, and prompts.

Once you can do this, a lot opens up: writing your own client, building a registry that indexes servers, or scanning a server before you trust it. I will ground the code in mcp-audit, a small security scanner I built that does exactly this connect-and-enumerate dance, but the technique is what matters and it is fully reusable.

The mental model

There are three moving parts:

  1. A transport, which carries bytes. It can be a child process you talk to over stdin/stdout (stdio), or an HTTP endpoint.
  2. A Client, which speaks the MCP protocol over whatever transport you hand it.
  3. The capability calls like listTools() that ask the server what it offers.

The nice part of the SDK design is that the Client does not care which transport it got. You build the transport, pass it in, and the rest of your code is identical. So let me build both transports first, then write one probe function they share.

Setup

npm install @modelcontextprotocol/sdk
Enter fullscreen mode Exit fullscreen mode

You want Node 18 or newer, and "type": "module" in your package.json since the SDK ships as ESM.

Transport one: stdio

Stdio is for local servers. You give it a command to run, and the SDK spawns that command as a child process and speaks MCP over its standard input and output. This is how most desktop MCP integrations work under the hood.

import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";

export function makeStdioTransport(opts: {
  command: string;
  args?: string[];
  env?: Record<string, string>;
  cwd?: string;
}) {
  return new StdioClientTransport({
    command: opts.command,
    args: opts.args ?? [],
    env: opts.env,
    cwd: opts.cwd,
    stderr: "ignore",
  });
}
Enter fullscreen mode Exit fullscreen mode

A detail worth knowing: the child process writes its MCP messages to stdout, so anything the server logs must go to stderr instead, or it corrupts the stream. Setting stderr: "ignore" keeps that noise out of your terminal. If you are debugging a server, switch it to "inherit" so you can see what it complains about.

Transport two: HTTP

For remote servers you use the Streamable HTTP transport. There is also an older SSE transport that some servers still expose, so it is worth supporting both with a flag.

import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js";
import type { Transport } from "@modelcontextprotocol/sdk/shared/transport.js";

export function makeHttpTransport(opts: {
  url: string;
  token?: string;
  headers?: Record<string, string>;
  useSse?: boolean;
}): Transport {
  const url = new URL(opts.url);
  const headers: Record<string, string> = { ...opts.headers };
  if (opts.token) headers["Authorization"] = `Bearer ${opts.token}`;

  const requestInit: RequestInit =
    Object.keys(headers).length > 0 ? { headers } : {};

  return opts.useSse
    ? new SSEClientTransport(url, { requestInit })
    : new StreamableHTTPClientTransport(url, { requestInit });
}
Enter fullscreen mode Exit fullscreen mode

The requestInit object is standard fetch options, so your bearer token and any custom headers ride along on every request. That is all authentication is here: headers you attach before connecting.

The probe: connect and enumerate

Now the payoff. Because both transports satisfy the same Transport interface, this function takes either one.

import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import type { Transport } from "@modelcontextprotocol/sdk/shared/transport.js";

export async function probe(transport: Transport) {
  const client = new Client(
    { name: "my-mcp-client", version: "0.1.0" },
    { capabilities: {} },
  );

  await client.connect(transport);
  try {
    const server = client.getServerVersion();
    const instructions = client.getInstructions();

    const tools = await safeList(() => client.listTools().then((r) => r.tools));
    const resources = await safeList(() =>
      client.listResources().then((r) => r.resources),
    );
    const prompts = await safeList(() =>
      client.listPrompts().then((r) => r.prompts),
    );

    return { server, instructions, tools, resources, prompts };
  } finally {
    await client.close().catch(() => undefined);
  }
}
Enter fullscreen mode Exit fullscreen mode

client.connect(transport) performs the MCP handshake. After that, getServerVersion() gives you the server's name and version, and getInstructions() returns the natural-language guidance the server wants clients to see. Then the three list calls enumerate everything it exposes.

The one subtlety is that a server is not required to implement every capability. A server with tools but no prompts will throw a "Method not found" error when you call listPrompts(), rather than returning an empty list. So I wrap each call in a small helper that treats that specific error as "the server has none of these":

async function safeList<T>(fn: () => Promise<T[]>): Promise<T[]> {
  try {
    return await fn();
  } catch (err) {
    const message = err instanceof Error ? err.message : String(err);
    if (/method not found|-32601|not supported/i.test(message)) {
      return [];
    }
    throw err;
  }
}
Enter fullscreen mode Exit fullscreen mode

Notice the finally block that always calls client.close(). Over stdio that shuts down the child process; leave it out and you leak spawned processes every time you probe.

Using it

// Local server over stdio
const stdio = makeStdioTransport({
  command: "node",
  args: ["./my-server.js"],
});
console.log(await probe(stdio));

// Remote server over HTTP
const http = makeHttpTransport({
  url: "https://example.com/mcp",
  token: process.env.MCP_TOKEN,
});
console.log(await probe(http));
Enter fullscreen mode Exit fullscreen mode

Same probe, two transports. You now have a structured inventory of the server: its identity, its instructions, and every tool, resource, and prompt name and schema it advertises.

One honest caveat

listTools() tells you what a server claims to offer, not what it actually does when invoked. The tool descriptions and input schemas are strings the server hands you, and a hostile server can lie in them, embedding instructions aimed at the model that will eventually read them. Enumeration is discovery, not trust. If you are connecting to servers you do not control, treat the returned metadata as untrusted input and inspect it before feeding it to an agent. That gap between "advertised" and "trustworthy" is the entire reason I ended up building a scanner around this probe in the first place.

If you want to see this technique wired into a full tool, with reporters and security rules layered on top of the same connect-and-enumerate core, the code lives at github.com/AgentPostmortem/mcp-audit. Clone it, point it at a server, and read along. The transport and probe files are the same shapes you just built here.

Top comments (1)

Collapse
 
arhancanli profile image
Arhan Canli •

For a scanner, two things make a single listTools() call a partial view. The list calls are paginated: the result can carry nextCursor, and listTools() doesn't follow it, so loop with { cursor } until it's absent, or the scan covers page one only. And the list is a snapshot: a server that declares tools.listChanged in getServerCapabilities() can send a different list after you've scanned it. Recording that flag and a hash of the full list in the report gives a client something to re-check when the list changes. Smaller ones: listResourceTemplates() is separate from listResources(), and templated resources are part of what a server exposes; and checking the declared capabilities before calling each list method would replace the "Method not found" regex.