The request lifecycle¶
This is what happens to a tools/call, from the transport to the bytes that go back. Resource
reads, prompts and completions follow the same path with the differences listed in 4.3.
flowchart TD
A[Transport: stdio line / HTTP POST / SDK dispatcher / fastmcp component] --> B[Adapter resolves the Principal]
B --> C["layer.call_tool(name, args, principal, context)"]
C --> D{"get_tool: known and visible\nto this principal?"}
D -->|no| E["NotFoundError: JSON-RPC -32602 'Unknown tool'"]
D -->|yes| F[CallInfo built, middleware chain, outermost first]
F --> G["terminal: policy.check_call\n(access, rate limit, policy audit)"]
G --> H["input pipeline: reverse pseudonyms,\nrefuse secrets in arguments"]
H --> I["validate_arguments (pydantic model\nbuilt from the handler signature)"]
I --> J{"handler is async?"}
J -->|yes| K[await on the event loop]
J -->|no| L["asyncio.to_thread (worker thread)"]
K --> M["asyncio.wait_for(timeout)"]
L --> M
M --> N["_finalise_tool_result: normalize, output pipeline\non structured / text / links / meta, size cap"]
N --> O[ToolResult travels back up the middleware chain]
O --> P["adapter: ToolResult.to_wire() into the framework's result type"]
G -. "AccessDenied / RateLimited" .-> Q["isError result with cloudg/error_code"]
H -. "secret in argument" .-> Q
I -. "InvalidArguments" .-> Q
M -. "timeout / exception" .-> Q
4.1 Step by step¶
- The transport decodes one JSON-RPC request. On stdio that is one line of stdin; on streamable HTTP it is the POST body; inside an SDK or fastmcp server it is whatever object the framework hands to cloudg's handler.
- The adapter works out who is calling. It fills a
RequestInfo(transport, lower-cased headers, session id, client info, an SDK OAuth access token if the host validated one, a principal already set by an auth middleware) and runs the principal resolver. The default resolver returns, in order: a principal set by an auth layer, a principal built from an OAuth token (client id as the id, scopes as roles), the local user for stdio and in-memory transports, and otherwiseanonymouswith roledefault. Section 8.8 covers custom resolvers. - The adapter builds a
ToolContextwith the principal, the request id, a progress callback and a log callback wired to the client's session, and callslayer.call_tool(name, arguments, principal=..., context=...). get_toolstrips the layer prefix, looks the name up and askspolicy.is_allowed(spec, principal). When a prefix is set, only prefixed names resolve. An unknown tool, or one the policy hides from this principal, is recorded in the policy's audit trail (policy.record_hidden, decisionnot_found) and raisesNotFoundError("Unknown tool: ..."). This is the only failure in a tool call that becomes a JSON-RPC error; hidden and nonexistent tools look the same to the caller.- The layer builds a
CallInfo(kind="tool", name, spec, arguments, principal, context)and runs the middleware chain, outermost first. Each middleware gets(info, call_next). The built-in audit, metrics, cache and concurrency middleware sit here (section 14). Middleware runs before the terminal's policy check; the cache middleware runscheck_callitself before it serves a hit (14.4). - The terminal step calls
policy.check_call(spec, principal, arguments). It raisesAccessDeniedError(code -31001) when the policy denies the call andRateLimitedError(code -31029) when a token bucket is empty, and it records the decision in the policy's own audit ring buffer. A denial can only happen here for a tool that is still listed, which is the case when the policy setshide_denied: false. - The input pipeline runs over the arguments. In the shipped profiles it reverses
pseudonyms, aliases and fences so the handler sees real identifiers, and the
standardprofile refuses arguments that contain secrets. A refusal here is written back to the policy's audit trail (policy.record_rejection), so the entrycheck_callmade as "allowed" does not stand. See MCP_PRIVACY.md. validate_arguments(handler, arguments)validates and coerces the arguments with a pydantic model generated from the handler's signature. A handler with many arguments can declare them as one pydantic model instead, in a single parameter namedargsafterctx(def find_assets(ctx, args: FindAssetsArgs)); the publishedinputSchemaand the validation are the same as for the flat signature. Unknown keys are rejected (extra="forbid"), defaults are filled in, and"5"becomes5for anintparameter. A failure raisesInvalidArgumentsErrorwith pydantic's error list asdata.- The handler runs. Coroutine functions are awaited on the server's event loop. Plain
functions run in a worker thread through
asyncio.to_thread, so a slow graph or ontology build does not block other requests. Either way the call is wrapped inasyncio.wait_for(..., timeout), where the timeout is the tool's owntimeout_secondsif set, else the layer'sdefault_timeout(300 seconds,--timeouton the CLI). A timeout cannot stop a worker thread; it only stops waiting for it (see 17.9). - The result is normalized. A
ToolResultreturned by the handler is kept. Anything else goes through_jsonable(pydantic models are dumped withraw_dataexcluded, dataclasses and objects withto_dict()are converted, enums become their values) and, if it is not a dict, gets wrapped as{"result": value}. - The output pipeline runs over the structured result. For a
ToolResultthe handler built itself, it also runs over each text block, the text of each embedded text resource, and the result's_meta. The fields of every resource link are transformed too. What the pipeline did (counts and labels, never the matched values) lands in_meta["cloudg/transforms"]. - For a plain (non-
ToolResult) return value, the transformed structured value is serialized as indented JSON into a single text block, so clients that ignorestructuredContentstill see the data. If that text is longer thanmax_output_chars(200,000 by default) it is cut and ends with... [truncated by cloudg mcp layer], and the transform report getstruncated_chars, the number of characters removed. The structured value itself is not cut. - Links the handler attached with
ctx.link(...)are appended asresource_linkcontent blocks, and_meta["cloudg/duration_ms"]is set. - The
ToolResultgoes back through the middleware (which can read or decorate it) to the adapter, which callsto_wire()and converts the dict to its framework's result type.
Any MCPLayerError raised from steps 5 to 9, by middleware, by the policy or by the handler,
is caught by call_tool and turned into an isError: true result. Its message goes through
the output pipeline too, and _meta gets cloudg/error_code and, when the error carried
data, cloudg/error_data. Other exceptions from the handler become an error result with code
handler_error and the text ExceptionType: message. The full traceback goes to the server
log only.
4.2 Anatomy of a result¶
Here is a real find_assets result under the strict profile, from
cloudg mcp call find_assets --policy strict --dataset prod=inventory/inventory-map.json --args
'{"internet_exposed": true, "limit": 2, "fields": ["name","type","account_id","arn"]}' --raw.
The text block (trimmed) carries the same JSON as structuredContent. Asset ids, names,
account and subscription ids, ARNs and even the dataset name are pseudonymized; _meta
reports what changed.
{
"content": [
{"type": "text", "text": "{\n \"dataset\": \"ds-1475e44a6f\",\n \"total\": 6, ..."}
],
"isError": false,
"structuredContent": {
"dataset": "ds-1475e44a6f",
"total": 6,
"offset": 0,
"returned": 2,
"next_cursor": "c2.d1894090b2cc",
"truncated": true,
"items": [
{"id": "res-8de954a6d9", "name": "res-8de954a6d9", "type": "EC2",
"account_id": "159502568516",
"arn": "arn:aws:ec2:us-east-1:159502568516:instance/res-5aa71a0ad9"},
{"id": "res-53547ed676", "name": "res-97535e063f", "type": "VIRTUAL_MACHINE",
"account_id": "7ddc072e-dc71-811e-92e1-6a274106cb38",
"arn": "/subscriptions/7ddc072e-dc71-811e-92e1-6a274106cb38/resourceGroups/rg-5ebd747ca7/providers/Microsoft.Compute/virtualMachines/res-97535e063f"}
]
},
"_meta": {
"cloudg/transforms": {
"pseudonymized": {"dataset_name": 1, "resource_name": 4, "aws_account_id": 1,
"aws_arn": 1, "azure_subscription_id": 1, "azure_resource_id": 1},
"content_annotations": {"audience": ["user", "assistant"], "priority": 0.6},
"annotations": {
"classification": {"sensitivity": "confidential", "pseudonymized_entities": ["..."],
"note": "Identifiers are pseudonyms; pass them back unchanged and tools resolve the real resources."},
"provenance": {"kind": "tool", "name": "find_assets", "policy": "strict",
"dataset": "ds-1475e44a6f", "...": "..."},
"transformed": {"pseudonymized": 9}
}
},
"cloudg/duration_ms": 3
}
}
The pseudonyms keep their shape (a 12-digit account id stays 12 digits, a subscription id
stays a UUID) and are stable for the life of the vault key, so the model can pass
res-8de954a6d9 back as a ref and the tool resolves the real asset.
The _meta keys the layer writes all start with cloudg/ (the META_PREFIX constant):
| Key | Where | Meaning |
|---|---|---|
cloudg/category, cloudg/sensitivity, cloudg/tags |
every listed tool, resource, template, prompt | Catalog metadata |
cloudg/transforms |
tool results, resource contents | Transform report (counts, annotations, truncation) |
cloudg/duration_ms |
tool results | Time from CallInfo creation to the end of finalization |
cloudg/error_code |
error results | Numeric JSON-RPC style code or a string (timeout, handler_error) |
cloudg/error_data |
error results | The error's data, transformed |
cloudg/cache |
cached results | "hit" when CachingMiddleware served it |
cloudg/retries |
retried results | How many retries RetryMiddleware needed |
4.3 Resources, prompts and completions¶
| Step | resources/read |
prompts/get |
completion/complete |
|---|---|---|---|
| Lookup | exact URI first, then the template whose pattern matches (longest template string tried first) | name with prefix stripped | prompt argument's completion function or template's completions[var] |
| Unknown or hidden | NotFoundError, protocol error; recorded with policy.record_hidden |
NotFoundError, protocol error; recorded |
empty {"values": [], "total": 0, "hasMore": false} |
| Required arguments | template variables come from the URI | missing required arguments raise InvalidArgumentsError (an empty string counts as missing) |
n/a |
| Middleware | yes, kind="resource", name = the URI, arguments = template variables |
yes, kind="prompt" |
yes, kind="completion", name = prompt name or template string, arguments = {"argument", "value", "context"}; never cached |
policy.check_call |
yes | yes | yes, recorded with kind completion; a denial or rate limit is raised (protocol error) |
| Input pipeline | over the template variables | over the prompt arguments | over the partial value and context.arguments |
| Handler errors | propagate as JSON-RPC errors | propagate as JSON-RPC errors | swallowed, empty result |
| Timeout | none | none | none |
| Output pipeline | over text contents (str or JSON); bytes become a blob and are not transformed |
over text, embedded text resources and links in each message | over the values, each wrapped as {argument_name: value} so key-based detectors recognize it |
| Result cap | none | none | first 100 values, total and hasMore reported |
A resource handler may return a str (sent as text with the spec's MIME type), bytes
(base64 blob), a list of TextResourceContents / BlobResourceContents, or any
JSON-serializable value (dumped as indented JSON). A prompt handler may return a
PromptResult, a list of PromptMessage, or a plain string, which becomes one user message.