FastMCP: Building and Deploying an MCP Server
The Model Context Protocol (MCP) is a standard way for an LLM client (Claude Desktop, Claude Code, or any custom agent) to discover and call tools, read resources, and use prompts exposed by an external server — instead of every integration inventing its own bespoke function-calling glue. FastMCP is the Python framework for building that server side: plain decorated functions in, a fully spec-compliant MCP server out.
How FastMCP Works
FastMCP wraps three MCP primitives as decorators on ordinary Python functions:
@mcp.tool— an action the model can invoke, with arguments. FastMCP reads the function's type hints and docstring to generate the JSON Schema the client needs, so there's no schema to hand-write or keep in sync.@mcp.resource— read-only data the client can fetch by URI, similar in spirit to a GET endpoint.@mcp.prompt— a reusable, parameterized prompt template the client can pull and fill in.
The server object itself just needs a name, and running it is a single call:
from fastmcp import FastMCP
mcp = FastMCP("Knowledge Base")
# ... @mcp.tool-decorated functions go here ...
if __name__ == "__main__":
mcp.run()
What actually changes between local use and a production deployment is the transport — how requests and responses cross the wire — covered below.
Example: Querying This Knowledge Base
A natural first tool for a docs site like this one is "let an agent search and
read the articles." Both tools below only ever touch files under docs/knowledge
— read_article resolves the path and rejects anything that escapes that root,
which matters the moment path is attacker- or model-controlled input rather
than a trusted constant.
import re
from pathlib import Path
from fastmcp import FastMCP
mcp = FastMCP("Knowledge Base")
DOCS_ROOT = Path(__file__).parent / "docs" / "knowledge"
def _iter_articles():
for path in DOCS_ROOT.rglob("*.md"):
if path.name != "index.md":
yield path
@mcp.tool
def search_articles(query: str) -> list[dict]:
"""Search knowledge base articles by keyword in their title or body."""
query = query.lower()
results = []
for path in _iter_articles():
text = path.read_text()
if query in text.lower():
title_match = re.search(r"^#\s+(.+)$", text, re.MULTILINE)
results.append({
"path": str(path.relative_to(DOCS_ROOT)),
"title": title_match.group(1) if title_match else path.stem,
})
return results
@mcp.tool
def read_article(path: str) -> str:
"""Return the full markdown content of one article, given its relative path."""
article_path = (DOCS_ROOT / path).resolve()
if not article_path.is_relative_to(DOCS_ROOT) or not article_path.exists():
raise ValueError(f"Unknown article: {path}")
return article_path.read_text()
if __name__ == "__main__":
mcp.run()
An agent can now ask search_articles("idempotency"), get back a path, and
follow up with read_article(...) to pull the full text into context — no
custom retrieval pipeline needed for a docs corpus this size.
Try it locally first
fastmcp dev server.py spins up the MCP Inspector, a browser UI for calling
tools by hand — the fastest way to sanity-check a tool's schema and output
before wiring it into any client.
Deployment
stdio — local clients
For a client running on the same machine (Claude Desktop, Claude Code), the
default mcp.run() uses stdio: the client spawns the server as a
subprocess and talks to it over stdin/stdout. No networking, no auth — the
process boundary is the security boundary. This is the right choice for a
personal tool like the knowledge-base server above.
Streamable HTTP — remote clients
To let a server be reached over a network instead, switch transports:
This serves MCP over Streamable HTTP, the spec's HTTP-based transport —
a single endpoint that responds with a normal JSON body for quick calls, or
switches to a chunked/SSE stream when the server needs to push multiple
messages back (progress updates on a long-running tool call, for instance).
mcp.http_app() returns the underlying ASGI app directly, so it can be run
with uvicorn or mounted inside a larger Starlette/FastAPI app like any other
route.
Deploying to AWS Lambda: Web Adapter vs. a Built-In Handler
Once the server speaks HTTP, Lambda is a
natural host for it — pay per request, no server to keep patched. There are
two ways to get an ASGI app like mcp.http_app() running inside Lambda, and
for an MCP server specifically they aren't equivalent.
A built-in ASGI adapter (e.g. Mangum) translates the incoming Lambda
event (API Gateway or Function URL) into an ASGI scope in-process, calls
the app, and translates the response back:
This ships as a plain zip package, needs no extra infrastructure, and is the simplest path when a server's tools are fast, synchronous request/response calls. Its limitation is that it buffers the entire response before handing it back to Lambda — there's no way to stream chunks out as they're produced, because the ASGI-to-Lambda-event translation only has a complete response object to work with.
The AWS Lambda Web Adapter takes the opposite approach: it doesn't translate the app at all. It runs the app exactly as it would run anywhere else —
FROM public.ecr.aws/awsguru/aws-lambda-adapter:0.8 AS adapter
FROM python:3.12-slim
COPY --from=adapter /lambda-adapter /opt/extensions/lambda-adapter
COPY . /app
WORKDIR /app
RUN pip install -r requirements.txt
CMD ["uvicorn", "server:app", "--host=0.0.0.0", "--port=8080"]
The adapter is a small Rust binary, added as a Lambda extension (layer or, as
above, baked into a container image). It receives the actual Lambda
invocation, forwards it to uvicorn over localhost:8080 as a real HTTP
request, and — when the Function URL is configured with
InvokeMode: RESPONSE_STREAM — streams the response back chunk by chunk
instead of buffering it.
| Built-in adapter (Mangum) | Lambda Web Adapter | |
|---|---|---|
| Code changes needed | Wrap the app in Mangum(app) |
None — app runs unmodified |
| Response streaming | No — buffers the full response | Yes — true chunked/SSE streaming |
| Deploys as | Zip package | Container image (or layer) |
| Portability | Lambda-specific handler | Same image runs on Fargate, App Runner, or a laptop unchanged |
| Cold start | Slightly lighter — no extra process | Slightly heavier — spins up a real HTTP server process |
Why this matters specifically for MCP
Streamable HTTP relies on being able to push more than one message per request when a tool call is long-running. A buffering adapter silently degrades that to "wait for the whole thing," which defeats the point for any tool that reports progress or streams partial results. For a production MCP server, the Web Adapter's real streaming is worth the extra container-image setup; Mangum is fine for a low-traffic server whose tools are all quick, one-shot calls — like the knowledge-base search above.
Integrating Into Projects
- Claude Desktop / Claude Code — point the client's MCP config at either a local command (stdio) or a deployed URL (HTTP):
or, for the deployed Lambda version, a "url" entry pointing at the Function
URL instead of a "command".
- From another service or agent framework — use
fastmcp.Clientto call tools programmatically, without a chat client in the loop:
from fastmcp import Client
async with Client("server.py") as client:
hits = await client.call_tool("search_articles", {"query": "idempotency"})
The same Client works against a stdio script or an http://... URL —
swapping deployment target doesn't change calling code.
- Deploy the same way other infrastructure gets deployed — drive the
container build and Lambda/Function URL creation through
CDK (or whatever IaC tool the project already
uses) rather than a manual
docker push+ console click, for the same reasons any other resource belongs in version control.
Summary
- FastMCP turns decorated Python functions into MCP tools/resources/prompts, generating their JSON Schema from type hints automatically.
- stdio is for local, same-machine clients; Streamable HTTP is for anything reached over a network.
- On Lambda, a built-in ASGI adapter (Mangum) is simplest but buffers responses; the AWS Lambda Web Adapter runs the app unmodified and supports true streaming — the better fit for MCP servers with long-running tools.
fastmcp.Clientcalls tools the same way regardless of whether the server is a local script or a deployed HTTP endpoint, so switching deployment target doesn't touch calling code.
Related Articles
- AWS Lambda — the execution model (cold starts, statelessness, triggers) that the Lambda deployment options above build on.
- AWS CDK — how to define and deploy the container image and Function URL as code rather than by hand.
- Amazon Bedrock AgentCore — AgentCore Gateway solves a related problem to a hand-written MCP server: turning existing APIs into governed MCP-compatible tools without writing the server yourself.