Wiring Claude Into Your Internal Tools With MCP: A Developer Walkthrough
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, l

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.
An MCP server that lets an assistant look up support tickets and add an internal note. It has three tools:
Nothing else is exposed. No raw SQL, no delete, no export.
pip install "mcp[cli]" Enter fullscreen mode Exit fullscreen mode # server.py from mcp.server.fastmcp import FastMCP mcp = FastMCP ( " tickets " ) if __name__ == " __main__ ": mcp. run () # stdio transport by default Enter fullscreen mode Exit fullscreen mode 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.
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 " ) Enter fullscreen mode Exit fullscreen mode The write connection is only used by one tool, and that tool does one thing.
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 } Enter fullscreen mode Exit fullscreen mode 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.
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 Enter fullscreen mode Exit fullscreen mode 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.
For Claude Code, adding a local server is one command:
claude mcp add tickets -- python /path/to/server.py Enter fullscreen mode Exit fullscreen mode 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.
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.