The Model Context Protocol (MCP) gives an AI client a standard way to call your tools. That's convenient, and it's also a new door into your systems. This walkthrough builds a small MCP server for an internal ticket database, and focuses on three habits I'd keep from the first line: narrow tools, limited data, and a log of every call.
The code is a sketch written from the MCP Python SDK documentation, not a tested library. The SDK changes quickly, so check the current docs for names and install steps before copying anything.
What we're building
An MCP server that lets an assistant look up support tickets and add an internal note. It has three tools:
-
search_tickets: read-only, returns a short list. -
get_ticket: read-only, returns selected fields of one ticket. -
add_internal_note: the only write, and it can only append a note.
Nothing else is exposed. No raw SQL, no delete, no export.
Step 1: Install and create the server
pip install "mcp[cli]" # server.py from mcp.server.fastmcp import FastMCP mcp = FastMCP("tickets") if __name__ == "__main__": mcp.run() # stdio transport by default FastMCP turns decorated Python functions into tools. It reads the type hints and docstring to describe each tool to the model, so write both carefully.
Step 2: Keep the database connection read-only
For the read tools, open the database in read-only mode. If a bug or a bad prompt tries to write, the connection refuses.
import sqlite3 def read_db(): return sqlite3.connect("file:tickets.db?mode=ro", uri=True) def write_db(): return sqlite3.connect("tickets.db") The write connection is only used by one tool, and that tool does one thing.
Step 3: Add narrow tools
Each tool returns only the fields the assistant needs. Customer emails and phone numbers stay out.
@mcp.tool() def search_tickets(query: str, limit: int = 5) -> list[dict]: """Search ticket subjects. Returns id, subject and status for up to 10 tickets.""" limit = min(limit, 10) with read_db() as db: rows = db.execute( "SELECT id, subject, status FROM tickets WHERE subject LIKE ? LIMIT ?", (f"%{query}%", limit), ).fetchall() return [{"id": r[0], "subject": r[1], "status": r[2]} for r in rows] @mcp.tool() def get_ticket(ticket_id: int) -> dict: """Get one ticket: subject, status, priority and the last public reply.""" with read_db() as db: row = db.execute( "SELECT subject, status, priority, last_public_reply FROM tickets WHERE id = ?", (ticket_id,), ).fetchone() if row is None: return {"error": "ticket not found"} return dict(zip(["subject", "status", "priority", "last_public_reply"], row)) @mcp.tool() def add_internal_note(ticket_id: int, note: str) -> dict: """Append an internal note to a ticket. Cannot edit or delete anything.""" if len(note) > 1000: return {"error": "note too long"} with write_db() as db: db.execute( "INSERT INTO ticket_notes (ticket_id, note) VALUES (?, ?)", (ticket_id, note), ) return {"ok": True} Three details matter here. The limit is capped on the server, so the model can't ask for 10,000 rows. The queries use parameters, so input never becomes SQL. And the write tool appends to a separate notes table, so it can't rewrite history.
Step 4: Log every call
When someone asks what the assistant did last Tuesday, you want a real answer. A small decorator records each call as one JSON line.
import functools import json import logging import time logging.basicConfig(filename="mcp_calls.log", level=logging.INFO, format="%(message)s") def audited(fn): @functools.wraps(fn) def wrapper(*args, **kwargs): start = time.time() status = "ok" try: return fn(*args, **kwargs) except Exception as err: status = f"error: {type(err).__name__}" raise finally: logging.info(json.dumps({ "tool": fn.__name__, "args": kwargs or list(args), "status": status, "ms": round((time.time() - start) * 1000), })) return wrapper Apply it under the tool decorator, for example @mcp.tool() on top of @audited. Keep the order in mind: the tool decorator should receive the wrapped function, so the log runs on every call.
Step 5: Connect it to a client
For Claude Code, adding a local server is one command:
claude mcp add tickets -- python /path/to/server.py Other clients use a config entry with a command and arguments. Check your client's docs for the exact file and format, because they differ.
Then test with real questions. Ask it to find a ticket, ask for something your tools don't support, and ask it to do something you didn't expose. The second and third tests show you where the boundary really is.
What I'd add before production
- Authentication and per-user permissions. A local stdio server inherits your own access. A shared server needs real identity checks.
- Rate limits. Cap calls per minute so a loop can't hammer the database.
- Output review. Spot-check responses for data that shouldn't be there, like names inside free-text fields.
- Approval for risky writes. Even an append-only tool deserves a human check if the note is customer-visible.
- Tests. Keep a small set of prompts that must succeed and a set that must be refused, and run them after every change.
The principle
An MCP server is a contract about what an assistant can touch. The narrower the contract, the easier it is to review, test, and trust. Start with the smallest set of tools that does the job, and add more only when a real task needs them.
If you want the broader picture of connecting Claude to business systems, there's a guide on how to connect Claude to any tool.
What's the first internal tool you'd expose through MCP, and what's the one thing you'd refuse to expose?
I'm Darius Tran, an AI Implementation Specialist at AI Hive, working on getting AI agents into production for enterprise teams.