[NewSearch millions of jobs from your AI agent with MCP→](/blog/jobspipe-mcp-server)

[All posts](/blog)

Guide·Jul 22, 2026·11 min read

# How to build an MCP server: a step-by-step guide with a real API

Most MCP tutorials wrap a to-do list and stop. This one wraps a real API and covers the parts that bite in production: tool descriptions the model can actually route on, structured errors instead of thrown exceptions, pagination that doesn't blow the context window, and the stdio-vs-HTTP decision. Full working server in Python and TypeScript, plus how to test it before you ship.

![Dvir Atias](/authors/dvir-atias.jpg)

Dvir Atias

Founder, JobsPipe

Almost every “build an MCP server” tutorial wraps a to-do list or a calculator, prints `Hello, world`, and stops. That teaches you the transport and nothing else. The moment you wrap a real API, the interesting problems show up: the model calls the wrong tool because your description is vague, a 200-result response eats the context window, an upstream 429 surfaces as a stack trace the model can’t reason about, and nobody can tell you whether the thing works because you never tested it outside the client.

This guide builds a server over a real, live API - the [JobsPipe jobs API](/jobs-api) - in both Python and TypeScript, and spends most of its time on the parts that decide whether an agent can actually use what you shipped.

## What an MCP server actually is

The [Model Context Protocol](https://modelcontextprotocol.io) is a JSON-RPC contract between a host (Claude, Cursor, your own agent) and a server that exposes capabilities. There are three kinds, and confusing them is the most common early mistake:

-   **Tools** are functions the model decides to call on its own. This is what you want 90% of the time.
-   **Resources** are read-only blobs the host attaches to context, chosen by the user, not the model. Good for files and docs.
-   **Prompts** are user-invoked templates - the slash commands in a client’s menu.

If you want the model to fetch something autonomously, it is a tool. Do not model an API endpoint as a resource and then wonder why the agent never reads it.

## stdio or HTTP: decide first

MCP servers speak over one of two transports, and this choice shapes everything downstream.

-   **stdio** - the host spawns your server as a subprocess and talks over stdin/stdout. Zero network setup, runs on the user’s machine, has their filesystem and env vars. Right for local tooling: git, databases behind a VPN, filesystem access.
-   **Streamable HTTP** - your server is a normal web service at a URL. One deployment serves every user, you control the release cadence, and you can meter it. Right for anything backed by a hosted API.

A wrapper over a remote API should almost always be HTTP. Shipping it as stdio means every user pip-installs your package, and every bug fix waits on them upgrading. We run [our own server](/blog/jobspipe-mcp-server) over HTTP at `mcp.jobspipe.dev/mcp` for exactly that reason.

## The Python server

The official SDK’s `FastMCP` class handles the protocol, so a working tool is a decorated function with type hints. Install it with `pip install "mcp[cli]" httpx`:

```
import os
import httpx
from mcp.server.fastmcp import FastMCP

mcp = FastMCP("jobs")
API = "https://api.jobspipe.dev/v1/jobs/search"
KEY = os.environ["JOBSPIPE_API_KEY"]

@mcp.tool()
async def search_jobs(
    title: str,
    remote: bool = False,
    country: str | None = None,
    max_age_days: int = 30,
    limit: int = 10,
) -> str:
    """Search live job postings across 30+ job boards and ATS platforms.

    Use this whenever the user asks about open roles, who is hiring,
    or hiring activity at a company. Returns postings that are verified
    open, newest first.

    Args:
        title: Role to search for, e.g. "senior rust engineer".
        remote: If true, only return remote-eligible roles.
        country: Two-letter ISO country code, e.g. "US", "DE".
        max_age_days: Only postings first seen within this many days.
        limit: Number of postings to return (1-25).
    """
    payload = {
        "job_title_or": [title],
        "posted_at_max_age_days": max_age_days,
        "limit": min(limit, 25),
    }
    if remote:
        payload["remote"] = True
    if country:
        payload["country_code_or"] = [country]

    async with httpx.AsyncClient(timeout=20) as client:
        r = await client.post(
            API,
            headers={"Authorization": f"Bearer {KEY}"},
            json=payload,
        )

    if r.status_code == 429:
        return "Rate limit reached. Tell the user to retry in about a minute."
    if r.status_code == 401:
        return "The API key is invalid or expired. Ask the user to check it."
    if r.status_code >= 400:
        return f"Search failed with status {r.status_code}. Do not retry the same query."

    jobs = r.json().get("data", [])
    if not jobs:
        return (
            f"No open postings matched '{title}'. "
            "Suggest widening: drop the seniority word, increase max_age_days, "
            "or remove the country filter."
        )

    lines = []
    for j in jobs:
        comp = j.get("compensation") or "salary not listed"
        lines.append(
            f"- {j['title']} at {j['company']} ({j.get('location', 'n/a')}) "
            f"| {comp} | posted {j.get('posted_at', 'n/a')} | {j['apply_url']}"
        )
    return "\n".join(lines)

if __name__ == "__main__":
    mcp.run(transport="stdio")
```

Four things in that file are doing real work, and they are the four things the toy tutorials skip.

## 1\. The docstring is the routing logic

The model never sees your code. It sees the tool name, the docstring, and the JSON schema derived from your type hints. That is the entire basis on which it decides to call you instead of guessing from memory.

A docstring that says `Searches for jobs.` will lose to the model’s own priors half the time. The version above states _when_ to reach for it (“whenever the user asks about open roles, who is hiring”) and what it guarantees (“verified open, newest first”). Write the description for a competent contractor who has never seen your product and has to choose between eleven tools in under a second.

Same rule for parameters. `country: str` invites `"Germany"`. `Two-letter ISO country code, e.g. "US", "DE"` gets you `DE`.

## 2\. Return errors, do not raise them

An uncaught exception in a tool becomes a protocol-level error. Most hosts surface that to the model as an opaque failure, and the usual result is the agent retrying the identical call until it gives up.

Return a plain-language string instead, and say what the model should do next. `Rate limit reached. Tell the user to retry in about a minute.` is a recoverable instruction. An `httpx.HTTPStatusError` traceback is not. This one change does more for perceived reliability than anything else on this list.

## 3\. Return prose, not raw JSON

It is tempting to `return r.json()` and let the model figure it out. Do not. A raw posting object from most job APIs runs 40 to 80 fields deep, and 25 of them will fill a context window with `"internal_source_id": null` noise that costs tokens and buys nothing.

Flatten to the fields a human would read aloud, one posting per line. Ten well-formed lines beat twenty-five JSON blobs, and the model quotes them back more accurately. If a downstream step genuinely needs the full object, expose a second `get_job(id)` tool rather than inflating every search result.

## 4\. Cap the result count in code

`min(limit, 25)` is not defensive programming, it is context management. Models will happily pass `limit=500` because you said the parameter exists. Clamp it server-side and mention the ceiling in the docstring.

For genuinely large result sets, return the first page plus an explicit cursor line the model can act on, such as `More results available. Call again with cursor="abc123".` Never stream an unbounded list into a tool response.

## The TypeScript version

Same server, same decisions, using `@modelcontextprotocol/sdk` and Zod for the schema:

```
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";

const server = new McpServer({ name: "jobs", version: "1.0.0" });
const KEY = process.env.JOBSPIPE_API_KEY!;

server.tool(
  "search_jobs",
  "Search live job postings across 30+ job boards and ATS platforms. " +
    "Use whenever the user asks about open roles, who is hiring, or " +
    "hiring activity at a company. Returns verified-open postings, newest first.",
  {
    title: z.string().describe('Role to search for, e.g. "senior rust engineer"'),
    remote: z.boolean().default(false).describe("Only remote-eligible roles"),
    country: z.string().length(2).optional().describe('ISO code, e.g. "US"'),
    limit: z.number().min(1).max(25).default(10),
  },
  async ({ title, remote, country, limit }) => {
    const res = await fetch("https://api.jobspipe.dev/v1/jobs/search", {
      method: "POST",
      headers: {
        Authorization: `Bearer ${KEY}`,
        "Content-Type": "application/json",
      },
      body: JSON.stringify({
        job_title_or: [title],
        ...(remote && { remote: true }),
        ...(country && { country_code_or: [country] }),
        limit,
      }),
    });

    if (!res.ok) {
      return {
        content: [{
          type: "text",
          text: res.status === 429
            ? "Rate limit reached. Retry in about a minute."
            : `Search failed (${res.status}). Do not retry the same query.`,
        }],
      };
    }

    const { data = [] } = await res.json();
    const text = data.length
      ? data.map((j: any) =>
          `- ${j.title} at ${j.company} (${j.location ?? "n/a"}) | ${j.apply_url}`
        ).join("\n")
      : `No open postings matched "${title}". Suggest widening the search.`;

    return { content: [{ type: "text", text }] };
  }
);

await server.connect(new StdioServerTransport());
```

Zod’s `.describe()` calls are not decoration - they become the JSON Schema field descriptions the model reads. Skipping them is the TypeScript equivalent of an empty docstring.

## Testing before you ship

Do not debug an MCP server through a chat client. The inspector gives you a browser UI that lists your tools, validates schemas, and lets you invoke them with arbitrary arguments:

```
npx @modelcontextprotocol/inspector python server.py
```

Check three things there. Does every tool appear with a non-empty description? Does an intentionally bad argument produce a readable string rather than a crash? Does a large result stay under a few thousand tokens? If all three pass, connect it for real:

```
claude mcp add jobs -- python /abs/path/to/server.py

# or, for an HTTP server
claude mcp add --transport http jobs https://your-server.example.com/mcp \
  --header "Authorization: Bearer YOUR_KEY"
```

## Going remote

Switching the Python server to HTTP is a one-line change - `mcp.run(transport="streamable-http")` - but the operational surface grows. You now own authentication (pass the user’s key through as a header rather than baking one into the environment), per-user rate limiting, and the fact that a slow upstream now blocks a shared process rather than one user’s laptop.

Pass credentials through instead of holding them. When our server receives a call, it forwards the caller’s own `jp_live_` key upstream, so quota and rate limits land on the right account and we never store a secret we don’t need. It also means there is no second login to build.

## The shortcut

If the API you were about to wrap already ships an MCP server, use it. Ours is one command and covers the full [filter set](/docs/ai-agents/mcp) - title and description phrases, company, country, employment type, seniority, remote, date windows, and paging - against 30+ sources:

```
claude mcp add --transport http jobspipe https://mcp.jobspipe.dev/mcp \
  --header "Authorization: Bearer jp_live_xxxxxxxxxxxxxxxx"
```

Build your own when you need tools that don’t exist yet, want to compose several APIs behind one interface, or need to enforce business-specific rules before results reach the model. Wrapping a single endpoint that someone already wrapped is work you can skip.

Next: the servers worth connecting once yours works - see [the best MCP servers for real data](/blog/best-mcp-servers-for-data) \- and [building a full agent loop](/blog/ai-job-search-agent) on top of one.

Get a free API key and have a working MCP server in about ten minutes.

## Frequently asked questions

### How do you build an MCP server?

Define a tool as a function, describe it precisely, and expose it over stdio or HTTP. Using the official Python SDK, a working tool is a decorated function with type hints - FastMCP derives the JSON schema from the signature and the docstring. The protocol is the easy part. The work is in the tool description, error handling, and response size, because those are what decide whether a model can actually use it.

### Should an MCP server use stdio or streamable HTTP?

Use stdio for local tooling and HTTP for anything backed by a hosted API. With stdio the host spawns your server as a subprocess, so it gets the user's filesystem and environment, which suits git, local databases, and file access. HTTP means one deployment serves every user and you control the release cadence, so bug fixes do not wait on users upgrading a package.

### Why does the model never call my MCP tool?

Almost always because the tool description is too vague. The model never sees your code - it routes on the tool name, the description, and the JSON schema derived from your type hints. A description like "Searches for jobs" loses to the model's own priors. State when to reach for the tool and what it guarantees, and describe every parameter, including expected formats like two-letter country codes.

[

← Previous

Job aggregator sites: where each one actually gets its listings

](/blog/job-aggregator-sites)[

Next →

The best MCP servers for real data in 2026

](/blog/best-mcp-servers-for-data)

---
Canonical URL: https://jobspipe.dev/blog/how-to-build-an-mcp-server
Title: How to build an MCP server: a step-by-step guide with a real API
Description: Most MCP tutorials wrap a to-do list and stop. This one wraps a real API and covers the parts that bite in production: tool descriptions the model can actually route on, structured errors instead of thrown exceptions, pagination that doesn't blow the context window, and the stdio-vs-HTTP decision. Full working server in Python and TypeScript, plus how to test it before you ship.