Building and testing tools, resources, and prompts for Claude
A custom MCP server connects Claude to internal systems such as project data, databases, or APIs without hard-coding every integration into the assistant itself. This article walks through building a minimal server with a real tool, the protocol shape of tools, resources, and prompts, and local testing before wiring it up to an assistant.
Table of Contents
- 1. What the Model Context Protocol actually solves
- 2. Protocol architecture: tools, resources, and prompts
- 3. Project setup: the skeleton of an MCP server
- 4. The first tool: querying internal project data
- 5. Resources: providing context instead of calling functions
- 6. Prompts: reusable interaction templates
- 7. Testing locally with the MCP Inspector
- 8. Connecting to Claude Code and Claude Desktop
- 9. Security, error handling, and a comparison of transports
- 10. Summary
- 11. FAQ
1. What the Model Context Protocol actually solves
The Model Context Protocol (MCP) is an open standard from Anthropic that describes how an AI assistant like Claude accesses external tools, data sources, and templates in a structured way. Before MCP, every integration had to be built individually into an assistant: one connection to a ticketing system, one to an internal database, another to a file system. Only the specific client knew about each of these integrations, and every backend change required a corresponding change inside the assistant. MCP separates these concerns: an MCP server encapsulates access to a system and speaks a uniform protocol that any MCP-capable client, such as Claude Code or Claude Desktop, understands without additional custom work.
For Magento and PHP developers this means, concretely, that a self-written MCP server can expose internal project data, such as the status of deployment scripts, open tickets from an internal tracker, or configuration values from a database, to Claude without copying sensitive credentials or business logic into the prompt context. The assistant instead calls a clearly defined tool that can be validated, logged, and access-controlled on the server side. That makes MCP a bridge between generic AI capability and project-specific knowledge, one that stays cleanly separated.
2. Protocol architecture: tools, resources, and prompts
MCP defines three primitive building blocks a server can expose. Tools are functions with clearly defined input parameters and a JSON schema that the client, meaning the language model, actively invokes to perform an action or retrieve data. A tool most closely resembles a classic function call: it has side effects or returns computed results. Resources, on the other hand, are passive, addressable data sources, such as a file, a database record, or an API endpoint, that the client can read without the model having to trigger an action. Resources suit content that is more naturally read like a document than invoked like a function.
Prompts are predefined, parametrizable templates for recurring interaction patterns that a user can select explicitly, for example a prompt that kicks off a structured code review workflow. All three building blocks are exchanged over JSON-RPC 2.0 as the transport format, and the server announces its capabilities during connection setup through a capability negotiation. The client first queries tools/list, resources/list, and prompts/list to learn what the server offers before actually using individual items with tools/call or resources/read. This explicit negotiation makes the protocol extensible without forcing breaking changes.
3. Project setup: the skeleton of an MCP server
For getting started, the official Python SDK mcp is a good fit: its FastMCP module provides a declarative, decorator-based API and fully takes care of the boilerplate for JSON-RPC handling, capability negotiation, and serialization. An equivalent TypeScript SDK also exists, which makes sense when the server already lives in a Node.js context, for instance alongside an existing Express application. Both SDKs support stdio, meaning communication over standard input and output, as well as HTTP with Server-Sent Events for remote servers, as transport mechanisms.
For local development tools that Claude Code or Claude Desktop start as a subprocess, stdio is the pragmatic default: the client starts the server process, communicates over the pipes, and terminates it again once the session ends. No open network port is required, which significantly reduces the attack surface for purely local tools. The project structure stays deliberately lean: a virtual environment, a pyproject.toml with the mcp[cli] dependency, and a single entry-point file that instantiates the server and blocks on incoming requests once started.
# Project setup for a minimal MCP server using the Python SDK
mkdir project-mcp-server && cd project-mcp-server
python3 -m venv .venv
source .venv/bin/activate
# Install the official MCP SDK with CLI/dev tooling included
pip install "mcp[cli]"
# Create the entry point file
touch server.py
# Run the server locally over stdio for a first smoke test
python server.py
# Or use the built-in dev inspector (see section 7)
mcp dev server.py
4. The first tool: querying internal project data
The most useful starting example is a tool that returns real internal data rather than simulating a trivial computation. A realistic scenario for a Magento project: a tool called get_deployment_status that reads the state of the most recently executed deploy script from a local SQLite database or a JSON log file and returns it in a structured form. It is critical that every tool carries a precise type signature: parameters are defined through Python type annotations, from which FastMCP automatically generates a JSON schema the client can inspect before calling it.
A tool's docstring is not merely documentation; it is part of the protocol itself. It is shown to the language model as the basis for deciding when and how the tool should be invoked. A precise, concise description with clear statements about parameters and return values reduces misfires considerably. Return values should be structured objects, not unformatted blocks of text, so the model can reliably process the results further.
# server.py - minimal MCP server exposing one real tool
from mcp.server.fastmcp import FastMCP
import sqlite3
from pathlib import Path
mcp = FastMCP("project-data-server")
DB_PATH = Path(__file__).parent / "deployments.db"
@mcp.tool()
def get_deployment_status(environment: str) -> dict:
"""Return the status of the most recent deployment for a given environment.
Args:
environment: Target environment name, e.g. "staging" or "production".
Returns:
A dict with keys: environment, status, timestamp, commit_hash.
"""
conn = sqlite3.connect(DB_PATH)
conn.row_factory = sqlite3.Row
try:
row = conn.execute(
"SELECT status, timestamp, commit_hash FROM deployments "
"WHERE environment = ? ORDER BY timestamp DESC LIMIT 1",
(environment,),
).fetchone()
finally:
conn.close()
if row is None:
return {"environment": environment, "status": "unknown", "timestamp": None, "commit_hash": None}
return {
"environment": environment,
"status": row["status"],
"timestamp": row["timestamp"],
"commit_hash": row["commit_hash"],
}
if __name__ == "__main__":
mcp.run(transport="stdio")
5. Resources: providing context instead of calling functions
While tools are meant for active actions, resources suit content that is more naturally read than computed. A typical example: a project's current composer.json, a changelog, or an overview of all registered cron jobs. Resources are addressed via URIs, for instance project://changelog/latest, and can be defined either statically or dynamically, with parameters embedded in a URI template. The client can query a list of available resources and load specific ones into context without the model having to call a tool for it.
The practical difference from a tool shows up in the user experience: resources can be explicitly selected by a user in clients such as Claude Desktop and attached to a conversation before the model even makes a request. That suits context that is almost always relevant, such as the project structure or a configuration overview, whereas tools are meant for one-off, on-demand queries such as a database search. The two mechanisms complement rather than replace each other, and a well-designed server typically uses both.
6. Prompts: reusable interaction templates
A prompt in the MCP sense is not a single text message but a server-defined template that accepts parameters and generates one or more structured messages from them. The difference from a simple copy-paste text snippet is that prompts appear in the client as their own selectable action, for instance as a slash command or menu entry, and can request typed parameters along the way. A server for a Magento project might offer a prompt called review_deployment that takes an environment as a parameter and builds a pre-formulated analysis request with embedded context data from it.
Prompts are particularly useful for standardizing team knowledge: instead of every developer using a slightly different phrasing for a recurring task such as a security review or a migration check, the template lives centrally in the server and is versioned like any other code. Changes to the template take effect for all users immediately, without individual prompt files having to be synchronized across different editors. In practice, prompts are used less often than tools, but they are an underrated tool for standardized workflows in teams.
# Exposing a static and a dynamic (templated) resource, plus a reusable prompt template
import json
from pathlib import Path
from mcp.server.fastmcp.prompts import base
CHANGELOG_PATH = Path(__file__).parent / "CHANGELOG.md"
@mcp.resource("project://changelog/latest")
def read_changelog() -> str:
"""Return the full contents of the project changelog file."""
return CHANGELOG_PATH.read_text(encoding="utf-8")
@mcp.resource("project://cronjobs/{environment}")
def read_cronjobs(environment: str) -> str:
"""Return the list of registered cronjobs for a given environment as JSON."""
cronjobs_file = Path(__file__).parent / f"cronjobs-{environment}.json"
if not cronjobs_file.exists():
return json.dumps({"environment": environment, "jobs": []})
return cronjobs_file.read_text(encoding="utf-8")
@mcp.prompt()
def review_deployment(environment: str) -> list[base.Message]:
"""Build a structured deployment review request for the given environment."""
return [
base.UserMessage(
f"Please review the last deployment on '{environment}'. "
f"Use the get_deployment_status tool to fetch the current state, "
f"then flag any status other than 'success' and explain likely causes."
)
]
7. Testing locally with the MCP Inspector
Before connecting an MCP server to an AI assistant, it is worth testing it with the official MCP Inspector, a browser-based debugging tool shipped directly with the SDK. The command mcp dev server.py starts the server, opens a local web interface, and lists every registered tool, resource, and prompt individually. Each tool can be invoked manually with freely chosen parameters, and the response appears immediately in the browser along with the complete JSON-RPC message. That surfaces errors in a type signature or in serialization long before a language model is even involved.
This separation matters: an error that shows up when testing with a real assistant could originate from the server, from an unclear tool description, or from the model's behavior itself. The Inspector eliminates the third source of error entirely, because it issues requests directly and deterministically. It also helps to add a simple set of automated tests that call the tool functions directly as plain Python functions, independent of the protocol layer, in order to catch regressions early when the database structure or return values change.
# Launch the MCP Inspector against the local server for manual testing
mcp dev server.py
# Opens a local web UI, typically at http://localhost:6274
# List all exposed tools, resources and prompts via the CLI as well
mcp inspect server.py --list-tools
mcp inspect server.py --list-resources
# Call a tool directly with a JSON payload for scripted smoke tests
echo '{"environment": "staging"}' | mcp call server.py get_deployment_status
8. Connecting to Claude Code and Claude Desktop
Once the server has been tested locally, connecting it to Claude Code happens through a simple configuration file that defines the start command and any optional environment variables. Claude Code reads this configuration either from a project-specific .mcp.json at the repository root or from the global user configuration, depending on whether the server should only be available for one project or for every session. After the session restarts, the tools provided by the server automatically appear in the list of available tools and can be called by the model just like any built-in tool.
For Claude Desktop, configuration works analogously through a JSON file in the application directory that contains one entry per server with command, arguments, and environment variables. For both clients it is important that the start command matches exactly what worked during local testing, including the correct path to the virtual environment or the Node binary. A common pitfall is a relative path that works when testing manually inside the project directory but fails when started by the client, because the client starts with a different working directory. Absolute paths reliably avoid this problem.
{
"mcpServers": {
"project-data-server": {
"command": "/absolute/path/to/project-mcp-server/.venv/bin/python",
"args": ["/absolute/path/to/project-mcp-server/server.py"],
"env": {
"PROJECT_DB_PATH": "/absolute/path/to/project-mcp-server/deployments.db"
}
}
}
}
9. Security, error handling, and a comparison of transports
An MCP server that accesses internal systems should follow the same security principles as any other interface with data access: validate inputs, check permissions, and never insert raw data unchecked into shell commands or SQL queries. Since the language model generates the parameters for a tool call, they are in principle no more trustworthy than user input coming from a web form. Parametrized queries instead of string concatenation, plus a strict whitelist of allowed values, for example for environment names, are mandatory here, not optional.
Errors should be returned as structured MCP error messages, not as raw exceptions or stack traces, so the model has an understandable basis for a user-facing answer. When choosing a transport: stdio for local tools started by the client itself, HTTP with Server-Sent Events for servers that are centrally hosted and accessed by multiple users at once. The table below summarizes the key differences.
| Criterion | Unsafe / unsuitable | Recommended pattern | Benefit |
|---|---|---|---|
| SQL parameter from tool input | String concatenation in the query | Parametrized queries | No SQL injection risk |
| Local developer tool | HTTP server with an open port | stdio transport as a subprocess | No network attack surface |
| Centrally hosted server | Forcing stdio over an SSH tunnel | HTTP with Server-Sent Events | Multiple clients at once |
| Error return | Raw stack trace as text | Structured MCP error message | Model can respond sensibly |
| Environment parameter | Arbitrary free text accepted | Enum/whitelist in the JSON schema | Invalid values surface early |
These principles apply regardless of whether the server is run internally for a single team or as part of a larger product. Anyone offering an MCP server publicly or to multiple teams should additionally plan for authentication, rate limiting, and audit logging, since every tool call effectively represents an authorized action on behalf of the respective user.
Mironsoft
Claude integration, MCP server development, and AI-assisted development workflows
Want a custom MCP server for your project?
We build tailored MCP servers that connect Claude securely to internal systems, databases, and APIs, including access control, error handling, and integration with Claude Code or Claude Desktop.
MCP server development
Custom tools, resources, and prompts for your tech stack
Security review
Validation, access control, and audit logging for existing servers
Team onboarding
Establishing Claude Code and MCP workflows across your dev team
10. Summary
A custom MCP server solves a concrete integration problem: Claude gets structured, controlled access to internal systems without every connection needing to be individually programmed into the assistant. Tools suit active queries and actions with a clear type signature, resources suit passive, addressable context such as files or configuration, and prompts suit standardized, team-wide interaction templates. The official Python SDK with FastMCP reduces the boilerplate to a minimum and makes getting started possible with a handful of lines of code.
The MCP Inspector is indispensable when testing, because it separates server bugs from model behavior before an assistant is even involved. When connecting to Claude Code or Claude Desktop, absolute paths and a clean configuration file determine whether the process is smooth or a frustrating debugging exercise. Security principles such as parametrized queries, whitelists, and structured error returns apply to MCP servers exactly as they do to any other interface with data access, since tool parameters are ultimately generated by the language model and therefore not inherently trustworthy.
Developing Your Own MCP Servers - Key Takeaways
Three building blocks
Tools for actions, resources for passive context, prompts for standardized templates. All exchanged over JSON-RPC 2.0.
SDK & transport
Python SDK with FastMCP for a fast start. stdio for local tools, HTTP/SSE for centrally hosted servers.
Test locally
mcp dev server.py launches the MCP Inspector, separating server bugs from model behavior before real integration.
Security
Treat tool parameters like user input: parametrized queries, whitelists, structured error messages.