How to Build an MCP Server

Learn how to build an MCP server that coding agents will actually use, from protocol design and tool schemas to registration, testing, and deployment.

::<>::->#->&&MCP SERVERAuricIDE · Blog

An agent can edit files, run tests, and reason about a repository. It can only act on state it can reach. A ticket database, a requirements store, a dependency graph: all of it sits outside the workflow until the agent makes a tool call. MCP turns that call into a typed, discoverable boundary between the model and your project data.

This article builds a local server over MCP's stdio transport, which is the simplest shape to start with. MCP also defines Streamable HTTP for servers that run independently. Either way, the protocol is the easy part. What decides whether an agent uses your server well are the names, the errors, the state boundary and the permissions.

The Problem an MCP Server Actually Solves

A coding agent already inspects files and runs commands through tools its client supplies. If your project state lives in SQLite, the agent could in principle reach it through the shell. It would have to know the schema and get the SQL right, every time, from a description it half remembers.

MCP lets you replace that broad access with narrow, named operations. A server exposes tickets, requirements, dependencies, test cases, history. The agent asks for unfinished work, creates a ticket, records a result. No schema knowledge, no invented SQL.

The useful mental model is narrow:

  • The client owns the conversation. With stdio it also launches the server process.
  • The server exposes a bounded set of operations and controls access to the state behind them.
  • The model picks among those operations from their names, descriptions and schemas.

That last point changes what you are building. The client hands the model each tool's name, description and input schema. Those three fields have to distinguish a tool from its neighbours and carry enough constraint that the model calls it correctly the first time.

Why Not Just Give the Agent a CLI?

Worth asking before you build anything, because the answer has become less obvious. A command line tool also offers named operations with documented arguments, and on a local machine the agent already has a shell to call them from. AuricIDE could have shipped a CLI and let agents work through that instead. It would have been a defensible design.

The difference is how the model finds out what it can do. A CLI describes itself in help text the model has to find, read and remember. MCP gives the client a machine-readable list of tools with typed inputs, and the client puts that list in front of the model every time it asks for an action. A wrong argument comes back as a schema error instead of a parse failure buried in output, and a tool added next month shows up without anyone rewriting a prompt.

A CLI gives you reach instead. It works in shells, scripts and pipelines that have never heard of this protocol, and a person can run it directly.

Where the server runs is a second question, and it is the one that shaped this design. A local server is a file the client starts itself, so the tools answer whether or not the application they belong to is open. Nothing to boot, no background service. Move that same server to a hosted process and the independence is the first thing you spend.

So there are two decisions here, not one: how the agent should discover your operations, and what has to be running before it can. Picking either by default is how you end up defending a design you never chose.

What an MCP Server Is as a Process

Over stdio, the server is a child process. The client launches it, writes JSON-RPC to its stdin and reads JSON-RPC from its stdout. Diagnostics go to stderr, because stdout belongs to the protocol. In this arrangement there is no port, no listening socket, no daemon, no TLS config and no hosted service.

Before anything else, the client sends initialize with its protocol version, its capabilities and who it is. The server answers with a compatible version and its own capabilities. The client then sends notifications/initialized. Only after that may either side use what they negotiated. A process that exposes functions without this lifecycle is not an MCP server yet.

How far the handshake has got
Messages
  1. initializeIts protocol version, its capabilities, and who it is.
  2. resultA compatible protocol version and the server’s own capabilities.
  3. notifications/initializedFrom here on, either side may use what they negotiated.
Channels
  • client serverstdin · requests
  • client serverstdout · responses
  • your terminal serverstderr · diagnostics

No port, no listening socket, no daemon.

Can the client call a tool?
tools/callallowed

The lifecycle is complete, so both sides may use the capabilities they negotiated.

The smallest thing that works:

import { FastMCP } from "fastmcp";
import { z } from "zod";

const server = new FastMCP({ name: "example", version: "1.0.0" });

server.addTool({
  name: "echo_text",
  description: "Return the supplied text. Use this to verify the server is reachable.",
  parameters: z.object({
    text: z.string().min(1).describe("Text to return")
  }),
  execute: async ({ text }) => text
});

await server.start({ transportType: "stdio" });

Four things matter here: a registered name, a description the model can act on, a validated parameter object, and a function. Naming the transport explicitly is worth the extra words, because a default you did not choose is a decision you cannot see.

Designing Tools a Model Will Use Correctly

Tool design is model UX. The schema is part of the instruction set. The description is what the model reads while deciding whether to call you at all.

A name like get_unfinished_tickets_overview says what it does. data_query leaves the model guessing. Keep the verbs concrete: create_ticket beats mutate_project_state, and list_blocking_dependencies tells the model what it gets back.

Decide explicitly whether your tools take shortened identifiers. Agents copy IDs between calls and truncate them. In auric-pm every ID parameter takes a full UUID or a unique prefix of at least four characters. A full UUID takes a fast path and is returned without a database lookup, so the operation that follows has to handle an ID that does not exist. A prefix with no match throws. An ambiguous prefix throws and names up to five candidates. It never quietly picks one.

Practical rule: an error should tell the model what to change in the next call.

Resolving a shortened id
Tickets

Pick a row to put its full id in the field.

Resolve
try:
resolved

Resolved to a3f91c02-7b4e-4c5a-9f21-0d8e3a1b6c47 — "Import validation".

Returning an empty list for a missing ticket creates a dangerous ambiguity. The model reads "no result" as "nothing to do here". A readable Ticket not found keeps absence and emptiness apart. A rejected dependency should name the constraint it broke, not surface a generic database failure.

Response size matters, because every result spends context. Return what the next decision needs, not the whole row graph. get_unfinished_tickets_overview returns unfinished tickets, the names of the tickets they depend on, and a heat count of how many dependencies point at each one. Enough for the next agent to see it is about to start blocked work, without pouring project history into the prompt.

Storage and State Behind the Tools

A local server still needs a clear state boundary. auric-pm keeps one database file per project, inside that project's folder. One project, one file, nothing shared between them.

That is a scope decision rather than a shortcut, and it deserves to be made on purpose. A single local file suits a store that is read constantly and written now and then, by agents working on one machine. Several agents writing at the same moment from different machines is a different problem with a different answer. Notice when you cross that line, before writes start colliding and work starts going missing.

Two rules matter more than the rest. First, treat related changes as one operation: creating a ticket and recording what it depends on should either both happen or neither. Half-written state is the kind an agent reads back as fact and then builds on.

Second, know which of your rules the database can enforce and which it cannot. It can only enforce what its structure expresses. If one field may point at either a ticket or a milestone, the database has no way to check that the thing it points at still exists. That check belongs in the tool the agent calls, or the agent will cheerfully create a reference to a record that was deleted last week.

Registering the Server With an Agent

Registration depends on the client, not on the protocol. For clients that read .mcp.json, auric-pm registers as a command and an argument list:

{
  "mcpServers": {
    "auric-pm": {
      "command": "npx",
      "args": [
        "tsx",
        "<project>/src/mcp/server.ts",
        "<project>/.auric/project.db"
      ]
    }
  }
}

Use absolute paths. A client may launch the process from a working directory you did not expect, and a relative path then resolves against nothing useful. Do not add network settings to a stdio entry; there is no listener for the client to contact.

How you write that file matters as much as what you write. Merge into it, preserving other servers and any keys you do not recognise. If the file on disk is not valid JSON, refuse the write instead of replacing somebody's configuration with a freshly generated one.

What to Verify Before Trusting the Server

Test against a real database, not a mock. What is worth testing is exactly what a mock invents: what happens when a constraint fires, when two writes arrive together, when a change has to be rolled back halfway. Create a temporary database, apply the schema, call the tool, read the rows back.

The identifier resolver deserves its own tests:

  • Full UUIDs: take the fast path with no lookup. The operation that follows has to handle an ID that is well-formed and absent.
  • Short prefixes: a unique prefix of at least four characters resolves.
  • Unknown prefixes: produce a readable error naming the prefix.
  • Ambiguity: several matches produce candidate IDs and never a guess.

Security lives at the tool boundary. Do not ship a general run_sql tool when named operations cover the work. Validate every parameter, enforce authorisation in server code, and keep destructive operations out of the default set unless the client can apply an approval policy. Write diagnostics to stderr and leave stdout to JSON-RPC. Redact secrets before they reach a log.

A Minimal Stdio Server

Start with one tool and call it from a real MCP client. Give each tool a single job. Validate its inputs. Return errors that say what to change. Keep state transitions explicit enough that the next agent, and the next model, can tell what happened.

The protocol is small. The part that takes judgment is deciding what an agent should be able to do, and saying so in names and schemas clear enough to survive a messy workflow.

AuricIDE is open source

AGPL v3, alpha, and built in the open. If the loop above sounds like the way you want to work, the code is the fastest way to judge it.

★ Star on GitHub