Skip to main content
Glama
mustafadeel

@auth0/agent-components

by mustafadeel
README.md
# @auth0/agent-components — Auth0 Universal Components for Agents

Turn an **Auth0 Form** into an **MCP App** — an interactive UI a Model Context
Protocol server serves to an MCP client, rendered in a sandboxed iframe. This is
**Auth0 Universal Components for Agents**: any Auth0 Form becomes a drop-in,
agent-invokable UI, no Auth0 Action required.

> Status: proof of concept. Works end-to-end against the official
> [MCP Apps](https://blog.modelcontextprotocol.io/posts/2026-01-26-mcp-apps)
> capability (SEP-1865), the [MCP Inspector](https://github.com/modelcontextprotocol/inspector),
> and CopilotKit (see `examples/copilotkit-poc`).

## Why

Auth0 Forms normally render only inside an Auth0 Action during Universal Login.
The [dx-flows-sdk](https://github.com/atko-cic/dx-flows-sdk) decoupled that: a
form can be embedded via the browser bundle outside a login redirect. MCP Apps
let a server hand a client interactive HTML to render in a sandboxed iframe. Put
them together and an agent can surface a real, fully-functional Auth0 Form —
sign-up, consent, profile, payment — as one of its tools.

## How it works

```
MCP host (e.g. MCP Inspector, CopilotKit)
 └─ sandboxed iframe  ← our ui:// HTML resource (text/html;profile=mcp-app)
      ├─ <script src="https://<tenant>/forms/sdk/forms.js">
      ├─ Auth0Forms.embed(formId, "#root", { fields: { session_token } })
      │    └─ form submits to its OWN Auth0 backend (/forms/api/...)  ← we never see the data
      └─ bridge: on af-submitForm-success → app.updateModelContext({ status: "completed" })
                                          → app.requestTeardown()   (close the app view)
                  on af-redirect          → app.openLink(url)       (never navigates the iframe)
```

**The form owns its data.** The Auth0 Form submits natively to its Auth0 backend.
The MCP layer never reads or transmits field values — it reports only a
**completion status** (`completed` / `cancelled` / `errored`, plus an optional
redirect target) so the agent knows the user finished.

## Packages

| Package | What it is |
|---|---|
| [`packages/agent-components`](packages/agent-components) | `@auth0/agent-components` — register Auth0 Forms as MCP Apps on any `McpServer`. |
| [`packages/mcp-apps-middleware-auth`](packages/mcp-apps-middleware-auth) | `@auth0/mcp-apps-middleware-auth` — adds authentication to CopilotKit's `@ag-ui/mcp-apps-middleware`, which [can't attach a credential](docs/mcp-apps-middleware-auth-gap.md) to its MCP connections. |
| [`examples/poc-server`](examples/poc-server) | Runnable MCP server POC; drive it from the MCP Inspector. Includes `scripts/discover-forms.mjs` (tenant form discovery via the Auth0 CLI). |
| [`examples/copilotkit-poc`](examples/copilotkit-poc) | CopilotKit as the MCP client — real Auth0 login (`@auth0/auth0-server-js`) → the form renders and submits inline. End-to-end. |

## Usage

```ts
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { registerAuth0Forms } from "@auth0/agent-components";

const server = new McpServer({ name: "my-server", version: "1.0.0" });

await registerAuth0Forms(
  server,
  [
    {
      formId: "your-form-id",
      tenantOrigin: "https://your-tenant.us.auth0.com",
      // inputMode: "prefill",         // expose form fields as optional agent inputs
      // ui: { csp: { frameDomains: ["https://js.stripe.com"] } }, // for payment/social steps
      onComplete: (r) => console.log(r.formId, r.status),         // status only — no field data
    },
  ],
  { assumeUiSupport: true }, // register before connect; see "Registration timing"
);

// ...connect your transport (StreamableHTTP / stdio / SSE)
```

Each form registers:
- a tool `open_form_<slug>` whose `_meta.ui.resourceUri` points at …
- a `ui://agent-components/<formId>` resource returning the form-app HTML + a CSP block.

## CSP

The host renders the resource under a strict default policy (`default-src 'none'`).
`@auth0/agent-components` automatically allowlists your `tenantOrigin` in both:
- `csp.resourceDomains` — so `forms.js`, styles, fonts, images load, and
- `csp.connectDomains` — so the bundle's `fetch` to `/forms/api/...` works.

If a form step nests another origin's iframe (Stripe, a social provider, a
captcha), declare it:

```ts
ui: { csp: { frameDomains: ["https://js.stripe.com", "https://hooks.stripe.com"] } }
```

Watch the browser console the first time you render a new form: any CSP violation
tells you exactly which origin to add.

## Session-backed forms (flow/router forms)

Forms with a FLOW/ROUTER node need an **authenticated session** — otherwise the
step after the router fails with `ERR_INVALID_FORM_SESSION`. The mechanism
(confirmed with the Forms team, verified end-to-end):

1. **Declare a hidden field** on the form (e.g. `session_token`).
2. The form's flow reads it — e.g. an `Update User` action with
   `user_id: {{fields.session_token}}`.
3. The MCP server, acting as an **OAuth resource server**, verifies the caller's
   Auth0 token, resolves the user's `sub`, **mints a short-lived trusted JWT** for
   it (signed with a shared secret the Forms backend trusts), and injects that into
   the hidden field per request.

Mark the form session-aware:

```ts
{
  formId: "ap_...",
  session: { field: "session_token" },   // must match the form's hidden field
}
```

…and initialize the client with the server-scoped trust config + a resolver that
reads the verified identity (verifier + `subFromExtra` come from the `/auth`
subpath, backed by `@auth0/auth0-api-js`):

```ts
import { createAgentComponents } from "@auth0/agent-components";
import { subFromExtra } from "@auth0/agent-components/auth";

const agentComponents = createAgentComponents({
  tenantOrigin: "https://your-tenant.auth0.com",
  assumeUiSupport: true,
  sessionTrust: { secret: process.env.FORMS_TRUST_SECRET! }, // shared with the Forms backend
  resolveUserSub: subFromExtra,                    // returns the caller's `sub`, or undefined
});
await agentComponents.register(server, forms);
```

The POC server (`examples/poc-server`) wires the full flow: `createAuth0Verifier`
(→ `@auth0/auth0-api-js`), a `ProtectedResourceMetadataBuilder` metadata endpoint,
and `requireBearerAuth` — so a `401 + WWW-Authenticate` challenge lets the client
run the Auth0 OAuth flow. The token/sub/minted-JWT never enter MCP model context —
the minted token rides `embed().fields` to the Auth0 Forms backend only.

> Note: a completed Auth0 Forms journey is single-use today (re-submitting a spent
> journey returns `ERR_INVALID_FORM_SESSION`). Universal Portals EPIC 7 adds
> re-completable journeys. The MCP App completes once and reports status, so a
> fresh tool invocation gets a fresh journey — re-submission isn't part of the flow.

## Registration timing

The MCP SDK forbids adding a capability **after** `server.connect(transport)`.
Two supported patterns:

- **Register before connect** (simplest) with `assumeUiSupport: true`. Correct for
  UI-capable clients (MCP Inspector, Claude). This is what the POC does.
- **Dynamic per-client gating** (`assumeUiSupport: "auto"`, the default): call
  `registerAuth0Forms` from `server.server.oninitialized` so the client's
  capabilities are known — and construct the server with
  `{ capabilities: { tools: {}, resources: {} } }`, or register one tool + one
  resource before connecting, so the post-connect registration doesn't try to add
  a new capability.

## Develop

```bash
npm install
npm run build      # builds all packages (agent-components builds its bridge first)
npm test           # unit tests (form→tool, ui-template CSP, bridge status-only contract)
```

## License

MIT