Build One

A complete tool server is genuinely about ten lines. Both SDKs build the tool definition from ordinary code (a schema in TypeScript, type hints and a docstring in Python), so you rarely write JSON by hand.

import { McpServer } from "@modelcontextprotocol/server";
import { StdioServerTransport } from "@modelcontextprotocol/server/stdio";
import * as z from "zod/v4";

const server = new McpServer({ name: "greeter", version: "1.0.0" });

server.registerTool(
  "greet",
  {
    // The model reads this description to decide whether to call the tool.
    description: "Greet someone by name",
    inputSchema: z.object({ name: z.string() }),
  },
  async ({ name }) => ({
    content: [{ type: "text", text: `Hello, ${name}!` }],
  })
);

await server.connect(new StdioServerTransport());

And the client side that connects to it:

import { Client } from "@modelcontextprotocol/client";
import { StdioClientTransport } from "@modelcontextprotocol/client/stdio";

const mcp = new Client({ name: "demo", version: "1.0.0" });

// The transport starts the server process for you.
await mcp.connect(
  new StdioClientTransport({ command: "node", args: ["greeter.js"] })
);

const { tools } = await mcp.listTools();
console.log(tools.map((t) => t.name)); // ["greet"]

const result = await mcp.callTool({
  name: "greet",
  arguments: { name: "Ada" },
});

console.log(result.content[0].text); // "Hello, Ada!"

The payoff is in listTools(): it returns schemas you can hand straight to a model's tool-use API. Adding a server adds capabilities to your assistant without writing code for that server specifically.