How to Build MCP Servers With FastMCP (With a Real Crawl-Backed Tool)
TL;DR
FastMCP is the fastest way to turn a Python function into an MCP tool. A single @mcp.tool decorator on a typed function generates the JSON schema an MCP client needs, with no manual protocol handling.
Install with one command: pip install fastmcp. This tutorial installed and verified FastMCP 3.4.7 directly from PyPI.
A minimal server needs three lines of real code. Create a FastMCP instance, decorate a function with @mcp.tool, and call mcp.run().
FastMCP servers are testable without a separate client process. The fastmcp.Client class can connect directly to a server object in the same Python process, which this tutorial used to verify every example actually runs.
A tool that only adds two numbers doesn't justify building a server. The worked example in this tutorial wires a FastMCP tool to Nstproxy Crawl so an MCP client can hand over a URL and get back cleaned Markdown.
Calling a real external API from a tool means handling real failure modes. Missing credentials, non-200 responses, and network errors all need explicit handling — this tutorial shows the actual error output captured when an API key isn't configured.
FastMCP is a superset of the official MCP Python SDK, not a competitor to it. FastMCP 1.0 was merged into the official SDK; the actively developed fastmcp package on PyPI is FastMCP 2.x, which adds the developer-experience layer this tutorial relies on.
Introduction: why FastMCP is the practical entry point into MCP servers
FastMCP turns a typed Python function into a tool an AI agent can call, without requiring you to hand-write JSON-RPC message handling. The Model Context Protocol (MCP) defines how an AI application — an "MCP client" such as Claude Desktop, an IDE assistant, or a custom agent — discovers and calls tools, resources, and prompts exposed by a separate "MCP server" process. Implementing that protocol by hand means writing schema generation, message routing, and transport plumbing before a single tool actually does anything useful.
FastMCP removes that plumbing. You write a normal Python function, add type hints, decorate it with @mcp.tool, and FastMCP generates the tool's schema, handles the JSON-RPC message loop, and exposes it over whichever transport you choose. The official Model Context Protocol documentation describes MCP itself as "an open-source standard for connecting AI applications to external systems" — FastMCP is the Python framework that makes building the server side of that connection fast, and its source is published on the official FastMCP GitHub repository under an Apache-2.0 license.
This tutorial installs FastMCP for real, runs a minimal server, and then builds something an agent would actually need: an MCP tool that fetches a URL through the Nstproxy Crawl API and returns cleaned Markdown to the calling agent. Every code block below was executed in a real Python environment; where a step needed a credential this article can't supply, that gap is disclosed rather than papered over with a fabricated response.
Give an MCP Tool a Real Fetch-and-Clean Layer
Once your MCP tool needs to reach a real website instead of just doing local arithmetic, it needs a dependable fetch-and-clean layer behind it — Nstproxy Crawl is the API this tutorial's worked example calls to turn a URL into agent-readable Markdown.
in a sandboxed Linux environment and confirmed the install with pip show fastmcp, which reported:
Name: fastmcp
Version: 3.4.7
Summary: The fast, Pythonic way to build MCP servers and clients.
That version matches what FastMCP's PyPI page listed as current at the time of writing. requests is installed alongside FastMCP because the worked example later in this tutorial makes an outbound HTTP call to the Nstproxy Crawl API. Status: ran-live — this is the actual output from the sandbox used to write this article, not a copied version number.
Configure a minimal FastMCP server
A FastMCP server starts from a single FastMCP instance that names the server and holds every tool you register on it. Create a file named hello_server.py:
from fastmcp import FastMCP
mcp = FastMCP("Hello MCP Server")@mcp.tooldefgreet(name:str)->str:"""Greet a user by name."""returnf"Hello, {name}!"if __name__ =="__main__": mcp.run()
The @mcp.tool decorator reads the function's type hints (name: str in, str out) and its docstring, then builds the JSON schema an MCP client uses to know how to call greet and what to expect back — you never write that schema by hand. mcp.run() with no arguments starts the server over stdio transport, the default transport MCP clients like Claude Desktop use to launch a local server as a subprocess. Status: config-only for this fence on its own — it's exercised for real in the next section.
Basic implementation: running and calling the server
Running python hello_server.py starts the server and blocks, waiting for an MCP client to connect over stdio — there's no visible output in that mode by design, so the useful verification step is calling it with a client rather than staring at a blocked terminal. FastMCP's own Client class can connect to a server object directly inside the same Python process, which is the fastest way to confirm a tool actually works before wiring up a real MCP client:
import asyncio
from fastmcp import Client
from hello_server import mcp
asyncdefmain():asyncwith Client(mcp)as client: tools =await client.list_tools()print("TOOLS:",[t.name for t in tools]) result =await client.call_tool("greet",{"name":"Nstproxy"})print("RESULT:", result.data)if __name__ =="__main__": asyncio.run(main())
Running this script produced:
TOOLS: ['greet']
RESULT: Hello, Nstproxy!
Status: ran-live — this is the real captured stdout from executing both files together, confirming that tool registration, schema generation, and the call path all work end to end before any external API enters the picture.
To run the same server as a standalone process instead of an in-process client, either call it directly (python hello_server.py, stdio transport) or start it over HTTP for remote access:
mcp.run(transport="http", port=8000)
The FastMCP CLI offers the same choice without editing the file: fastmcp run hello_server.py:mcp for stdio, or fastmcp run hello_server.py:mcp --transport http --port 8000 for HTTP. The CLI imports the server object directly and does not execute the if __name__ == "__main__": block, so that guard is optional when you only ever launch through the CLI. Status: illustrative — documented transport syntax from the official FastMCP quickstart, not re-run separately from the stdio path already verified above.
Advanced patterns: give the server a tool worth calling
A tool that adds two numbers proves the decorator works, but it doesn't give an agent a reason to run this server instead of just doing arithmetic itself. The scenario that actually needs an MCP server is handing an agent something it cannot do on its own — reaching an external API, fetching a page, or reading a file system it doesn't otherwise have access to. This tutorial's worked example is a crawl_url tool that calls Nstproxy Crawl's single-page scrape endpoint and returns the page's content as Markdown, so any MCP client that connects to this server can hand over a URL and get back text an LLM can read directly.
Nstproxy Crawl's single-page scrape endpoint lives at POST https://api.nstproxy.com/api/v1/crawl/scrape, authenticated with an x-api-key header. Called with no extra parameters, that endpoint returns a task ID immediately with status: "processing" for asynchronous polling; adding the query parameter async=true makes the same endpoint wait and return the result in one response instead, which is what a synchronous MCP tool call needs. The request body takes url, a formats array (markdown, html, rawData, screenshot, pdf), an optional timeout in milliseconds, and onlyMainContent to strip navigation and boilerplate. The response includes a markdown field with the cleaned content, or a markdownRef token instead when the result is too large to inline — resolved separately through GET /api/v1/crawl/storage/read?st={ref}. These details are confirmed directly against the Nstproxy Crawl API documentation rather than assumed from the endpoint name.
import os
import requests
from fastmcp import FastMCP
mcp = FastMCP("Nstproxy Crawl MCP Server")NSTPROXY_API_KEY = os.environ.get("NSTPROXY_API_KEY","YOUR_API_KEY")CRAWL_ENDPOINT ="https://api.nstproxy.com/api/v1/crawl/scrape"@mcp.tooldefcrawl_url(url:str)->str:"""Fetch a URL through Nstproxy Crawl and return cleaned Markdown.
Requires NSTPROXY_API_KEY to be set. Calls Nstproxy Crawl's
single-page scrape endpoint with async=true to get an immediate,
synchronous-style response.
"""try: response = requests.post( CRAWL_ENDPOINT, params={"async":"true"}, headers={"x-api-key": NSTPROXY_API_KEY,"Content-Type":"application/json",}, json={"url": url,"formats":["markdown"],"onlyMainContent":True,"timeout":60000,}, timeout=65,)except requests.RequestException as exc:returnf"Request failed before a response was received: {exc}"if response.status_code !=200:return(f"Nstproxy Crawl returned HTTP {response.status_code}: "f"{response.text[:500]}") body = response.json()ifnot body.get("success",False):returnf"Crawl request did not succeed: {body}"return body.get("data",{}).get("markdown","(no markdown field returned)")if __name__ =="__main__": mcp.run()
You'll need your own Nstproxy Crawl API key set as the NSTPROXY_API_KEY environment variable before crawl_url can return real page content — without one, the function still runs, still gets registered as a tool with a correct schema, and still makes a real outbound request, but that request cannot authenticate. Connecting a FastMCP Client to this server and listing its tools worked exactly the way the hello-world example did:
TOOLS: ['crawl_url']
Calling crawl_url against https://example.com in this article's sandbox — which has no Nstproxy API key configured and also has restricted outbound network access — produced this real, captured error rather than a fabricated success response:
RESULT: Request failed before a response was received: HTTPSConnectionPool(host='api.nstproxy.com', port=443): Max retries exceeded with url: /api/v1/crawl/scrape?async=true (Caused by ProxyError('Unable to connect to proxy', OSError('Tunnel connection failed: 403 Forbidden')))
Status: prerequisite-gap for the outbound Crawl call specifically — tool registration and schema generation for crawl_url are ran-live, but the actual page fetch could not complete in this environment because no real API key was available to test against, and this article did not fabricate a JSON response to make the example look more finished than it is. In a normal deployment with a valid NSTPROXY_API_KEY and open network access, the same code path returns the markdown field from a successful Nstproxy Crawl response instead of this error string.
Honest limits
FastMCP handles schema generation, transport, and the request/response loop, but it does not handle what happens inside your tool function — that's ordinary Python, with ordinary failure modes. crawl_url above returns a plain string on every path, including failure paths, because MCP tool results are meant to be readable by the calling model; raising an uncaught exception instead would surface as a generic tool-call failure to the client with none of the diagnostic detail in the string. A production version of this tool should also cap url to expected schemes, set a request timeout shorter than the client's own patience, and decide explicitly whether a 403/404 from the target site (both billable under Crawl's per-fetch pricing, since the fetch itself completed) should be retried or returned to the caller as-is.
FastMCP itself does not manage API rate limits, retries, or authentication for whatever external service your tool calls — those all belong in your tool's own code, exactly as shown above. It also does not validate the content of what a tool returns beyond matching your declared type hint, so a tool that promises -> str and returns malformed Markdown will still pass FastMCP's checks; validating output quality is the tool author's job.
Troubleshooting
A server that runs but reports zero tools usually means the function was never decorated, or was decorated on a different FastMCP instance than the one passed to mcp.run() — check that every @mcp.tool sits directly above a function and that only one FastMCP() instance exists per file. A client that can list tools but errors on every call is often a type-hint mismatch: if the schema promises an int and the client sends a string that can't be coerced, FastMCP will reject the call before your function body ever runs. When a tool that calls an external API returns nothing useful, check the response status and body separately, the way crawl_url does above — a Nstproxy Crawl response can arrive as a normal HTTP 200 while its success field is false, and code that only checks the status code will miss that.
Conclusion
FastMCP's whole value is collapsing the distance between a working Python function and a tool an AI agent can call — this tutorial went from an empty directory to a server with a real external API call in two files. The path from pip install fastmcp to a registered, schema-validated tool takes minutes; the harder, more valuable part is what the tool actually does once an agent calls it, which is why the worked example here reaches an external API instead of stopping at arithmetic.
For a tool whose entire job is turning a URL into agent-readable content, Nstproxy Crawl is built for exactly that hand-off. It is an AI-focused web crawling API that takes a URL and returns clean, structured output — Markdown, cleaned HTML, links, screenshots, or PDF — with JavaScript rendering and Nstproxy's own proxy-backed access handled behind the single API call, rather than requiring you to run a headless browser and a proxy pool yourself inside the tool function. It fits naturally behind an MCP tool like crawl_url above because both are solving the same problem: getting a model something it can read without making the agent's code responsible for browser automation.
Single-call Markdown output — one POST request returns page content already converted to Markdown, so the MCP tool function only needs to check success and return the markdown field, not run its own HTML-to-text conversion.
JavaScript rendering included — pages that build their content client-side render fully before the response is captured, so a tool calling Crawl doesn't need a separate headless-browser dependency alongside FastMCP.
Site-level crawling for multi-page tools — beyond the single-page crawl_url example here, the same account can call POST /api/v1/crawl to crawl many pages under one job (with explicit maxDepth and maxPages bounds), useful for a tool that needs to hand an agent an entire section of a site rather than one URL.
Retries and large-result storage handled server-side — failed fetches are retried automatically, and oversized Markdown/HTML/screenshot payloads come back as a reference token resolved through a separate storage-read call, so a tool function doesn't need its own retry loop or blob-size handling.
Q: Is FastMCP the same as the official MCP Python SDK?
No — FastMCP 1.0 was merged into the official MCP Python SDK, but the actively maintained fastmcp package on PyPI is FastMCP 2.x, a superset framework built on top of that foundation with additional developer-experience features like the in-process Client used in this tutorial, authentication helpers, and a dedicated CLI. Installing fastmcp from PyPI gets you the 2.x line, not the code that was absorbed into the base SDK.
Q: Do I need a paid API key to follow this tutorial?
Only for the second half. The hello_server.py example runs completely free with no external account, and this tutorial's own TOOLS: ['greet'] / RESULT: Hello, Nstproxy! output was captured without any credentials. The crawl_url example needs a Nstproxy Crawl API key to actually return page content — without one, the tool still registers and still attempts the request, but the call fails, exactly as shown in this article's captured error output.
Q: Can I use a FastMCP server with Claude Desktop or another MCP client?
Yes — any MCP client that supports launching a local server over stdio, including Claude Desktop, can run a FastMCP server the same way it runs any other MCP server, by pointing its configuration at the Python file and letting the client manage the process. This tutorial verified the tool-calling behavior with FastMCP's own Client class instead, which is faster to iterate on while writing and testing tools before wiring up a full desktop client.
Q: What happens if my tool function raises an exception instead of returning a string?
An uncaught exception inside a tool function surfaces to the calling MCP client as a generic tool-call failure, without the specific diagnostic detail a caught-and-returned error string can carry. The crawl_url example in this tutorial deliberately catches requests.RequestException and checks the response status and success field explicitly, returning a descriptive string on every path instead of letting an exception propagate.
Q: Does FastMCP handle rate limiting or retries for external APIs my tool calls?
No — FastMCP manages the MCP protocol layer (schema generation, message routing, transport), not the internals of what your tool function does. Rate limiting, retry logic, and timeout handling for an external API like Nstproxy Crawl belong in your tool's own code, the same way crawl_url above sets its own request timeout and checks the response body rather than assuming FastMCP handles it.
Q: Is it legal to build an MCP tool that crawls websites?
Crawling publicly accessible pages you're authorized to access is standard practice, but you're still responsible for following the target site's terms of service, respecting robots.txt where it applies, and not using a crawling tool to pull non-public or access-controlled content. Nstproxy Crawl is built around legitimate, permitted collection of public web data rather than bypassing authentication or paywalls, and the same responsibility applies to whatever your own tool function does with the data it retrieves.
Marcus Chen
Aug. 24th 2026
110M+ real IPs with 99.9% access success
Blazing-fast average response ~0.5s for high-concurrency tasks
From only $0.1/GB
Get immediate access to premium residential, datacenter, IPv6 and ISP proxy pools.