MCP for Local Development: Running Servers on Your Own Machine
Why Local MCP Development Even Matters
The first time you connect a large language model to a tool that can read your filesystem, query your database, or hit a live API, you learn a hard lesson fast: you do not want to debug that connection in production. The Model Context Protocol (MCP) gives AI models a standard way to call external tools, but "standard protocol" does not mean "safe to skip local testing." Every MCP server you build is a small program with its own request handling, error paths, and security surface — and the fastest, cheapest, lowest-risk place to shake out those problems is on your own machine.
Running MCP servers locally is not a workaround or a lesser version of the "real" deployment. For a huge share of use cases — a personal coding assistant that reads your project files, a data analysis tool that queries a local Postgres instance, an internal utility that only your team touches — local is the *final* destination, not a rehearsal. Even when you plan to ship a server to production eventually, local development is where you build the muscle memory: how the client discovers your server, how tool calls get routed, what happens when a tool throws an exception mid-response, and how you keep secrets out of your shell history.
This article walks through the practical mechanics of running MCP servers locally: the transport options, a minimal working server, configuration wiring for popular clients, debugging techniques, and the security habits that keep a local server from becoming a local liability.
The Two Transports You'll Actually Use
MCP servers talk to clients over one of a few transports, but for local development two matter far more than the rest.
stdio (standard input/output) is the default for anything running on your own machine. The client — say, your code editor's AI assistant — spawns your server as a child process and communicates by writing JSON-RPC messages to its stdin and reading responses from its stdout. There's no network involved, no port to bind, no TLS certificate to fumble with. This is why almost every "getting started" MCP tutorial uses stdio: it's the lowest-friction path from zero to a working tool call.
HTTP with Server-Sent Events (or the newer Streamable HTTP transport) is what you reach for when the server needs to run independently of the client's process lifecycle, or when multiple clients need to share one running instance. You might use this locally too — for example, running a long-lived server that indexes a large codebase once and then serves many quick queries, rather than re-indexing on every client launch.
For most local development, start with stdio. It's simpler to reason about, and switching to HTTP later is a matter of changing the transport layer, not rewriting your tool logic — if you structure your code well (more on that below).
Building a Minimal MCP Server You Can Run Today
Let's build something small and real: a server that exposes one tool for reading a local project's package metadata. This is deliberately simple so the mechanics stay visible.
# server.py
import json
from pathlib import Path
from mcp.server.fastmcp import FastMCP
mcp = FastMCP("project-inspector")
@mcp.tool()
def read_package_info(project_path: str) -> str:
"""Read name, version, and dependencies from a package.json file."""
pkg_file = Path(project_path).expanduser() / "package.json"
if not pkg_file.exists():
return json.dumps({"error": f"No package.json found at {project_path}"})
try:
data = json.loads(pkg_file.read_text())
except json.JSONDecodeError as e:
return json.dumps({"error": f"Invalid JSON: {e}"})
return json.dumps({
"name": data.get("name", "unknown"),
"version": data.get("version", "0.0.0"),
"dependency_count": len(data.get("dependencies", {})),
})
if __name__ == "__main__":
mcp.run(transport="stdio")Run it directly to sanity-check the process starts without errors:
python server.pyNothing will happen visually — it's now sitting on stdio waiting for a client to talk to it over JSON-RPC. That silence is normal and, honestly, a little unnerving the first time you see it. Kill it with Ctrl+C and move on to wiring it into a real client.
Wiring the Server Into a Local Client
Most MCP-capable clients (Claude Code, Claude Desktop, various editor extensions) use a similar JSON configuration pattern: a name for the server, the command to launch it, and any arguments or environment variables it needs.
{
"mcpServers": {
"project-inspector": {
"command": "python",
"args": ["/Users/you/projects/mcp-demo/server.py"],
"env": {
"LOG_LEVEL": "debug"
}
}
}
}A few details here matter more than they look like they should:
- Use absolute paths. The client spawns your server from its own working directory, not yours. A relative path to
server.pywill fail silently or point at the wrong file. This is the single most common "why isn't my server showing up" bug. - Match the interpreter to your environment. If your server depends on packages installed in a virtual environment, point
commandat that environment's Python binary (e.g.,/Users/you/projects/mcp-demo/.venv/bin/python), not the system Python. - Pass secrets via `env`, not `args`. Command-line arguments are visible in process listings (
ps auxshows them to anyone with shell access on the machine). Environment variables are marginally better isolated, and you can source them from a local.envfile that never gets committed.
After editing the config, restart the client fully — most MCP clients only read this file at startup, not on a live-reload basis. If the tool doesn't appear in the client's tool list after a restart, that's your signal to go check logs before touching the code.
Debugging: Where the Real Time Goes
Local MCP development spends more time in the debugging loop than in the "write the tool" step, so it's worth being deliberate about it.
Start with the MCP Inspector. Anthropic ships a standalone inspector tool that lets you launch your server and interact with it directly, without a full client in the loop. It's the fastest way to isolate whether a bug is in your server or in the client integration.
npx @modelcontextprotocol/inspector python server.pyThis opens a local web UI where you can see the tools your server advertises, call them with arbitrary arguments, and inspect the raw JSON-RPC request/response pairs. When a tool call fails inside a full client, reproduce it here first — it strips away a layer of complexity.
Log to stderr, never stdout. This trips up almost everyone at least once. Because stdio transport uses stdout for the actual protocol messages, any print() statement you add for debugging will corrupt the JSON-RPC stream and produce baffling parse errors on the client side. Route all diagnostic output to stderr instead:
import sys
def debug_log(message: str) -> None:
print(f"[project-inspector] {message}", file=sys.stderr)Most MCP client hosts capture the server's stderr and make it available in their own logs or developer console, so this becomes your primary window into what the server is actually doing.
Watch for the process lifecycle mismatch. A stdio server lives and dies with its client connection. If you're mid-edit on server.py and the client already has an instance running, your changes won't take effect until you restart the client (or at least force it to respawn the server process). This catches people coming from web development, where a file save triggers an instant reload. MCP stdio servers have no such thing out of the box — though you can build your own reload wrapper with a tool like watchdog if the iteration speed genuinely bothers you.
Handling State and Long-Running Processes
Not every MCP server is stateless. If your server needs to hold a database connection pool, cache expensive computations, or maintain a session across multiple tool calls, you need to think about lifecycle explicitly.
from contextlib import asynccontextmanager
from mcp.server.fastmcp import FastMCP
@asynccontextmanager
async def lifespan(server):
# Startup: acquire resources once
db_pool = await create_connection_pool()
try:
yield {"db_pool": db_pool}
finally:
# Shutdown: release resources cleanly
await db_pool.close()
mcp = FastMCP("data-server", lifespan=lifespan)
@mcp.tool()
async def query_records(ctx, table_name: str, limit: int = 10) -> str:
pool = ctx.request_context.lifespan_context["db_pool"]
async with pool.acquire() as conn:
rows = await conn.fetch(f"SELECT * FROM {table_name} LIMIT $1", limit)
return str(rows)Two things stand out in real-world use. First, connection pools created at startup and torn down at shutdown are far more efficient than opening a fresh connection per tool call — the difference is very noticeable once you're making a dozen calls in a single agent session. Second, notice the table_name is interpolated directly into the query string above — that's a placeholder for illustration, not something you should ship. Table and column identifiers can't be parameterized the way values can in most database drivers, so if table_name ever comes from model-generated input, validate it against an allowlist of known table names before it touches SQL. Local development is exactly where you want to catch this pattern, before the same code path reaches a shared server.
Environment Isolation and Dependency Management
A subtlety that catches people building their second or third MCP server: these servers accumulate dependencies fast, and running several of them side by side with a shared global Python or Node environment leads to version conflicts you won't see coming.
Treat every MCP server like its own deployable unit, even in local development:
# Python: isolate per-server
cd mcp-demo
python -m venv .venv
source .venv/bin/activate
pip install mcp fastmcp# Node: same principle, per-project lockfile
cd mcp-demo
npm init -y
npm install @modelcontextprotocol/sdkIf you're running five or six MCP servers locally — one for git operations, one for a local database, one for filesystem search, one for a note-taking app — each with its own virtual environment or node_modules, that's normal and expected. It costs some disk space, but it means updating one server's dependencies never breaks another's, and it means you can point your client config at each server's isolated interpreter without cross-contamination.
Security Habits for Local Servers
It's tempting to think of localhost as a safe zone, but a local MCP server that reads your filesystem, executes shell commands, or holds API keys is a real attack surface — especially once a model is deciding which tool calls to make based on a prompt it didn't fully control.
A few habits worth adopting from day one:
- Scope filesystem tools tightly. If a tool reads or writes files, constrain it to a specific base directory and reject any path that resolves outside it (watch for
../traversal). Don't hand a tool the entire filesystem just because it's convenient during a demo. - Never let a tool construct shell commands from raw model output. If you expose a "run command" tool, pass arguments as a list to
subprocess.runrather than building a shell string withshell=True. String interpolation into a shell command is exactly the kind of thing that looks fine in testing and becomes a problem the day the model produces unexpected input. - Keep secrets out of the config file itself when you can. Reference environment variables that are sourced from your shell profile or a local
.envfile excluded from version control, rather than hardcoding API keys directly into the client's MCP config JSON. That config file gets copied around, screenshotted for tutorials, and pasted into support requests more often than people expect. - Review tool descriptions, not just tool logic. The text you write in a tool's docstring is what the model reads to decide when and how to call it. A vague or overly permissive description ("can modify any file") invites the model to use the tool in ways you didn't intend. Be as specific in your prompts to the model as you are in your code.
None of this is exotic security advice — it's the same discipline you'd apply to any code that accepts untrusted input and has side effects. The difference with MCP is that the "untrusted input" is now model-generated, and it can be creative in ways a human filling out a form usually isn't.
Testing Your Server Without a Full Client
Beyond the Inspector, it's worth writing actual automated tests for your MCP tools, treating them like any other function with inputs and outputs — because that's what they are underneath the protocol wrapper.
import pytest
from server import read_package_info
def test_reads_valid_package_json(tmp_path):
pkg = tmp_path / "package.json"
pkg.write_text('{"name": "demo", "version": "1.2.0", "dependencies": {"react": "^18.0.0"}}')
result = read_package_info(str(tmp_path))
assert '"name": "demo"' in result
assert '"dependency_count": 1' in result
def test_handles_missing_file(tmp_path):
result = read_package_info(str(tmp_path))
assert "error" in result
def test_handles_malformed_json(tmp_path):
pkg = tmp_path / "package.json"
pkg.write_text("{not valid json")
result = read_package_info(str(tmp_path))
assert "error" in resultBecause @mcp.tool() decorators typically wrap a plain function, you can import and call that function directly in a test file without spinning up the protocol layer at all. Run these with your usual test runner:
pytest test_server.py -vThis matters more than it might seem, because MCP tool functions tend to be the layer where edge cases live — malformed input, missing files, network timeouts to whatever backend the tool wraps. Catching those with a fast, protocol-free unit test suite is much cheaper than catching them through a live agent session where the failure surfaces three tool calls downstream from the actual bug.
Multiple Servers, One Client: Managing the Full Local Setup
Once you've built more than one or two servers, your client configuration starts to look like a small fleet:
{
"mcpServers": {
"project-inspector": {
"command": "/Users/you/projects/mcp-demo/.venv/bin/python",
"args": ["/Users/you/projects/mcp-demo/server.py"]
},
"local-db": {
"command": "node",
"args": ["/Users/you/projects/db-mcp/dist/index.js"],
"env": {
"DATABASE_URL": "postgresql://localhost:5432/dev"
}
},
"git-tools": {
"command": "uvx",
"args": ["mcp-server-git", "--repository", "/Users/you/projects/main-repo"]
}
}
}A couple of practical notes once you're at this scale. First, name your servers descriptively — the client surfaces these names when the model decides which tool to call, and "server1" versus "git-tools" makes a real difference in how reliably the model picks the right one. Second, be honest with yourself about which servers you actually need running at any given time. Every server the client spawns is a process consuming memory and, if it holds a connection pool or watches a filesystem, potentially doing background work you don't need in every session. It's fine to comment out or remove entries for servers you're not actively using rather than keeping a permanently maxed-out config.
Working With Resources and Prompts, Not Just Tools
Most local MCP tutorials focus entirely on tools — functions the model can call — but the protocol also defines resources and prompts, and local development is a good place to get comfortable with both, because they change how much work your tools have to do.
A resource is read-only context your server can expose without the model needing to explicitly call a function for it. Think of it as the difference between a model asking "please read me the contents of config.yaml" through a tool call, versus that file simply being available as background context the client can attach when relevant.
@mcp.resource("config://project-settings")
def get_project_settings() -> str:
"""Expose the current project's settings file as a readable resource."""
settings_path = Path("./project.yaml")
if not settings_path.exists():
return "No project.yaml found in current directory."
return settings_path.read_text()Prompts, meanwhile, are reusable templates your server can offer — a way to package up a well-tested instruction pattern so users (or the client on their behalf) don't have to retype it every session.
@mcp.prompt()
def code_review_prompt(file_path: str) -> str:
"""Generate a structured code review prompt for a given file."""
return (
f"Review the code in {file_path}. Check for: "
f"1) obvious bugs, 2) unclear naming, 3) missing error handling. "
f"Be specific about line numbers where possible."
)Locally, this distinction matters because it changes what you're debugging. If a tool call is failing, the bug is almost always in your function logic or its input validation. If a *resource* isn't showing up when expected, the bug is usually in how the client is configured to surface resources at all — some clients require the user to explicitly attach a resource rather than auto-including it. Knowing which piece owns which failure mode saves a lot of guessing.
Handling Errors So the Model Can Actually Recover
A detail that separates a merely functional local server from a genuinely pleasant one to use: what happens when a tool call fails. It's tempting to let an unhandled exception bubble up and let the client show a raw stack trace, but that gives the model almost nothing to work with. A well-behaved tool should catch the failure modes it can anticipate and return a message the model can reason about and potentially recover from.
@mcp.tool()
def read_package_info(project_path: str) -> str:
"""Read name, version, and dependencies from a package.json file."""
try:
resolved = Path(project_path).expanduser().resolve()
except (OSError, RuntimeError) as e:
return json.dumps({"error": f"Could not resolve path: {e}"})
pkg_file = resolved / "package.json"
if not pkg_file.exists():
return json.dumps({
"error": f"No package.json at {resolved}",
"hint": "Check that the path points to a Node project root.",
})
if not pkg_file.is_file():
return json.dumps({"error": f"{pkg_file} exists but is not a file."})
try:
data = json.loads(pkg_file.read_text())
except json.JSONDecodeError as e:
return json.dumps({"error": f"Invalid JSON at line {e.lineno}: {e.msg}"})
except UnicodeDecodeError:
return json.dumps({"error": "File is not valid UTF-8 text."})
return json.dumps({
"name": data.get("name", "unknown"),
"version": data.get("version", "0.0.0"),
"dependency_count": len(data.get("dependencies", {})),
})Notice the "hint" field in the missing-file case. That's not decoration — a specific, actionable hint gives the model a concrete next step (try a different path, ask the user to clarify) instead of just a dead end. When you're testing locally, deliberately trigger every error branch you've written — pass a nonexistent path, a directory with no package.json, a file with truncated JSON — and confirm the message you get back is one you'd actually want to receive if you were the model trying to decide what to do next.
Iterating Quickly With a Local Test Harness
Restarting a full client every time you change a line of server code gets old fast, especially once your tool logic has any real complexity. A lightweight pattern that speeds this up considerably is writing a small local harness that talks to your server the same way a client would, but without the overhead of the full application.
# harness.py — talk to your own server like a client would
import asyncio
from mcp import ClientSession, StdioServerParameters
from mcp.client.stdio import stdio_client
async def main():
server_params = StdioServerParameters(
command="python",
args=["server.py"],
)
async with stdio_client(server_params) as (read, write):
async with ClientSession(read, write) as session:
await session.initialize()
tools = await session.list_tools()
print("Available tools:", [t.name for t in tools.tools])
result = await session.call_tool(
"read_package_info",
arguments={"project_path": "."},
)
print("Result:", result.content)
asyncio.run(main())Run this with a plain python harness.py and you get the full request/response cycle — initialization handshake, tool discovery, tool invocation — without touching your editor's AI panel or waiting for a client restart. It's especially useful when you're iterating on error messages or output formatting, where you want to see the exact string the model would receive, not just a UI's rendering of it. Keep this harness in the repo alongside your server; it doubles as executable documentation for anyone else who needs to understand how the server behaves.
From Local to Something More Durable
Running an MCP server locally will take you a long way — for personal tooling, small team utilities, and anything where "your machine" is also the intended audience, it may be all you ever need. But there's a natural next question once a server proves useful: what happens when a teammate wants it too, or when the tool needs to be reachable from somewhere other than your laptop, or when you want it to survive a reboot without you remembering to relaunch it.
That's a different set of problems — process supervision, remote transport security, authentication between client and server, versioning a tool's interface without breaking clients mid-update. They're worth understanding, but they're squarely a "later" concern, not a "day one" one. Get the local loop solid first: a server that starts reliably, fails predictably, logs clearly, and does exactly what its tool description says it does. That discipline transfers directly to whatever comes after.
If you want to go deeper into taking a server from a local prototype to something teams can rely on — including packaging, versioning, and connecting servers to real production systems — that's exactly the territory we cover in Building & Integrating MCP Servers, one of the hands-on modules here at teachyou.ai.
BootcampA 30-day guided bootcamp: build, harden and ship a production autonomous agent from scratch.
AI AgentsUnderstand how AI agents really work: the loop, the tools, the memory, and why most agent projects fail.