Building an MCP Server for Your Own Systems

The protocol is the easy part. Tool design is where these projects succeed or quietly fail.

Function calling wires one model to one system. An MCP server exposes your systems once, to any client that speaks the protocol. That distinction is academic when you have a single integration and decisive by the time you have nine, each with its own bespoke glue and its own separate set of bugs.

Choose the transport for the boundary, not the demo

  • stdio — the server runs as a subprocess on the same machine. Simple, no network surface, no authentication story of its own. Right for local developer tooling
  • streamable HTTP — the server is a service. Multiple clients, real authentication, deployable behind the gateway you already run. Right for anything a team shares

Most internal servers should be HTTP from the start. Moving a stdio server to HTTP later means revisiting every assumption you made about identity, because stdio quietly inherits the identity of whoever started the process — which is fine on a laptop and unacceptable on a server.

Tool granularity decides whether the model succeeds

The instinct is to mirror your API: one tool per endpoint. This produces servers that are technically complete and practically unusable, because answering a real question requires the model to chain six calls correctly while holding intermediate state it has no good place to keep.

Design tools around tasks instead. A single find_overdue_invoices(customer, days) is one call the model gets right. Listing customers, then invoices, then filtering by date in the model's head is three calls and a comparison it will occasionally get wrong.

  • fewer tools with meaningful arguments beat many thin wrappers
  • the description is a prompt — write it for a reader who cannot see your schema
  • return compact structured data; a 4,000-token JSON dump crowds out the reasoning
  • name the failure in the result rather than throwing, so the model can correct itself

Authorisation belongs to your code

An agent is not a user, and its instructions can come from content it read a moment ago. Treat every tool call as an untrusted request that happens to be well-formed.

# the wrong shape: the server holds one privileged token
every tool runs as service_account -> full data access

# the shape that survives an audit
inbound request carries the end user's identity
  -> server resolves that user's scopes
  -> every tool call authorised server-side, per call
  -> writes require an explicit scope, never inherited from reads

Separate read tools from write tools, and keep destructive operations behind a scope that has to be granted deliberately. If a tool can delete something, assume something will eventually ask it to.

Failure modes worth designing for

  • unbounded results — always paginate, always cap, and say in the response that you did
  • long-running work — return a handle and let the model poll rather than holding a request open
  • schema drift — version the tool contract; agents breaking silently is the worst outcome
  • injection through returned data — content from your own database can carry instructions

Testing looks different

There is no deterministic pass or fail for whether a model used a tool correctly. What works is a fixed set of realistic questions, run against the server, scored on whether the right tools were called with sensible arguments. It is cheap to build and it catches the case that unit tests never will — where an edit to a description quietly makes a tool invisible to the model.

A tool the model never selects is indistinguishable from a tool that does not exist. Most MCP debugging turns out to be description debugging.

From the same work

Have systems worth exposing to an agent?