> ## Documentation Index
> Fetch the complete documentation index at: https://docs.constants.io/llms.txt
> Use this file to discover all available pages before exploring further.

# MCP Apps (Inline Widgets)

> How Constants tools render inline with their native UI inside ChatGPT, Claude, and any MCP Apps-compatible host

## Overview

Every Constants tool ships as an [MCP App](https://modelcontextprotocol.io/docs/extensions/apps) widget. When a host that supports MCP Apps (ChatGPT, Claude Desktop, VS Code Insiders, Goose, Postman, MCPJam) calls a Constants tool, it receives a pointer to a UI resource and renders the widget inline in the chat.

Under the hood, each worker's frontend is split into two independent LLM-generated React components: an **input renderer** (collects parameters on `constants.io`) and an **output renderer** (displays the result). External MCP Apps hosts serve **only the output renderer** — the model has already collected arguments via the tool call, so no input UI is needed in the chat. The on-site tool page at `constants.io` renders both renderers in adjacent iframes.

Hosts that do not implement the MCP Apps extension still work fine. They read the `content[].text` narration and ignore the widget pointer.

<Note>
  The MCP Apps standard is described at
  [modelcontextprotocol.io/docs/extensions/apps](https://modelcontextprotocol.io/docs/extensions/apps).
  Constants implements the standard fields alongside optional ChatGPT
  extensions for backwards compatibility with the Apps SDK.
</Note>

## Tool descriptor shape

`tools/list` returns each worker with three MCP Apps fields beyond the
baseline spec:

```json theme={null}
{
  "name": "my_tool_abc12345",
  "description": "...",
  "inputSchema": { "type": "object", "properties": { ... } },
  "annotations": {
    "readOnlyHint": true,
    "openWorldHint": true,
    "destructiveHint": false
  },
  "_meta": {
    "ui": {
      "resourceUri": "ui://widget/{workerId}/{hash}.html",
      "domain": "https://constants.io",
      "prefersBorder": false,
      "csp": {
        "connectDomains": ["https://constants.io"],
        "resourceDomains": ["https://constants.io"]
      }
    },
    "openai/outputTemplate": "ui://widget/{workerId}/{hash}.html"
  }
}
```

* **`annotations`** — `readOnlyHint`, `openWorldHint`, `destructiveHint`. Required by ChatGPT for safe tool ranking. Inferred from the tool description during generation; authors can override in the studio.
* **`_meta.ui.resourceUri`** — the widget HTML lives at this MCP resource URI. Fetch it via `resources/read`.
* **`_meta["openai/outputTemplate"]`** — ChatGPT compatibility alias. Same URI, same semantics.

Interactive workers also emit a hidden `{toolName}__interact` companion tool with `_meta.ui.visibility: ["app"]`. The model never sees it; only the rendered widget can call it via `tools/call` over the bridge.

## Widget URI

```
ui://widget/{workerId}/{componentHash}-{baseRuntimeFingerprint}.html
```

* `outputHash` — first 12 hex chars of sha256(output renderer blob). Flips when the author edits the output renderer. Input-side edits do NOT rotate the widget URI — external hosts don't load the input renderer, so an input change is irrelevant to them.
* `baseRuntimeFingerprint` — first 8 hex chars of sha256 over the in-repo widget runtime (`COMMON_BASE_JS` + `OUTPUT_BASE_JS` + `INTERACTIVE_BASE_CSS` + `INTERACTIVE_BASE_HTML` + `COMPONENTS_BUNDLE` + shadcn CSS vars + the assembly template). Flips on every Constants deploy that touches the runtime.

Hosts should use the URI as a cache key. Both hash segments rotate naturally — no manual invalidation required.

## Tool-call response shape

`tools/call` for a worker returns the MCP Apps tri-payload shape:

```json theme={null}
{
  "structuredContent": {
    "summary": "Tool ran successfully.",
    "runId": "...",
    "status": "success",
    "outputPreview": { /* trimmed preview for the model */ }
  },
  "content": [
    { "type": "text", "text": "Tool ran successfully. Run: https://constants.io/run/..." }
  ],
  "_meta": {
    "ui": { "resourceUri": "ui://widget/..." },
    "openai/outputTemplate": "ui://widget/...",
    "widget": {
      "toolName": "my_tool_abc12345",
      "output": { /* full untruncated output */ },
      "outputSchema": { /* per worker.interfaceSchema.outputSchema */ },
      "runId": "...",
      "runUrl": "https://constants.io/run/...",
      "files": [
        {
          "hash": "...",
          "filename": "report.pdf",
          "mimeType": "application/pdf",
          "size": 120034,
          "downloadUrl": "https://...signed..."
        }
      ],
      "source": null
    }
  }
}
```

Field-level visibility per the MCP Apps spec:

* `structuredContent` — model-visible; keep it small.
* `content[].text` — model-visible (CLI hosts render this as text).
* `_meta.widget.*` — widget-only; never surfaced to the model.

## Setup required (missing credentials)

When a widget-bound tool is called but its credentials are unresolved, Constants short-circuits the execution and returns:

```json theme={null}
{
  "structuredContent": {
    "summary": "Tool needs credentials before it can run.",
    "runId": "",
    "status": "setup_required",
    "missingCredentials": ["gcpServiceAccount"]
  },
  "content": [
    { "type": "text", "text": "Tool needs credentials before it can run. Configure credentials: https://constants.io/tools/my-tool?setup=credentials" }
  ],
  "_meta": {
    "widget": {
      "output": null,
      "setupRequired": {
        "missingCredentials": ["gcpServiceAccount"],
        "setupUrl": "https://constants.io/tools/my-tool?setup=credentials"
      }
    }
  }
}
```

The widget renders a CTA linking back to `constants.io` where the user can bind the missing credentials. The sandbox never fires — no credits, no logs.

## Widget bridge (`ui/*` JSON-RPC over postMessage)

Each widget speaks the standard MCP Apps bridge. Hosts should implement these methods:

| Direction     | Method                                  | Purpose                                                                                                                                                |
| ------------- | --------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ |
| Widget → Host | `ui/initialize`                         | Handshake step 1. Host replies with capabilities + host context.                                                                                       |
| Widget → Host | `ui/notifications/initialized`          | Handshake step 2 (required). Spec-compliant hosts (Claude) keep the iframe at `visibility: hidden` and withhold `tool-result` until they receive this. |
| Host → Widget | `ui/notifications/tool-input`           | Pre-flight: what arguments came in on the tool call.                                                                                                   |
| Host → Widget | `ui/notifications/tool-result`          | Delivers the tri-payload result.                                                                                                                       |
| Host → Widget | `ui/notifications/host-context-changed` | Notifies the widget when theme, displayMode, container dimensions, or other host context fields change.                                                |
| Widget → Host | `tools/call`                            | Re-run or action dispatch. Host routes back to Constants.                                                                                              |
| Widget → Host | `ui/notifications/size-changed`         | Pushed automatically by the SDK's auto-resize ResizeObserver so the iframe grows to fit content.                                                       |
| Widget → Host | `ui/request-display-mode`               | Ask the host to switch the widget to `inline` / `fullscreen` / `pip`. Host returns the actual mode (which may differ).                                 |
| Widget → Host | `ui/message`                            | Post a text message into the host's chat (`{ role: "user", content: ContentBlock[] }`).                                                                |
| Widget → Host | `ui/update-model-context`               | Push state the model should know.                                                                                                                      |

Constants speaks this bridge through [`@modelcontextprotocol/ext-apps`](https://www.npmjs.com/package/@modelcontextprotocol/ext-apps)'s `App` class, which is bundled into the widget HTML at build time. The handshake, auto-resize, and bridge requests are all handled by the SDK; widgets only need to set `app.ontoolresult` and call `app.connect()`.

See the [MCP Apps specification](https://modelcontextprotocol.io/docs/extensions/apps) for full method semantics.

## Embedding the MCP endpoint directly

If you're building a custom MCP client and want to render Constants widgets yourself:

1. Authenticate with an API key (`wk_...`) or OAuth JWT over `Authorization: Bearer ...`.
2. `tools/list` to discover widgets — look for `_meta.ui.resourceUri`.
3. `resources/read` with that URI to fetch the HTML.
4. Render inside a sandboxed iframe with `mimeType: "text/html;profile=mcp-app"` — this mime type is required for the host bridge to be enabled.
5. Wire up the `ui/*` bridge over `postMessage`.

The [App Bridge](https://apps.extensions.modelcontextprotocol.io/api/modules/app-bridge.html) module from `@modelcontextprotocol/ext-apps` handles rendering and bridge plumbing for you. The [mcp-ui](https://github.com/MCP-UI-Org/mcp-ui) React component library is another option.

## Compatibility with non-Apps MCP clients

Clients like Codex, Claude Code CLI, and the Cursor remote MCP path don't implement the MCP Apps extension. They:

* ignore `_meta.ui.resourceUri` on the tool descriptor,
* ignore `_meta.widget` on the result,
* read `content[0].text` for a one-line summary and the run URL,
* optionally parse `structuredContent` if they want machine-readable output.

Nothing breaks — the widget is purely additive.
