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
CEO & Founder
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:
- The name. A stable identifier the model calls. Use
snake_caseverbs:validate_phone, notPhoneValidator. - The metadata. A short human
titleand adescriptionthe model reads. More on this next, because it matters more than anything else here. - The handler. Takes the parsed arguments, returns
contentblocks.
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:
- What does this do, in the first clause.
- What comes back, named as fields, so the model knows whether it needs this tool before calling it.
- What does it cost, if it costs anything.
- 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_verifytool 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.
Meet the Expert Behind the Insights
Real-world experience from building and scaling B2B SaaS companies

Robby Frank
Head of Growth at 1Lookup
"Calm down, it's just life"
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.