SmartGateSmartGate

MCP Server Example: One Tool, from Config to First Call

A complete MCP server example is short enough to follow in one sitting: the host reads a configuration entry, starts the server, asks for the tool list, and calls a tool. This page follows one server that exposes two tools — remember a passage, read it back — through every step in order, including the two parts most examples skip: the descriptions a model actually reads before it calls anything,…

Short answer: A complete MCP server example is short enough to follow in one sitting: the host reads a configuration entry, starts the server, asks for the tool list, and calls a tool. This page follows one server that exposes two tools — remember a passage, read it back — through every step in order, including the two parts most examples skip: the descriptions a model actually reads before it calls anything, and the record that proves the call arrived.

Key takeaways

  • The config entry belongs to the host. Name, launch command and transport live in the host's own file; the server never sees it, which is why the same server works in several clients.
  • tools/list is the contract. Names, descriptions and JSON Schemas are what a model chooses from, so a vague description produces vague calls no matter how good the code is.
  • One call is one request. Arguments are validated against the schema the server published, and the result comes back as a list of content parts.
  • A failure belongs inside the result. A tool that returns its own error lets the host tell a failed call from a dropped one.
  • Prove the call arrived. Count it and correlate it, the way the example's own test harness does and the way a hosted gateway does for you in production.
  • Next: get one tool answering, then add the read path and the record before an agent runs in a loop, because those two are what you will need at 3am.

The example in six steps, and what each step produces

The example is a server with two tools. One takes a passage of text, extracts the entities inside it and stores both. The other takes the identifier the first call returned and gives the record back. That is small enough to hold in your head and complete enough to exercise the awkward parts: a write, a read, a miss, and a caller that has to be told which happened.

Six steps, each producing something you can inspect:

  1. Write the server with two tool declarations. One file, two functions, names and descriptions in the declarations.
  2. Start it and watch the transport. Over stdio the host launches the process; over HTTP the server listens and the host dials it. Either way the process is the same code.
  3. Add the configuration entry. The host reads it, opens a client, and keeps that client for the lifetime of the session.
  4. Read the tool list. Two entries come back with their schemas. This response is the whole surface a model sees.
  5. Call the write tool. A passage goes in, a result comes back carrying an identifier and the entities that were found.
  6. Call the read tool with that identifier. Either the record returns, or the call answer says there is nothing under that id — and both answers are useful.

Everything after step six is deployment: who else may call, how often, and where the record of the call lives. The steps below take the six in order and say what the code on each one is doing.

Step one: the config entry the host reads

The entry is an object in a file the host owns, keyed by the name you choose. For a local server it carries a launch command and its arguments; for a remote one it carries a URL and the headers to send. Both shapes exist in the same file, which is the reason a server can move from a laptop to a host without changing anything about its code — only the entry changes.

Field What it decides What goes wrong without it
name The identifier the host shows and logs Two entries collide and one silently wins
command, args The process the host spawns for a local server The host reports a server it cannot start
url The endpoint the host posts to for a remote server The client dials nothing and no tools appear
headers The credential each request carries The endpoint refuses every call with no tool evidence
transport Which of the two shapes this entry is The host guesses, and a wrong guess is a silent failure

Two habits keep the entry honest. Keep the credential out of the file — reference an environment variable or a secret store, so a copied configuration does not hand somebody a key. And remember which side of the exchange you own: the file is the client's, not the server's, so a server that tries to write its own entry is editing a machine it cannot see. The server explained covers the endpoint side of the same pairing, and the transport field is the part that changes most often — transports and revisions is where that decision is documented in full.

Step two: the tool list your server publishes

The first request that matters is the one that asks what the server can do. The answer is a list, and each entry has three things worth designing: a name that is stable, a description that reads like an instruction rather than a label, and an input schema in JSON Schema that is strict about what it accepts. The description is not documentation for a human — it is the text a model reads to decide whether this is the tool for the job, so "Store a passage" produces worse calls than "Store a passage of text and return its identifier; use this before asking for a passage by id".

Two properties of that list surprise people. It is usually fetched once per session, so a tool that appears later in the process may not be visible to a host that already asked. And the schema is a promise the host enforces: an argument that does not match is rejected before your function runs, which is a good failure — the alternative is a tool that receives nonsense and fails somewhere deeper. Annotations on the entry are hints rather than guarantees; marking a tool read-only is how a host decides whether to confirm a call with the user, and it is worth the one line it costs.

The tools reference catalogues the fields and their types; the two other things a server can publish are read differently, because a resource is addressed by URI and a prompt is chosen by a person rather than called.

Step three: the first call, and what an AI gateway would record

A call is one request carrying the tool name and an arguments object, and the interesting operational question is not what it returns but whether it happened. That request is one JSON-RPC message with a method, an identifier and an arguments object, and the message format walkthrough is where those parts are defined — including the malformed shapes that make a call fail before it ever reaches your tool. The example's own test suite answers that question by wrapping the HTTP POST it sends, so the test can count calls and collect the identifiers threaded through them:

# backend/tools/host_platform_scenario_test.py — source lines 132–139 (post_json)
def post_json(
        url: str, body: dict, extra_headers: dict[str, str], timeout: float
    ) -> tuple[int, float, str]:
        nonlocal post_count
        post_count += 1
        if correlation_id:
            correlation_ids_seen.add(correlation_id)
        return _post_json(url, body, extra_headers, timeout)

Read the three lines as instrumentation rather than as plumbing. The counter increments before the call leaves, so a test can assert that the request was made exactly once — the failure this catches is a retry that fires when it should not, which is the quiet way an agent loop doubles its spend. The identifier collection is the second half: every call in a run carries a correlation id, and the set of ids seen is what lets an operator pull one request out of a log written by a dozen concurrent callers. And the wrapper returns the underlying response untouched, so instrumentation cannot change the thing it measures.

That is the same pair of facts a hosted layer records for you: which call, and one identifier that ties the request to its result. When a caller you do not control reaches your server, someone has to answer "who called, how often, and where is the record", and the gateway layer is the shape of that answer; keys and authentication covers the identity half of it, which the correlation id deliberately is not.

LangChain for agentic RAG callers: the error path a client shows

The first call that fails is more instructive than the one that works, because every framework that drives tools has to render the failure into its own vocabulary. A caller built on an agent framework will not print your error string; it will map it into a small set of cases it knows how to handle. The excerpt below is that mapping done properly — a bounded set of codes, with a default that keeps the interface working when something new arrives:

# lib/dashboard/friendly-errors.ts — source lines 150–166 (asDashboardErrorCode)
function asDashboardErrorCode(
  code: string | undefined,
): DashboardErrorCode {
  const allowed: DashboardErrorCode[] = [
    "REDIS_NOT_CONFIGURED",
    "REDIS_UNAVAILABLE",
    "BACKEND_OFFLINE",
    "NETWORK",
    "RATE_LIMIT",
    "UNAUTHORIZED",
    "UNKNOWN",
  ];
  if (code && allowed.includes(code as DashboardErrorCode)) {
    return code as DashboardErrorCode;
  }
  return "UNKNOWN";
}

Three decisions in eleven lines. The allowed list is exhaustive, so a caller can switch on it without a fallback branch for every unknown string. The default is UNKNOWN rather than a thrown error, which keeps a user interface rendering while still leaving a signal an operator can count — a rising UNKNOWN rate is a new failure mode arriving, and that is a better alarm than an exception trace. And the type narrowing in the signature is the contract: whatever the server said, this function promises a member of that list.

Carry it back to your own server. When a tool cannot do its job, return a failure inside the result with a stable machine-readable code — the credential is missing, the upstream is down, the argument is out of range — and put the human sentence in a separate message field. A client that has a code can decide whether to retry, ask the user, or stop; a client that only has prose has to guess, and guessing is how one failed call becomes a loop. Writing the client side covers the other half of that conversation.

Step four: the tool body, and the memory it writes

Behind the write tool is an ordinary function that turns text into entities, and the shape of it is a lesson about tools in general:

# backend/smartgate/modules/memory/entity_extraction.py — source lines 123–144 (extract_entities)
def extract_entities(text: str) -> List[Tuple[str, str]]:
    """Extract named entities, quoted text, and noun compounds from text.

    This is the public API that accepts a string. It loads the spaCy model
    internally and delegates to _extract_entities_from_doc().

    Args:
        text: Input text to extract entities from.

    Returns:
        Deduplicated list of (entity_type, entity_text) tuples.
        Entity types: PROPER, QUOTED, COMPOUND, NOUN.
        Returns empty list if spaCy is unavailable.
    """
    from mem0.utils.spacy_models import get_nlp_full

    nlp = get_nlp_full()
    if nlp is None:
        return []

    doc = nlp(text)
    return _extract_entities_from_doc(doc)

The docstring is the contract, and it names the four entity types the caller should expect: proper nouns, quoted text, noun compounds and plain nouns. Two design decisions in it are worth copying into any tool you write. The model is loaded inside the call rather than at import time, so a server starts fast and a caller that never uses this tool never pays for its dependencies. And when the model is unavailable the function returns an empty list instead of raising — a deliberate degradation, whose consequence is that a passage still gets stored, just with no entities extracted from it. The alternative, a raise, would lose the user's text because a secondary feature was missing, which is the wrong trade for a write path.

The storage shape follows what the agent-memory frameworks settled on, and the names they use are worth knowing even if you build your own: Letta documents memory as blocks an agent can read and edit, and graph-backed stores keep extracted entities as nodes linked to the passages they came from (Letta, mem0). The practical rule that comes out of those designs is to store the raw passage alongside anything you extract from it, because extraction is lossy and the original is the only thing a better extractor can reprocess later. How agent memory is stored goes into the storage choices.

Step five: the read path, and what a memory miss returns

The read tool is where a server decides what "not found" means, and the excerpt below is the answer that ages best:

# backend/smartgate/modules/memory/algorithm.py — source lines 973–1014 (Memory.get)
def get(self, memory_id):
        """
        Retrieve a memory by ID.

        Args:
            memory_id (str): ID of the memory to retrieve.

        Returns:
            dict: Retrieved memory.
        """
        capture_event("mem0.get", self, {"memory_id": memory_id, "sync_type": "sync"})
        memory = self.vector_store.get(vector_id=memory_id)
        if not memory:
            return None

        promoted_payload_keys = [
            "user_id",
            "agent_id",
            "run_id",
            "actor_id",
            "role",
        ]

        core_and_promoted_keys = {"data", "hash", "created_at", "updated_at", "id", "text_lemmatized", "attributed_to", *promoted_payload_keys}

        result_item = MemoryItem(
            id=memory.id,
            memory=memory.payload.get("data", ""),
            hash=memory.payload.get("hash"),
            created_at=memory.payload.get("created_at"),
            updated_at=memory.payload.get("updated_at"),
        ).model_dump()

        for key in promoted_payload_keys:
            if key in memory.payload:
                result_item[key] = memory.payload[key]

        additional_metadata = {k: v for k, v in memory.payload.items() if k not in core_and_promoted_keys}
        if additional_metadata:
            result_item["metadata"] = additional_metadata

        return result_item

The first two lines are the important ones: the lookup is by identifier, and an absent record returns nothing rather than raising. A miss is a normal outcome — the id was mistyped, the record was deleted, the caller asked a different server — and a tool that raises on it forces every caller to distinguish "no record" from "the server is broken" using string matching. Returning a null answer lets the host tell the model there is no record and let the model decide, which is exactly the behaviour you want in an agent loop.

The rest of the function is a small piece of data discipline that is easy to skip and expensive to skip later. A handful of payload keys are promoted to the top level of the returned record — user, agent, run, actor, role — because those are the fields clients filter on. The core keys are kept as they are. Everything else in the stored payload is collected into a metadata bucket rather than dropped, so a field a newer client wrote survives a round trip through an older server. Apply the same rule to your own tool: never silently discard a field you did not recognise, because the client that sent it is still reading.

How SmartGate compares with the server you just built

How a caller reaches it What you write yourself What is recorded
The example, over stdio The host launches the process on your machine Both tool bodies, and everything around them Nothing, unless you add it
The same server, hosted Streamable HTTP at an address you publish The tool bodies, plus TLS, keys and limits Whatever you instrument
A hosted endpoint (SmartGate) One POST endpoint with seven tools behind it Nothing but the configuration entry One row per call, with per-key limits and a cap on spend
A generic proxy in front A forwarding hop with its own credential The proxy rules, and still the records Request counts; tool names stay invisible to it

The trade in the fourth row is the one to watch. A proxy that forwards bytes can count requests and refuse them, but it cannot say which tool was called, because it never parsed the call — and "which tool was called" is the first question anyone asks when a bill or an incident arrives. The hosted endpoint exists so that the answer is a row rather than a project: the free plan carries all seven tools with 2 million tokens a month and needs no card, and the paid plans add request ceilings, log retention and a larger pool (plans).

How to get started

  1. Declare one tool and read its entry in the tool list. Names and descriptions first; the code behind them can be three lines.
  2. Call it from a client you did not write. Use the inspector or any host, and read the request and response in the debugging tools before you trust the transport.
  3. Add the read path and test the miss. A tool that can return nothing is a tool your callers can reason about.
  4. Instrument the call. A counter and one identifier per call is twenty minutes of work and it is the difference between an incident you diagnose and one you guess at.
  5. Read the record after your first real call. Logs and tracing covers retention and what to keep, and if you would rather not run it yourself, the hosted endpoint provides it.
  6. Compare against a server you did not write. Reading one public server's tool list is faster than designing your own conventions in isolation.

The example in this page runs end to end on the free plan once the entry points at the hosted endpoint: 2 million tokens a month with all seven tools, and start free needs no card. Questions about a deployment that spans teams start at the contact form. the Python SDK route and the protocol's own walkthrough cover the two other ends of the path this page walks.

Frequently Asked Questions

Limitations and what this does not do

  • Four of the eight planned sections quote code. The remaining four matched no unique symbol and are written from the published specification and the frameworks' documentation. Where a section shows no fence, that is the recorded reason rather than an omission.
  • The excerpts come from four different files. One is a dashboard error module on the client side, one is the repository's own platform scenario test, and two are memory-module code. They are quoted because they show real shapes, not because the example server is assembled from them.
  • This is not a copy-paste project. There is no full listing here: the declarations, the transport handler and the entry point are the parts you write, and they differ per language.
  • Identity stops at the edge of the page. Which key a caller uses, how it is scoped and how it is revoked are decisions this page names and does not make.
  • Retention is a policy decision. Once a server remembers something, deleting it is an operation someone has to design, and no protocol detail will decide it for you.

Method note

Nothing on this page was retyped from an editor. Each fence was cut 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 a fence records the file and the exact source lines. All four symbols were pinned by whole-name containment (rule A level 2) and confirmed by the service's slot-proof endpoint before the prose was written.

Four of the eight planned sections carry a fence and four do not. The unquoted sections — the client-side error vocabulary's neighbours, the two memory frameworks in the plan, and the course phrasing — matched no unique symbol in the codebase, so they are written from the published specification and the frameworks' own documentation. That is a recorded verdict, not a gap. Demand figures come from this project's own keyword run, recorded in research_brief.md and search_volume.json: langchain for agentic rag and what is ai gateway measured about 90 US searches a month each, and the remaining planned phrases about 70 each.

Slice provenance

# SERP keyword Symbol File Source lines How it was pinned sha256(12)
1 langchain for agentic rag asDashboardErrorCode lib/dashboard/friendly-errors.ts 150–166 rule A L2 → slot-proof 4d85da9a12f4
2 what is ai gateway post_json backend/tools/host_platform_scenario_test.py 132–139 rule A L2 → slot-proof 7199ec06ced5
3 letta agent memory extract_entities backend/smartgate/modules/memory/entity_extraction.py 123–144 rule A L2 → slot-proof 3658095d2dd3
4 neo4j agent memory Memory.get backend/smartgate/modules/memory/algorithm.py 973–1014 rule A L2 → slot-proof 60a98a5f09ef

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