Realtime admission
Let a tool of your own decide who gets realtime delivery — event streams, poll leases, webhook subscriptions and resource subscriptions — so a plan limit such as "one realtime consumer per workspace" lives in your data, not in the gateway config.
Realtime admission puts one question in front of every realtime delivery: may this caller hold it? The gateway asks a tool that you name. The tool looks at your data (a plan, a seat count, a row per open connection) and answers yes or no. On a no, the caller gets the error that your tool wrote.
Use it for a limit that the gateway cannot know by itself, for example "the free plan has one realtime consumer per workspace".
Enable it
mcp:
realtime:
admission:
tool: realtime.admit # a tool under mcp.capabilities.tools
renew_interval_ms: 60000 # default 60000, minimum 1000
on_error: deny # deny (default) or allow
capabilities:
tools:
- name: realtime.admit
description: Decides who gets realtime delivery.
backend:
kind: sql
# ... one statement that reads the plan and the open leases
| Key | Default | Meaning |
|---|---|---|
tool | — | The tool binding that the gateway calls. The gateway refuses a config that names a tool it does not have. |
renew_interval_ms | 60000 | How often the gateway asks again while a caller holds the delivery. |
on_error | deny | The decision when the call fails or the answer has no decision: deny refuses the caller, allow admits it. |
The admission tool is internal. tools/list does not show it, and a
caller's own tools/call of it gets -32602. Only the gateway calls it.
What the gateway asks
The gateway calls the tool as the caller: the call has the caller's identity and goes through the same policy, gates and backend as any tool call. A SQL statement can thus read the caller's subject, claims or workspace the same way as in any other tool.
| Argument | Value |
|---|---|
op | admit when the caller opens the delivery, renew while it holds it, release when it lets go. |
lease_id | One id per held delivery. It is the same in all three calls and on every replica. |
kind | events or resource. |
delivery | stream, poll or webhook for events; listen or subscribe for a resource. |
event | {name, arguments}, for kind: events. |
resource | {uri}, for kind: resource. |
client | {name, version} of the MCP client, when it named itself. |
Which calls a delivery makes:
| Delivery | admit | renew | release |
|---|---|---|---|
events/stream | At the open. | Each interval while the stream is open. | When the stream ends. |
events/poll | The first poll of a lease. | A later poll, at most once per interval. | Never: the lease runs out when the caller stops. |
events/subscribe (webhook) | A new subscription. | A refresh, at most once per interval. | events/unsubscribe. |
subscriptions/listen | Once per resources/updated target. | Each interval while the stream is open. | When the stream ends. |
resources/subscribe | At the subscribe. | Each interval while the subscription is held. | resources/unsubscribe. A subscription that ends another way is released at the next interval. |
release is best effort. A gateway that stops hard sends none. Give each
lease a lifetime on your side and let a renew extend it: at least twice
renew_interval_ms. A lease that gets no renew then expires by itself.
What the tool answers
The answer is one JSON object with a boolean allow. The gateway finds it in
structuredContent, in a text content block that holds JSON, in a row of a
SQL result, or in a JSON column of that row.
Admit the caller:
{ "allow": true, "renewAfterSeconds": 30 }
renewAfterSeconds (or renew_after_seconds) is optional. It can make the
next renew come sooner than renew_interval_ms, never later, and not sooner
than one second.
Refuse the caller:
{
"allow": false,
"error": {
"code": "realtime_limit",
"message": "The free plan has one realtime consumer per workspace.",
"details": { "limit": 1, "held": 1 }
}
}
error.code | The caller gets |
|---|---|
| A number | That JSON-RPC code, error.message, and error.data (or error.details) as data. |
| A string, or none | -32013, error.message, and the whole error object as data. |
On events/*, -32013 follows the events error numbering:
with error_codes: sep_3415 it is -32025.
A refused admit fails the request. A refused renew ends the delivery: an
event stream gets a notifications/events/terminated frame with the error, a
poll or a webhook refresh gets the error as its answer, and a resource
subscription stops.
When the call fails
A call that fails, a tool result with isError: true, and an answer without
an allow are all "no decision". on_error then decides:
deny(default): the caller gets-32603, "realtime admission is unavailable; retry later". The tool's own failure text stays in the gateway log.allow: the caller is admitted, and asked again afterrenew_interval_ms.
Metrics
| Metric | Labels | Counts |
|---|---|---|
mcpg_realtime_admission_total | op, delivery, outcome (allow, deny) | Each decision. |
mcpg_realtime_admission_errors_total | op | Each call that gave no decision. |
Operating notes
- Each replica renews what it serves. Poll leases and webhook
subscriptions are paced per replica, so a caller that moves between
replicas can cause more than one
renewper interval. Makerenewidempotent onlease_id. - Count leases, not calls. Key your rows on
lease_id. Anadmitfor alease_idthat you already hold is the same lease, not a second consumer. - A reload applies at the next call. A new tool, interval or
on_errortakes effect at the nextadmitorrenew. Withoutmcp.realtime.admissionevery caller is admitted.
See also
- MCP Events — streams, poll leases and webhook subscriptions.
- Resource watching — what makes a resource subscription fire.
- Configuration reference — every key.