Skip to main content

reserve serve and the MCP endpoint

8 min readStableIntermediate

reserve serve answers the same checks over HTTP and the Model Context Protocol instead of the command line, for a script or an AI agent to call. One process serves two paths on one address: POST /check for plain HTTP, and /mcp for MCP. Both return the same JSON an answer takes under --json.

Starting the server

Bash
Bash
reserve serve                        # 127.0.0.1:4243, until interrupted
reserve serve --bind 0.0.0.0 --port 8080
--bindaddressDefault: 127.0.0.1

Address to bind. Parsed as an IP address by the argument parser, so a value that isn't one is refused before the server starts.

--portintegerDefault: 4243

Port to bind.

Loopback is the default because a fresh install shouldn't expose anything to the network. Reaching wider is the operator's explicit choice with --bind, and the listener speaks plain HTTP with no TLS, so on any non-default bind the names being checked cross the network in cleartext.

The lookups the server runs use built-in settings rather than the pacing flags: source auto, IANA referrals allowed, a 10-second per-request timeout, and no result cache. The run ends on interrupt, the same as any other reserve command. Both flags also appear in the command reference.

POST /check

One name per request.

Bash
Bash
curl -s -X POST http://127.0.0.1:4243/check \
  -H 'Content-Type: application/json' \
  -d '{"name":"example.test"}'

Request body

namestringbodyrequired

The domain name to check, extension included. It goes through the same validator a typed name does, then gets split into a name and a known extension.

dnsbooleanbodyDefault: false

Add the DNS record set to the answer as a dns field. Left off, the field is absent entirely.

Originstringheader

Checked when present. Only 127.0.0.1, localhost, and ::1 pass; anything else gets a 403. A request with no Origin header at all, which is what curl and a server-side client send, is accepted.

A body over 2MB is refused before it's read into memory.

Response body

Every field below is what reserve --json prints for the same name, so a client that already parses CLI output needs no second parser.

domainstringrequired

The full name that was checked.

namestringrequired

The label to the left of the extension.

suffixstringrequired

The extension, such as com or com.bd.

statusobjectrequired

An object carrying status, one of available, taken, or unknown. When it's unknown a reason field says why, such as no-service.

sourcestring | nullrequired

Which source decided the verdict: registry, text, dns, or null when nothing could answer.

elapsedobjectrequired

How long the lookup took, as secs and nanos.

cachedbooleanrequired

Whether the answer was reused rather than looked up fresh.

responderstring

The registry server that answered. Absent when none did.

registrationobject

Registrar, dates, statuses, nameservers, DNSSEC, and abuse contact, when the answering source supplied them.

dnsobject

The DNS record set, present only when the request asked for it. Each record type carries a state of present, absent, or unknown, its values, and a reason when unknown.

registry_pagestring

The IANA root database page for the extension. Present only for an available name.

buy_atstring[]

Registrar search links for an available name. No price is shown or implied. Omitted when empty.

An unanswerable lookup comes back as unknown with its reason, never as available:

JSON
JSON
{
  "domain": "example.test",
  "name": "example",
  "suffix": "test",
  "status": { "status": "unknown", "reason": "no-service" },
  "source": null,
  "elapsed": { "secs": 3, "nanos": 431134125 },
  "cached": false
}

Error responses

Every refusal carries the same two-field shape and a stable identifier a script can branch on.

error.codestringrequired

A stable identifier, such as origin.rejected, request.invalid, or name.invalid.

error.messagestringrequired

What went wrong, in prose. Any text that came from the request is scrubbed first, so a hostile name can't forge a line of output.

JSON
JSON
{
  "error": {
    "code": "name.invalid",
    "message": "`-bad-.com` is not a usable domain name: a label starts or ends with a hyphen"
  }
}

A rejected Origin returns 403 with origin.rejected. An unreadable JSON body returns 400 with request.invalid. A name that fails validation, and a name whose extension isn't in the catalog, both return 400 with name.invalid. None of them crash the server or hang the connection.

POST /mcp

The MCP endpoint speaks two revisions. The stateless 2026-07-28 revision is what the server advertises; the 2025-11-25 revision and its two predecessors (2025-06-18 and 2025-03-26) are answered through the older initialize handshake. Whichever leg handles a request, the check it runs and the answer it returns are identical.

GET /mcp returns 405. No listChanged or subscribe capability is advertised, so there's no stream to open.

Choosing a revision

The server decides from the request itself, in this order:

  1. Method server/discover is stateless. It exists only in that revision.
  2. Otherwise, a protocol version inside params._meta["io.modelcontextprotocol/protocolVersion"] decides: 2026-07-28 is stateless, one of the three stateful versions is stateful, and anything else is treated as stateless so it gets refused with the list of versions this server does speak.
  3. Otherwise, method initialize is stateful.
  4. Otherwise, an MCP-Protocol-Version header reading 2026-07-28 is stateless.
  5. Anything left is stateful.

A header that disagrees with the body is refused rather than trusted.

The stateless revision (2026-07-28)

Every request stands alone: no handshake, no session id, nothing held between requests. Three headers are required, and each is cross-checked against the body.

Mcp-Methodstringheaderrequired

Must equal the method in the body.

Mcp-Namestringheader

Required on tools/call, where it must equal params.name. Not used by the other methods.

MCP-Protocol-Versionstringheaderrequired

Must equal the protocol version named inside _meta.

A missing or mismatched header is refused with JSON-RPC code -32020 and a 400. Inside params._meta, both io.modelcontextprotocol/protocolVersion and io.modelcontextprotocol/clientCapabilities are required; a missing one is -32602 with a 400. A version this server doesn't speak is -32022, whose error.data names both supported and requested. An unknown method is -32601 with a 404. A JSON-RPC notification, meaning a message with no id at all, gets a bare 202 and no body.

Three methods answer: server/discover, tools/list, and tools/call. The first two carry cache hints, resultType: "complete", ttlMs: 3600000, and cacheScope: "public"; tools/call carries resultType alone.

Bash
Bash
curl -s -X POST http://127.0.0.1:4243/mcp \
  -H 'Content-Type: application/json' \
  -H 'Mcp-Method: server/discover' \
  -H 'MCP-Protocol-Version: 2026-07-28' \
  -d '{"jsonrpc":"2.0","id":1,"method":"server/discover",
       "params":{"_meta":{
         "io.modelcontextprotocol/protocolVersion":"2026-07-28",
         "io.modelcontextprotocol/clientCapabilities":{}}}}'
JSON
JSON
{
  "jsonrpc": "2.0",
  "id": 1,
  "result": {
    "resultType": "complete",
    "supportedVersions": ["2026-07-28"],
    "capabilities": { "tools": {} },
    "_meta": {
      "io.modelcontextprotocol/serverInfo": {
        "name": "reserve",
        "version": "0.6.0"
      }
    },
    "ttlMs": 3600000,
    "cacheScope": "public"
  }
}

The check tool

tools/list returns exactly one tool, on both revisions.

namestringrequired

The domain name to check, such as your-domain-name.com.

dnsbooleanDefault: false

Include the DNS record set in the answer.

The schema sets additionalProperties: false, so an argument the tool doesn't define is rejected by a validating client.

A successful call returns structuredContent holding the same object /check returns, content[0].text holding that object serialized as text, and isError: false. A malformed domain is the tool's own finding rather than a bad request, so it comes back with isError: true and the reason as text, never as a protocol error. Naming a tool other than check is a protocol error, -32602.

Bash
Bash
curl -s -X POST http://127.0.0.1:4243/mcp \
  -H 'Content-Type: application/json' \
  -H 'Mcp-Method: tools/call' \
  -H 'Mcp-Name: check' \
  -H 'MCP-Protocol-Version: 2026-07-28' \
  -d '{"jsonrpc":"2.0","id":2,"method":"tools/call",
       "params":{"_meta":{
         "io.modelcontextprotocol/protocolVersion":"2026-07-28",
         "io.modelcontextprotocol/clientCapabilities":{}},
         "name":"check","arguments":{"name":"example.test"}}}'

The stateful revision (2025-11-25 and two predecessors)

This leg starts with initialize. The server echoes the requested version back when it's one of the three it supports, and falls back to 2025-11-25 otherwise. The response carries an Mcp-Session-Id header, and every later request has to send it back.

Bash
Bash
curl -s -D- -X POST http://127.0.0.1:4243/mcp \
  -H 'Content-Type: application/json' \
  -d '{"jsonrpc":"2.0","id":1,"method":"initialize",
       "params":{"protocolVersion":"2025-11-25","capabilities":{},
                 "clientInfo":{"name":"example-client","version":"1.0.0"}}}'
JSON
JSON
{
  "jsonrpc": "2.0",
  "id": 1,
  "result": {
    "protocolVersion": "2025-11-25",
    "capabilities": { "tools": {} },
    "serverInfo": { "name": "reserve", "version": "0.6.0" }
  }
}

notifications/initialized is accepted with a 202 and an empty body, and no request is ever gated on having received it. ping answers with an empty result and needs no session, since it's one of the two messages a client may send before the handshake finishes.

tools/list and tools/call behave as they do on the stateless leg, minus the cache hints and resultType, and both require the session header. A missing Mcp-Session-Id is -32000 with a 400; an unknown or expired one is -32000 with a 404.

DELETE /mcp ends a session early. With a live session id it returns 204, with an unknown one 404, and with no header at all 400.

Session lifetime

Session state lives on the stateful leg only. A stateless request never touches it.

  • A session id is 256 bits from a cryptographically secure random source, hex-encoded to 64 characters. It's never a counter, a timestamp, or anything derived from process state.
  • Sessions are held in memory only and never written to disk.
  • A session idles out after 30 minutes with no activity. Once expired it reads the same as one that never existed, so a stale client re-initializes cleanly.
  • The store holds at most 1,000 sessions. At the ceiling the least recently seen session is dropped to make room, so a caller that opens sessions and abandons them can't exhaust memory.

This tool has no accounts, so a session id is a connection handle rather than a credential.

Security boundaries

The Origin header is validated on every request that carries a body or a session: POST /check, POST /mcp, and DELETE /mcp. Refusing a non-loopback origin stops a malicious web page from using a visitor's browser to reach a reserve serve instance on that visitor's own machine.

There is no login and no per-caller identity on either leg. Anyone who can reach the bound address and port can ask it to check a name, which is why the loopback default is a deliberate boundary rather than an oversight.

The serve subcommand's threat model section in the project's SECURITY.md carries the full detail, including what the model covers and what it leaves to the operator.

Was this page helpful?

© 2026 Reserve. All rights reserved.