SmartGateSmartGate

Python MCP Server Tutorial: Build It, Then Govern It

A Python MCP server is a script that declares tools with type hints and a docstring, then runs over stdio for a local host or Streamable HTTP for everything else. The official SDK writes the schema, the parsing and the protocol handling for you. What it does not write is the second half: keys, limits and one audit row per call.

Short answer: A Python MCP server is a script that declares tools with type hints and a docstring, then runs over stdio for a local host or Streamable HTTP for everything else. The official SDK writes the schema, the parsing and the protocol handling for you. What it does not write is the second half: keys, limits and one audit row per call.

Key takeaways

  • Fifteen lines is a server. One server object, one decorated function per tool, and no request parsing, validation or schema to maintain by hand.
  • The same package is the client. A URL means Streamable HTTP; a subprocess means stdio; both are the same client object.
  • Stdio first, HTTP second. The development command opens the Inspector; the HTTP transport is the one you deploy and the one a gateway can meter.
  • The host's config block is transport-shaped, not language-shaped. A TypeScript server receives the same JSON.
  • Governance is the half you write. A hashed key, a counter store for limits, and a record of every call that arrived.
  • Next: get one tool answering over HTTP, then put a rate limit and an audit row in front of it before a real agent connects.

What a python mcp server is, in fifteen lines

The Python SDK's own tutorial is the shortest honest description of the shape: install the package with its command-line extra, create one server object with a name, and stack decorators on ordinary typed functions — one per tool, one per templated resource. That is a complete server, and the SDK's documentation lists what you did not write: no JSON Schema, because the type hints are the schema; no request parsing, no validation code, no protocol handling (Python SDK). The package is the client too, which matters later: a client that takes a URL speaks Streamable HTTP, and a client that takes a command launches the server as a subprocess, so a tutorial that teaches one side of the pair has taught half of what the same dependency does.

Two properties are worth internalising before the first line of your own code. The first is that a tool is a function with a docstring, and the docstring is the description a model will read — so the quality of that sentence is the quality of your tool's discoverability, not a comment. The second is that the decorator is where metadata lives that you would otherwise duplicate: names, descriptions and the annotations a host uses to decide whether to ask the user for confirmation. Both come from the same declaration, which is why a server written this way cannot drift from its own documentation as easily as a hand-built JSON-RPC endpoint can. If the server half is new to you, MCP server covers what the endpoint, the registry and the session question look like from the outside; this page builds one and then bounds it.

mcp server tutorial: the order that gets you to a running server

The measured demand for this phrase is modest — about 1,900 US searches a month for python mcp server, and 210 for the tutorial phrasing — and the reason is that the work is only worth searching for once. The order below is the one the SDK's documentation implies, and each step produces something you can look at.

  1. Install the SDK with its command-line extra, using whichever package manager your project already uses. The extra is what adds the development and run commands; the plain package is the library. Python 3.10 or newer is the floor.
  2. Write one server file with two declarations. A tool that takes two integers and returns their sum, and a resource addressed by a templated URI, are enough to exercise both code paths. Type hints carry the schema and the docstring carries the description.
  3. Open it in the MCP Inspector before you wire it into anything. The SDK's development command launches the server and points the protocol's own debugging client at it (Inspector). Call the tool by hand and read the response: this is the moment a schema mistake is cheap.
  4. Then serve it over Streamable HTTP. The run command takes a transport argument, and the HTTP transport is the one that survives a deployment because it has an address a remote host can reach rather than a pair of pipes.
  5. Point a client at it and call the tool again. The same call that worked in the Inspector has to work through the client, and if it does not, the difference is the transport rather than the tool.
  6. Only then add governance. A server that one host reaches in one session does not need a rate limit. A server that a fleet reaches needs a key, a counter, and a record — which is the rest of this page.

Skip step three and the first bug you meet is a client that "found no tools", which is almost never a client bug.

typescript mcp server or python: the config is transport-shaped

Whichever language the server is written in, the block a host pastes is about the transport:

# lib/connect/mcp-config-templates.ts — source lines 161–181 (buildGenericMcpConfigJson)
function buildGenericMcpConfigJson(
  mcpUrl: string,
  apiKeyPlaceholder: string = API_KEY_PLACEHOLDER,
): string {
  return JSON.stringify(
    {
      mcpServers: {
        smartgate: {
          type: "streamable-http",
          url: mcpUrl,
          headers: buildMcpAuthHeaders(
            apiKeyPlaceholder,
            PLATFORM_AGENT_ID.Generic,
          ),
        },
      },
    },
    null,
    2,
  );
}

Read it as a contract rather than as a template. type selects the HTTP transport instead of a spawned process, url is the endpoint the host will post to, and headers carries the Authorization value — here as a placeholder that the client replaces with a real key, which is the right default for a block that gets pasted into a chat window. The nesting matters as much as the fields: everything the host needs sits under a named server entry, so merging this block into a file that already configures three other servers is a copy of one object rather than a rewrite. Nothing in the block says Python. A TypeScript server that publishes Streamable HTTP takes the same shape, and the reason to write it in Python is the SDK you already know, not the protocol. The client-side walkthrough for each host is separate, and if you are the one writing the client rather than the server, the per-client pages are in build an MCP client.

mcp server hosting: hints per host instead of a README

Once the block is generated rather than copied, the next question is what a given host needs to be told, and the answer is per-host guidance attached to the block:

# lib/connect/mcp-config-templates.ts — source lines 313–320 (getMcpPlatformHints)
function getMcpPlatformHints(platform: McpPlatform): McpPlatformHint[] {
  const docsLink: McpPlatformHint = {
    kind: "link",
    before: "Official docs: ",
    href: PLATFORM_DOCS_URL[platform],
    label: platform === "Generic" ? "MCP Streamable HTTP spec" : `${platform} MCP setup`,
    after: ".",
  };
# lib/connect/mcp-config-templates.ts — source lines 387–398 (getMcpPlatformHints)
    case "OpenClaw":
      return [
        docsLink,
        {
          kind: "text",
          children: `Merge under mcp.servers in ${PLATFORM_CONFIG_PATH.OpenClaw}. Not Cursor mcpServers.`,
        },
        {
          kind: "text",
          children:
            "Recommended: run the openclaw mcp set command below. mcp.* changes hot-apply; gateway restart usually not required.",
        },

Two excerpts from one function. The first builds the one hint every platform shares: a link to that platform's own documentation, with a label that falls back to the protocol specification when the host is not a specific product. The second is the OpenClaw case, and it is instructive because every sentence in it corrects a plausible mistake — the servers live under a nested key rather than the flat mcpServers object most hosts use, a command is the recommended path rather than a hand-edited file, changes apply without a restart, and the transport is named explicitly. That is what hosting guidance actually consists of: not a description of your server, but the specific ways this host differs from the last one the reader configured. The measured demand for these phrases is small — mcp server hosting at 90, openclaw mcp server at 90 — and that is expected, because the question is asked once per host, at the moment a correct server appears not to work. The two neighbours for that moment are MCP server list and, for the protocol details, the transports reference.

openclaw mcp server: an install command instead of a hand-edited file

Some hosts can be configured by command, and the difference between those hosts and the rest is worth encoding in one place:

# lib/connect/mcp-config-templates.ts — source lines 213–225 (getMcpPlatformInstallCommand)
function getMcpPlatformInstallCommand(
  platform: McpPlatform,
  mcpUrl: string,
): string | null {
  switch (platform) {
    case "OpenClaw":
      return buildOpenClawMcpSetCommand(mcpUrl);
    case "Claude Desktop":
      return buildClaudeDesktopMcpAddCommand(mcpUrl);
    default:
      return null;
  }
}

Four lines of dispatch and two real answers. A host that can be configured by a command gets one built for it, and everything else returns nothing rather than a guessed string — which is the honest shape for a helper that feeds a UI. The design lesson transfers to your own server: if you ship a setup path, prefer the version the user cannot mistype. A command that writes the configuration is verifiable — it either exits cleanly or it does not — while a file the user edits by hand is only as good as their editor and their attention to a nested key. There is a second reason this matters for a hosted server and not a local one. A local server's setup instructions are the same for everyone: the same command, the same absolute path, the same environment variable. A hosted endpoint that many hosts reach turns setup into a matrix of per-host commands, and the matrix is exactly the thing worth generating instead of documenting.

mcp server docker: the filename a container still needs

Containerising the server changes where it runs and nothing about how a host finds it, and the filename is the part people expect to be uniform:

# lib/connect/mcp-config-templates.ts — source lines 249–264 (mcpConfigFilename)
function mcpConfigFilename(platform: McpPlatform): string {
  switch (platform) {
    case "Claude Desktop":
      return "claude_desktop_config.json";
    case "Cursor":
      return "mcp.json";
    case "Windsurf":
      return "mcp_config.json";
    case "Hermes":
      return "mcp.json";
    case "OpenClaw":
      return "openclaw-smartgate-snippet.json";
    default:
      return "smartgate-mcp.json";
  }
}

A switch with six answers, and it is the clearest statement of a rule worth writing down: the configuration file belongs to the host, not to the server. Claude Desktop reads one name, Cursor and Hermes both read another, Windsurf reads a third, OpenClaw takes a snippet, and anything unknown gets a sensible default rather than an error. None of those decisions is yours to make from inside a container — a container has no idea which client will connect to it, and a container that tried to write a host's configuration would be writing into the wrong machine entirely. What a container does need is the half that is genuinely its own: a fixed listening port, an env-var-driven key rather than one baked into the image, and no reliance on the working directory, because a container's working directory is an implementation detail of the image rather than a place a host can find files. Get those right and the block above is the same one the hosted endpoint would give out.

mcp server json: the body your host sends and the row it leaves

The call itself is small, and the version of it you write yourself tells you what a hosted tool has to add:

# backend/smartgate/api/mcp.py — source lines 109–126 (smart_fetch)
@server.tool(
        name="smart_fetch",
        description=TOOL_DESCRIPTIONS["smart_fetch"],
        annotations=tool_annotations("smart_fetch"),
    )
    async def smart_fetch(
        url: str = Field(description="Full HTTP or HTTPS URL to fetch."),
        timeout: int = Field(default=30, description="HTTP timeout in seconds."),
    ) -> str:
        _, registry = _app_state()
        module = registry.get("fetch")
        ctx = _tool_ctx()
        return await _run_with_audit(
            "fetch",
            ctx,
            module.process(ctx, url=url, timeout=timeout),
            {"url": url},
        )

Every line of that declaration is ordinary Python — a name, a description read from a shared table, annotations derived from the tool name, and two parameters with defaults and descriptions — and the body is the part worth reading twice. It resolves the module from the registry rather than importing a function by name, builds a call context, and returns through a shared audited path with the arguments that should be recorded. That shared exit is what makes the JSON your host sends and the row in the audit view the same event: one request, one execution, one record. A server you write yourself can reproduce the declaration in an afternoon and will not reproduce the exit by accident, because the exit is the boring part — error translation, timing, budget check, trace identifier — and it is the part a debugger needs first. If you are comparing that shape with the REST surface you already run, the trade is worked through in MCP vs REST API.

mcp server deployment: the first governed call

Deployment is where a working server stops being a demonstration, and the smoke test is one call rather than a checklist:

# backend/smartgate/api/mcp.py — source lines 128–148 (smart_search)
@server.tool(
        name="smart_search",
        description=TOOL_DESCRIPTIONS["smart_search"],
        annotations=tool_annotations("smart_search"),
    )
    async def smart_search(
        query: str = Field(description="Search query string."),
        max_results: int = Field(
            default=10,
            description="Maximum number of results to return (1–50).",
        ),
    ) -> str:
        _, registry = _app_state()
        module = registry.get("search")
        ctx = _tool_ctx()
        return await _run_with_audit(
            "search",
            ctx,
            module.process(ctx, query=query, max_results=max_results),
            {"query": query},
        )

The pattern is the same as the tool above — declaration, module lookup, call context, audited return — and the useful difference is the argument cap. The result limit is declared as a bounded number with its range in the description, which is the smallest example of the whole argument for a gateway: a tool that takes a limit is a tool whose cost you control at the call site, and a tool that takes an unbounded string is one whose cost you discover from the invoice. For a deployment the sequence is: call one read-only tool through the endpoint, find its row in the logs view, and only then connect the agent that will use it in a loop. A row that disagrees with the response means the server and the audit path disagree about the same call, which is a defect worth fixing before there is traffic. The hosted equivalent of that first call is documented alongside the endpoint on the endpoint page.

mcp server cloud: the counter store a hosted server needs

The moment your server runs more than one instance, the limits you wrote become a question about shared state:

# lib/redis/config.ts — source lines 26–29 (isTcpRedisConfigured)
function isTcpRedisConfigured(): boolean {
  const url = redisUrl();
  return Boolean(url && (url.startsWith("redis://") || url.startsWith("rediss://")));
}

Four lines, and the narrowness is deliberate: two TCP schemes are accepted and everything else — including the HTTP-based stores that serverless deployments prefer — returns false, so a caller can tell "no counter store" apart from "a store this path does not speak". That distinction is what makes the check usable in a health report, and it is the first thing to look at when a rate limit stops biting. The cloud consequence is the one people meet in production: a counter held in instance memory is not a counter, it is a per-instance guess, so two instances behind a load balancer admit twice the traffic — and three admit three times. Everything the limit claims depends on a store the processes share, which is why the check exists as a function rather than as a deployment note.

mcp server vercel and other serverless hosts: the mode resolved at boot

Serverless runtimes change the answer to the previous question, and the resolution order is where that shows up:

# lib/redis/config.ts — source lines 38–42 (resolveRedisMode)
function resolveRedisMode(): RedisMode {
  if (isUpstashRestConfigured()) return "upstash";
  if (isTcpRedisConfigured()) return "tcp";
  return "none";
}

Priority order, and each step is a deployment reality rather than a preference. A REST-style store wins first, because a serverless function may open no outbound TCP sockets at all — the classic network restriction of that environment. A direct TCP URL comes second, which is the right answer for a long-running container or a virtual machine. And no store at all is the honest third answer rather than a hidden default, which means the server still answers calls and its limiter runs the only branch it can reach. State that as a property you selected rather than a failure to avoid: on a serverless host, the mode a server resolves at boot is the mode its limits will behave like, and a boot log line naming the mode is worth more than a dashboard. The platform picture is in the Vercel and AWS documentation for the two shapes — long-running and per-request — that this resolution has to serve.

mcp server security: normalize the token before you count it

Security work on an MCP server starts with the numbers your limits are made of, and those numbers come from a tokenizer that decorates its output:

# backend/smartgate/modules/context_gate/utils.py — source lines 105–111 (get_pure_token)
def get_pure_token(token, model_name):
    if "bert-base-multilingual-cased" in model_name:
        return token.lstrip("##")
    elif "xlm-roberta-large" in model_name:
        return token.lstrip("▁")
    else:
        raise NotImplementedError()

Three cases and a refusal. Tokens carry a continuation marker — a pair of hashes for one model family, a leading underscore-like character for another — and this function strips the marker so the token is counted as the caller thinks of it, while an unrecognised tokenizer raises instead of guessing. The refusal is the security-relevant decision: a count derived from the wrong tokenizer is a count that is wrong in a direction nobody notices, and every limit, budget and estimated saving downstream inherits the error. The same reasoning applies to where the count comes from. A server that accepts a token count from its client is trusting the party with an incentive to under-report, so the count that matters is one the server computes or verifies itself, from the tokenizer it selected rather than the one it was told about.

mcp server authentication: hash the key before you store it

Keys are stored as digests, and the digest is a cross-language contract rather than an internal detail:

# backend/smartgate/core/api_key_hash.py — source lines 6–13 (hash_api_key)
def hash_api_key(raw_key: str) -> str:
    """Must match Next `lib/api-keys` hashApiKey (SMARTGATE_API_KEY_SALT + ':' + raw)."""
    salt = settings.api_key_salt
    h = hashlib.sha256()
    h.update(salt.encode())
    h.update(b":")
    h.update(raw_key.encode())
    return h.hexdigest()

The docstring is the whole point: this hash must equal the one the other half of the product computes, from the same secret, with the same separator, over the same bytes. That is why the input is assembled explicitly rather than left to a library's default concatenation, and why a salt is part of the shape — a bare digest of a guessable key is a rainbow table waiting to happen, while a secret salt makes the digest useless to anyone who does not have the salt. Three consequences for a server you operate. The salt is a deployment secret, so rotating it is a migration rather than an edit: existing keys stop matching until they are re-issued. The digest is for lookup, not for password-style protection, so a low-entropy key format is the real weakness and length is the cheap fix. And because two implementations must agree, the algorithm is not a place for a unilateral upgrade — changing it means changing both sides in the same release. The wider identity picture, including the OAuth path a remote transport can use, is in MCP OAuth and auth.

mcp server aws and the other managed runtimes

The section that is missing from this page's excerpts is the platform one, and it is worth being explicit about why: no single symbol pins "deploy this to a managed runtime", because the answer is a property of the environment rather than of the code. Two shapes cover almost every managed host. A per-request runtime starts your process for a call and may freeze it afterwards, so the server must be stateless between requests, must not rely on a background thread surviving, and must resolve its counter store per invocation. A long-running container keeps the process alive, so an in-process cache is real for the lifetime of the instance — and wrong the moment a second instance starts, unless the counters are shared. Both can serve Streamable HTTP, and a stateless server is the one that needs no session identifier to work. The honest summary is that managed hosting does not change the protocol; it changes which of your two or three storage assumptions survive, and the two checks above are how you find out which.

How SmartGate compares with the server you just built

Transport What you write Limits and audit What you pay
Your own Python server stdio locally, Streamable HTTP when deployed The tools, and their governance if you need any Yours to build, and to keep Your infrastructure and your time
A single-purpose local server stdio in a spawned process The tools only None — the process trusts its parent Your machine
SmartGate (hosted) Streamable HTTP at one POST endpoint, stateless Nothing: point a host at the endpoint Per-key rate, daily cap clamped to the plan, and an audit row per call Free tier: 2M tokens a month, 120 MCP requests a minute per key (pricing)

The comparison is deliberately not a feature list. A server you wrote yourself is the right answer for a tool nobody else calls, and it stays the right answer as long as the only governance you need is your own discipline. The moment the server has users you do not share a desk with, the four things you would end up building — an endpoint every host can reach, keys that are hashed and rotated, limits that survive a second instance, and a record of who called what — are the four things this page's excerpts are drawn from. Start with the free tier and compare the per-key limits and log retention on the pricing page.

How to get started

  1. Get one tool answering. Install the SDK with its command-line extra, declare one typed function, and open it in the Inspector. Nothing else in this page matters until that works.
  2. Serve it over HTTP and point the hosted client at your own server to confirm the transport, or point a host at the hosted endpoint using the generated block on the connect page. The comparison is the interesting part: the same tool call, two addresses.
  3. Add the three governance pieces before the agent loop. A hashed key stored as a digest, a shared counter store if more than one instance will run, and one audit row per call.
  4. Read the record. After the first tool call, open the logs view and match the row against the response. A missing row with a successful response is an identity question, not a lost record.
  5. Read the neighbours for the halves this page leaves out. The tool schemas are catalogued in MCP tools reference, the retention and tracing layer is in MCP logging and observability, and the protocol end to end is in Model Context Protocol explained.

Frequently Asked Questions

Limitations and what this does not do

  • The SDK's own documentation is newer than this page. Package layout, command names and helper classes move between releases; the linked guides are the version to follow.
  • This is not a deployment guide. Managed runtimes differ in ways a page cannot enumerate; the platform documentation is the authority on limits, cold starts and network rules.
  • Your server's governance is yours. Nothing here adds limits to a server you wrote — the excerpts show the shape of that layer, not an implementation you can copy into your project.
  • The excerpts are windows, not whole files. The platform-hints excerpt keeps two of six per-host cases, and the prose says what the other cases contain rather than quoting them.
  • Two declarations also appear on a sibling page. The fetch and search tool declarations are quoted on the MCP server page from the same module; here they are read for the call and its record rather than for the registry.
  • Hashing is not the whole authentication story. What a key may do, how it is scoped, and how it is revoked are policy decisions this page only names.

Sources

Method note

The code in this article is not transcribed. Each block was cut directly out of the slice body returned by the SmartGate slice API and re-asserted byte-for-byte as a substring of that body before publication; the first line inside every fence records the file and the exact source lines. Symbols were pinned by whole-name containment (rule A level 2) and confirmed by the service's slot-proof endpoint before any prose was written. Nine excerpts are whole slice bodies; the platform-hints excerpt keeps the shared link builder and the OpenClaw case from a switch that has a case per host, and the cases it leaves out are described in the prose.

The build half of this page quotes no code on purpose. Section 1:1 (mcp server tutorial) returned no candidate symbol at all and section 4:1 (mcp server aws) returned several equally plausible ones; both were recorded as such rather than forced, and both are written from the SDK's and the platforms' public documentation. Demand figures come from this project's own keyword run, recorded in research_brief.md and search_volume.json: the head phrase python mcp server at 1,900 US searches a month, mcp server tutorial at 210, mcp server security at 170, mcp server authentication at 140, mcp server aws at 140, typescript mcp server at 110, mcp server hosting and openclaw mcp server at 90 each, mcp server docker at 70, mcp server json at 30, mcp server deployment at 20, and mcp server cloud and mcp server vercel at 10 each.

Slice provenance

# SERP keyword Symbol File Source lines How it was pinned sha256(12)
1 mcp server security get_pure_token backend/smartgate/modules/context_gate/utils.py 105–111 rule A L2 → slot-proof d0f34a4f3bd0
2 mcp server authentication hash_api_key backend/smartgate/core/api_key_hash.py 6–13 rule A L2 → slot-proof aba53f9b1f58
3 typescript mcp server buildGenericMcpConfigJson lib/connect/mcp-config-templates.ts 161–181 rule A L2 → slot-proof 51f127a16fc8
4 mcp server hosting getMcpPlatformHints lib/connect/mcp-config-templates.ts 313–320, 387–398 rule A L2 → slot-proof 097aa9ab5578
5 openclaw mcp server getMcpPlatformInstallCommand lib/connect/mcp-config-templates.ts 213–225 rule A L2 → slot-proof 8ee797e82a2b
6 mcp server docker mcpConfigFilename lib/connect/mcp-config-templates.ts 249–264 rule A L2 → slot-proof d60503ebfb68
7 mcp server json smart_fetch backend/smartgate/api/mcp.py 109–126 rule A L2 → slot-proof fdc9a4259783
8 mcp server deployment smart_search backend/smartgate/api/mcp.py 128–148 rule A L2 → slot-proof 12366ba0d241
9 mcp server cloud isTcpRedisConfigured lib/redis/config.ts 26–29 rule A L2 → slot-proof 7ca086a475d5
10 mcp server vercel resolveRedisMode lib/redis/config.ts 38–42 rule A L2 → slot-proof 3a9dd8e9daf4

Every fenced block above was cut from the slice body and re-asserted against it byte-for-byte before publication. 10 of 12 sections pinned, 1 abstentions, 1 misses.