---
title: "How to build an MCP server: a step-by-step guide with a real API"
description: "A runnable tutorial that builds a jobs MCP server on POST /v1/jobs/search with the TypeScript SDK pinned to the version the hosted JobsPipe server runs: tool definition with zod schemas and"
canonical: https://jobspipe.dev/blog/how-to-build-an-mcp-server
date_published: 2026-07-22
date_modified: 2026-07-22
author: Dvir Atias
---

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

A runnable tutorial that builds a jobs MCP server on POST /v1/jobs/search with the TypeScript SDK pinned to the version the hosted JobsPipe server runs: tool definition with zod schemas and structured output, a Streamable HTTP transport on Hono, a stdio variant, how to test it with curl, the MCP inspector and Claude Code, and the four decisions that decide whether a model can use it.

Most “build an MCP server” tutorials wrap a to-do list, print `Hello, world` and stop. That teaches the transport and nothing else. The interesting problems only show up when you wrap a real API: the model calls the wrong tool because the description is vague, a page of results eats the context window, an upstream 429 surfaces as a stack trace the model cannot reason about, and nobody can say whether the thing works because it was never tested outside a chat client.

This guide builds a server over a live API, the JobsPipe jobs search at `POST /v1/jobs/search`, using the same TypeScript SDK version and the same patterns as the hosted JobsPipe MCP server. The code is complete and runs as written. If you only want live job data in an agent, skip to the shortcut at the end: the hosted server is one command.

## What you are building

One tool, `search_jobs`, that takes the search filters as a typed input, forwards them to the API under the caller’s own key, and returns the postings as structured content. It is served over Streamable HTTP so one deployment can serve every user, with a stdio variant for local testing. This is a trimmed copy of the server behind `mcp.jobspipe.dev`, whose source is public on [GitHub](https://github.com/jobspipe/mcp). If you have never seen the protocol before, read [what an MCP server is](https://jobspipe.dev/blog/what-is-an-mcp-server) first; this post assumes you know that tools, resources and prompts are different things.

Try it without a key

```
curl -d '{"limit":3}' https://api.jobspipe.dev/v1/sandbox/jobs/search
```

Only `/v1/sandbox/*` needs no key.

## Step 1: install the SDK

Pin the versions. The MCP SDK moves quickly and the tool registration API changed more than once in 2025; the version below is what the hosted server runs today, and every snippet in this post is checked against it. Zod 3.25 ships the v4 API under the `zod/v4` import path, which is what the SDK’s schema compatibility layer expects.

```
mkdir jobs-mcp && cd jobs-mcp && npm init -y
npm i @modelcontextprotocol/sdk@1.29.0 zod@3.25.32 hono@4.7.5 @hono/node-server@^1.14.2

mkdir src
touch src/mcp-server.ts src/index.ts src/stdio.ts
```

Set `"type": "module"` in `package.json`. The examples run with `bun run`; on Node use `npx tsx` instead, the imports are the same.

## Step 2: define the tool

Everything the model will ever know about your server lives in this file: the tool name, its description, the input schema and the output schema. The handler is the smallest part.

```
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import type { ZodRawShapeCompat } from "@modelcontextprotocol/sdk/server/zod-compat.js";
import * as z from "zod/v4";

const API = "https://api.jobspipe.dev";

const SEARCH_INPUT = {
  job_title_or: z
    .array(z.string())
    .optional()
    .describe("Match jobs whose title contains any of these phrases."),
  job_country_code_or: z
    .array(z.string())
    .optional()
    .describe('ISO country codes to include, e.g. ["US","GB"].'),
  remote: z
    .boolean()
    .optional()
    .describe("true returns remote-only, false excludes remote."),
  job_seniority_or: z
    .array(z.string())
    .optional()
    .describe("Seniority levels to include."),
  source_or: z
    .array(z.string())
    .optional()
    .describe(
      "Boards to include: linkedin, indeed, greenhouse, lever, ashby, workable, smartrecruiters, workday, ycombinator, paylocity.",
    ),
  posted_at_max_age_days: z
    .number()
    .optional()
    .describe("Only postings newer than this many days."),
  max_ghost_score: z
    .number()
    .optional()
    .describe(
      "Exclude jobs whose ghost-likelihood score (0-100) exceeds this. Unscored jobs always pass.",
    ),
  limit: z
    .number()
    .int()
    .min(1)
    .max(25)
    .optional()
    .describe("Max rows to return. Default 10."),
  cursor: z
    .string()
    .optional()
    .describe("metadata.next_cursor from the previous page."),
} satisfies Record<string, z.ZodType>;

const JOB_ROW = z.looseObject({
  id: z.string().nullish(),
  job_title: z.string().nullish(),
  company: z.string().nullish(),
  location: z.string().nullish(),
  remote: z.boolean().nullish(),
  min_annual_salary_usd: z.number().nullish(),
  max_annual_salary_usd: z.number().nullish(),
  date_posted: z.string().nullish(),
  last_seen_at: z.string().nullish(),
  ghost_score: z.number().nullish(),
  url: z
    .string()
    .nullish()
    .describe("Link to the posting. This is the field to send a user to."),
});

const SEARCH_OUTPUT = {
  metadata: z
    .looseObject({
      total_results: z.number().nullish(),
      next_cursor: z.string().nullish(),
    })
    .nullish(),
  data: z.array(JOB_ROW).nullish(),
};

async function searchJobs(
  apiKey: string,
  body: Record<string, unknown>,
): Promise<unknown> {
  const res = await fetch(API + "/v1/jobs/search", {
    method: "POST",
    headers: {
      authorization: "Bearer " + apiKey,
      accept: "application/json",
      "content-type": "application/json",
    },
    body: JSON.stringify(body),
  });
  const text = await res.text();
  let parsed: unknown = null;
  try {
    parsed = text ? JSON.parse(text) : null;
  } catch {
    parsed = text;
  }
  if (!res.ok) {
    const message =
      parsed && typeof parsed === "object" && "error" in parsed
        ? String((parsed as Record<string, unknown>)["error"])
        : "upstream responded " + res.status;
    throw new Error(message);
  }
  return parsed;
}

function asStructured(data: Record<string, unknown>) {
  return {
    content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }],
    structuredContent: data,
  };
}

function asError(message: string) {
  return {
    content: [{ type: "text" as const, text: JSON.stringify({ error: message }) }],
    isError: true,
  };
}

export function buildMcpServer(apiKey: string): McpServer {
  const server = new McpServer({ name: "jobs", version: "1.0.0" });

  server.registerTool(
    "search_jobs",
    {
      title: "Search job postings",
      description:
        "Search live job postings from 30+ sources, normalized into one schema. Use it whenever the user asks about open roles or who is hiring. All filters are optional and combined with AND. Results are newest first.",
      inputSchema: SEARCH_INPUT as unknown as ZodRawShapeCompat,
      outputSchema: SEARCH_OUTPUT as unknown as ZodRawShapeCompat,
      annotations: {
        readOnlyHint: true,
        destructiveHint: false,
        openWorldHint: true,
      },
    },
    async (args) => {
      const body: Record<string, unknown> = { limit: 10 };
      for (const [key, value] of Object.entries(args)) {
        if (value !== undefined) body[key] = value;
      }
      try {
        return asStructured(
          (await searchJobs(apiKey, body)) as Record<string, unknown>,
        );
      } catch (error) {
        return asError(error instanceof Error ? error.message : "search failed");
      }
    },
  );

  return server;
}
```

Four things in this file are doing real work. `registerTool` takes a config object with `title`, `description`, `inputSchema`, `outputSchema` and `annotations`; the older positional `server.tool()` form still exists but does not carry output schemas or annotations. The schemas are plain objects of zod fields, not `z.object()`, and the cast to `ZodRawShapeCompat` is how the SDK accepts zod v4 shapes. `looseObject` matters for the output: the API returns far more fields than the schema lists, and a strict object would fail validation on every call. Finally, the handler returns `structuredContent` next to the text block, so clients that understand structured output get typed data and the rest get JSON text.

`buildMcpServer(apiKey)` takes the key as an argument on purpose. A server built per request can forward each call under the caller’s own key, so quota and rate limits land on the right account and the server itself holds no secrets.

## Step 3: serve it over Streamable HTTP

This is the transport that lets one deployment serve every user. The server is stateless: no session id, a JSON response instead of an SSE stream, and a fresh `McpServer` per request, closed when the response closes.

```
import { serve } from "@hono/node-server";
import type { HttpBindings } from "@hono/node-server";
import { RESPONSE_ALREADY_SENT } from "@hono/node-server/utils/response";
import { Hono } from "hono";
import { createMiddleware } from "hono/factory";
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
import { buildMcpServer } from "./mcp-server.ts";

type Env = { Bindings: HttpBindings; Variables: { apiKey: string } };

const requireApiKey = createMiddleware<Env>(async (c, next) => {
  const header = c.req.header("authorization");
  const bearer =
    header && header.toLowerCase().startsWith("bearer ")
      ? header.slice(7).trim()
      : c.req.header("x-api-key");
  if (!bearer) {
    return c.json(
      { error: "Send a JobsPipe API key as Authorization: Bearer jp_live_..." },
      401,
    );
  }
  c.set("apiKey", bearer);
  return next();
});

const app = new Hono<Env>();

app.get("/healthz", (c) => c.text("ok"));

app.post("/mcp", requireApiKey, async (c) => {
  const body = await c.req.json().catch(() => undefined);
  const server = buildMcpServer(c.get("apiKey"));
  const transport = new StreamableHTTPServerTransport({
    sessionIdGenerator: undefined,
    enableJsonResponse: true,
  });
  const { incoming, outgoing } = c.env;
  outgoing.on("close", () => {
    void transport.close();
    void server.close();
  });
  await server.connect(transport);
  await transport.handleRequest(incoming, outgoing, body);
  return RESPONSE_ALREADY_SENT;
});

serve({ fetch: app.fetch, port: 3103 });
```

`handleRequest` writes straight to the Node response, which is why the handler returns `RESPONSE_ALREADY_SENT` instead of a Hono response. The hosted server adds three things on top of this file: CORS headers for browser clients, an OAuth path so a client can sign in with a JobsPipe account instead of pasting a key, and a `GET /mcp` that returns a discovery document. None of them change the transport code.

### The stdio variant

For a server that runs on the user’s machine, swap the transport and read the key from the environment. Same `buildMcpServer`.

```
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { buildMcpServer } from "./mcp-server.ts";

const apiKey = process.env.JOBSPIPE_API_KEY;
if (!apiKey) throw new Error("Set JOBSPIPE_API_KEY");

await buildMcpServer(apiKey).connect(new StdioServerTransport());
```

Never write to stdout in a stdio server; the protocol owns that stream. Log to stderr or not at all.

## How to test an MCP server

Test outside a chat client first. A chat client hides the request, the schema and the error, which are the three things that break. Start the HTTP server with `bun run src/index.ts` and send it a raw JSON-RPC request. Streamable HTTP requires an `Accept` header that lists both JSON and event streams, and the stateless server answers `tools/list` without a prior `initialize`:

```
curl -s http://localhost:3103/mcp \
  -H "Authorization: Bearer $JOBSPIPE_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Accept: application/json, text/event-stream" \
  -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'

curl -s http://localhost:3103/mcp \
  -H "Authorization: Bearer $JOBSPIPE_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Accept: application/json, text/event-stream" \
  -d '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"search_jobs","arguments":{"job_title_or":["data engineer"],"remote":true,"posted_at_max_age_days":14,"limit":3}}}'
```

The first call should return one tool with a non-empty description and a JSON schema that lists every filter with its description. The second should return a `content` block and a `structuredContent` object with `data` and `metadata`. Send a bad key next and confirm you get a 401 with a readable body rather than a crash.

The MCP inspector gives you the same thing with a browser UI: it lists the tools, validates the schemas and lets you invoke them with arbitrary arguments. Point it at the HTTP server, or let it spawn the stdio one:

```
npx @modelcontextprotocol/inspector
# choose "Streamable HTTP", URL http://localhost:3103/mcp,
# and add an Authorization header in the UI

npx @modelcontextprotocol/inspector -e JOBSPIPE_API_KEY=$JOBSPIPE_API_KEY bun run src/stdio.ts
```

Check three things there. Does the tool appear with the full description? Does an argument outside the schema, say `limit: 500`, come back as a validation error the model could read? Does a normal call stay under a few thousand tokens? If all three pass, connect it to a real client. Claude Code takes a URL and a header on the command line, and a project-level `.mcp.json` does the same for a whole repo:

```
claude mcp add --transport http jobs http://localhost:3103/mcp \
  --header "Authorization: Bearer $JOBSPIPE_API_KEY"

claude mcp add jobs-local -e JOBSPIPE_API_KEY=$JOBSPIPE_API_KEY -- bun run src/stdio.ts
```

```
{
  "mcpServers": {
    "jobs": {
      "type": "http",
      "url": "http://localhost:3103/mcp",
      "headers": { "Authorization": "Bearer jp_live_..." }
    }
  }
}
```

Cursor reads the same `mcpServers` shape from `.cursor/mcp.json` without the `type` field. In Claude Code, run `/mcp` to see the connection state and the tool list, then ask for “remote data engineer roles posted in the last two weeks” and watch which arguments the model chose. If it passed `"Germany"` where you wanted `"DE"`, the fix is in the parameter description, not in code.

## Four decisions that decide whether the model can use it

**The description is the routing logic.** The model never sees your code. It sees the tool name, the description and the schema derived from your zod fields, and that is the whole basis on which it decides to call you instead of answering from memory. A description that says “Searches for jobs” loses to the model’s priors half the time. Say when to reach for the tool and what it guarantees. Every `.describe()` becomes a schema description the model reads; skipping them is the TypeScript equivalent of an empty docstring.

**Return errors, do not throw them.** An uncaught exception becomes a protocol-level error, and most hosts surface that as an opaque failure the model retries until it gives up. A result with `isError: true` and a readable message is something the model can act on. The upstream call above throws only so the handler can turn the message into exactly that.

**Structured output beats raw dumps, and prose beats both for small results.** `structuredContent` lets a client hand typed rows to code without re-parsing. But if your tool only ever feeds a chat answer, flatten to one line per posting: title, company, location, salary, link. Ten well-formed lines beat twenty-five JSON blobs.

**Cap the result count in code.** `max(25)` on `limit` is context management, not defensive programming. Models will pass `limit: 500` because the parameter exists. For larger sets return the first page and a `next_cursor` the model can pass back, which is what the `cursor` field is for.

## The shortcut: use the hosted server

If the API you were about to wrap already ships an MCP server, use it. The JobsPipe server runs at `https://mcp.jobspipe.dev/mcp` over Streamable HTTP and exposes four tools: `search_jobs` with the full filter set, `detect_company_tech_stack`, `list_pricing_plans` and `search_documentation`, which an agent can call first to look up a filter name before searching. Connect with a JobsPipe sign-in, or pass a `jp_live_` key as a bearer header:

```
claude mcp add --transport http jobspipe https://mcp.jobspipe.dev/mcp

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

Agents with a shell can read [/agents.md](https://jobspipe.dev/agents.md) and set themselves up, including the CLI (`npm i -g jobspipe-cli`). Build your own server when you need tools that do not exist yet, want to compose several APIs behind one interface, or must enforce business rules before results reach the model. Wrapping an endpoint that someone already wrapped is work you can skip. Next: [the MCP servers worth connecting](https://jobspipe.dev/blog/best-mcp-servers-for-data) once yours works, and [a full agent loop](https://jobspipe.dev/blog/ai-job-search-agent) on top of one.

## Frequently Asked Questions

### How do you build an MCP server?

Install the SDK, register a tool with a name, a description, an input schema and an output schema, write a handler that calls your API and returns the result as content, then connect the server to a transport. With the TypeScript SDK that is McpServer, registerTool with zod fields, and either StdioServerTransport for a local server or StreamableHTTPServerTransport behind an HTTP route. The protocol is the easy part; the description, the error handling and the response size decide whether a model can use it.

### Which MCP SDK version does this tutorial use?

@modelcontextprotocol/sdk 1.29.0 with zod 3.25.32, imported as zod/v4. That is the version the hosted JobsPipe MCP server runs, and every snippet is written against it. The registerTool config form with inputSchema, outputSchema and annotations is the current API; the older positional server.tool form still works but cannot declare output schemas.

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

Use stdio for local tooling and Streamable HTTP for anything backed by a hosted API. With stdio the host launches 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, you control the release cadence, and you can meter usage per caller by forwarding each request under the caller's own key.

### How do you test an MCP server?

Outside a chat client first. Send a raw JSON-RPC tools/list and tools/call with curl, including an Accept header that lists both application/json and text/event-stream. Then open the MCP inspector with npx @modelcontextprotocol/inspector to browse the tools, validate the schemas and call them with arbitrary arguments. Only then connect it to Claude Code with claude mcp add and check which arguments the model actually chooses.

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

Almost always because the description is too vague. The model never sees your code; it routes on the tool name, the description and the JSON schema generated from your zod fields. 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 formats such as two-letter country codes.

### Do I need to build an MCP server to use JobsPipe with Claude or Cursor?

No. JobsPipe runs a hosted MCP server at https://mcp.jobspipe.dev/mcp over Streamable HTTP with search\_jobs, detect\_company\_tech\_stack, list\_pricing\_plans and search\_documentation. Connect with a JobsPipe sign-in or a jp\_live\_ key as a bearer header. Build your own only when you need tools that do not exist yet or want to compose several APIs behind one interface.

Related

## Keep reading

-   [Reference · Sep 9, 2026What is an MCP server? How MCP works, with a real example](https://jobspipe.dev/blog/what-is-an-mcp-server)
-   [Guide · Sep 9, 2026Why Do You Need an MCP Server?](https://jobspipe.dev/blog/why-do-you-need-an-mcp-server)
-   [Product · Jun 24, 2026Search millions of jobs from your AI agent with the JobsPipe MCP server](https://jobspipe.dev/blog/jobspipe-mcp-server)
-   [Guide · Sep 9, 2026When Did MCP Servers Come Out?](https://jobspipe.dev/blog/when-did-mcp-servers-come-out)
-   [Reference · Jul 18, 2026The JobsPipe developer portal: API docs, OpenAPI spec, MCP server and SDKs](https://jobspipe.dev/blog/jobspipe-developer-portal)


## Further reading

- [How Does MCP Work Under the Hood?](https://jobspipe.dev/blog/how-does-mcp-work-under-the-hood)
- [JobsPipe MCP Server - Model Context Protocol](https://jobspipe.dev/mcp-server)

---
Try it with no key: `curl -d '{"limit":3}' https://api.jobspipe.dev/v1/sandbox/jobs/search`. AI agents: the full machine-readable index of this site is https://jobspipe.dev/llms.txt?src=md-twin - API quickstart, MCP server, pricing. Free key (1,000 jobs to start): https://jobspipe.dev/signup