Build your own MCP server
When an existing connector is not enough, and how to design the tools you expose.

Build your own MCP server when no connector exists for a system your agent needs, or when the one that exists exposes the wrong things: raw API endpoints the model has to chain together, no cost control, or more write access than you are willing to give. In every other case, use the existing connector. The code of a server is the easy part. The work is in deciding which tools to expose, how to describe them, and what the server refuses to do whatever the model asks.
I co-founded Derrick, a B2B data enrichment and prospecting tool, and I built its MCP server myself. That build shapes the advice below, but the examples are generic patterns, not a description of how Derrick’s server works.
Build or reuse
| Situation | Decision |
|---|---|
| The vendor publishes an MCP server that covers your reads and writes | Reuse it, with a restricted key |
| The vendor has an API but no MCP server | Build a thin server over the API |
| The data lives in your own database or internal app | Build, with a read-only user first |
| A connector exists but exposes 60 low-level endpoints | Build a small server with the 5 tools your task needs |
| You need a guardrail the connector lacks (budget, scope, approval) | Build, and put the guardrail in the server |
| The task always runs the same steps in the same order | Do not build an MCP server; write a script |
The last line saves the most time. MCP is worth it when the model has to choose what to call next. A fixed pipeline does not need a model to choose anything.
Design tools around tasks, not endpoints
The model picks tools by reading their names and descriptions, the way a new colleague reads a menu. An API designed for developers often needs three calls to do one useful thing: create a job, poll it, fetch the result. Expose that as one tool that waits and returns the result. The agent then has one decision to make instead of three chances to get the sequence wrong.
A few rules I apply to every tool:
- One tool, one job a person would name:
find_account_by_domain,list_open_deals,log_qualification. Notqueryorexecute. - Separate reads from writes. A tool that reads never changes anything, so you can allow it freely and gate the others.
- Few parameters, each with a type, an example and a constraint. Every optional parameter is another way for the model to guess.
- Fewer than you think. Ten clear tools beat forty overlapping ones, because two tools with close descriptions get confused.
Write the tool definition
A tool is declared with a name, a description and a JSON Schema for its input. The description is the most important text in the whole server: it is the only instruction the model gets at the moment it decides. Say when to use the tool, what it costs, what it returns and what it will not do.
{
"name": "find_work_email",
"description": "Find the work email of one person at one company. Use it once you have the person's full name and the company's website domain. Charges credits only when an email is found. Returns the email with its verification status, or found=false. Never guess an address: if nothing is found, report it as not found.",
"inputSchema": {
"type": "object",
"properties": {
"full_name": {
"type": "string",
"description": "First and last name, e.g. Marie Dupont"
},
"company_domain": {
"type": "string",
"description": "Website domain without https or path, e.g. acme.fr"
}
},
"required": ["full_name", "company_domain"]
}
}
The protocol also lets a server mark a tool with hints, for example that it only reads. Some clients use those hints to decide what can run without asking. They help, but they are declarations, so the server still has to enforce what it declares. The specification has the full shape of a tool.
Return results the model can use
What a tool returns is read by a model with limited room. Return the fields the task needs, in a stable structure, and cap the size.
| Return | Bad | Good |
|---|---|---|
| A found record | The full 300-field API object | The 6 fields the task uses |
| A long list | 5,000 rows in one response | 50 rows, a total count and a cursor for the next page |
| Nothing found | An empty string | {"found": false, "credits_charged": 0} |
| A bad input | A stack trace | “company_domain must look like acme.fr, without https” |
| A failure upstream | A timeout the model retries forever | “Provider unavailable, nothing was charged, try again later” |
The total count in the second row prevents the most common silent failure: the agent works on the first page and reports success on the whole list. And error messages are instructions. Write them so the model knows what to change, or that it must stop and tell the user.
A minimal handler, whatever SDK you use, has that shape:
async function findWorkEmail({ full_name, company_domain }, user) {
if (!/^[a-z0-9.-]+\.[a-z]{2,}$/i.test(company_domain)) {
return fail("company_domain must look like acme.fr, without https or a path.");
}
const hit = await provider.lookup(full_name, company_domain, user.apiKey);
if (!hit) return ok({ found: false, credits_charged: 0 });
return ok({ found: true, email: hit.email, status: hit.status, credits_charged: hit.cost });
}
The official SDKs handle the protocol, and their APIs change between versions, so take the boilerplate from the build-a-server guide rather than from an old blog post. One detail from that guide catches everyone once: a server that talks over standard input and output must never print logs to standard output, or it corrupts the messages. Log to standard error.
Put the guardrails in the server
Anything you write in the agent’s instructions is a request. Anything the server enforces is a rule. So the controls that matter live in the server:
- Authentication per user. Each call runs with the caller’s own key and permissions, never with a shared admin account.
- Cost preview for anything paid. A first call with
preview: truereturns the estimated cost and the balance. The real call only runs if it carries the amount the preview returned. The agent cannot spend without showing the figure to the person first. - Limits. A maximum number of rows per call and per hour, so a retry loop cannot drain a budget.
- Content is data. Text the server returns from web pages, emails or CRM notes can contain instructions. The server labels it as content, and no tool executes anything found in it.
- A log of every call: who, which tool, which arguments, what it cost. That is how you answer “why did this happen” a week later.
The question of which actions an agent may take alone is covered in the security chapter. The server is where those answers become impossible to bypass.
Test it like a product
Before connecting the server to an agent, list its tools with the official Inspector and call each one by hand with a valid input, an invalid input and an input that should return nothing. Then connect it and run the same checks through the agent, as in the connection chapter.
Then watch the health check itself. The routines chapter tells how, in November 2026, a data service my agents depend on passed its health check while every real query failed. A health check has to exercise the real path: one real read, on a known record, compared with the expected value. Anything less tells you the process is running, not that the tool works.
Your next step: pick the one system your agents need most and that has no good connector, write the definitions of three tools for it (two reads, one write) with descriptions that state cost and limits, and have a colleague who does not know the system read them. If they cannot tell which tool to use for a given request, neither will the model.