Home Documentation Build a server

How to build your own MCP server

From empty folder to a server Claude can call — in about 15 minutes.

This page is adapted from the official MCP docs — a simpler walkthrough focused on the essentials. Full tutorial and SDKs for other languages:

modelcontextprotocol.io/docs/develop/build-server

What an MCP server is made of

An MCP server can expose three kinds of capabilities. Most servers implement only the first — tools. The other two are optional.

Tools

Functions the AI can call (with the user's approval). For example: "get weather in Moscow", "read a row from the database", "hit an API".

Resources

File-like data the client can read on its own: file contents, an API response, a table schema. Used less often than tools.

Prompts

Pre-written prompt templates for common scenarios — the user picks them manually in the client.

In this guide we build a small server with a single tool. That is enough for Claude to see and invoke your logic.

Prerequisites

  • Python 3.10+ (for the Python path) and the mcp SDK ≥ 1.2.0
  • Node.js 16+ (for the TypeScript path) and the @modelcontextprotocol/sdk package
  • Claude Desktop installed — so you can test the server locally

Python server (recommended for getting started)

We will use FastMCP — a wrapper over the SDK that auto-generates the tool schema from Python type hints and docstrings. No manual registration.

1. Install dependencies (uv — a fast package manager)

# macOS / Linux
curl -LsSf https://astral.sh/uv/install.sh | sh

uv init weather && cd weather
uv venv && source .venv/bin/activate
uv add "mcp[cli]" httpx

2. Minimal server weather.py

from mcp.server.fastmcp import FastMCP

mcp = FastMCP("weather")

@mcp.tool()
def hello(name: str) -> str:
    """Поприветствовать пользователя по имени."""
    return f"Привет, {name}!"

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

The @mcp.tool() decorator turns any Python function into an MCP tool. The function name becomes the tool name, the docstring becomes its description, and type annotations define the input schema.

3. Run

uv run weather.py

The server listens on stdio and waits for an MCP client. If you see no errors — you are good.

TypeScript server

The alternative path — the official TypeScript SDK. Convenient if you already have an npm project or want to wrap an existing REST API.

1. Install

mkdir weather && cd weather
npm init -y
npm install @modelcontextprotocol/sdk zod
npm install -D @types/node typescript
mkdir src && touch src/index.ts

2. Minimal server src/index.ts

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: "weather", version: "1.0.0" });

server.registerTool(
    "hello",
    {
        description: "Поприветствовать пользователя по имени",
        inputSchema: { name: z.string().describe("Имя пользователя") }
    },
    async ({ name }) => ({
        content: [{ type: "text", text: `Привет, ${name}!` }]
    })
);

const transport = new StdioServerTransport();
await server.connect(transport);

registerTool takes a name, a parameter schema via Zod, and an async handler. The response must be in the shape { content: [{ type: "text", text: "..." }] }.

3. Build and run

npx tsc
node build/index.js

You will also need a tsconfig.json with target ES2022 and module Node16 — full example in the SDK repo.

Pitfall: logging

On stdio transport, any write to stdout corrupts JSON-RPC and breaks the server. This is the #1 cause of "server doesn't respond".

  • In Python, use print(..., file=sys.stderr) or the logging module. Plain print() is forbidden.
  • In TypeScript, use console.error() — it writes to stderr. console.log() will break the server.
  • For HTTP transport (what MCPBay servers use) this rule does not apply — stdout is safe.

Plug into Claude Desktop

Open the config (see paths in the "Connect" page). Add your server to the mcpServers section — the entry for local stdio servers differs from remote ones.

Entry for the Python server

{
  "mcpServers": {
    "weather": {
      "command": "uv",
      "args": [
        "--directory",
        "/абсолютный/путь/к/weather",
        "run",
        "weather.py"
      ]
    }
  }
}

Entry for the TypeScript server

{
  "mcpServers": {
    "weather": {
      "command": "node",
      "args": ["/абсолютный/путь/к/weather/build/index.js"]
    }
  }
}

Replace with your own absolute path. Get it via pwd on macOS/Linux or cd on Windows. On Windows use double backslashes or forward slashes.

Save and fully restart Claude Desktop. The tool will appear in the dropdown under the input field.

What is next

HTTP transport

For public servers use Streamable HTTP instead of stdio. That's what MCPBay uses — the server lives at a URL, no local install needed on the client side.

MCP Inspector

The official debugger: npx @modelcontextprotocol/inspector launches a GUI showing all server requests and responses.

SDKs for other languages

Beyond Python and TypeScript there are official SDKs for Java (Spring AI), Kotlin, C# (Microsoft.Extensions.AI), Rust, Go, and Swift. Full list in the original docs.

Full documentation

Concepts, protocol spec, advanced features (sampling, OAuth, completion):

modelcontextprotocol.io

How to publish your MCP to the catalog

Publishing means your server shows up in the public MCPBay catalog so other people can find it and connect it in their AI client — Claude, Cursor, ChatGPT.

Not the same as the "Add MCP server" button

In the catalog, "Add MCP server" just saves a server into your own cabinet for personal use — nobody else sees it. Publishing is a separate action: you submit your server so it is reviewed and listed for everyone.

Before you publish

There are two paths. Self-hosted: a working remote MCP server already running at a public HTTPS URL — the catalog links to your endpoint. Or MCPBay hosting: no infrastructure needed — point the submission at a GitHub repository with a Dockerfile and the platform builds and runs the server for you (see "Hosting on MCPBay" below).

How to submit

  1. Open the "Submit a server" form.
  2. Fill in the name, your server URL (the MCP endpoint), a category, a short tagline and description, and a logo. The logo shows on the catalog card and in users' cabinets.
  3. Want MCPBay hosting? Tick "Host on mcpbay.pro", put the GitHub repository link in the "Source code" field and connect GitHub — the MCPBay Hosting app must be installed on that repository. You can leave the "Server URL" field empty — the platform assigns the address.
  4. Submit it. The entry goes to moderation, and once approved it is published in the public catalog.
Open the submit form

Tips for a good listing

  • A clear name and a concise description of what the server does.
  • The right category, so people browsing find it where they expect.
  • A working public URL (for self-hosted servers) — moderators open it and check the server responds.
  • A logo — servers without one show a neutral placeholder.

If your MCP needs each user's own keys or tokens, you'll declare a field manifest. See the field manifest guide below for the format and how to ship it.

Want users to see their own usage of your server in their cabinet? Add the My usage integration below — account linking plus per-user usage events.

Hosting on MCPBay

Don't want to run your own server? Enable hosting when you submit: the platform builds your MCP from a GitHub repository and runs it on isolated infrastructure. The server gets an address like https://name.run.mcpbay.pro/mcp with the platform's OAuth gate automatically in front.

What your server must satisfy

  • A GitHub repository with the source code and a Dockerfile at the repo root (branch main by default).
  • Your GitHub account linked and the MCPBay Hosting GitHub App installed on that repository — this proves ownership and gives the platform access to the code (private repositories work too).
  • The Dockerfile's CMD or ENTRYPOINT must use the exec form (a JSON array, e.g. CMD ["node","build/index.js"]), not the shell form.
  • The server listens on the port from the PORT environment variable.
  • The MCP endpoint is served at /mcp (Streamable HTTP).
  • A single long-running process — no background daemons that outlive the main one.

Hosting manifest (mcpbay.json)

Put an mcpbay.json file in the repo root to declare your server's env variables. Variables with "required": true are mandatory: the deploy waits in "Awaiting secrets" until you set their values in your cabinet under "Environment secrets".

{
  "env": [
    { "name": "API_KEY", "required": true, "description": "external API key" }
  ]
}

The Dockerfile is validated at submission time: CMD/ENTRYPOINT must use the exec form (JSON array). After launch, use the "Check now" button in your cabinet to confirm the server responds and its tools are visible.

The manifest is optional: you can list the same variable names directly in the submission form (one per line). Form values take precedence over the manifest.

What happens after approval

  1. The platform clones the repository and builds the container image on an isolated builder.
  2. The image goes through a security scan: critical vulnerabilities with an available fix block the rollout.
  3. The server starts in an isolated virtual machine and gets the name.run.mcpbay.pro subdomain.
  4. The platform's OAuth gate runs automatically in front: users connect by signing in with their mcpbay.pro account in the AI client; requests without a token get 401. Tool-call statistics are collected by the platform.

Secrets and environment variables

Your server's API keys and other secrets are set in your cabinet — the "Environment secrets" section in the submission's deployment block (available after the first deploy). Values are encrypted, applied to the server as environment variables and never shown again. Names with the MCPBAY_ and FLY_ prefixes, and PORT, are reserved by the platform.

Deployment status — build, address, errors — is visible in your cabinet on the submission card.

Good to know

Hosting capacity is limited — moderation approves requests subject to available capacity and can suspend or remove a server from hosting.

While hosting is active the submission cannot be deleted from the cabinet — hosting must be stopped via moderation first.

Field manifest: so users get inputs for their keys/tokens

A manifest is a small JSON file that declares which keys or tokens your server needs from each user. With it, the user gets a form to paste their own values — without it, no key form appears for your server.

When you need it

Only if your MCP needs each user's own keys or tokens from a third-party service — for example the user's own API key for an outside platform.

If your MCP needs no user keys, skip this section entirely — no manifest is required and nothing changes for you.

How it works for the user

  1. The manifest declares the fields your server needs.
  2. Each user pastes their own values in "Connections" inside their /account.
  3. Values are stored encrypted, per-user — never shared, never shown back.
  4. At call time your MCP receives them automatically (store-and-inject).

No manifest means no key form is shown for your server.

The format

One JSON file per provider, named manifests/<provider>.json. The keys below are fixed; the string values in this example are illustrative.

{
  "provider": "acme",
  "purpose": "ACME lets each seller manage their own catalog. Enter the API key from your ACME account settings.",
  "fields": [
    {
      "name": "api_key",
      "label": "ACME API Key",
      "kind": "secret",
      "help": "Found in ACME → Settings → API."
    }
  ]
}
  • provider — a lowercase slug (pattern ^[a-z][a-z0-9_-]{1,30}$). It must equal the credential_provider on your submission, which is how MCPBay binds the manifest to your server.
  • purpose — shown to the user: what they are connecting and why.
  • fields[] — one entry per value you ask for. Each entry has:
  • name — a slug for the field (pattern ^[a-z][a-z0-9_]{0,40}$). Your MCP receives the value under this key.
  • label — the human-readable label shown next to the input.
  • kind — either text or secret. secret renders a masked, write-only password input that is never shown back once saved.
  • help — optional hint, e.g. where to find the key.

Tips

  • Declare only the fields you genuinely need — collect the minimum (data minimization).
  • Write a clear purpose and help so the user understands exactly what to paste and where to find it.
  • Use kind: "secret" for anything sensitive — it becomes a masked, write-only field.

How to ship it

Manifests are operator-reviewed and ship with the service. Right now the operator binds your manifest to your submission (sets the credential_provider field) by hand.

So send your manifest JSON together with your submission — paste it in the description or send it via support — and it gets wired in. Uploading a manifest directly through the submit form is not available yet.

"My usage": let users see their own stats in their cabinet

Until your MCP is integrated, usage stats are anonymous and the user's cabinet shows "Account not linked". Once you integrate, a user links their account with a one-time code and then sees their own usage and spend under "My MCPs" → "My usage" — more trust and retention. It is also the foundation for paid tools (billing) later.

Two linking tracks

  1. MCP hosted on MCPBay. As a developer you do nothing: the platform puts its IdP OAuth gate in front of the server and collects usage statistics — the user just signs in with their mcpbay.pro account in the AI client. Account linking and per-user stats for hosted servers are the platform's job, not your code's.
  2. MCP with its own auth. If your MCP already has its own per-user identity (its own OAuth), use the device-code track below unchanged: the get_mcpbay_link_code tool + billing.create_link_code + usage.record_tool_use.

How linking works (device-code flow)

  1. In their cabinet the user clicks "My usage" on an unlinked MCP and gets a "enter the code" prompt.
  2. In their AI client, the assistant calls the get_mcpbay_link_code tool in your MCP. Your server mints a one-time code and returns it to the user.
  3. The user pastes the code in their cabinet. MCPBay redeems it and links their mcpbay account to the identity inside your MCP.
  4. From then on, the usage events your MCP records are attributed to that user.

What you add to your MCP

  1. A stable per-user identity inside your MCP. An external_subject — for example the OAuth sub claim. Without it, linking does not apply: a server with no per-user subject can't attribute usage to anyone.
  2. A tool named exactly get_mcpbay_link_code. The cabinet tells users that exact name, so it must match. It generates an 8-character code from the alphabet A–Z2–9 (no 0/O/1/I), shown to the user as XXXX-XXXX, stores only its SHA-256 hash via billing.create_link_code(...), and returns the code plus a short instruction (enter it on mcpbay.pro under "My usage", valid 10 minutes).
  3. Usage events after each tool call. Call usage.record_tool_use(...) once per invocation. It is a silent no-op until the subject is linked (and, for free tools, until the server is in the user's "My MCPs" — a consent gate), so you can call it unconditionally.

Copy-paste starter (Python / FastMCP)

Drop this into your server, set SUBMISSION_ID, and wire get_current_subject() to your own per-user identity. Both SQL functions live in the shared MCPBay Postgres — your MCP's database role is granted EXECUTE on them at onboarding (see the note below).

# ── MCPBay "My usage" integration ─────────────────────────────
# Mint a one-time link code + record per-user tool usage.
import hashlib
import secrets

import asyncpg  # or psycopg — any async Postgres driver
from mcp.server.fastmcp import FastMCP

mcp = FastMCP("your-server")

# Your numeric id from the MCPBay catalog (ask the operator at onboarding).
SUBMISSION_ID = 0  # TODO: replace with your submission id

# Code alphabet: A–Z 2–9 without ambiguous chars (I, L, O, U, 0, 1).
# MCPBay normalises the entered code to ^[A-Z2-9]{8}$, so this is a safe subset.
_ALPHABET = "ABCDEFGHJKMNPQRSTVWXYZ23456789"


def _make_code() -> tuple[str, bytes]:
    """Return (display 'XXXX-XXXX', sha256 hash) — only the hash is stored."""
    raw = "".join(secrets.choice(_ALPHABET) for _ in range(8))
    digest = hashlib.sha256(raw.encode("ascii")).digest()
    return f"{raw[:4]}-{raw[4:]}", digest


async def get_current_subject() -> str | None:
    """Your OAuth identity for the current user (e.g. the 'sub' claim).
    Return a stable string per user, or None if no identity is present."""
    raise NotImplementedError  # TODO: wire to your auth


async def _pool() -> asyncpg.Pool:
    """Your shared MCPBay Postgres pool (DSN from env)."""
    raise NotImplementedError  # TODO: return your asyncpg pool


@mcp.tool()
async def get_mcpbay_link_code() -> str:
    """Get a one-time code to link this account to your mcpbay.pro cabinet.
    The user enters it under "My MCPs" → "My usage". Valid 10 minutes."""
    subject = await get_current_subject()
    if subject is None:
        return "Sign in first, then ask for a link code."
    display, code_hash = _make_code()
    pool = await _pool()
    async with pool.acquire() as conn:
        # Stores only the hash; ttl 1–60 min (default 10). One active code per
        # (submission, subject) — calling again reissues and voids the previous.
        expires_at = await conn.fetchval(
            "SELECT billing.create_link_code($1, $2, $3, $4)",
            SUBMISSION_ID, subject, code_hash, 10,
        )
    return (
        f"Your link code: {display}\n"
        f"Enter it on mcpbay.pro under 'My usage'. Valid ~10 minutes "
        f"(until {expires_at})."
    )


async def record_usage(tool_name: str, status: str, duration_ms: int) -> None:
    """Record one usage event. Silent no-op until the subject is linked —
    call it unconditionally after every tool, success or error."""
    subject = await get_current_subject()
    if subject is None:
        return
    pool = await _pool()
    async with pool.acquire() as conn:
        await conn.execute(
            "SELECT usage.record_tool_use($1, $2, $3, $4, $5)",
            SUBMISSION_ID, subject, tool_name, status, duration_ms,
        )

Request a usage-API binding when you host with us

The two functions are locked down: your MCP's least-privilege database role is granted EXECUTE on them and bound to your submission (anti-spoofing keys the binding to your role, so another server's submission is unreachable). The operator wires this up by hand at onboarding — there is no self-service yet. So ask for the usage-API connection when you place your server.

Reference implementation: the live SmartCart server ("Продукты Лента", smartcart.mcpbay.pro) implements get_mcpbay_link_code and records usage exactly this way — the alphabet, hashing, and SQL calls above mirror its code.

Ready to publish your server?

Submit your server to the MCPBay catalog — with your own public HTTPS endpoint, or hosted on MCPBay infrastructure right away.

Submit a server