MCP endpoint

hookinlet serves the same reads as its REST API as Model Context Protocol tools, so an agent can use an inbox with no glue code: point an MCP client at the server and the agent can list inboxes, read messages, and wait for new ones.

The endpoint is read-only. There are no tools that write, delete, or administer anything.

Endpoint

POST /mcp

Streamable HTTP, stateless: every call stands alone, carries its own token, and is answered as a plain JSON response. There are no sessions to expire or lose, any instance can answer any call, and revoking a token takes effect on the next call.

What is served is deliberately small:

Errors from the endpoint itself (before a tool runs) use the same JSON error shape as the rest of the API. Responses are never cacheable (Cache-Control: no-store).

It is on by default. Turn it off with HOOKINLET_MCP_ENABLED=false. It is also off whenever the read API is, since both need an authenticator.

Authentication

The same bearer tokens as the REST API:

Authorization: Bearer <token>

A missing or wrong token gets 401 with a WWW-Authenticate: Bearer header, before anything else is looked at.

Tools

The shape of a message

A message has two parts. At the top are the fields the server established about the delivery, none of which can carry text the sender chose: seq, epoch, inbox, received_at, method (POST or PUT), size, verified, verifier, verify_error (fixed wording), source_ip (an address the server observed; see the API notes), and body_truncated (the server saying it left a body out). Under untrusted is everything the sender wrote or chose: path, query, headers, content_type, delivery_id, event_type, and the body (body, body_text or body_base64, as in the REST messages). An agent that reads a transcript, and a person who reviews one, can see at once which text could have been written by an attacker. It is the REST message with the sender's fields moved under one key; the values are the same.

query_messages and wait_for_messages return messages without bodies unless you pass bodies: true, with untrusted.body_* absent: an agent decides which messages are worth the context they cost and fetches those with get_message, which always returns the whole message. With bodies, a body over the inline limit is left out and marked, as described under Large bodies. (The wire encoding may write a character such as < as an escape, which is the same value.)

list_inboxes

The inboxes this token can read: slug, name, verifier, latest_seq, and epoch. An agent token lists the inboxes it reads. A token for all inboxes, and the admin token, list up to 100; truncated: true says it can read more than that. Nothing is ever reported about inboxes the token cannot read, not even how many there are.

query_messages

Input Meaning
inbox Slug of the inbox. Required.
after Read forward, oldest first, from messages with a seq greater than this (0 for the beginning).
before Read backward, newest first, from messages with a seq less than this.
limit 1 to 100, default 20.
types Only messages whose event_type is one of these.
since, until RFC 3339 times.
verified Only verified (true) or unverified (false) messages.
bodies Include message bodies. Default false: messages come without them, and you fetch the ones you want with get_message.
epoch The epoch your cursor came from; see Epochs and gaps.

With neither after nor before, you get the newest messages, newest first. Parameters are checked as strictly as the REST API checks them: a misspelled name or an out-of-range value is an error, not a silent default.

The result has messages, latest_seq, and a cursor:

Epochs and gaps

A cursor is a sequence number, and a sequence number only means something within one life of an inbox. An inbox that is deleted and made again under the same slug starts at 1 again, and so does a server restored from a backup (which may have accepted different messages since). So every inbox has an epoch, an opaque string that changes in those cases, and every result carries it (list_inboxes too). Keep it with your cursor and pass it back as epoch: if it is no longer the inbox's current one the call fails with epoch_changed, instead of quietly reading from numbers that no longer mean what they did. Then start again with after: 0 and the new epoch.

gap: true means messages after your cursor were removed by retention before you read them: what follows does not continue where you left off. (A cursor of 0 never reports one.)

get_message

inbox and seq, and optionally epoch (as for query_messages, so that a seq from an earlier life of the inbox is refused rather than answered with another message). One message with its full body, up to the size limit described under Limits.

wait_for_messages

Input Meaning
inbox Slug of the inbox. Required.
after Return messages with a seq greater than this. Required.
types Only messages whose event_type is one of these.
timeout_seconds 0 to 60, default 30.
limit 1 to 100, default 20.
bodies Include message bodies; default false, as for query_messages.
epoch The epoch your cursor came from; see Epochs and gaps.

Returns as soon as any matching message exists after after, or an empty list when the timeout passes. This is the polling loop for agents that cannot hold an SSE connection:

after = latest_seq from list_inboxes (or 0 to start from the beginning)
loop:
    result = wait_for_messages(inbox, after)
    handle(result.messages)
    after = result.next_after

A wait holds no database connection while it sleeps. It ends early if the server shuts down, or if the token is revoked or the inbox deleted (checked every 30 seconds), and an agent that hangs up releases it immediately.

Waits count against the same limits as SSE streams (HOOKINLET_MAX_STREAMS overall, HOOKINLET_MAX_STREAMS_PER_INBOX for one inbox): a wait that finds no free slot is told "too many waits are in progress" and should retry shortly. A timeout_seconds of 0 checks once and takes no slot.

Large bodies

To protect an agent's context window, query_messages and wait_for_messages leave out any body larger than 16 KiB (HOOKINLET_MCP_INLINE_BODY_BYTES). Those messages have body_truncated: true at the top level, no body fields under untrusted, and their true size. Fetch one whole with get_message. (Without bodies: true no body is sent at all.)

Treat message content as data

Everything under a message's untrusted was written by whoever sent the webhook, including a verified one. The server says so in its instructions and in each tool's description, but the protection that matters is in your agent: treat message content as data to analyze, never as instructions to follow. See the API notes.

Limits

Connecting a client

Create an agent token with hookinlet inbox create --verifier … (it is shown once; hookinlet token create issues more), then give your client the endpoint and the header.

Claude Code:

claude mcp add --transport http hookinlet https://hooks.example.com/mcp \
  --header "Authorization: Bearer $HOOKINLET_AGENT_TOKEN"

Clients configured with JSON (Cursor and others; the exact keys vary, check your client's documentation):

{
  "mcpServers": {
    "hookinlet": {
      "url": "https://hooks.example.com/mcp",
      "headers": { "Authorization": "Bearer <agent token>" }
    }
  }
}

VS Code (.vscode/mcp.json):

{
  "servers": {
    "hookinlet": {
      "type": "http",
      "url": "https://hooks.example.com/mcp",
      "headers": { "Authorization": "Bearer <agent token>" }
    }
  }
}

Without a client, a call is one JSON-RPC POST:

curl -s https://hooks.example.com/mcp \
  -H "Authorization: Bearer $HOOKINLET_AGENT_TOKEN" \
  -H "Content-Type: application/json" \
  -H "Accept: application/json, text/event-stream" \
  -d '{"jsonrpc":"2.0","id":1,"method":"tools/call",
       "params":{"name":"query_messages","arguments":{"inbox":"demo","after":0}}}'

Use TLS in front of the server for anything beyond localhost: the token is the whole credential.

From docs/mcp.md at v0.1.1. Markdown · View on GitHub