11 KiB
Two-CLT Modeling for an MCP Tool Output
A custom MCP server tool backed by an Apex Invocable Action returns the platform's invocable-action result envelope. The real payload the tool consumer cares about lives under outputValues; the surrounding fields (actionName, isSuccess, errors, sortOrder, version, …) are envelope metadata.
To render this with an HXL widget we model it as two object-based CLTs and wire them with a renderer that bridges the nesting. Both are ordinary CLTs of equal standing — nothing in the platform or the metaschema distinguishes an "envelope type" from a "response type." The only reason two files exist is that one CLT (the envelope) must reference the other (the response) by name via c__<name>, and a CLT cannot reference itself — so the two need distinct deployed names, nothing more. Don't invent a role-label pair for the two CLTs themselves ("Payload CLT"/"Envelope CLT", "Outer CLT"/"Inner CLT") — name and describe each by what it actually models (see below), and in prose refer to them by that same identifier: "the <toolApiName> envelope" / "the <toolApiName>Response". ("Payload" and "response" remain fine as ordinary words for the data itself — e.g. "response fields", "the payload the tool consumer cares about" — the rule is about not naming or labeling the CLTs by an invented role.)
Naming convention
| Artifact | Convention | Example |
|---|---|---|
| Envelope CLT | <toolApiName> |
getFlightDetails |
| Response CLT | <toolApiName>Response |
getFlightDetailsResponse |
| Widget | <toolApiName>Widget |
getFlightDetailsWidget |
<toolApiName>is derived deterministically from the Apex class / Invocable Action name, not the label: lower-camelCase the class name and strip a trailingAction,Test, orWidgetActionsuffix if present.AccountSummaryWidgetAction→accountSummaryGetFlightDetailsAction→getFlightDetailsGetAccountSummaryTest→getAccountSummary- Only when no class/action name is available at all (e.g. a bare
samplewith noactionNameresolvable to a class) fall back to camelCasing the tool/action label (e.g.Get Account Summary→accountSummary).
- Both CLT names are derived from the same single
<toolApiName>— there is no separate naming decision to make per artifact, and no free-standing role word (no "Result", "OutputValues", "Payload", "Envelope", and no_CLTsuffix either — a Lightning Type is identified by living underlightningTypes/, not by a suffix on its name).Responseis not a role label; it is literally what the class is (the Invocable Action's declaredList<...Response>return-element type) — the same word the Apex source itself already uses (e.g.GetAccountSummaryResponse,FlightDetailsResponse). - The envelope CLT is structurally generic but cannot be a single shared CLT — its
outputValuesmust be typed to a tool-specific response CLT viac__<responseCLT>. One envelope CLT per tool.
Naming convention supersedes the earlier hand-verified prototype. Two bundles were manually fixed and successfully deployed during development using an older
<toolApiName>Result/<toolApiName>OutputValuesnaming pair — that proved the structural pattern (two object-based CLTs,@apexClassTypefor nested fields, two-level renderer bindings) deploys correctly. The naming convention above replaces those two suffixes; every other structural rule in this file (root keys,unevaluatedProperties, thec__reference, nested-object typing, renderer bindings) is unchanged and still matches what was verified.
Response CLT
Object-based CLT whose properties are exactly the response @InvocableVariable fields (1:1). Root lightning:type is lightning__objectType. platform-custom-lightning-type-generate injects and enforces "unevaluatedProperties": false on every object-based CLT (its metaschema rejects a CLT without it) — this orchestrator does not fight that, it matches it in every example and every generated file. Give both CLTs a real, tool-specific description (never "") — it is what a consumer sees when picking a referenced CLT. Both CLTs also carry a root-level "lightning:tags": ["mcp"] — it marks the type as MCP-tool-generated per platform-custom-lightning-type-generate/assets/primitive-types-and-constraints.md.
{
"title": "Get Account Summary Response",
"description": "Response fields from the GetAccountSummaryTest invocable-action (GetAccountSummaryResponse)",
"type": "object",
"lightning:type": "lightning__objectType",
"lightning:tags": ["mcp"],
"unevaluatedProperties": false,
"properties": {
"accountName": { "title": "accountName", "lightning:type": "lightning__textType" },
"accountIndustry":{ "title": "accountIndustry","lightning:type": "lightning__textType" },
"contactCount": { "title": "contactCount", "lightning:type": "lightning__integerType" },
"totalOpportunityAmount": { "title": "totalOpportunityAmount", "lightning:type": "lightning__numberType" }
}
}
Nested-object payload fields (a second, additive case)
The example above covers a flat payload — every @InvocableVariable field is a primitive. Some invocable responses instead have a field whose type is itself an Apex class (e.g. GetFlightDetailsAction.FlightDetailsResponse.flightInfo, typed SearchFlightsAction.Flight). Both shapes are in scope; pick the branch per field:
- Never type that property as a bare
{"type":"object"}(opaque, unrenderable, and not what deploys) and never inline it as a nestedlightning__objectType(rejected by the CLT metaschema, same as the envelope↔response relationship below). - Do type it as
"@apexClassType/<ns>__<OuterClass>$<InnerClass>"— e.g."lightning:type": "@apexClassType/c__SearchFlightsAction$Flight"— exactly the Apex-backed-CLT conventionplatform-custom-lightning-type-generatealready documents for@apexClassType/namespace__ClassName$InnerClass. - The widget and renderer then flatten through it — see the nested-binding note at the end of the "Default renderer" section below, and the full walkthrough in
examples/nested-object-source-prompt.md. - A field typed
List<ApexClass>(a list of nested objects) is out of scope for the beta single-response flow — surface it in the build plan rather than emitting a schema for it, the same way amaxOccurs > 1scalar is surfaced.
{
"title": "Get Flight Details Response",
"description": "Response fields from the GetFlightDetailsAction invocable-action (FlightDetailsResponse)",
"type": "object",
"lightning:type": "lightning__objectType",
"lightning:tags": ["mcp"],
"unevaluatedProperties": false,
"properties": {
"flightInfo": {
"title": "Flight Info",
"lightning:type": "@apexClassType/c__SearchFlightsAction$Flight"
}
}
}
Envelope CLT
Object-based CLT that mimics the tool-result envelope. outputValues is typed to the response CLT via the referenced-CLT pattern c__<responseCLT> — not inlined as a nested lightning__objectType (nested object typing is rejected by the CLT metaschema; see platform-custom-lightning-type-generate).
{
"title": "Get Account Summary",
"description": "Invocable-action result envelope for the GetAccountSummaryTest MCP tool",
"type": "object",
"lightning:type": "lightning__objectType",
"lightning:tags": ["mcp"],
"unevaluatedProperties": false,
"properties": {
"actionName": { "title": "actionName", "lightning:type": "lightning__textType" },
"isSuccess": { "title": "isSuccess", "lightning:type": "lightning__booleanType" },
"outputValues": { "title": "outputValues", "lightning:type": "c__getAccountSummaryResponse" }
}
}
- The
c__<responseCLT>string is the referenced type's registered identifier / FQN, not itstitle. It must match the response CLT's deployed name. - The response CLT must be deployed before the envelope CLT.
- Include only the envelope scalars the widget or the platform needs (
actionName,isSuccess, andoutputValuesat minimum). Addmessageetc. only when rendered.
Default renderer (in the ENVELOPE CLT)
The renderer is the default renderer.json at the envelope CLT bundle root, parallel to schema.json — NOT under lightningDesktopGenAi/. It assigns the widget and bridges the envelope nesting to the flat widget schema.
{
"renderer": {
"componentOverrides": {
"$": {
"definition": "@widget/c/accountSummaryWidget",
"attributes": {
"accountName": "{!$attrs.outputValues.accountName}",
"accountIndustry":"{!$attrs.outputValues.accountIndustry}",
"contactCount": "{!$attrs.outputValues.contactCount}",
"totalOpportunityAmount": "{!$attrs.outputValues.totalOpportunityAmount}"
}
}
}
}
}
The binding path is the crux: each widget attribute (left, flat) maps to the response field nested under the envelope's outputValues node (right) via {!$attrs.outputValues.<field>}. Because the envelope CLT's outputValues is typed to the response CLT, the runtime can resolve outputValues.<field> against the response CLT's properties.
Nested-object response fields bind one level deeper. When a response field is itself an Apex-class reference (the flightInfo case above), the widget flattens to that class's own leaf fields, and the renderer binding goes two levels deep: {!$attrs.outputValues.flightInfo.flightId}, {!$attrs.outputValues.flightInfo.origin}, etc. — outputValues.<objectField>.<leaf>, not outputValues.<objectField> alone (which would bind the widget to an unresolvable object, not a renderable leaf).
Why two CLTs and not one
The platform binds the tool's output rendition to the CLT whose shape matches the tool output schema — that is the envelope, not the response. So the renderer (and thus the widget assignment) must live in the envelope CLT. But the widget wants a flat attribute contract, so the renderer flattens the nesting via outputValues.. The response CLT exists purely to type the outputValues node so those nested paths resolve. Inlining the response fields as a nested lightning__objectType inside the envelope is rejected by the CLT metaschema — hence a separate, referenced response CLT.
The widget schema is generic, not CLT-derived
The widget schema is a standalone contract: properties.attributes.properties built from the response field list (name + primitive lightning:type), nothing more. It happens to share the field set with the response CLT for this flow, but it is not typed against the CLT, does not carry unevaluatedProperties, and would look identical if the same field list arrived from any other source platform-widget-generate supports. The widget schema and body know nothing about the envelope. The widget binds {!$attrs.accountName} (flat); only the renderer knows the field actually lives at outputValues.accountName. This keeps the widget reusable and lets platform-widget-generate author it exactly as it would for any flat response.