Skip to main content
Home
Products
Free Tools
Industries
Compare
Resources
Pricing

HowtoBuildanMCPServerThatAgentsCanActuallyUse

A practical build guide for MCP servers: pick a transport, register tools with schemas a model can read, write errors an agent can act on, and choose an auth model.

Robby Frank

Robby Frank

CEO & Founder

August 10, 2026
11 min read

Building an MCP server is not hard. The SDK does most of the mechanical work, and a server that registers one tool is about thirty lines. Building one an agent uses correctly is the harder part, and almost all of that difficulty lives in three places: the transport you pick, the way you describe your tools, and what your errors say.

This guide walks the whole path in order. The code is TypeScript against the official @modelcontextprotocol/sdk, which is what the 1Lookup MCP server is built on.

Step 1: Pick a transport first, because it decides everything else

MCP servers speak over one of two transports, and this is the first fork in the road.

Local (stdio). The client launches your server as a child process on the user's machine and talks to it over standard input and output. Nothing is exposed to the network. Credentials come from environment variables the user sets in their client config.

Remote (streamable HTTP). Your server is a normal HTTP endpoint. The client connects over the network to a URL. Nothing runs on the user's machine, so nothing needs installing, and you can ship a fix without asking anyone to update anything.

The choice cascades:

Local (stdio) Remote (streamable HTTP)
Where it runs The user's machine Your infrastructure
Install step Yes None, just a URL
Shipping an update The user has to update You deploy
Auth An API key in a config file OAuth, with a browser consent step
Can reach local files and tools Yes No
You can see errors No Yes

If your server's whole job is to touch the user's filesystem, run local commands, or talk to a database only they can reach, you need stdio. If it fronts a hosted API, go remote. The install step is a real conversion tax, and an API key pasted into a config file is a credential you can never rotate on the user's behalf.

Note the fifth row too: with a remote server you get logs. With a local one, a tool that fails on someone's laptop fails silently and you find out from a support ticket, if at all.

Step 2: Stand up the smallest thing that runs

Two dependencies:

npm install @modelcontextprotocol/sdk zod

A minimal local server, end to end:

#!/usr/bin/env node
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";

const server = new McpServer({
  name: "example",
  version: "0.1.0",
});

server.registerTool(
  "get_status",
  {
    title: "Get Service Status",
    description: "Return the current status of the example service. Free to call.",
    inputSchema: {},
  },
  async () => ({
    content: [{ type: "text", text: JSON.stringify({ status: "ok" }, null, 2) }],
  }),
);

async function main() {
  const transport = new StdioServerTransport();
  await server.connect(transport);
  console.error("[example] running on stdio");
}

main().catch((error) => {
  console.error("[example] fatal error:", error);
  process.exit(1);
});

One thing that trips people up on their first stdio server: never write to stdout. Standard output is the protocol channel. A stray console.log corrupts the message stream and the client drops the connection with an unhelpful parse error. Log to stderr with console.error, as above.

Three parts to a tool registration, and they are the whole API surface:

  1. The name. A stable identifier the model calls. Use snake_case verbs: validate_phone, not PhoneValidator.
  2. The metadata. A short human title and a description the model reads. More on this next, because it matters more than anything else here.
  3. The handler. Takes the parsed arguments, returns content blocks.

Step 3: Write tool descriptions like they are the only documentation, because they are

This is the step people skip, and it is the one that decides whether your server works.

With a REST API, a developer reads your docs once at integration time and compiles that knowledge into code. With MCP, the model reads your tool list at the start of every session and has nothing else. Your description string is your documentation, your pricing page, and your usage warning, all in one sentence.

A good description answers four questions in order:

  1. What does this do, in the first clause.
  2. What comes back, named as fields, so the model knows whether it needs this tool before calling it.
  3. What does it cost, if it costs anything.
  4. When should you not use it, if there is an obvious wrong case.

Here is the description string our production phone tool actually returns:

Validate a phone number: confirms it is real and active, and returns line type, carrier, DNC status, and a fraud/risk score. Spends 1 credit.

And the free one:

Get the 1Lookup organization's plan and remaining credits. Free, spends no credits. Call before a large run.

That last clause is a behavioral instruction, not a description. It exists because agents about to spend money should check the balance first, and telling them so in the tool list is far more reliable than hoping.

Parameter schemas need the same care. A vague parameter is the single biggest cause of an agent calling the right tool the wrong way:

server.registerTool(
  "validate_phone",
  {
    title: "Validate Phone Number",
    description:
      "Validate a phone number: confirms it is real and active, and returns line type, carrier, DNC status, and a fraud/risk score. Spends 1 credit.",
    inputSchema: {
      phone: z
        .string()
        .min(1)
        .describe(
          "The phone number to validate. Include the country calling code for international numbers (e.g. +442079460958); US/Canada numbers may omit it.",
        ),
      country: z
        .string()
        .length(2)
        .optional()
        .describe(
          "Optional ISO 3166-1 alpha-2 country code hint (e.g. GB). Prefer including the country calling code in `phone`.",
        ),
    },
  },
  handler,
);

Note what the .describe() calls are doing. They give a concrete example of the expected format, and they state a preference between two valid options. Both of those are things a human would have learned from your docs. The model can only learn them here.

Two more rules that hold up:

  • Constrain in the schema, not in prose. z.enum(["phone", "email", "ip"]) stops a wrong value from ever reaching your handler. A sentence asking nicely does not.
  • Keep the tool list short. A model choosing between eight well-named tools chooses well. A model choosing between eighty chooses badly, and every extra tool is context the model pays for on every single turn. We expose five tools over MCP and roughly 40 data products over the REST API, and that split is deliberate.

Step 4: Write errors for the model, not for the log file

An API error goes to a log a human reads later. An MCP error goes straight into the model's context and the model acts on it immediately. So an error should name the next action, not just the failure.

Compare these two responses to the same condition:

Error 402: Payment Required
Insufficient credits. Call get_account to check the balance, or ask the
account owner to top up.

The first produces a retry loop or a dead stop. The second names a specific tool the agent can call and a specific human action, so a capable agent resolves it in one turn.

The pattern in code: catch upstream status codes and translate each one into a sentence with a next step.

function describe(status: number, code: string | undefined, message: string) {
  if (status === 402 || code === "INSUFFICIENT_CREDITS") {
    return {
      text: `Insufficient credits: ${message} Call get_account to check the balance, or ask the account owner to top up.`,
      isError: true,
    };
  }
  if (status === 403 || code === "UPGRADE_REQUIRED") {
    return { text: `Upgrade required: this connector needs a paid plan. ${message}`, isError: true };
  }
  if (status === 429) {
    return { text: `Rate limited: ${message} Retry shortly.`, isError: true };
  }
  return { text: `Error (${code || status}): ${message}`, isError: true };
}

Set isError: true rather than throwing. A thrown exception can surface as a protocol-level failure the model cannot reason about. An error result lands in the conversation as text the model reads and responds to, which is what you want.

Two related habits:

  • Do not fail a whole batch on one bad item. Return a per-item array with an inline error entry for the item that failed, plus totals for succeeded and failed. Our bulk_verify tool takes up to 50 values and does exactly this, so one malformed number does not cost the agent the other 49 results.
  • Cap concurrency inside the tool. A batch handler that fires 50 parallel upstream requests will rate-limit itself. Run a small worker pool (we use three) so one tool call cannot trigger a 429 against your own backend.

Step 5: Decide the auth model

For a local server, auth is an API key from an environment variable, and there is not much more to it. Fail fast and loudly if it is missing, with a message that says exactly where to get one:

const apiKey = process.env.EXAMPLE_API_KEY;

if (!apiKey) {
  console.error("[example] Missing required environment variable EXAMPLE_API_KEY.");
  process.exit(1);
}

For a remote server, an API key is the wrong answer. It has to be generated, pasted into a config file, and lives forever until someone remembers to rotate it. Use OAuth instead, which for MCP means a specific stack:

  • Dynamic client registration (RFC 7591), so a client can register itself with no manual app setup on either side.
  • PKCE with S256, required rather than optional, so an intercepted authorization code is useless.
  • Discovery documents at /.well-known/oauth-protected-resource (RFC 9728) and /.well-known/oauth-authorization-server (RFC 8414), so the client can find your authorization server without being told.
  • Short-lived, audience-bound access tokens, with refresh tokens that rotate on use.
  • A narrow scope set. One scope that means "call the tools" is usually right. Do not invent a scope taxonomy nobody will read.

That is what the hosted 1Lookup server runs: OAuth 2.1 with a single lookup scope, one-hour access tokens, thirty-day refresh tokens, and authorization codes that are single-use and expire in 60 seconds. There is no API key anywhere in the flow, which means no key ever reaches a prompt or a config file. The full breakdown is on our MCP OAuth reference page.

Step 6: Ship it

The last mile, in order of how often it gets forgotten.

Set a per-request time budget and design inside it. Our endpoint runs with a 60 second maximum duration. That is generous for an interactive tool call and far too short for a long job. If your work does not reliably finish inside your budget, do not expose it as a synchronous tool. Return a job handle and add a second tool to poll it.

Add tool annotations. Directories and clients look for readOnlyHint, destructiveHint, and openWorldHint on every tool. They tell a client whether a call is safe to run without asking the user first. If your tools are all read-only lookups, saying so explicitly is the difference between an agent that runs smoothly and one that prompts for confirmation on every call.

Test in more than one client. Claude and Cursor differ in how they surface tool descriptions, how they handle long results, and how they present the consent screen. A server that reads well in one can read badly in the other.

Write the setup page. Whatever you build, someone has to add it. For a remote server that is one URL and a screenshot of the consent screen, which is roughly what our Claude setup and Cursor setup pages are.

Then submit it. The official MCP registry, plus the client-specific directories, are how anyone finds your server. Most of them want a documentation URL, a privacy policy URL, tool annotations, and a support contact. Have those ready before you start filling in forms.

If you would rather not build one

Building an MCP server in front of an API you already own is worth it. Building one in front of someone else's API usually is not, because they probably have one.

For phone, email, and IP data, 1Lookup runs a hosted MCP server at https://app.1lookup.io/api/mcp over streamable HTTP with OAuth 2.1. Five tools, no API key, no install, and the same credits as the REST API: one per phone, email, or IP lookup, one per record for bulk_verify, and get_account free. Programmatic access is a paid feature, so a free-plan account gets an HTTP 403 with code UPGRADE_REQUIRED on every tool call, and a 7-day trial is available.

Add the URL in Claude or Cursor, or read the MCP vs API comparison if you are still deciding which entry point your project needs.

mcp
ai agents
developer guide
api integration
About the Author

Meet the Expert Behind the Insights

Real-world experience from building and scaling B2B SaaS companies

Robby Frank - Head of Growth at 1Lookup

Robby Frank

Head of Growth at 1Lookup

"Calm down, it's just life"

12+
Years Experience
1K+
Campaigns Run

About Robby

Self-taught entrepreneur and technical leader with 12+ years building profitable B2B SaaS companies. Specializes in rapid product development and growth marketing with 1,000+ outreach campaigns executed across industries.

Author of "Evolution of a Maniac" and advocate for practical, results-driven business strategies that prioritize shipping over perfection.

Core Expertise

Technical Leadership
Full-Stack Development
Growth Marketing
1,000+ Campaigns
Rapid Prototyping
0-to-1 Products
Crisis Management
Turn Challenges into Wins

Key Principles

Build assets, not trade time
Skills over credentials always
Continuous growth is mandatory
Perfect is the enemy of shipped

Try It on Your Own Data

Sign up and run your own phone numbers, emails, and IP addresses through the 1Lookup API. The free trial lasts 7 days.