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.
reserve serve and the MCP endpoint
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
reserve serve # 127.0.0.1:4243, until interrupted
reserve serve --bind 0.0.0.0 --port 8080--bindaddressDefault: 127.0.0.1--portintegerDefault: 4243Port 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.
curl -s -X POST http://127.0.0.1:4243/check \
-H 'Content-Type: application/json' \
-d '{"name":"example.test"}'Request body
namestringbodyrequiredThe 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: falseAdd the DNS record set to the answer as a dns field. Left off, the field is
absent entirely.
OriginstringheaderChecked 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.
domainstringrequiredThe full name that was checked.
namestringrequiredThe label to the left of the extension.
suffixstringrequiredThe extension, such as com or com.bd.
statusobjectrequiredAn object carrying status, one of available, taken, or unknown. When
it's unknown a reason field says why, such as no-service.
sourcestring | nullrequiredWhich source decided the verdict: registry, text, dns, or null when
nothing could answer.
elapsedobjectrequiredHow long the lookup took, as secs and nanos.
cachedbooleanrequiredWhether the answer was reused rather than looked up fresh.
responderstringThe registry server that answered. Absent when none did.
registrationobjectRegistrar, dates, statuses, nameservers, DNSSEC, and abuse contact, when the answering source supplied them.
dnsobjectThe 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_pagestringThe 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:
{
"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.codestringrequiredA stable identifier, such as origin.rejected, request.invalid, or
name.invalid.
error.messagestringrequiredWhat 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.
{
"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:
- Method
server/discoveris stateless. It exists only in that revision. - Otherwise, a protocol version inside
params._meta["io.modelcontextprotocol/protocolVersion"]decides:2026-07-28is 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. - Otherwise, method
initializeis stateful. - Otherwise, an
MCP-Protocol-Versionheader reading2026-07-28is stateless. - 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-MethodstringheaderrequiredMust equal the method in the body.
Mcp-NamestringheaderRequired on tools/call, where it must equal params.name. Not used by the
other methods.
MCP-Protocol-VersionstringheaderrequiredMust 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.
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":{}}}}'{
"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.
namestringrequiredThe domain name to check, such as your-domain-name.com.
dnsbooleanDefault: falseInclude 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.
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.
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"}}}'{
"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.