Building an MCP Server for Job Search: A Deep Dive into FastMCP
Lessons from building a production MCP server that connects Claude to Adzuna's job search API

Most API integrations stop at "here's how to call an endpoint." Model Context Protocol (MCP) goes further. It's a way of teaching AI assistants not just what tools exist, but when, why, and how to combine them to solve real problems.
To explore this properly, I built an MCP server that connects Claude to Adzuna's job search API. What surprised me wasn't the API work. It was how much MCP is really about clear contracts, intent, and communication, not just code.
In this article, I break down the architecture of the server, the MCP design patterns that actually worked, and the mistakes I made along the way so you can build better MCP tools without learning everything the hard way.
New to MCP? It's an open protocol that lets AI assistants discover and call external tools through a standard interface, think of it as "USB-C for LLM tools."
What We're Building#
The server exposes seven tools that work as a workflow, not a menu:
| Tool | Purpose | Flow Position |
|---|---|---|
get_categories | Fetch valid job category tags | First (scoping) |
search_jobs | Query jobs with filters | Second (discovery) |
get_salary_histogram | Salary distribution data | Parallel (research) |
get_top_companies | Major employers by hiring volume | Parallel (research) |
get_geodata | Salary/jobs by geographic region | Parallel (research) |
get_salary_history | Historical salary trends | Parallel (research) |
get_api_version | API health check | As needed |
The key insight: tools should form a coherent workflow. When Claude hears "Find me React jobs in London paying over £60k," it shouldn't just call search_jobs. It should first fetch valid categories, then search with appropriate filters and salary constraints.
Three workflows emerge:
- Job Discovery:
get_categories→search_jobs - Salary Research:
get_salary_histogram+get_geodata+get_salary_history - Employer Research:
get_top_companies
Why FastMCP#
FastMCP is a Python framework that handles the MCP protocol so you can focus on tool logic. The alternative is implementing the protocol yourself: JSON-RPC message framing, capability negotiation, transport handling. FastMCP gives you decorators instead.
Here's the entire server initialization:
from fastmcp import FastMCP
mcp = FastMCP(
"Adzuna Jobs",
instructions="""
Adzuna Jobs API - Search jobs across 12 countries.
IMPORTANT:
- All salaries are ANNUAL in LOCAL CURRENCY (GBP for gb, USD for us, etc.)
- Country codes are ISO 3166-1 alpha-2 (use 'gb' not 'uk')
WORKFLOWS:
1. Job Search: get_categories → search_jobs
2. Salary Research: get_salary_histogram + get_geodata + get_salary_history
3. Company Research: get_top_companies
""",
)That instructions parameter is crucial: it's sent to the AI and shapes how it uses your tools. Notice the explicit workflow guidance. When Claude sees "call get_categories before search_jobs," it learns the sequence.
Core Architecture#
Authentication: Validate Early, Fail Fast#
The server requires API credentials. Rather than discovering missing keys mid-conversation, validate at startup:
import os
from dotenv import load_dotenv
load_dotenv()
ADZUNA_APP_ID = os.getenv("ADZUNA_APP_ID")
ADZUNA_APP_KEY = os.getenv("ADZUNA_APP_KEY")
if not ADZUNA_APP_ID or not ADZUNA_APP_KEY:
raise ValueError(
"Missing Adzuna API credentials. "
"Set ADZUNA_APP_ID and ADZUNA_APP_KEY in your .env file."
)
def get_auth_params():
"""Single source of truth for credentials."""
return {"app_id": ADZUNA_APP_ID, "app_key": ADZUNA_APP_KEY}This surfaces configuration problems immediately. No cryptic runtime errors three tool calls deep.
HTTP Layer: One Function, Consistent Behavior#
Every Adzuna call flows through a single helper:
import httpx
BASE_URL = "https://api.adzuna.com/v1/api"
async def make_request(endpoint: str, params: dict = None) -> dict:
"""All API calls go through here."""
auth_params = get_auth_params()
if params:
auth_params.update(params)
async with httpx.AsyncClient() as client:
response = await client.get(
f"{BASE_URL}/{endpoint}",
params=auth_params,
timeout=30.0
)
if response.status_code != 200:
error_data = response.json()
raise Exception(
f"API Error {response.status_code}: {error_data.get('display', 'Unknown error')}"
)
return response.json()Why this matters:
- Consistent error format: Every error starts with "API Error {code}:" so patterns emerge
- Auth injection: Tools don't think about credentials
- Timeout protection: 30 seconds is generous but prevents hangs
- Async throughout: FastMCP tools are async; the HTTP client should match
Writing Tools for AI#
This is the non-obvious part. Your docstrings aren't documentation. They're prompts that teach the AI how to use your tools.
The AI never sees your elegant code. It sees your docstrings. A clever implementation with a vague docstring will be used incorrectly.
Before: A Human-Readable Docstring#
@mcp.tool
async def search_jobs(country: str, keywords: str = None) -> dict:
"""Search for jobs."""
# ...Claude sees this and has no idea what country should look like, whether it's required, or how this tool relates to others.
After: An AI-Readable Docstring#
@mcp.tool
async def search_jobs(
country: str,
keywords: str | None = None,
location: str | None = None,
salary_min: int | None = None,
category: str | None = None,
sort_by: str | None = None,
) -> dict:
"""
Search for jobs on Adzuna.
IMPORTANT: All salaries are ANNUAL in LOCAL CURRENCY.
Args:
country: ISO 3166-1 alpha-2 code. Use 'gb' for UK, not 'uk'.
keywords: Search terms for title/description. Case insensitive.
salary_min: Minimum ANNUAL salary in LOCAL CURRENCY.
Note: Jobs without listed salaries are excluded when filtering.
category: Job category tag. IMPORTANT: Call get_categories(country)
first to get valid tags. Common: "it-jobs", "engineering-jobs"
sort_by: "date" (newest), "salary" (highest), "relevance" (default)
Example workflow:
1. get_categories("gb") -> find "it-jobs" tag
2. search_jobs("gb", keywords="python", category="it-jobs", salary_min=50000)
Errors:
- "API Error 400: Invalid category" -> call get_categories first
- "API Error 429: Too many requests" -> rate limit, wait and retry
"""The difference is dramatic. Claude now knows what format country needs, that salary filtering excludes unlisted jobs, which tool to call first, and how to interpret errors.
The Prerequisite Pattern#
Here's a pattern many MCP developers miss: prerequisite tools. Category tags aren't guessable ("it-jobs" vs "IT Jobs"), they're country-specific, and they're cacheable. By making get_categories a prerequisite with explicit instructions, you teach Claude a workflow, not just an endpoint.
Lessons Learned#
Docstrings are your primary interface. I spent hours on clean architecture when I should have spent that time on better docstrings.
Tools should form workflows, not menus. My first version had isolated tools. Adding cross-references ("call X before Y") transformed usability.
Repeat critical information. "Annual salary in local currency" appears in server instructions, in search_jobs, in get_salary_histogram, everywhere. LLMs don't always read everything.
Test with real conversations. Unit tests catch bugs. Real conversations catch usability problems. Run your server, talk to it, notice where Claude gets confused.
Wrapping Up#
MCP servers aren't complicated to build. The hard part is communication: writing docstrings that teach, designing tools that compose, and surfacing errors that help rather than confuse.
The patterns I've covered work for any MCP server: prerequisite tools, interpretation guidance, workflow instructions, redundant context. Whether you're wrapping a job API, a database, or a custom service, these patterns will help AI assistants use your tools correctly.
Try it yourself:
- GitHub: adzuna-job-search-mcp
- Smithery: adzuna/adzuna-job-search-mcp
If you build something with MCP, I'd love to hear about it.
Folarin Akinloye is an AI Engineer based in London, UK. He builds production-ready agentic AI systems, multi-agent architectures, and sophisticated RAG implementations, and writes about the engineering decisions behind them.