MCP in Practice: Building a Custom MCP Server for Your Own Systems

November 6, 2026 12 min read

Where the primer left off

At the end of last month we published MCP, Explained: The Standard That Turns AI Assistants into Real Colleagues — our primer on the Model Context Protocol, the open standard that gives AI assistants one consistent way to connect to tools and data instead of a bespoke integration for every app. If you haven’t read it, start there: this deep-dive assumes you have. It covers why one standard “plug” beats a drawer full of hand-built integrations, and where the fast-growing ecosystem of ready-made servers fits in.

The primer also finished with a tease. Ready-made MCP servers exist for the famous platforms, but they can only cover software that lots of people share. The systems a business actually runs its week on — the booking database, the job tracker, the internal admin panel — have never been seen by anyone outside the building, so no ready-made server exists for them. Connecting an assistant to those systems means writing a small piece of software: a custom MCP server that sits between the assistant and your own systems.

That is the part the primer could only gesture at, and it is what this article does in full. We will build one — small, complete and working — and use it to work through the decisions that separate a toy from something you would point at systems other people depend on: what the assistant is allowed to do, how the server holds its credentials, how you can prove afterwards exactly what it did, and what keeping it maintained looks like as MCP evolves.

What a custom MCP server actually is

Beneath the specifications, an MCP server is a modest program that does three things:

  1. It declares a set of tools — functions an AI assistant is permitted to call — each with a plain-English description and a typed schema for its inputs.
  2. It runs the requested function when asked, correctly and only as designed.
  3. It speaks the MCP protocol, so any MCP-compatible client — Claude Desktop, your code editor, your own software — can discover the tools on offer and call them without bespoke wiring.

The framing matters, so it is worth saying plainly: the server is not a bridge that lets the model wander around your network, and it is not an open socket into your database. The server is the boundary. Everything an assistant can do with your systems is exactly the set of tools you chose to define, described in words you chose. Perhaps because of that, a genuinely useful server is usually a couple of hundred lines: it is not reimplementing your systems, just sitting politely in front of them and offering a few well-chosen verbs.

The worked example: an internal job tracker

For the worked example we will use a deliberately familiar scenario. A small firm keeps its job-tracking data — reference numbers, clients, statuses, due dates — in a SQLite database that a handful of people look at every day. We want an assistant that can answer practical questions: what is scheduled today, what is still open, and what is the story behind job J-2041.

The table looks like this:

CREATE TABLE jobs (
    reference TEXT PRIMARY KEY,  -- e.g. 'J-2041'
    client    TEXT NOT NULL,
    summary   TEXT NOT NULL,
    status    TEXT NOT NULL,     -- 'open', 'done', 'cancelled'
    due_date  TEXT NOT NULL,     -- ISO date, e.g. '2026-11-13'
    notes     TEXT
);

We will write the server in Python, using the official Model Context Protocol SDK, which takes care of all protocol work and leaves us a single readable file. (There is also an official TypeScript SDK; every principle below applies to it unchanged.)

"""server.py — a minimal, deliberately read-only MCP server
in front of an internal job-tracking database.

Generic example for illustration; not taken from any real codebase.
"""

import json
import os
import sqlite3
import time

from mcp.server.fastmcp import FastMCP

DB_PATH = os.environ.get("YARD_DB_PATH", "/srv/yard/yard.db")
AUDIT_LOG = os.environ.get("YARD_AUDIT_LOG", "/var/log/yard-manager/audit.log")

mcp = FastMCP("yard-manager")


def _audit(tool: str, params: dict) -> None:
    """Write one line to the audit log before anything else happens."""
    record = {
        "time": time.strftime("%Y-%m-%dT%H:%M:%S"),
        "tool": tool,
        "params": params,
    }
    try:
        with open(AUDIT_LOG, "a") as f:
            f.write(json.dumps(record) + "\n")
    except OSError:
        # A broken audit log must never stop serving read-only queries.
        pass


def _read(sql: str, params: tuple = ()) -> list[tuple]:
    """Run one fixed, parameterised query against a read-only connection."""
    conn = sqlite3.connect(f"file:{DB_PATH}?mode=ro", uri=True)
    try:
        return conn.execute(sql, params).fetchall()
    finally:
        conn.close()


@mcp.tool()
def today_schedule() -> list[dict]:
    """All jobs scheduled for today, ordered by reference."""
    _audit("today_schedule", {})
    rows = _read(
        "SELECT reference, client, summary, due_date "
        "FROM jobs WHERE due_date = date('now', 'localtime') "
        "ORDER BY reference"
    )
    return [
        {"reference": r[0], "client": r[1], "summary": r[2], "due": r[3]}
        for r in rows
    ]


@mcp.tool()
def list_open_jobs(limit: int = 20) -> list[dict]:
    """Open jobs, soonest due date first. At most 50 rows are returned."""
    _audit("list_open_jobs", {"limit": limit})
    limit = max(1, min(limit, 50))  # hard cap: never more than 50 rows
    rows = _read(
        "SELECT reference, client, summary, due_date "
        "FROM jobs WHERE status = 'open' ORDER BY due_date LIMIT ?",
        (limit,),
    )
    return [
        {"reference": r[0], "client": r[1], "summary": r[2], "due": r[3]}
        for r in rows
    ]


@mcp.tool()
def get_job(reference: str) -> dict:
    """Look up a single job by its reference, e.g. 'J-2041'."""
    _audit("get_job", {"reference": reference})
    rows = _read(
        "SELECT reference, client, summary, status, due_date, notes "
        "FROM jobs WHERE reference = ?",
        (reference,),
    )
    if not rows:
        return {"error": f"No job found with reference {reference}"}
    r = rows[0]
    return {
        "reference": r[0],
        "client": r[1],
        "summary": r[2],
        "status": r[3],
        "due": r[4],
        "notes": r[5],
    }


if __name__ == "__main__":
    mcp.run(transport="stdio")

That genuinely is the whole server. The SDK turns each @mcp.tool()-decorated function into a tool with a name, a machine-readable schema, and — as we will see shortly, importantly — a description. Any MCP-compatible client can now list those three tools and call them.

To connect, the client launches the server as a process on its own machine. This “stdio” transport is the simplest and most contained option — no network port, no listening service, no exposure beyond the client itself. In a client such as Claude Desktop, attaching the server is a small configuration entry:

{
  "mcpServers": {
    "yard-manager": {
      "command": "/opt/yard/venv/bin/python",
      "args": ["/opt/yard/server.py"]
    }
  }
}

From that point, “what’s on the schedule today, and is anything overdue?” simply works: the client discovers the tools, the model chooses today_schedule() and list_open_jobs(), and the answers come from the real database rather than an invented guess.

And notice what is deliberately absent. There is no update_job() and no delete_job(), and there is very definitely no generic run_sql() tool. Three read operations, each named for its purpose — that is the whole surface.

Minimal exposure, on purpose

The most valuable design decision in the example above is a negative one. It is tempting to give an assistant broad access — a “run any SQL you like” tool feels flexible, and demos love it — but it hands the direction of your systems over to whatever the model decides, under summarisation pressure, with typos and all. Instead, treat every tool as a deliberate, named decision about what an assistant may do:

  • Read-only first. Every tool in the example only reads. Writes come later, if ever — added one at a time and reviewed with the care you would give a public API endpoint. Many genuinely useful assistants never need write access at all; “what’s due today?” is where most of the value sits.
  • Purpose-named tools. today_schedule, list_open_jobs, get_job — the tool names and descriptions say what the assistant may do. In this SDK, the docstrings become the contract the model sees, so writing them plainly is part of the security design, not an afterthought of documentation.
  • Whitelisted queries. All SQL lives in the server, fixed and parameterised. The model supplies arguments — a reference, a limit — never query text. Those ? placeholders matter: without them, a reference that happens to end in '; DROP TABLE jobs goes straight into your database.
  • Hard limits. That min(limit, 50) cap means no single call can pour your entire table into an assistant’s context. Quietly important: an eager model that fetches ten thousand rows in one call gets the answer wrong, and your token bill runs right along with it.
  • A reduced view of the data. The tools return the fields an assistant genuinely needs — reference, client, summary, status, due date. If the table holds anything you would not show a visitor, the server is where you simply don’t select it.

The rule of thumb is the one we would give any small firm: if you wouldn’t let a new colleague do it on day one, don’t give the assistant a tool that can do it either.

Credentials, least privilege and auditability

The tool list defines what the assistant asks for; how the server connects to your systems defines the ceiling of what any bug, typo or misconfiguration could actually do. Get that layer wrong and a beautifully minimal tool list is window dressing.

The example already does part of it: sqlite3.connect(...mode=ro) opens the database in read-only mode at the connection level, so even a badly wrong query cannot modify anything. But a proper least-privilege posture is layered:

  • A dedicated, restricted identity for the server. On a Linux host, run the server as its own system user with read access to the database file and nothing else. For a networked database, create a dedicated account whose privileges match the tool list exactly — in our example, SELECT and no more. If the server is ever compromised, the blast radius is the view you chose to expose, not your whole system.
  • No secrets in the code. Paths, tokens and connection strings come from the environment or a secrets store, never hard-coded. That is why the example reads DB_PATH and AUDIT_LOG from environment variables rather than scattering literals through the tools.
  • Prefer the local, contained path. A stdio server is spawned by the client, on the machine where it is needed, with no listening port to reach from elsewhere. Exposing a server over HTTP for shared team use is a materially different posture — it needs authentication on every request and transport-level protections appropriate to the network it faces — and should be adopted deliberately, not by default.
  • A small tool list keeps credentials small. Each tool you add is a fresh reason to widen the database account’s privileges. Three read-only tools need read permission on three queries’ worth of data; a sprawling tool surface quietly grows an entitlement you would never hand to a person.

Then there is auditability — the _audit() line in the example that quietly underpins the whole design. Before any data moves, the server writes the timestamp, tool name and exact parameters to a local log. The details are trivial; the principle is not:

  • You can answer “who asked what”. When someone says “the assistant told a customer that job J-2041 was cancelled”, you can check precisely which tools were called, when, and with what arguments. On a networked server you would extend this to record which authenticated client made each call.
  • The log is ground truth, not the transcript. Conversations summarise themselves; parameters are exact. An audit trail of every tool call is the difference between “I think it only looked things up” and knowing it did.
  • The audit log shows your exposure. Speculative or repeated calls — a model diligently asking for the same missing job every few seconds — appear plainly in the log, and that is your cue to sharpen a tool description or tighten a limit.

None of this is exotic. It is the same hygiene you would apply to any small internal service — applied at the one place where your systems and an AI assistant meet.

Keeping it maintained as MCP evolves

MCP is young and moving: the specification and the official SDKs pick up capabilities and polish every few months, and the client landscape changes with them. The good news is that a properly built server is cheap to maintain, precisely because of everything above:

  • The protocol lives in the SDK, not in your code. Our example file contains no protocol logic at all — no message framing, no JSON-RPC, no schema plumbing. When the spec evolves, you update the SDK, run your tests, and your server inherits the conformance work.
  • Descriptions and tests keep docstrings honest. The model acts on your tool descriptions, so keep them in step with the code, and put the tools under test like any other function — a realistic database file, assertions on results, and the error paths included.
  • Small tool lists age well. Three well-named tools are a five-minute audit. You can upgrade the SDK, re-verify and redeploy over a coffee break; every additional tool is a reason upgrades move slower.
  • Adopt on your schedule. Read the release notes when the SDK you depend on ships a new version and take updates deliberately. Nothing in the example relies on preview features, so each SDK update is routine rather than dramatic.

The maintenance burden of a small, read-only server is modest and predictable — a fraction of what the previous model, hand-built per-application integration, asked of you.

From example to your systems

Turning yard-manager into production copy is a short, unhurried checklist: confirm the tool surface (the three reads above are already useful, and stay read-only), run under the restricted account with its own audit log directory, put the queries under test, run once against a copy of your real data to watch what the log records, and pin the SDK version you deploy.

However you build or deploy it, the core unit of value is the same one this example delivers: a named assistant capability — “check today’s schedule” — backed by a service whose scope, credentials and audit trail you chose.

If you’d like this for your systems

The yard-manager above is deliberately generic; your business has its own vocabulary — customers, bookings, jobs, tickets — and its own answers to what an assistant should and shouldn’t see. Deciding that surface is the interesting design work, and it is exactly the kind of work we do.

If your AI tools are stuck summarising instead of doing — if the assistant cannot see the systems your team actually uses day to day — get in touch. Tell us what your tools are, and we will sketch the tool list and access boundary a custom MCP server for your systems would take. You can usually tell from that sketch alone whether it is worth building, before you have spent a penny on it.

Contact Elvin Technologies to book a conversation.