← Field Notes · July 21, 2026 · 6 min read · AIOProductOS Team

How to Build an MCP Server (What We Learned Building Two)

The tutorials stop at hello-world. What decides whether your MCP server is usable: tool surface, return shapes, transport, auth, and versioning.

Every page-one result for this query is a hello-world walkthrough: install the SDK, declare one tool that returns a string, point Claude Desktop at it, done. Those tutorials are correct and they take about twenty minutes. They also stop exactly where the real work starts.

We have built two MCP servers and shipped both publicly — the AIOProductOS spine MCP (hosted Streamable HTTP with OAuth 2.1, plus an npm stdio package, 71 tools) and AIOProductOS Studio, a free MIT-licensed local server with 14 tools that films product demos. Most of what we got wrong the first time had nothing to do with the protocol.

How do you build an MCP server?

Pick a transport, then declare tools. Each tool gets a name, a plain-language description, and a typed input schema, and returns structured content. Run it locally over stdio for personal or filesystem work, or host it over HTTP with OAuth for multi-tenant data. The protocol is small; the design decisions around it are not.

Diagram of MCP server architecture: local stdio transport versus remote hosted transport

If you have not read the protocol basics yet, start with what MCP actually is — host, server, tool — then come back.

The part the tutorials already cover

Here is the minimum, in TypeScript with the official SDK. It is worth seeing once, because it is genuinely this small:

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: "acme", version: "1.0.0" });

server.registerTool(
  "get_account",
  {
    description:
      "Fetch one customer account by id, including plan and MRR. " +
      "Use when the user names a specific account.",
    inputSchema: { accountId: z.string().describe("Account UUID") },
  },
  async ({ accountId }) => {
    const account = await db.accounts.find(accountId);
    return {
      content: [{ type: "text", text: JSON.stringify(account) }],
      structuredContent: account,
    };
  },
);

await server.connect(new StdioServerTransport());

That runs. Point an MCP host at node ./server.js and the model can call it. Everything after this is judgement, and judgement is what decides whether anyone keeps your server installed.

How many tools, and what to name them

The first instinct is to mirror your API: one tool per endpoint. That produces a server the model cannot use well, because tool selection is a retrieval problem. The model reads names and descriptions and picks. Forty tools with overlapping names means forty chances to pick the wrong one.

Two rules survived contact with reality for us. First, a tool should map to a question a user would ask, not to a database table. get_customer_360 beats five separate lookups the model has to compose. Second, names must be disambiguating on their own, because the model often sees the list before it sees the docs. list_tasks and get_task are fine; search and query next to each other are not.

Tool count is not a vanity metric either way. Studio has 14 tools because filming a demo is a small, bounded verb set. The spine MCP has 71 because a product record genuinely has that many distinct questions. Neither number is a target — both are consequences.

What a tool should return

This is the single biggest quality lever and almost nobody covers it. A tool that returns prose forces the model to re-parse English it just generated the request for. A tool that returns typed records lets the model do arithmetic, filter, and join.

Return structured content. Include ids so follow-up calls are possible. Include the units and the currency. When a result is empty, say so explicitly rather than returning an empty array with no context — “no accounts matched, 0 of 412 scanned” prevents the model from inventing an explanation.

And keep returns small. A tool that dumps 4,000 rows into context burns the budget the model needs to reason with. Paginate, and say in the description that you do.

stdio or remote: pick by where the data lives

Both of ours exist because they answer different questions. The decision is not about scale — it is about whose machine the data is on.

Local (stdio)Remote (hosted HTTP)
How it runsSubprocess on the user’s machine, npx or uvxAn HTTPS endpoint you operate (Streamable HTTP)
AuthNone needed — inherits the user’s machineRequired: OAuth 2.1 with PKCE, or a scoped token
Best forFiles, browsers, local devices, personal toolingMulti-tenant product data, shared workspaces
Distributionnpm / PyPI package, config snippetA URL users paste into their host
Ops burdenNear zero; the user runs itUptime, rate limits, tenancy isolation, audit
Who it’s forIndividual developersTeams on a hosted product

Studio is local because filming a browser and writing MP4 files only makes sense on the machine that has the browser. The spine MCP is primarily remote because the data is a multi-tenant product record — but we ship a stdio package too, for people who would rather not add a connector URL.

Auth is not an afterthought for remote servers

If your server is remote, auth is part of the product, not a wrapper around it. The current shape is OAuth 2.1 with PKCE and dynamic client registration, so a host can connect without you hand-issuing credentials per user. Get the scopes right at the same time: a token that can read the whole tenant is a bad default when the model on the other end will call whatever the description makes look relevant.

Two things worth doing early. Enforce permissions below the tool layer — in the database, ideally with row-level security — so a tool bug is not a tenancy breach. And if you handle model keys, don’t: let users bring their own.

The distribution problem nobody warns you about

Once the same server exists as a local package and a hosted endpoint, you have a versioning problem. We learned this the hard way: for a stretch, our hosted endpoint and our desktop bundle advertised different tool counts. Nothing was broken, exactly. But a user reading the docs and a user reading the tool list saw different products, and there is no worse first impression for an agent-facing surface.

The rule we now hold: every surface ships the same version and the same tool set, in one pass. Hosted endpoint, npm package, desktop bundle, registry entry. If one lags, the whole release lags.

Also: never rename tools once published. Someone’s saved prompt depends on the old name.

Testing against a real host

Unit tests on your handlers are necessary and insufficient. The thing you are actually shipping is whether a model picks the right tool, and you can only test that by connecting a real host and asking real questions in the user’s words — not in your schema’s words. Half our description rewrites came from watching a model reach for the wrong tool, which is always a naming failure, never a model failure.

When you should not build an MCP server

Three honest cases where the answer is no.

Someone already built it. If you want your AI assistant to read GitHub, Postgres, or Linear, those servers exist and are better maintained than a weekend clone. Check the official registry and the ecosystem roundups first.

Your API is one endpoint. If the whole surface is a single call with two parameters, an MCP server is ceremony. Give the agent the endpoint.

You have no auth story and the data is sensitive. A remote MCP server is an authenticated, model-driven interface to your production data. If you cannot answer “what happens when a model calls the delete tool with a hallucinated id,” build the guardrails before the server.

And one more, which is less about the server and more about what it exposes: a server that fronts one siloed tool inherits that silo. We wrote about that tradeoff in spine MCP vs tool MCP. The protocol is easy. What you put behind it is the product.

See what a production spine MCP exposes, and how to connect any MCP client, at aioproductos.com/product/mcp.

Frequently asked questions

What language can I write an MCP server in?

Any language with an MCP SDK — official SDKs exist for TypeScript, Python, Java, Kotlin, C#, Go, Ruby, Rust, and Swift. TypeScript and Python are the most common because both distribute easily: a Node package runs with npx and a Python package with uvx, so users install nothing permanently. Pick the language your underlying system already lives in; the protocol layer is thin, and the hard part is the data access underneath it.

Do I need to host an MCP server, or can it run locally?

Both work, and the choice follows your data. A local server runs as a subprocess on the user's machine over stdio, needs no hosting and no auth, and can touch local files, browsers, and devices. A remote server runs as an HTTP endpoint you host, needs OAuth or token auth, and is the right shape when the data lives in your multi-tenant backend rather than on the user's laptop.

How long does it take to build an MCP server?

A working server with one or two tools takes an afternoon with an official SDK. A server people keep installed takes considerably longer, because the time goes into deciding which tools to expose, naming them so a model picks the right one, shaping the returns so results are typed rather than prose, and adding auth and versioning. Budget days for the protocol and weeks for the judgement.

Keep reading

See the join on your own stack.

One record per customer — revenue, feedback, work, and code. Flat plans from $199/mo, every module included — a 14-day onboarding runway on your own data, then a 30-day money-back guarantee.