Skip to content

The request lifecycle

Section 4 · From docs/MCP.md on GitHub

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

  1. 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.
  2. 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 otherwise anonymous with role default. Section 8.8 covers custom resolvers.
  3. The adapter builds a ToolContext with the principal, the request id, a progress callback and a log callback wired to the client's session, and calls layer.call_tool(name, arguments, principal=..., context=...).
  4. get_tool strips the layer prefix, looks the name up and asks policy.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, decision not_found) and raises NotFoundError("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.
  5. 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 runs check_call itself before it serves a hit (14.4).
  6. The terminal step calls policy.check_call(spec, principal, arguments). It raises AccessDeniedError (code -31001) when the policy denies the call and RateLimitedError (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 sets hide_denied: false.
  7. 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 standard profile refuses arguments that contain secrets. A refusal here is written back to the policy's audit trail (policy.record_rejection), so the entry check_call made as "allowed" does not stand. See MCP_PRIVACY.md.
  8. 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 named args after ctx (def find_assets(ctx, args: FindAssetsArgs)); the published inputSchema and the validation are the same as for the flat signature. Unknown keys are rejected (extra="forbid"), defaults are filled in, and "5" becomes 5 for an int parameter. A failure raises InvalidArgumentsError with pydantic's error list as data.
  9. 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 in asyncio.wait_for(..., timeout), where the timeout is the tool's own timeout_seconds if set, else the layer's default_timeout (300 seconds, --timeout on the CLI). A timeout cannot stop a worker thread; it only stops waiting for it (see 17.9).
  10. The result is normalized. A ToolResult returned by the handler is kept. Anything else goes through _jsonable (pydantic models are dumped with raw_data excluded, dataclasses and objects with to_dict() are converted, enums become their values) and, if it is not a dict, gets wrapped as {"result": value}.
  11. The output pipeline runs over the structured result. For a ToolResult the 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"].
  12. For a plain (non-ToolResult) return value, the transformed structured value is serialized as indented JSON into a single text block, so clients that ignore structuredContent still see the data. If that text is longer than max_output_chars (200,000 by default) it is cut and ends with ... [truncated by cloudg mcp layer], and the transform report gets truncated_chars, the number of characters removed. The structured value itself is not cut.
  13. Links the handler attached with ctx.link(...) are appended as resource_link content blocks, and _meta["cloudg/duration_ms"] is set.
  14. The ToolResult goes back through the middleware (which can read or decorate it) to the adapter, which calls to_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.