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:
- Only
POSTwithContent-Type: application/json.GETandDELETEget405. - One JSON-RPC request per POST. A batch (a JSON array) is refused with
400: it would run many calls at once, each of them able to hold a wait open or build a large result, from one small request. - Only the methods a tools-only server needs:
initialize,notifications/initialized,notifications/cancelled,ping,tools/list, andtools/call. Anything else, including resources, prompts, and subscriptions, gets400. - A request body over 1 MiB gets
413.
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>
- An agent token reads the inboxes it was issued for. Any other inbox, or one that does not exist, is "not found", with the same text, so a token never reveals what else is on the server.
- The admin token reads every inbox.
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:
next_after(forward reads): pass it asafternext time. Keep it even whenmessagesis empty. Following it visits every message exactly once, in order, and it skips ahead past messages that did not match your filters.next_before(backward reads): pass it asbeforeto continue, ornullwhen the oldest message has been returned.epochandgap: see below.
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
- Tool calls share the REST API's limit on concurrent reads (16 by default;
Options.MaxConcurrentReadsfor embedders). A call that cannot get a slot in 10 seconds is told the server is busy. A waitingwait_for_messagesholds a slot only while it reads. - A
query_messagesorwait_for_messagesresult is at most about 1 MiB as sent. Each result carries its data twice (the structured content and its text form) and the encoding can make some characters several bytes longer, so the server measures what it is about to send and returns fewer messages thanlimitwhen needed, always at least one.next_after(ornext_before) continues from the last message returned, so nothing is skipped. get_messagereturns up to about 4 MiB as sent. A message too large for that is refused with the URL of its/rawendpoint, which streams the bytes.- The inline body limit cannot be set above 64 KiB, the size the REST API loads.
- Requests to
/mcpare limited to 1 MiB.
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