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: for a server hosted on MCPBay declare them in mcpbay.json with "scope": "user"; otherwise 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 root or in the server folder (see “How to lay out the repository” below).
  • 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.

How to lay out the repository

In the submission form (and in your cabinet when you change the repository), give a link to GitHub. Once GitHub is connected, the form shows which Dockerfile and which branch the server will be built from; if there are several Dockerfiles, you can pick one from a list.

Accepted links:

  • the repository: https://github.com/owner/repository;
  • the server folder: …/tree/branch/folder — handy for a monorepo;
  • the Dockerfile itself: …/blob/branch/path/Dockerfile;
  • an SSH address: git@github.com:owner/repository.git.

Which Dockerfile we build when the repository has several:

  1. the link points at a file — that file;
  2. the folder from the link has a file named Dockerfile — that one (a link without a folder means the repository root);
  3. there is exactly one Dockerfile inside the folder from the link (for a root link — in the whole repository) — that one;
  4. otherwise the form asks you to pick a Dockerfile from a list.

A Dockerfile is a file named Dockerfile, Dockerfile with any suffix after a dot (Dockerfile.prod), any name ending in .Dockerfile (server.Dockerfile), or Containerfile. The node_modules, vendor and .devcontainer folders are not searched.

The branch is the one in the link (…/tree/branch/…). No branch in the link — your repository’s default branch.

mcpbay.json is looked up next to the Dockerfile and, if it is not there, at the repository root.

Limitation: the build sees only the folder that holds the Dockerfile. A Dockerfile in a subfolder that copies files from the repository root (a shared lockfile, workspace packages, ../shared) will not build. Keep the server folder self-contained, or put the Dockerfile at the root.

Hosting manifest (mcpbay.json)

Put an mcpbay.json file next to the Dockerfile (or at the repository root) and declare the keys and other variables your server needs in its env section. For each one, say whose key it is — that decides who enters it and where.

{
  "env": [
    { "name": "SERVICE_KEY", "scope": "server", "isRequired": true,
      "description": "Key from the service dashboard (shared by all users)" },
    { "name": "USER_API_KEY", "scope": "user", "isRequired": true, "isSecret": true,
      "description": "Your personal key: Settings → API in your service account",
      "placeholder": "sk_live_..." },
    { "name": "USER_REGION", "scope": "user", "isRequired": false, "isSecret": false,
      "description": "Region of your service account", "placeholder": "eu-west-1" }
  ]
}

Variable fields are the same as for variables in server.json of the official MCP registry, plus scope. name — the name (A–Z, digits, _). scope — whose key it is: "server" (the default) is a shared server key that you enter once in your cabinet in Developer mode, under "Environment secrets", and the server gets it as an environment variable; "user" is a per-user key that each user enters in their own cabinet in User mode, and you never see its values. isRequired — true when the server cannot work without the key: without a shared key the deploy waits in "Awaiting secrets", without a per-user key the user is told to add it. isSecret — true by default: the value is masked on input and never shown again; false is for non-secret settings such as a region. description — a hint for whoever enters the key: what it is and where to get it. placeholder — a sample value shown in the empty input. The older required field is still understood. Links in description are clickable: an https://… or www.… address and a markdown link [text](https://…) — for example, "Get the key on the [service API page](https://example.com/api)".

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 wins over the form: if mcpbay.json has an env section, the keys come from it — at submission and on every build — and the key fields in the form are not used. Changed your keys? Commit the manifest and press "Rebuild". No manifest, or no env section in it — the keys come from the submission form.

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 shared keys (scope "server") are set in your cabinet in Developer mode — 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. Users' per-user keys (scope "user") never land here: each user enters their own in their own cabinet, and you do not see them. Names with the MCPBAY_ and FLY_ prefixes, and PORT, are reserved by the platform.

Per-user keys in your server code

Your server is shared by all users, so per-user keys (scope "user") do not arrive as environment variables but with every request from the platform — in the X-Mcpbay-Credentials HTTP header. Its value is a JSON object {NAME: value} encoded as base64url without "=" padding. Names are the ones from mcpbay.json. The object holds only the keys the user has saved. Only the platform sets this header: anything a client sends in X-Mcpbay-* is stripped before your server, and the user's mcpbay token never reaches it. Read the keys in the tool handler, on every call:

TypeScript (official SDK, versions 1 and 2):

// Works with the official TypeScript SDK v1 (extra.requestInfo.headers)
// and v2 (ctx.http.req.headers).
function mcpbayKeys(ctx: any): Record<string, string> {
  const h = ctx?.http?.req?.headers ?? ctx?.requestInfo?.headers;
  const raw = typeof h?.get === "function"
    ? h.get("x-mcpbay-credentials")
    : h?.["x-mcpbay-credentials"];
  if (typeof raw !== "string" || !raw) return {};
  return JSON.parse(Buffer.from(raw, "base64url").toString("utf8"));
}

server.registerTool("search", {
  description: "Search listings",
  inputSchema: { query: z.string() },
}, async ({ query }, ctx) => {
  const apiKey = mcpbayKeys(ctx).USER_API_KEY;
  if (!apiKey) {
    return { isError: true, content: [{ type: "text",
      text: "Add your USER_API_KEY in MCPBay → Connections." }] };
  }
  // call the third-party API with apiKey ...
});

Python (official SDK, FastMCP):

import base64, json
from mcp.server.fastmcp import FastMCP, Context

mcp = FastMCP("example")

def mcpbay_keys(ctx: Context) -> dict[str, str]:
    request = getattr(ctx.request_context, "request", None)
    raw = request.headers.get("x-mcpbay-credentials") if request is not None else None
    if not raw:
        return {}
    return json.loads(base64.urlsafe_b64decode(raw + "=" * (-len(raw) % 4)))

@mcp.tool()
async def search(query: str, ctx: Context) -> str:
    api_key = mcpbay_keys(ctx).get("USER_API_KEY")
    if not api_key:
        # FastMCP turns an exception into a tool error (isError).
        raise ValueError("Add your USER_API_KEY in MCPBay → Connections.")
    # call the third-party API with api_key ...

If a user has not set a required per-user key (isRequired: true), the platform itself answers the tool call with an error that links to Connections — your server is not called. An optional key may be missing from the header: handle that yourself. The tool list (tools/list) and the handshake arrive without keys — the server must work without them. Never log key values or return them in responses. Shared server keys (scope "server") still arrive as environment variables.

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.

Tools and health checks

A server is shown in the catalog and on the home page only when it has at least one tool and answers the platform's checks. You can still submit and keep a server without tools in your cabinet — it simply stays out of the catalog, and the cabinet tells you why.

How the platform checks your server

Following the MCP specification, a check is protocol-level: the MCP handshake (initialize) and the tool list (tools/list). The platform never calls your tools and never sends API keys. If several checks in a row fail, the server is hidden from the catalog and it comes back on its own after the first successful check; you are notified both ways. A sleeping server is not a failure — the check wakes it up.

Fixed the server? Press “Rebuild” in your cabinet if it is hosted on MCPBay — the platform takes the latest commit from GitHub, builds and checks it — or “Check now” if you host it yourself. Without that, a self-hosted server is re-checked once a day.

Servers that need third-party API keys

If your server works with a paid or private API, you do not need your own key or subscription for the checks. The server must start and list its tools with no key at all, and read the key only when a tool is called. When the key is missing, return a tool error (isError: true) that asks the user to add their key — do not exit and do not refuse the handshake. If every user has their own key, declare it in mcpbay.json with "scope": "user".

Declaring tools in mcpbay.json

The platform learns your tools from the server itself. As a fallback — for example before the first successful build, or when the server cannot list its tools without a key — list them in the “tools” section of mcpbay.json (next to the Dockerfile or at the repository root). The server's own answer always wins over the declared list.

{
  "tools": [
    { "name": "search_listings",
      "title": "Search listings",
      "description": "Find listings by keyword and price range" }
  ]
}

Each tool needs a name (letters, digits, _ - ., up to 128 characters, unique) and a description; title and inputSchema are optional. Up to 200 tools. The file is read when you submit the server and on every build.

Not hosted on MCPBay? Publish the same list at https://<your-host>/.well-known/mcp/server-card.json — the platform reads it when your server cannot be reached.

If a build fails

Every failure carries a code, and the deployment block on your submission card explains it: what happened, what to do, the full build log and — for a blocked security scan — the exact vulnerabilities. The “Rebuild” button there clones the latest commit of your branch and builds again.

The security scan blocks a rollout only on CRITICAL vulnerabilities that already have a published fix; findings with no fix available do not block. There are no exceptions to this gate — the fix is to update the base image or the package, not to waive the finding.

The build log is kept for a limited time and shown in your cabinet. Do not print secrets during the build — anything your build writes to stdout ends up in that log.

The server started but does not answer

The container is running but does not answer the MCP handshake at /mcp. Usually the server exits right after startup, listens on the wrong port, or demands environment variables it does not have.

Open the server logs in your cabinet — the real reason is there. If your server needs keys that each user provides, declare them in the manifest (the section below) instead of requiring them at startup: a hosted server must come up without user keys.

Vulnerabilities in the image

The image built, but the scanner found critical vulnerabilities that already have a published fix. We do not roll such an image out.

Update your base image to a current tag, or upgrade the packages listed below to the “Fixed in” version, then rebuild.

Image build failed

The build command exited with an error. The reason is in the build log.

Open the log, find the first error line and fix it. Reproduce locally with: docker build .

Could not fetch the code

The platform could not clone the repository: the branch is missing, the repository is empty, or access was revoked.

Check that the branch exists and has code, and that the MCPBay Hosting app is installed on the repository.

Dockerfile not found

No Dockerfile was found at the repository link in your submission.

Add a Dockerfile to the repository, or give a link to the server folder that has it (…/tree/branch/folder).

CMD or ENTRYPOINT uses the shell form

The platform runs your process under its own proxy and must know the exact start command. The shell form hides it.

Replace CMD npm start with the exec form: CMD ["npm","start"].

Unsafe build context

The repository contains a symlink that points outside it. We refuse to build that.

Remove the symlink and push the change.

No access to the repository

The MCPBay Hosting app has no access to the repository: it was removed from the GitHub account, the repository was excluded from it, or it was suspended.

In your cabinet, press “Reconnect GitHub” and install the MCPBay Hosting app on this repository, then “Rebuild”. If the repository moved, change it in the “GitHub repository” block.

GitHub denied access

We could not obtain an access token for the repository.

Check that the MCPBay Hosting app is installed on the repository and rebuild.

The container did not start

The image built and passed the scan, but it could not start — usually the container exits right after launch.

Make sure the server listens on the port from the PORT variable and does not exit at startup.

The server exits right after start

The image was rolled out, but your server process exits a few seconds after launch, and the platform keeps restarting it until it gives up. The server is not published; the failure in your cabinet shows the last lines it printed.

Read the server log lines shown with the failure — the reason is usually there. If the server demands keys that each user provides, declare them in mcpbay.json with "scope": "user" instead of requiring them at startup: a hosted server must come up without user keys. Put your own keys under “Environment secrets”. Then press “Rebuild”.

The server needs a key to start

Your server exits at startup because an environment variable is missing — usually an API key for a third-party service. A hosted server is shared by many users and each of them brings their own key, so there is no key at startup and there cannot be. The server is not published.

Make the server start without keys: it must answer the MCP handshake and list its tools with no key at all, and read the key only when a tool is called — if the key is missing, return a tool error asking the user to add it. Declare the keys users enter in mcpbay.json with "scope": "user". If the key is your own rather than the user's, put it under “Environment secrets”. Then press “Rebuild”.

The server does not answer

The process is running, but in three minutes it never started accepting connections on its port, so no request can reach it. The server is not published.

Make sure the server listens on the port from the PORT variable rather than a hard-coded one, and speaks HTTP rather than stdio. Check the server log lines shown with the failure, then press “Rebuild”.

Missing environment variables

Your server declares required variables that have no value yet.

Set the values under “Environment secrets” below — the deploy continues on its own.

The build timed out

The build exceeded its time limit.

Slim the image: a lighter base image, a multi-stage build, a .dockerignore.

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

A server hosted on MCPBay does not need this section: per-user keys are declared in mcpbay.json with "scope": "user". Below is the field manifest for servers the operator wires in by hand. It 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.

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