Middleware¶
14.1 Signature and order¶
async def middleware(info: CallInfo, call_next) -> Any:
...
result = await call_next(info)
...
return result
CallInfo fields: kind (tool, resource, prompt, completion), name (tool or
prompt name, the URI for resources, the prompt name or template string for completions),
spec, arguments (tool or prompt arguments, template variables, or for completions
{"argument": name, "value": partial, "context": {...}}, all as received: pseudonyms not yet
reversed), principal, context (the ToolContext) and started (time.monotonic() at
creation). call_next returns a ToolResult for tools, a list of resource contents for
resources, a PromptResult for prompts and the {"values", "total", "hasMore"} dict for
completions. Raise an MCPLayerError
to reject; for tools the layer turns it into an error result.
Middleware passed to the layer runs outermost first; layer.use(mw) appends one innermost.
The CLI builds this stack:
AuditLogMiddleware (if --audit-log)
MetricsMiddleware (always)
ConcurrencyLimitMiddleware (if --max-concurrency)
CachingMiddleware (if --cache-ttl)
terminal: policy check, input pipeline, validation, handler, output pipeline
Middleware runs before the terminal's policy.check_call and sees results after the output
pipeline. So the audit log records transform reports, and cached results are already
transformed for the principal they were cached for. CachingMiddleware runs check_call
itself before it serves a hit, so a cached answer spends a rate-limit token, gets the same
access decision and lands in the policy's audit buffer like a fresh one.
Completions pass through the chain too, so the audit log and metrics include them; the cache
never stores them, and the concurrency limiter counts them when its kinds include
resource or completion.
14.2 AuditLogMiddleware¶
from cloudg.mcp.middleware import AuditLogMiddleware
AuditLogMiddleware(path=None, *, stream=None, salt=None, hash_values=True)
| Argument | Meaning |
|---|---|
path |
JSONL file to append to, created with mode 0600 and its directory created if needed |
stream |
Alternatively an open text stream |
| neither | Lines go to the cloudg.mcp.audit Python logger at INFO |
salt |
HMAC key for value hashes; default $CLOUDG_MCP_AUDIT_SALT, else random per process |
hash_values |
False logs argument keys only |
Argument values are never written. Each is reduced to the first 16 hex characters of
HMAC-SHA256(salt, canonical JSON of the value), so someone holding the salt can confirm
whether a given value was queried without the log disclosing it. A line from the HTTP
walk-through (keys are sorted; one line per call):
{"argument_hashes":{"group_by":"8b696647bea14c90"},"argument_keys":["group_by"],"duration_ms":14.11,"event":"mcp.call","kind":"tool","name":"count_assets","outcome":"ok","principal":{"id":"alice","roles":["analyst","default"]},"request_id":3,"sensitivity":"internal","transforms":{"annotations":{"classification":{"sensitivity":"internal"},"provenance":{"category":"inventory","dataset":"prod","generated_at":"2026-10-08T15:15:37+00:00","kind":"tool","name":"count_assets","policy":"standard","principal_roles":["analyst","default"]}},"content_annotations":{"audience":["user","assistant"],"priority":0.45}},"ts":"2026-10-08T15:15:37.292+00:00"}
A completion from the in-process run (the argument name, the partial value and the context are hashed like any other arguments):
{"argument_hashes":{"argument":"4c28dc8c565760ab","context":"395bcd84fe55f989","value":"dad9f4d26c10bf63"},"argument_keys":["argument","context","value"],"duration_ms":0.35,"event":"mcp.call","kind":"completion","name":"cloudg://assets/{+ref}","outcome":"ok","principal":{"id":"local","roles":["default","local"]},"request_id":null,"sensitivity":"confidential","ts":"2026-10-08T15:14:31.950+00:00"}
And a failed resource read:
{"argument_hashes":{"ref":"06d94df112a60014"},"argument_keys":["ref"],"duration_ms":0.67,"error":{"code":-32602,"type":"ReferenceNotFoundError"},"event":"mcp.call","kind":"resource","name":"cloudg://assets/does-not-exist","outcome":"rejected","principal":{"id":"local","roles":["default","local"]},"request_id":null,"sensitivity":"confidential","ts":"2026-10-08T15:14:31.952+00:00"}
| Field | Meaning |
|---|---|
ts |
UTC timestamp, milliseconds |
event |
Always mcp.call |
kind, name |
What was called |
principal |
{"id", "roles"} |
request_id |
JSON-RPC id when the adapter supplied one |
argument_keys, argument_hashes |
Sorted keys; keyed hashes of the values |
duration_ms |
Wall time inside this middleware |
outcome |
ok, error (an isError result), denied, rate_limited, rejected (other MCPLayerError), cancelled, exception |
sensitivity |
The spec's sensitivity |
error |
{"type", "code"} for raised errors, {"code"} for error results |
transforms |
The transform report, when there is one |
Because the arguments are logged before the input pipeline, a pseudonymized argument is hashed
as the pseudonym. Calls to unknown or hidden primitives never reach the middleware (the
lookup fails first); the policy records them instead, as decision not_found. The policy's
trail is separate from this log (see MCP_PRIVACY.md and the
privacy_audit_log tool). Call close() on shutdown
if you created the middleware with a path.
14.3 MetricsMiddleware and cloudg://metrics¶
MetricsMiddleware() (alias TimingMiddleware) counts calls, errors and rejections and keeps
latency per primitive (kind:name), with p95 over the last 256 calls. snapshot() returns a
dict, reset() clears it, and register_metrics_resource(layer_or_registry, metrics,
uri="cloudg://metrics", replace=True) serves the snapshot as a resource (category server,
sensitivity internal). The CLI always does both. A read after three tool calls and one
resource read:
{
"uptime_seconds": 0.6,
"calls": 4,
"errors": 0,
"rejected": 0,
"by_primitive": {
"resource:cloudg://datasets/prod/summary": {"calls": 1, "errors": 0, "rejected": 0, "avg_ms": 0.52, "p95_ms": 0.52, "max_ms": 0.52, "last_call": "2026-10-08T15:14:31+00:00"},
"tool:get_asset": {"calls": 2, "errors": 0, "rejected": 0, "avg_ms": 2.39, "p95_ms": 4.67, "max_ms": 4.67, "last_call": "2026-10-08T15:14:31+00:00"},
"tool:load_dataset": {"calls": 1, "errors": 0, "rejected": 0, "avg_ms": 5.43, "p95_ms": 5.43, "max_ms": 5.43, "last_call": "2026-10-08T15:14:31+00:00"}
}
}
Completions show up as completion:<prompt or template> once any have run. errors counts
error results and unexpected exceptions; rejected counts denials, rate
limits, other MCPLayerErrors and cancellations.
14.4 CachingMiddleware¶
CachingMiddleware(ttl=60.0, maxsize=256, *, predicate=None, cache_resources=False)
cache.attach(layer) # clear the cache on every layer change event
cache.invalidate() # clear by hand
cache.stats() # {"size", "hits", "misses", "ttl"}
The rules:
- By default only tools whose annotations say both read-only and idempotent are cached
(
is_cacheable_tool(spec)). Tools withcloud_accessorexecare not idempotent unless they say so, so live lookups are not cached.predicate(info) -> boolreplaces that test;cache_resources=Truealso caches resource reads. Completions are never cached. - A hit first runs
policy.check_call, the step a hit skips in the terminal: the access decision, the rate-limit token and the policy audit entry apply as for a fresh call, and a rejection propagates the same way. - The key is
(principal id, sorted roles, kind, name, canonical JSON of the arguments), so one principal's transformed result is never served to another. - Error results are not cached.
- Any call to a tool that is not read-only clears the whole cache, and so does every layer
change event once
attachwas called (the CLI attaches it). - Entries expire after
ttlseconds; the least recently used entry goes whenmaxsizeis exceeded. - Hits are deep copies marked with
_meta["cloudg/cache"] = "hit".
A second get_asset(ref="bastion") in the in-process run came back with
"cloudg/cache": "hit" and an audit duration_ms of 0.12 against 4.69 for the first call.
14.5 ConcurrencyLimitMiddleware¶
At most limit calls of the given kinds run at once, and at most per_principal per
principal id when set. A call that waits longer than queue_timeout seconds for a slot fails
with RateLimitedError("Server busy: too many concurrent MCP calls"), which a tool caller
sees as an error result with -31029. queue_timeout=None waits forever. Completions count
when kinds contains "resource" or "completion". in_flight reports
the current count. --max-concurrency N adds it with limit=N.
14.6 RetryMiddleware¶
RetryMiddleware(retries=2, *, backoff=0.5, retry_on=(ConnectionError, TimeoutError),
retry_codes=("timeout",))
Retries read-only, idempotent tools when the call raises one of retry_on or returns an
error result whose cloudg/error_code is in retry_codes. The wait doubles each time
(0.5 s, 1 s, ...). A result that needed retries gets _meta["cloudg/retries"]. The CLI does
not add it. Keep in mind that a timed-out sync handler keeps running in its thread, so
retrying timeouts can stack threads doing the same work.
14.7 Writing your own¶
Middleware is any async callable. This one tags results with a request label and refuses tool calls outside office hours for a role:
import datetime
from cloudg.mcp import AccessDeniedError, ToolResult
async def office_hours(info, call_next):
if info.kind == "tool" and "contractor" in info.principal.roles:
hour = datetime.datetime.now().hour
if not 8 <= hour < 18:
raise AccessDeniedError("Contractors can call tools between 08:00 and 18:00",
data={"hour": hour})
result = await call_next(info)
if isinstance(result, ToolResult):
result.meta["example.com/request-label"] = f"{info.kind}:{info.name}"
return result
layer = CloudGMCPLayer(policy="standard", middleware=[office_hours])
# or: layer.use(office_hours)
Run at 20:00 against the test registry's echo tool, a contractor got
{"content": [{"type": "text", "text": "Contractors can call tools between 08:00 and 18:00"}],
"isError": true, "_meta": {"cloudg/error_code": -31001, "cloudg/error_data": {"hour": 20}}},
and the local user's result carried "example.com/request-label": "tool:echo" in _meta.
Use your own reverse-DNS prefix for _meta keys; prefixes whose second label is mcp or
modelcontextprotocol are reserved by the protocol.
Middleware sees arguments before the input pipeline, so do not branch on identifiers in them when a pseudonymizing profile is active. Middleware that needs real identifiers belongs inside the handler, or in a transform (see MCP_PRIVACY.md).