Gateway
Gatewaybeta6 min

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

yaml
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
KeyDefaultMeaning
tool—The tool binding that the gateway calls. The gateway refuses a config that names a tool it does not have.
renew_interval_ms60000How often the gateway asks again while a caller holds the delivery.
on_errordenyThe 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.

ArgumentValue
opadmit when the caller opens the delivery, renew while it holds it, release when it lets go.
lease_idOne id per held delivery. It is the same in all three calls and on every replica.
kindevents or resource.
deliverystream, 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:

Deliveryadmitrenewrelease
events/streamAt the open.Each interval while the stream is open.When the stream ends.
events/pollThe 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/listenOnce per resources/updated target.Each interval while the stream is open.When the stream ends.
resources/subscribeAt 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:

json
{ "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:

json
{
  "allow": false,
  "error": {
    "code": "realtime_limit",
    "message": "The free plan has one realtime consumer per workspace.",
    "details": { "limit": 1, "held": 1 }
  }
}
error.codeThe caller gets
A numberThat 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 after renew_interval_ms.

Metrics

MetricLabelsCounts
mcpg_realtime_admission_totalop, delivery, outcome (allow, deny)Each decision.
mcpg_realtime_admission_errors_totalopEach 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 renew per interval. Make renew idempotent on lease_id.
  • Count leases, not calls. Key your rows on lease_id. An admit for a lease_id that you already hold is the same lease, not a second consumer.
  • A reload applies at the next call. A new tool, interval or on_error takes effect at the next admit or renew. Without mcp.realtime.admission every caller is admitted.

See also