diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ba57b8aa8..6baf35f81 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -35,7 +35,7 @@ jobs: - uses: pnpm/action-setup@v5 - - uses: actions/setup-node@v6 + - uses: actions/setup-node@v7 with: node-version-file: ".nvmrc" cache: "pnpm" @@ -57,7 +57,7 @@ jobs: - uses: pnpm/action-setup@v5 - - uses: actions/setup-node@v6 + - uses: actions/setup-node@v7 with: node-version-file: ".nvmrc" cache: "pnpm" @@ -109,7 +109,7 @@ jobs: - uses: pnpm/action-setup@v5 - - uses: actions/setup-node@v6 + - uses: actions/setup-node@v7 with: node-version-file: ".nvmrc" cache: "pnpm" @@ -135,7 +135,7 @@ jobs: - uses: pnpm/action-setup@v5 - - uses: actions/setup-node@v6 + - uses: actions/setup-node@v7 with: node-version-file: ".nvmrc" cache: "pnpm" diff --git a/.github/workflows/deploy-infra.yml b/.github/workflows/deploy-infra.yml index ce432fe18..d7cc40aff 100644 --- a/.github/workflows/deploy-infra.yml +++ b/.github/workflows/deploy-infra.yml @@ -17,7 +17,7 @@ jobs: - uses: pnpm/action-setup@v5 - - uses: actions/setup-node@v6 + - uses: actions/setup-node@v7 with: node-version-file: ".nvmrc" cache: "pnpm" diff --git a/.github/workflows/deploy-landing.yml b/.github/workflows/deploy-landing.yml index e538d35a1..ea9bfc64c 100644 --- a/.github/workflows/deploy-landing.yml +++ b/.github/workflows/deploy-landing.yml @@ -22,7 +22,7 @@ jobs: - uses: pnpm/action-setup@v5 - - uses: actions/setup-node@v6 + - uses: actions/setup-node@v7 with: node-version-file: ".nvmrc" cache: "pnpm" diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index fd94fa35a..09aa6cee8 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -19,7 +19,7 @@ jobs: steps: - uses: actions/checkout@v7 - - uses: actions/setup-node@v6 + - uses: actions/setup-node@v7 with: node-version-file: ".nvmrc" registry-url: "https://registry.npmjs.org" diff --git a/README.md b/README.md index ecdf91bd6..092794c78 100644 --- a/README.md +++ b/README.md @@ -144,6 +144,7 @@ Explore all our example templates in the [Examples](https://docs.skybridge.tech/ | Auth WorkOS AuthKit | WorkOS AuthKit | Full OAuth authentication with WorkOS AuthKit and personalized coffee shop search. | [View code](https://github.com/alpic-ai/skybridge/tree/main/examples/auth-workos) | | Auth Stytch | Stytch | Full OAuth authentication with Stytch and personalized coffee shop search. | [View code](https://github.com/alpic-ai/skybridge/tree/main/examples/auth-stytch) | | Auth Auth0 | Auth0 | Full OAuth authentication with Auth0 and personalized coffee shop search. | [View code](https://github.com/alpic-ai/skybridge/tree/main/examples/auth-auth0) | +| Auth Authplane | Authplane | Full OAuth authentication with Authplane and personalized coffee shop search. | [View code](https://github.com/alpic-ai/skybridge/tree/main/examples/auth-authplane) | ### UI and component libraries diff --git a/docs/api-reference/authplane-provider.mdx b/docs/api-reference/authplane-provider.mdx new file mode 100644 index 000000000..5cca13909 --- /dev/null +++ b/docs/api-reference/authplane-provider.mdx @@ -0,0 +1,85 @@ +--- +title: authplaneProvider +sidebarTitle: "Authplane" +description: "Wire OAuth from an Authplane authorization server" +--- + +`authplaneProvider` wires authentication through [Authplane](https://authplane.ai), so your tools receive a signed-in user. + +## Example + +```ts server.ts highlight={1,7-10} +import { authplaneProvider, McpServer } from "skybridge/server"; + +const server = new McpServer( + { name: "personal-shopper", version: "0.0.1" }, + { capabilities: {} }, + { + oauth: await authplaneProvider({ + issuer: process.env.AUTHPLANE_ISSUER, + resource: process.env.SERVER_URL, + }), + }, +); +``` + +## Signature + +```ts +authplaneProvider(opts: AuthplaneProviderOptions): Promise; +``` + +## Parameters + +### `opts` + +- **`issuer`** is the authorization server's issuer identifier, for example `https://auth.acme.com`. + +- **`resource`** is this server's resource identifier: the public URL clients reach, advertised in its protected-resource metadata. Required, unlike the other providers — see below. + +- **`audience`** overrides the expected `aud`, which defaults to `resource`. Set it only when the resource is configured in Authplane with an explicit audience override. + +It also accepts the shared [`CustomProviderOptions`](/api-reference/custom-provider#parameters) options: `serverUrl`, `scopes`, `requiredScopes`, and `metadataOverrides`. + +Dynamic Client Registration is supported natively, so clients register directly with Authplane and this server stays out of the authorization path. + +## Why `resource` is required + +Authplane binds the access token's `aud` to the RFC 8707 resource indicator the client sends, and the client reads that value from the `resource` field of this server's protected-resource metadata. Setting `resource` gives the deployment one fixed identifier for both, so it is required rather than optional. + +Three values must therefore be identical, and OAuth compares identifiers by exact string match: + +1. the value this server advertises as its `resource` metadata; +2. the resource registered in Authplane; +3. the `aud` Authplane mints, which it takes from (2). + +A mismatch between 1 and 2 fails the authorization request with `invalid_target`, before any token exists; between 1 and 3, token verification fails. Register `resource` in Authplane character for character and all three agree. + +### Pathless origins + +The advertised resource is the URL-normalised form of `resource`, so a bare origin is advertised with a root path: `https://acme.example.com` is advertised as `https://acme.example.com/`. The provider asks for the advertised form up front, and names it if the two differ: + +``` +authplaneProvider: `resource` must be given in the form it will be advertised. +"https://acme.example.com" is advertised as "https://acme.example.com/". +Use "https://acme.example.com/", or a path-qualified URL such as +"https://acme.example.com/mcp", and register the same value in Authplane. +``` + +So if your resource is a bare origin, register it in Authplane **with** the trailing slash. Uppercase hosts and explicit default ports normalise the same way. Path-qualified URLs are unchanged by normalisation, and are the most specific identifier available — which is what [RFC 8707 §2](https://www.rfc-editor.org/rfc/rfc8707#section-2) asks clients to send. + +## Returns + +A `Promise` for the [`OAuthConfig`](/api-reference/custom-provider#returns) you pass to the [`oauth`](/api-reference/mcp-server#constructor) constructor option. + + + + Set up sign-in with a hosted provider + + + Add sign-in to your app end to end + + + Wire OAuth from any IdP's discovery document + + diff --git a/docs/api-reference/overview.mdx b/docs/api-reference/overview.mdx index ab67d84c6..2c48e24e4 100644 --- a/docs/api-reference/overview.mdx +++ b/docs/api-reference/overview.mdx @@ -26,6 +26,7 @@ Every Skybridge export. Server APIs run in your MCP server and work with any hos | [`useDisplayMode`](/api-reference/use-display-mode) | Read and request inline, pip, or fullscreen. | | | [`useDownload`](/api-reference/use-download) | Save files to the user's device. | | | [`useFiles`](/api-reference/use-files) | Upload and pick host-managed files. | | +| [`useHostInfo`](/api-reference/use-host-info) | Identify which host is rendering the view. | | | [`useLayout`](/api-reference/use-layout) | Read theme, max height, and safe-area insets. | | | [`useOpenExternal`](/api-reference/use-open-external) | Open a URL outside the view iframe. | | | [`useRegisterViewTool`](/api-reference/use-register-view-tool) | Expose a tool that runs inside the view. | | diff --git a/docs/api-reference/use-host-info.mdx b/docs/api-reference/use-host-info.mdx new file mode 100644 index 000000000..b854190f3 --- /dev/null +++ b/docs/api-reference/use-host-info.mdx @@ -0,0 +1,81 @@ +--- +title: useHostInfo +description: "Identify which host is rendering the view" +--- + +import { Compat } from "/components/compat.jsx"; + + + +The same [view](/build/view) runs inside every host — Claude, Cursor, Goose, and others. `useHostInfo` reports which one, taken from the MCP Apps `ui/initialize` handshake, so the view can adapt copy, shortcuts, or layout to the host it's rendering in. It runs only on MCP Apps hosts; the name is normalized to a [`Host`](#host) slug when recognized, otherwise passed through as a raw string. + +## Example + +An empty state suggests the next action using the wording that fits the host. + +```tsx highlight={4} +import { useHostInfo } from "skybridge/web"; + +function EmptyState() { + const { name } = useHostInfo(); + const hint = + name === "claude" + ? "Ask Claude to add your first item." + : "Send a message to add your first item."; + + return

{hint}

; +} +``` + +## Returns + +### `name` + +```tsx +name: Host | (string & {}) | undefined; +``` + +The host's reported name. It resolves to a [`Host`](#host) slug for recognized hosts, a raw string for hosts not yet mapped, and `undefined` until the handshake completes — the view renders first and re-renders once the host responds. The `(string & {})` keeps the known slugs in autocomplete while still accepting any string. + +### `version` + +```tsx +version: string | undefined; +``` + +The host's version string, or `undefined` until the handshake completes. + +## Host + +The recognized hosts, as normalized slugs. An unrecognized host surfaces its raw reported name instead. + +```tsx +type Host = + | "chatgpt" + | "claude" + | "cursor" + | "goose" + | "mistral-vibe" + | "alpic"; +``` + +| Slug | Reported `hostInfo.name` | +| --- | --- | +| `chatgpt` | `chatgpt` | +| `claude` | `Claude` | +| `cursor` | `Cursor` | +| `goose` | `MCP-UI Host` | +| `mistral-vibe` | `Le Chat` | +| `alpic` | `alpic-playground` | + + + + Read the host's locale and device capabilities + + + Read a raw MCP Apps context value by key + + + Adapt the view to the host it runs in + + diff --git a/docs/docs.json b/docs/docs.json index fc3a75141..23c5f3a5b 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -118,6 +118,7 @@ "icon": "fingerprint", "pages": [ "api-reference/auth0-provider", + "api-reference/authplane-provider", "api-reference/clerk-provider", "api-reference/descope-provider", "api-reference/stytch-provider", @@ -133,6 +134,7 @@ "api-reference/use-display-mode", "api-reference/use-download", "api-reference/use-files", + "api-reference/use-host-info", "api-reference/use-layout", "api-reference/use-open-external", "api-reference/use-register-view-tool", @@ -203,6 +205,7 @@ "group": "Auth", "pages": [ "examples/auth-auth0", + "examples/auth-authplane", "examples/auth-clerk", "examples/auth-descope", "examples/auth-stytch", diff --git a/docs/examples/auth-authplane.mdx b/docs/examples/auth-authplane.mdx new file mode 100644 index 000000000..a637e0b4a --- /dev/null +++ b/docs/examples/auth-authplane.mdx @@ -0,0 +1,22 @@ +--- +title: Authplane +description: Full OAuth authentication with Authplane and personalized coffee shop search. +--- + +import { ChatExample } from "/components/chat-example.jsx"; + +The Authplane example app demonstrates a full OAuth authentication flow using [Authplane](https://authplane.ai), with a personalized coffee shop finder view that displays user-specific favorites. + + + +## Skybridge APIs used + +- [`authplaneProvider`](/api-reference/authplane-provider) +- [`registerTool`](/api-reference/register-tool) +- [`useToolInfo`](/api-reference/use-tool-info) diff --git a/docs/guides/auth-providers.mdx b/docs/guides/auth-providers.mdx index 25e58eb86..1d98d12fd 100644 --- a/docs/guides/auth-providers.mdx +++ b/docs/guides/auth-providers.mdx @@ -5,7 +5,7 @@ sidebarTitle: "Identity Providers" icon: "fingerprint" --- -[Authenticating users](/build/auth) wires sign-in through a hosted identity provider in one constructor option, so your tools receive a signed-in user. ChatGPT and Claude drive the flow the same way; what varies is the provider. The sections below cover one each: [Auth0](#auth0), [Clerk](#clerk), [Descope](#descope), [Stytch](#stytch), and [WorkOS](#workos), plus a [custom provider](#any-other-provider) for any other. +[Authenticating users](/build/auth) wires sign-in through a hosted identity provider in one constructor option, so your tools receive a signed-in user. ChatGPT and Claude drive the flow the same way; what varies is the provider. The sections below cover one each: [Auth0](#auth0), [Authplane](#authplane), [Clerk](#clerk), [Descope](#descope), [Stytch](#stytch), and [WorkOS](#workos), plus a [custom provider](#any-other-provider) for any other. These providers require sign-in on every request by default. Set [`auth: { allowsAnonymous: true }`](/api-reference/register-tool#auth) on any tool to [mix public and authenticated tools](/build/auth#mix-public-and-authenticated-tools): the server then serves anonymous requests and Skybridge enforces each tool's own auth declaration before the handler runs. @@ -40,6 +40,26 @@ const server = new McpServer( See the runnable [`auth-auth0`](https://github.com/alpic-ai/skybridge/tree/main/examples/auth-auth0) example. +## Authplane + +[Authplane](https://authplane.ai) binds the token's `aud` to this server's resource identifier, so the provider takes both the advertised resource and the expected audience from `resource`. Dynamic Client Registration is supported natively, so clients register with Authplane directly and your server stays out of the authorization path. + +1. Deploy or point at an Authplane authorization server and note its URL, e.g. `https://auth.acme.com`. +2. Register this MCP server as a protected resource, using the public URL clients will reach — the same value you pass as `resource`, character for character. + +Pass the authorization server URL and this server's public URL to [`authplaneProvider`](/api-reference/authplane-provider): + +```ts server.ts highlight={2-5} +const server = new McpServer(serverInfo, capabilities, { + oauth: await authplaneProvider({ + issuer: process.env.AUTHPLANE_ISSUER, + resource: process.env.SERVER_URL, + }), +}).registerTool(/* search-products, requires oauth2 */); +``` + +See the runnable [`auth-authplane`](https://github.com/alpic-ai/skybridge/tree/main/examples/auth-authplane) example. + ## Clerk [Clerk](https://clerk.com/) access tokens carry no `aud` claim, so there is no audience to configure. diff --git a/docs/images/showcase-authplane.png b/docs/images/showcase-authplane.png new file mode 100644 index 000000000..445d2386a Binary files /dev/null and b/docs/images/showcase-authplane.png differ diff --git a/examples/auth-authplane/.env.example b/examples/auth-authplane/.env.example new file mode 100644 index 000000000..2692652be --- /dev/null +++ b/examples/auth-authplane/.env.example @@ -0,0 +1,16 @@ +# Authplane Configuration +# URL of your Authplane authorization server. Dynamic Client Registration is +# supported natively, so no registration proxy is needed. +AUTHPLANE_ISSUER=https://auth.example.com + +# Public URL of this MCP server — its resource identifier. Authplane binds the +# token `aud` to the resource indicator the client sends, and the client reads +# that from this server's protected-resource metadata, so it must be the URL +# clients actually reach. +# +# Register this exact string as the resource in Authplane; identifiers are +# compared byte for byte. +SERVER_URL=http://localhost:3000/mcp + +# Environment +NODE_ENV=development diff --git a/examples/auth-authplane/README.md b/examples/auth-authplane/README.md new file mode 100644 index 000000000..22d152bcc --- /dev/null +++ b/examples/auth-authplane/README.md @@ -0,0 +1,135 @@ +# Auth Example — Authplane + +An example MCP app built with [Skybridge](https://docs.skybridge.tech/home): a personalized coffee shop finder demonstrating full OAuth authentication with [Authplane](https://authplane.ai). + +## What This Example Showcases + +- **Transport-Level Auth**: Auth is enforced at the `/mcp` transport level — unauthenticated requests receive HTTP 401 before reaching any tool handler +- **Authplane OAuth**: One-line setup with `authplaneProvider`, which discovers the authorization server's OAuth metadata and verifies JWTs against its JWKS +- **Native Dynamic Client Registration**: Clients register directly with Authplane, so no registration proxy is needed and this server stays out of the authorization path +- **One resource identifier**: Authplane binds the token `aud` to the RFC 8707 resource indicator, so `resource` is both the advertised resource and the expected audience — no second value to keep in sync +- **Branded provider via `oauth:`**: Passing `oauth: await authplaneProvider(...)` auto-mounts the well-known metadata endpoints and Bearer verification — no manual router +- **Personalized Results**: Favorites are highlighted and sorted first, keyed off the `sub` claim of the verified token +- **User Identity in Widgets**: The signed-in user's identity reaches the widget through `extra.authInfo` +- **Simplified Server Setup**: Uses [`server.run()`](https://docs.skybridge.tech/api-reference/run) and `.use()` for a single-file server with no manual Express boilerplate +- **Structured Content & Metadata**: Server passes structured data to widgets via `structuredContent` +- **Hot Module Replacement**: [Live reloading](https://docs.skybridge.tech/concepts/fast-iteration#hmr-with-vite-plugin) of widget components during development +- **Local DevTools**: [DevTools](https://docs.skybridge.tech/devtools) at `http://localhost:3000` for local testing + +## Getting Started + +### Prerequisites + +- Node.js 24+ +- An Authplane authorization server reachable from this app + +### Local Development + +#### 1. Install + +```bash +npm install +# or +yarn install +# or +pnpm install +# or +bun install +``` + +#### 2. Configure Authplane + +1. Point `AUTHPLANE_ISSUER` at your authorization server. Its discovery document is read from `/.well-known/openid-configuration`, falling back to `/.well-known/oauth-authorization-server`. +2. Register this MCP server as a protected resource, using the same URL you set as `SERVER_URL`, character for character. +3. Create a `.env` file in the project root: + +```env +AUTHPLANE_ISSUER=https://auth.example.com +SERVER_URL=http://localhost:3000/mcp +``` + +> **`SERVER_URL` must be the URL clients actually reach**, and must be registered in Authplane as the resource, character for character. Authplane mints the token `aud` from the resource indicator the client sends, and the client takes that from this server's advertised protected-resource metadata. OAuth identifiers are compared exactly, so a value differing by a trailing slash or host case is a different resource and the authorization request fails with `invalid_target`. +> +> `authplaneProvider` advertises `SERVER_URL` exactly as given and uses it as the expected audience, so the two cannot disagree. +> +> **Bare origins are advertised with a root path.** The advertised resource is the URL-normalised form, so `https://example.com` is advertised as `https://example.com/`. The provider asks for the advertised form at startup and names it if the two differ — so if your resource is a bare origin, register it in Authplane **with** the trailing slash. A path such as `/mcp` is unchanged by normalisation, which is why this example uses one. +> +> If the resource is configured in Authplane with an explicit audience override, pass that value as `audience` rather than relying on the default. + +> **No Authplane deployment yet?** Authplane can also be self-hosted, including locally in Docker for +> development — see the [Authplane documentation](https://authplane.ai) for setup. + +#### 3. Start your local server + +```bash +npm run dev +# or +yarn dev +# or +pnpm dev +# or +bun dev +``` + +This command starts: + +- Your MCP server at `http://localhost:3000/mcp`. +- Skybridge DevTools UI at `http://localhost:3000/`. + +#### 4. Project structure + +``` +├── src/ +│ ├── server.ts # Server entry: McpServer + authplaneProvider auth + widget + run() +│ ├── env.ts # Env validation +│ └── coffee-data.ts # Mock coffee shop data & search +│ ├── views/ +│ │ └── search-coffee-paris.tsx # Coffee shop widget +│ ├── helpers.ts # Type-safe Skybridge hooks +│ └── index.css # Parisian theme styles +├── nodemon.json # Dev server config +└── package.json +``` + +### Create your first widget + +#### 1. Add a new widget + +- Register a widget in `src/server.ts` with a unique name (e.g., `my-widget`) using [`registerTool`](https://docs.skybridge.tech/api-reference/register-tool) +- Create a matching React component at `src/views/my-widget.tsx`. **The file name must match the widget name exactly**. + +#### 2. Edit widgets with Hot Module Replacement (HMR) + +Edit and save components in `src/views/` — changes will appear instantly inside your App. + +#### 3. Edit server code + +Modify files in `server/` and refresh the connection with your testing MCP Client to see the changes. + +### Testing your App + +You can test your App locally by using our DevTools UI on `http://localhost:3000` while running the dev command. + +To test your app with other MCP Clients like ChatGPT, Claude or VSCode, see [Testing Your App](https://docs.skybridge.tech/quickstart/test-your-app). + +## Deploy to Production + +Skybridge is infrastructure vendor agnostic, and your app can be deployed on any cloud platform supporting MCP. + +> Set `SERVER_URL` to your deployed URL and register that same URL as the protected resource in Authplane. Leaving it at the local default will make every token fail verification once deployed. + +The simplest way to deploy your App in minutes is [Alpic](https://alpic.ai/). + +1. Create an account on [Alpic platform](https://app.alpic.ai/). +2. Connect your GitHub repository to automatically deploy at each commit. +3. Use your remote App URL to connect it to MCP Clients, or use the Alpic Playground to easily test your App. + +[![Deploy it on Alpic](https://assets.alpic.ai/button.svg)](https://app.alpic.ai/new/clone?repositoryUrl=https://github.com/alpic-ai/skybridge&rootDir=examples/auth-authplane) + +## Resources + +- [Skybridge Documentation](https://docs.skybridge.tech/) +- [Authplane Documentation](https://authplane.ai) +- [Apps SDK Documentation](https://developers.openai.com/apps-sdk) +- [Model Context Protocol Documentation](https://modelcontextprotocol.io/) +- [Alpic Documentation](https://docs.alpic.ai/) diff --git a/examples/auth-authplane/alpic.json b/examples/auth-authplane/alpic.json new file mode 100644 index 000000000..e686c6837 --- /dev/null +++ b/examples/auth-authplane/alpic.json @@ -0,0 +1,5 @@ +{ + "$schema": "https://assets.alpic.ai/alpic.json", + "installCommand": "npm install", + "startCommand": "npm run --silent start" +} diff --git a/examples/auth-authplane/nodemon.json b/examples/auth-authplane/nodemon.json new file mode 100644 index 000000000..248a4bad4 --- /dev/null +++ b/examples/auth-authplane/nodemon.json @@ -0,0 +1,5 @@ +{ + "watch": ["src"], + "ext": "ts,json", + "exec": "tsx src/server.ts" +} diff --git a/examples/auth-authplane/package.json b/examples/auth-authplane/package.json new file mode 100644 index 000000000..0f00555d4 --- /dev/null +++ b/examples/auth-authplane/package.json @@ -0,0 +1,39 @@ +{ + "name": "skybridge-auth-authplane-example", + "version": "0.0.1", + "private": true, + "description": "Skybridge Auth Example - OAuth Authentication with Authplane", + "type": "module", + "scripts": { + "dev": "skybridge dev", + "dev:tunnel": "skybridge dev --tunnel", + "build": "skybridge build", + "start": "skybridge start", + "deploy": "alpic deploy" + }, + "dependencies": { + "@alpic-ai/insights": "^1.142.1", + "dotenv": "^16.6.1", + "react": "^19.2.7", + "react-dom": "^19.2.7", + "skybridge": "^1.1.0", + "vite": "^8.1.5", + "zod": "^4.4.3" + }, + "devDependencies": { + "@skybridge/devtools": "^1.2.3", + "@types/express": "^5.0.6", + "@types/node": "^22.20.0", + "@types/react": "^19.2.17", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^6.0.2", + "alpic": "^1.142.1", + "nodemon": "^3.1.14", + "tsx": "^4.22.4", + "typescript": "^5.9.3" + }, + "workspaces": [], + "engines": { + "node": ">=24.0.0" + } +} diff --git a/examples/auth-authplane/src/coffee-data.ts b/examples/auth-authplane/src/coffee-data.ts new file mode 100644 index 000000000..d7ad4a188 --- /dev/null +++ b/examples/auth-authplane/src/coffee-data.ts @@ -0,0 +1,137 @@ +export interface CoffeeShop { + id: string; + name: string; + neighborhood: string; + rating: number; + specialty: string; + imageUrl: string; + isFavorite?: boolean; +} + +const COFFEE_SHOPS: CoffeeShop[] = [ + { + id: "shop-1", + name: "Cafe de Flore", + neighborhood: "Saint-Germain-des-Pres", + rating: 4.5, + specialty: "Classic French cafe culture", + imageUrl: + "https://images.unsplash.com/photo-1495474472287-4d71bcdd2085?w=400", + }, + { + id: "shop-2", + name: "Coutume Cafe", + neighborhood: "Le Marais", + rating: 4.7, + specialty: "Specialty roasting", + imageUrl: + "https://images.unsplash.com/photo-1501339847302-ac426a4a7cbb?w=400", + }, + { + id: "shop-3", + name: "Boot Cafe", + neighborhood: "Le Marais", + rating: 4.4, + specialty: "Third-wave espresso", + imageUrl: + "https://images.unsplash.com/photo-1442512595331-e89e73853f31?w=400", + }, + { + id: "shop-4", + name: "Cafe Kitsune", + neighborhood: "Palais Royal", + rating: 4.3, + specialty: "Japanese-inspired lattes", + imageUrl: + "https://images.unsplash.com/photo-1509042239860-f550ce710b93?w=400", + }, + { + id: "shop-5", + name: "Telescope Cafe", + neighborhood: "Palais Royal", + rating: 4.6, + specialty: "Filter coffee experts", + imageUrl: + "https://images.unsplash.com/photo-1498804103079-a6351b050096?w=400", + }, + { + id: "shop-6", + name: "Fragments", + neighborhood: "Le Marais", + rating: 4.5, + specialty: "Organic brunch & coffee", + imageUrl: + "https://images.unsplash.com/photo-1511920170033-f8396924c348?w=400", + }, + { + id: "shop-7", + name: "Holybelly", + neighborhood: "Canal Saint-Martin", + rating: 4.4, + specialty: "Australian-style flat whites", + imageUrl: + "https://images.unsplash.com/photo-1507133750040-4a8f57021571?w=400", + }, + { + id: "shop-8", + name: "Cafe Oberkampf", + neighborhood: "Oberkampf", + rating: 4.8, + specialty: "Single origin pour-overs", + imageUrl: + "https://images.unsplash.com/photo-1461023058943-07fcbe16d735?w=400", + }, +]; + +const MOCK_FAVORITES = ["shop-1", "shop-5", "shop-8"]; + +export interface SearchParams { + query?: string; + minRating?: number; + userId: string; +} + +export interface SearchResult { + shops: CoffeeShop[]; + totalCount: number; +} + +export function searchCoffeeShops(params: SearchParams): SearchResult { + const { query, minRating } = params; + + let results = [...COFFEE_SHOPS]; + + if (query) { + const lowerQuery = query.toLowerCase(); + results = results.filter( + (shop) => + shop.name.toLowerCase().includes(lowerQuery) || + shop.specialty.toLowerCase().includes(lowerQuery), + ); + } + + if (minRating !== undefined) { + results = results.filter((shop) => shop.rating >= minRating); + } + + // Always personalized since auth is required + results = results.map((shop) => ({ + ...shop, + isFavorite: MOCK_FAVORITES.includes(shop.id), + })); + + results.sort((shopA, shopB) => { + if (shopA.isFavorite && !shopB.isFavorite) { + return -1; + } + if (!shopA.isFavorite && shopB.isFavorite) { + return 1; + } + return shopB.rating - shopA.rating; + }); + + return { + shops: results, + totalCount: results.length, + }; +} diff --git a/examples/auth-authplane/src/env.ts b/examples/auth-authplane/src/env.ts new file mode 100644 index 000000000..481e2835c --- /dev/null +++ b/examples/auth-authplane/src/env.ts @@ -0,0 +1,24 @@ +import "dotenv/config"; + +function requireEnv(name: string): string { + const value = process.env[name]; + if (!value) { + throw new Error(`Missing required environment variable: ${name}`); + } + return value; +} + +export const env = { + NODE_ENV: + (process.env.NODE_ENV as "development" | "production") || "development", + /** Authplane authorization server URL. */ + AUTHPLANE_ISSUER: requireEnv("AUTHPLANE_ISSUER"), + /** + * Public URL of this MCP server — its resource identifier. Authplane binds + * the token `aud` to the resource indicator the client sends, and the client + * reads that from this server's advertised protected-resource metadata, so + * it must be the URL clients actually reach, registered in Authplane as the + * same string. + */ + SERVER_URL: process.env.SERVER_URL || "http://localhost:3000/mcp", +}; diff --git a/examples/auth-authplane/src/helpers.ts b/examples/auth-authplane/src/helpers.ts new file mode 100644 index 000000000..9fb4d6fa0 --- /dev/null +++ b/examples/auth-authplane/src/helpers.ts @@ -0,0 +1,4 @@ +import { generateHelpers } from "skybridge/web"; +import type { AppType } from "./server.js"; + +export const { useCallTool, useToolInfo } = generateHelpers(); diff --git a/examples/auth-authplane/src/index.css b/examples/auth-authplane/src/index.css new file mode 100644 index 000000000..63c8a8405 --- /dev/null +++ b/examples/auth-authplane/src/index.css @@ -0,0 +1,239 @@ +@import url("https://fonts.googleapis.com/css2?family=Playfair+Display:wght@500;600;700&family=Inter:wght@400;500;600&display=swap"); + +:root { + --bg: #faf8f5; + --bg-surface: #ffffff; + --bg-inset: #f5f0e8; + --fg: #1a1a2e; + --fg-muted: #6b6b7b; + --border: #e8e0d4; + --accent: #2c3e50; + --accent-light: #d4a574; + --gold: #c9a66b; + --navy: #1a1a2e; + --cream: #fdfcfa; + --rose: #c17b7b; +} + +* { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +body { + font-family: "Inter", system-ui, -apple-system, sans-serif; + color: var(--fg); + background: var(--bg); + line-height: 1.6; + border-radius: 16px; + -webkit-font-smoothing: antialiased; +} + +.container { + padding: 1.25rem; + min-height: 300px; + background: linear-gradient(180deg, var(--cream) 0%, var(--bg) 100%); +} + +.header { + display: flex; + align-items: center; + gap: 0.75rem; + margin-bottom: 1.25rem; + padding-bottom: 1rem; + border-bottom: 1px solid var(--border); +} + +.header-icon { + font-size: 1.5rem; +} + +.header-title { + font-family: "Playfair Display", Georgia, serif; + font-size: 1.35rem; + font-weight: 600; + color: var(--navy); + letter-spacing: -0.02em; +} + +.header-badge { + margin-left: auto; + font-size: 0.7rem; + font-weight: 500; + text-transform: uppercase; + letter-spacing: 0.05em; + padding: 0.35rem 0.75rem; + border-radius: 2rem; + background: var(--bg-inset); + color: var(--fg-muted); + border: 1px solid var(--border); +} + +.header-badge.personalized { + background: linear-gradient(135deg, #d4a574 0%, #c9a66b 100%); + color: #fff; + border: none; + box-shadow: 0 2px 8px rgba(201, 166, 107, 0.3); +} + +.grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); + gap: 1rem; +} + +.card { + border-radius: 12px; + overflow: hidden; + background: var(--bg-surface); + border: 1px solid var(--border); + box-shadow: 0 2px 12px rgba(26, 26, 46, 0.06); + transition: + transform 0.2s ease, + box-shadow 0.2s ease; +} + +.card:hover { + transform: translateY(-2px); + box-shadow: 0 8px 24px rgba(26, 26, 46, 0.1); +} + +.card-image-container { + position: relative; +} + +.card-image { + width: 100%; + height: 140px; + object-fit: cover; +} + +.card-favorite { + position: absolute; + top: 0.75rem; + right: 0.75rem; + color: var(--rose); + font-size: 1.25rem; + filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 0.2)); +} + +.card-content { + padding: 1rem; +} + +.card-header { + display: flex; + justify-content: space-between; + align-items: flex-start; +} + +.card-name { + font-family: "Playfair Display", Georgia, serif; + font-weight: 600; + font-size: 1.05rem; + color: var(--navy); +} + +.card-rating { + display: flex; + align-items: center; + gap: 0.25rem; + font-size: 0.85rem; + background: var(--bg-inset); + padding: 0.2rem 0.5rem; + border-radius: 1rem; +} + +.card-rating-star { + color: var(--gold); +} + +.card-rating-value { + color: var(--fg); + font-weight: 500; +} + +.card-location { + display: flex; + align-items: center; + gap: 0.35rem; + margin-top: 0.5rem; + font-size: 0.8rem; + color: var(--fg-muted); +} + +.card-specialty { + margin-top: 0.75rem; + font-size: 0.8rem; + color: var(--fg-muted); + font-style: italic; +} + +.footer { + margin-top: 1.5rem; + text-align: center; + font-size: 0.8rem; + color: var(--fg-muted); + padding-top: 1rem; + border-top: 1px solid var(--border); +} + +.centered { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 1rem; + min-height: 280px; + text-align: center; + padding: 2rem; +} + +.icon-large { + font-size: 2.5rem; +} + +@keyframes spin { + to { + transform: rotate(360deg); + } +} + +.animate-spin { + animation: spin 1.5s ease-in-out infinite; +} + +@keyframes fadeIn { + from { + opacity: 0; + transform: translateY(12px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.animate-fade-in { + animation: fadeIn 0.4s ease-out forwards; +} + +.card:nth-child(1) { + animation-delay: 0s; +} +.card:nth-child(2) { + animation-delay: 0.05s; +} +.card:nth-child(3) { + animation-delay: 0.1s; +} +.card:nth-child(4) { + animation-delay: 0.15s; +} +.card:nth-child(5) { + animation-delay: 0.2s; +} +.card:nth-child(6) { + animation-delay: 0.25s; +} diff --git a/examples/auth-authplane/src/server.ts b/examples/auth-authplane/src/server.ts new file mode 100644 index 000000000..b5fed226d --- /dev/null +++ b/examples/auth-authplane/src/server.ts @@ -0,0 +1,116 @@ +import { intentMiddleware } from "@alpic-ai/insights"; +import { type AuthInfo, authplaneProvider, McpServer } from "skybridge/server"; +import * as z from "zod"; +import { searchCoffeeShops } from "./coffee-data.js"; +import { env } from "./env.js"; + +/** + * Auth Example - Full OAuth Authentication with Authplane + * + * This example demonstrates a fully authenticated MCP server where users + * must sign in via OAuth before using any tools. Auth is enforced at the + * transport level — unauthenticated requests to /mcp receive HTTP 401. + * + * Auth is wired with the branded `authplaneProvider`: from the authorization + * server URL it discovers the OAuth metadata, then auto-mounts the well-known + * endpoints and Bearer JWT verification (against Authplane's JWKS). Dynamic + * Client Registration is native, so clients register directly with Authplane + * and this server stays out of the authorization path. + * + * `resource` is required here, unlike the other providers: Authplane binds the + * token `aud` to the RFC 8707 resource indicator the client sends, and the + * client takes that from this server's advertised protected-resource metadata. + * It serves as both the advertised resource and the expected audience. Register + * the same string as the resource in Authplane — identifiers are compared byte + * for byte. + */ + +const server = new McpServer( + { + name: "auth-coffee", + version: "0.0.1", + }, + { capabilities: {} }, + { + oauth: await authplaneProvider({ + issuer: env.AUTHPLANE_ISSUER, + resource: env.SERVER_URL, + }), + }, +) + .mcpMiddleware(intentMiddleware()) + .registerTool( + { + name: "search-coffee-paris", + description: + "Search for coffee shops in Paris. Shows personalized results with your favorites highlighted and sorted first. Requires authentication.", + inputSchema: { + query: z + .string() + .optional() + .describe( + "Search query (name or specialty, e.g., 'latte', 'espresso')", + ), + minRating: z + .number() + .min(1) + .max(5) + .optional() + .describe("Minimum rating (1-5)"), + }, + annotations: { + readOnlyHint: true, + openWorldHint: true, + destructiveHint: false, + }, + view: { + component: "search-coffee-paris", + description: "Search for coffee shops in Paris", + csp: { + resourceDomains: ["https://images.unsplash.com"], + }, + }, + _meta: { + "openai/widgetAccessible": true, + }, + }, + ({ query, minRating }, extra) => { + const auth = extra.authInfo as AuthInfo; + + // `sub` identifies the signed-in user and is what favourites key off. + // Access tokens carry no profile claims, so there is no display name to + // show — `email` is read in case a deployment maps one in, and the view + // falls back to a neutral label when it is absent rather than rendering + // a raw identifier. + const subject = auth.extra?.subject as string | undefined; + const email = auth.extra?.email as string | undefined; + const userName = email?.split("@")[0]; + + const results = searchCoffeeShops({ + query, + minRating, + userId: subject ?? auth.clientId, + }); + + return { + structuredContent: { + shops: results.shops, + totalCount: results.totalCount, + userName, + }, + content: [ + { + type: "text", + text: userName + ? `Found ${results.totalCount} coffee shops in Paris for ${userName}` + : `Found ${results.totalCount} coffee shops in Paris, with your favourites first`, + }, + ], + isError: false, + }; + }, + ); + +export default await server.run(); + +export type AppType = typeof server; diff --git a/examples/auth-authplane/src/views/search-coffee-paris.tsx b/examples/auth-authplane/src/views/search-coffee-paris.tsx new file mode 100644 index 000000000..41fd4b8b0 --- /dev/null +++ b/examples/auth-authplane/src/views/search-coffee-paris.tsx @@ -0,0 +1,72 @@ +import { useToolInfo } from "../helpers.js"; +import "@/index.css"; + +function SearchCoffeeParis() { + const { output, isPending, isSuccess } = useToolInfo<"search-coffee-paris">(); + + if (isPending) { + return ( +
+ +

Searching coffee shops...

+
+ ); + } + + if (!isSuccess || !output) { + return ( +
+

No results found

+
+ ); + } + + const { shops, totalCount, userName } = output; + + return ( +
+
+ + Coffee Shops in Paris + + {userName ? `${userName}'s picks` : "Your picks"} + +
+ +
+ {shops.map((shop) => ( +
+
+ {shop.name} + {shop.isFavorite && ( + + )} +
+
+
+ {shop.name} +
+ + + {shop.rating.toFixed(1)} + +
+
+
+ 📍 + {shop.neighborhood} +
+

{shop.specialty}

+
+
+ ))} +
+ +
+ Showing {shops.length} of {totalCount} coffee shops +
+
+ ); +} + +export default SearchCoffeeParis; diff --git a/examples/auth-authplane/tsconfig.json b/examples/auth-authplane/tsconfig.json new file mode 100644 index 000000000..0b2c66f70 --- /dev/null +++ b/examples/auth-authplane/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "skybridge/tsconfig", + + "compilerOptions": { + "paths": { + "@/*": ["./src/*"] + } + }, + + "include": ["src", ".skybridge/**/*.d.ts"] +} diff --git a/examples/auth-authplane/vite.config.ts b/examples/auth-authplane/vite.config.ts new file mode 100644 index 000000000..5f9a70659 --- /dev/null +++ b/examples/auth-authplane/vite.config.ts @@ -0,0 +1,20 @@ +import path from "node:path"; +import react from "@vitejs/plugin-react"; +import { skybridge } from "skybridge/vite"; +import { defineConfig } from "vite"; + +// https://vite.dev/config/ +export default defineConfig({ + server: { + forwardConsole: { + unhandledErrors: true, + logLevels: ["error"], + }, + }, + plugins: [skybridge(), react()], + resolve: { + alias: { + "@": path.resolve(__dirname, "./src"), + }, + }, +}); diff --git a/packages/core/src/server/auth/providers/authplane.test.ts b/packages/core/src/server/auth/providers/authplane.test.ts new file mode 100644 index 000000000..d4444bf3e --- /dev/null +++ b/packages/core/src/server/auth/providers/authplane.test.ts @@ -0,0 +1,178 @@ +// @vitest-environment node +import { afterEach, describe, expect, it, vi } from "vitest"; +import { authplaneProvider } from "./authplane.js"; + +afterEach(() => vi.restoreAllMocks()); + +const ISSUER = "https://auth.acme.com"; + +function discoveryDoc(issuer: string, scopes: string[]) { + return { + issuer, + authorization_endpoint: `${issuer}/authorize`, + token_endpoint: `${issuer}/token`, + registration_endpoint: `${issuer}/register`, + response_types_supported: ["code"], + scopes_supported: scopes, + jwks_uri: `${issuer}/.well-known/jwks.json`, + }; +} + +function mockDiscovery(docs: Record) { + return vi + .spyOn(globalThis, "fetch") + .mockImplementation(async (input: RequestInfo | URL) => { + const url = String(input); + const entry = Object.entries(docs).find( + ([issuer]) => url === `${issuer}/.well-known/openid-configuration`, + ); + if (entry === undefined) { + return new Response(null, { status: 404 }); + } + return new Response(JSON.stringify(discoveryDoc(...entry)), { + headers: { "content-type": "application/json" }, + }); + }); +} + +describe("authplaneProvider", () => { + it("derives the audience from the resource the server advertises", async () => { + mockDiscovery({ [ISSUER]: ["checkout"] }); + + const config = await authplaneProvider({ + issuer: ISSUER, + resource: "https://coffee.example.com/mcp", + }); + + expect(config.verify.audience).toBe("https://coffee.example.com/mcp"); + expect(config.verify.issuer).toBe(ISSUER); + expect(config.verify.jwksUri).toBe(`${ISSUER}/.well-known/jwks.json`); + expect(config.baseUrl).toBe("https://coffee.example.com/mcp"); + }); + + it.each([ + "https://coffee.example.com/mcp", + "https://coffee.example.com/v2/mcp", + "https://coffee.example.com/", + "http://localhost:3000/mcp", + "https://coffee.example.com:8443/mcp", + ])("passes %s through as both resource and audience", async (resource) => { + mockDiscovery({ [ISSUER]: ["checkout"] }); + + const config = await authplaneProvider({ issuer: ISSUER, resource }); + + expect(config.baseUrl).toBe(resource); + expect(config.verify.audience).toBe(resource); + }); + + // The metadata router serialises the resource identifier through `URL` before + // advertising it. Anything that serialisation would rewrite is refused here, + // so the advertised identifier is always the configured one. + it.each([ + // Pathless origin — gains a root path. + ["https://coffee.example.com", "https://coffee.example.com/"], + ["http://localhost:3000", "http://localhost:3000/"], + // Uppercase host — lowercased. + ["https://COFFEE.EXAMPLE.COM/mcp", "https://coffee.example.com/mcp"], + // Explicit default port — dropped. + ["https://coffee.example.com:443/mcp", "https://coffee.example.com/mcp"], + ])("rejects %s and names %s instead", async (resource, published) => { + const fetchSpy = mockDiscovery({ [ISSUER]: ["checkout"] }); + + expect(() => authplaneProvider({ issuer: ISSUER, resource })).toThrow( + `is advertised as ${JSON.stringify(published)}`, + ); + expect(fetchSpy).not.toHaveBeenCalled(); + }); + + it("lets an explicit audience override the default", async () => { + mockDiscovery({ [ISSUER]: ["checkout"] }); + + const config = await authplaneProvider({ + issuer: ISSUER, + resource: "https://coffee.example.com/mcp", + audience: "urn:acme:coffee", + }); + + expect(config.verify.audience).toBe("urn:acme:coffee"); + }); + + it.each([ + ["coffee.example.com/mcp", /must be an absolute URL/], + ["ftp://coffee.example.com/mcp", /must use the http or https scheme/], + ["https://coffee.example.com/mcp#frag", /must not include a fragment/], + ])("rejects the malformed resource %s", async (resource, message) => { + const fetchSpy = mockDiscovery({ [ISSUER]: ["checkout"] }); + + expect(() => authplaneProvider({ issuer: ISSUER, resource })).toThrow( + message, + ); + expect(fetchSpy).not.toHaveBeenCalled(); + }); + + it("rejects a malformed issuer before any discovery", async () => { + const fetchSpy = mockDiscovery({ [ISSUER]: ["checkout"] }); + + expect(() => + authplaneProvider({ + issuer: "auth.acme.com", + resource: "https://coffee.example.com/mcp", + }), + ).toThrow(/`issuer` must be an absolute URL/); + expect(fetchSpy).not.toHaveBeenCalled(); + }); + + it("accepts an issuer carrying a trailing slash", async () => { + // Discovery resolves the issuer itself; the provider passes it through + // rather than rewriting the operator's value. + mockDiscovery({ [ISSUER]: ["checkout"] }); + + const config = await authplaneProvider({ + issuer: `${ISSUER}/`, + resource: "https://coffee.example.com/mcp", + }); + + expect(config.verify.issuer).toBe(ISSUER); + }); + + it("accepts a local http issuer for development", async () => { + const local = "http://localhost:9000"; + mockDiscovery({ [local]: ["checkout"] }); + + const config = await authplaneProvider({ + issuer: local, + resource: "http://localhost:3000/mcp", + }); + + expect(config.verify.issuer).toBe(local); + expect(config.verify.audience).toBe("http://localhost:3000/mcp"); + }); + + it("forwards scopes and the required-scope floor", async () => { + mockDiscovery({ [ISSUER]: ["checkout", "profile"] }); + + const config = await authplaneProvider({ + issuer: ISSUER, + resource: "https://coffee.example.com/mcp", + scopes: ["checkout"], + requiredScopes: ["checkout"], + }); + + expect(config.scopesSupported).toEqual(["checkout"]); + expect(config.requiredScopes).toEqual(["checkout"]); + }); + + it("advertises the registration endpoint for dynamic client registration", async () => { + mockDiscovery({ [ISSUER]: ["checkout"] }); + + const config = await authplaneProvider({ + issuer: ISSUER, + resource: "https://coffee.example.com/mcp", + }); + + expect(config.oauthMetadata.issuer).toBe(ISSUER); + expect(config.oauthMetadata.registration_endpoint).toBe( + `${ISSUER}/register`, + ); + }); +}); diff --git a/packages/core/src/server/auth/providers/authplane.ts b/packages/core/src/server/auth/providers/authplane.ts new file mode 100644 index 000000000..1c45fcbda --- /dev/null +++ b/packages/core/src/server/auth/providers/authplane.ts @@ -0,0 +1,108 @@ +import type { OAuthConfig } from "../index.js"; +import { type CustomProviderOptions, customProvider } from "./custom.js"; + +/** Options accepted by {@link authplaneProvider}. */ +export type AuthplaneProviderOptions = { + /** + * The authorization server's issuer identifier (RFC 8414 §2) — your + * Authplane deployment, e.g. `https://auth.acme.com` (or + * `http://localhost:9000` in local development). + */ + issuer: string; + /** + * This server's resource identifier (RFC 9728 §1.2): the public URL clients + * reach, advertised as the `resource` field of its protected-resource + * metadata. Required, unlike the other providers. + * + * Authplane binds the access token's `aud` to the RFC 8707 `resource` + * parameter the client sends, and the client takes that value from the + * advertised metadata. Setting it explicitly gives the deployment one fixed + * identifier, which is what the audience is checked against. + * + * Resource identifiers are compared by exact string match, so give it in the + * form it will be advertised and register that same string in Authplane. RFC + * 8707 §2 asks for the most specific URI available, e.g. + * `https://acme.example.com/mcp`. + */ + resource: string; + /** + * Expected token `aud`. Defaults to `resource`. + * + * RFC 8707 §2 lets an authorization server use the resource identifier + * verbatim as the audience or map it to another value; set this only for a + * resource configured in Authplane with such an override, and pass that + * value verbatim. + */ + audience?: string; +} & Omit; + +/** + * Rejects anything that cannot serve as an OAuth identifier: RFC 8707 §2 + * requires an absolute URI and forbids a fragment, and the discovery and + * protected-resource metadata URLs are both built from the scheme and host. + */ +function parseIdentifier(value: string, option: string): URL { + let parsed: URL; + try { + parsed = new URL(value); + } catch { + throw new Error( + `authplaneProvider: \`${option}\` must be an absolute URL, got ${JSON.stringify(value)}`, + ); + } + if (parsed.protocol !== "https:" && parsed.protocol !== "http:") { + throw new Error( + `authplaneProvider: \`${option}\` must use the http or https scheme, got ${JSON.stringify(value)}`, + ); + } + if (!parsed.host) { + throw new Error( + `authplaneProvider: \`${option}\` must include a host, got ${JSON.stringify(value)}`, + ); + } + if (parsed.hash) { + throw new Error( + `authplaneProvider: \`${option}\` must not include a fragment, got ${JSON.stringify(value)}`, + ); + } + return parsed; +} + +/** + * OAuth provider for Authplane. `issuer` is the authorization server's issuer + * identifier and `resource` is this server's resource identifier, which also + * supplies the expected token audience. + * + * Dynamic Client Registration is supported natively, so no registration proxy + * is needed: clients register with Authplane directly and this server stays out + * of the authorization path. Pass `serverUrl` to advertise this server as the + * authorization server instead (see {@link customProvider}). + */ +export function authplaneProvider( + opts: AuthplaneProviderOptions, +): Promise { + const { issuer, resource, audience, ...rest } = opts; + + parseIdentifier(issuer, "issuer"); + const parsedResource = parseIdentifier(resource, "resource"); + + // The advertised resource is the URL-normalised form of this value. Where + // normalisation would change the string — a bare origin gaining a root path, + // an uppercase host, an explicit default port — the configured and advertised + // identifiers would differ, and the audience check compares them exactly. + // Require the advertised form up front so the two always match. + if (parsedResource.href !== resource) { + throw new Error( + `authplaneProvider: \`resource\` must be given in the form it will be advertised. ` + + `${JSON.stringify(resource)} is advertised as ${JSON.stringify(parsedResource.href)}. ` + + `Use ${JSON.stringify(parsedResource.href)}, or a path-qualified URL such as "https://acme.example.com/mcp", and register the same value in Authplane.`, + ); + } + + return customProvider({ + issuer, + audience: audience ?? resource, + baseUrl: resource, + ...rest, + }); +} diff --git a/packages/core/src/server/index.ts b/packages/core/src/server/index.ts index c1da372df..9ee24dddb 100644 --- a/packages/core/src/server/index.ts +++ b/packages/core/src/server/index.ts @@ -1,5 +1,6 @@ export type { OAuthConfig } from "./auth/index.js"; export { auth0Provider } from "./auth/providers/auth0.js"; +export { authplaneProvider } from "./auth/providers/authplane.js"; export { clerkProvider } from "./auth/providers/clerk.js"; export { customProvider } from "./auth/providers/custom.js"; export { descopeProvider } from "./auth/providers/descope.js"; diff --git a/packages/core/src/web/bridges/mcp-app/bridge.ts b/packages/core/src/web/bridges/mcp-app/bridge.ts index 74d2f8a7e..727872495 100644 --- a/packages/core/src/web/bridges/mcp-app/bridge.ts +++ b/packages/core/src/web/bridges/mcp-app/bridge.ts @@ -47,6 +47,7 @@ export class McpAppBridge implements Bridge { toolInput: null, toolCancelled: null, toolResult: null, + hostInfo: null, }; private listeners = new Map void>>(); private app: App; @@ -86,9 +87,10 @@ export class McpAppBridge implements Bridge { try { await this.app.connect(); const hostContext = this.app.getHostContext(); - if (hostContext) { - this.updateContext(hostContext); - } + this.updateContext({ + ...hostContext, + hostInfo: this.app.getHostVersion() ?? null, + }); } catch (err) { console.error(err); } diff --git a/packages/core/src/web/bridges/mcp-app/types.ts b/packages/core/src/web/bridges/mcp-app/types.ts index f97b07c51..22bb9be6c 100644 --- a/packages/core/src/web/bridges/mcp-app/types.ts +++ b/packages/core/src/web/bridges/mcp-app/types.ts @@ -4,6 +4,7 @@ import type { McpUiToolInputNotification, McpUiToolResultNotification, } from "@modelcontextprotocol/ext-apps"; +import type { Implementation } from "@modelcontextprotocol/sdk/types.js"; export type McpToolState = { toolInput: NonNullable< @@ -11,6 +12,7 @@ export type McpToolState = { > | null; toolResult: McpUiToolResultNotification["params"] | null; toolCancelled: McpUiToolCancelledNotification["params"] | null; + hostInfo: Implementation | null; }; export type McpAppContext = McpUiHostContext & McpToolState; diff --git a/packages/core/src/web/hooks/index.ts b/packages/core/src/web/hooks/index.ts index 4b3a4d2df..f8e2f9e64 100644 --- a/packages/core/src/web/hooks/index.ts +++ b/packages/core/src/web/hooks/index.ts @@ -8,6 +8,7 @@ export { export { useDisplayMode } from "./use-display-mode.js"; export { type DownloadFn, useDownload } from "./use-download.js"; export { useFiles } from "./use-files.js"; +export { type Host, type HostInfo, useHostInfo } from "./use-host-info.js"; export { type LayoutState, useLayout } from "./use-layout.js"; export { type OpenExternalFn, useOpenExternal } from "./use-open-external.js"; export { useRegisterViewTool } from "./use-register-view-tool.js"; diff --git a/packages/core/src/web/hooks/test/utils.ts b/packages/core/src/web/hooks/test/utils.ts index 85e9c7d15..250024e0a 100644 --- a/packages/core/src/web/hooks/test/utils.ts +++ b/packages/core/src/web/hooks/test/utils.ts @@ -21,6 +21,7 @@ const DEFAULT_CONTEXT: McpUiHostContext = {}; export type McpAppHostMockOptions = { hostCapabilities?: McpUiHostCapabilities; downloadFileResult?: McpUiDownloadFileResult; + hostInfo?: McpUiInitializeResult["hostInfo"]; }; export const getMcpAppHostPostMessageMock = ( @@ -32,7 +33,7 @@ export const getMcpAppHostPostMessageMock = ( case "ui/initialize": { const result: McpUiInitializeResult = { protocolVersion: "2025-06-18", - hostInfo: { name: "test-host", version: "1.0.0" }, + hostInfo: options.hostInfo ?? { name: "test-host", version: "1.0.0" }, hostCapabilities: options.hostCapabilities ?? {}, hostContext: initialContext, }; diff --git a/packages/core/src/web/hooks/use-host-info.test.ts b/packages/core/src/web/hooks/use-host-info.test.ts new file mode 100644 index 000000000..177c5cc19 --- /dev/null +++ b/packages/core/src/web/hooks/use-host-info.test.ts @@ -0,0 +1,71 @@ +import { renderHook, waitFor } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { HostAdaptor } from "../bridges/adaptor.js"; +import { McpAppBridge } from "../bridges/mcp-app/bridge.js"; +import { + getMcpAppHostPostMessageMock, + MockResizeObserver, +} from "./test/utils.js"; +import { type Host, useHostInfo } from "./use-host-info.js"; + +const stubHost = (hostInfo?: { name: string; version: string }) => { + vi.stubGlobal("parent", { + postMessage: getMcpAppHostPostMessageMock({}, { hostInfo }), + }); +}; + +describe("useHostInfo", () => { + beforeEach(() => { + HostAdaptor.resetInstance(); + McpAppBridge.resetInstance(); + vi.stubGlobal("openai", undefined); + vi.stubGlobal("skybridge", { hostType: "mcp-app" }); + vi.stubGlobal("ResizeObserver", MockResizeObserver); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + vi.resetAllMocks(); + McpAppBridge.resetInstance(); + HostAdaptor.resetInstance(); + }); + + it("is undefined before the handshake resolves, then populated after", async () => { + stubHost({ name: "Claude", version: "1.2.3" }); + const { result } = renderHook(() => useHostInfo()); + + expect(result.current.name).toBeUndefined(); + expect(result.current.version).toBeUndefined(); + + await waitFor(() => { + expect(result.current.name).toBe("claude"); + expect(result.current.version).toBe("1.2.3"); + }); + }); + + it.each<[string, Host]>([ + ["chatgpt", "chatgpt"], + ["Claude", "claude"], + ["Cursor", "cursor"], + ["MCP-UI Host", "goose"], + ["Le Chat", "mistral-vibe"], + ["alpic-playground", "alpic"], + ])("normalizes reported name %j to slug %j", async (reported, slug) => { + stubHost({ name: reported, version: "1.0.0" }); + const { result } = renderHook(() => useHostInfo()); + + await waitFor(() => { + expect(result.current.name).toBe(slug); + }); + }); + + it("preserves an unrecognized reported name as-is", async () => { + stubHost({ name: "Some Future Host", version: "9.9.9" }); + const { result } = renderHook(() => useHostInfo()); + + await waitFor(() => { + expect(result.current.name).toBe("Some Future Host"); + expect(result.current.version).toBe("9.9.9"); + }); + }); +}); diff --git a/packages/core/src/web/hooks/use-host-info.ts b/packages/core/src/web/hooks/use-host-info.ts new file mode 100644 index 000000000..5fdd2ca44 --- /dev/null +++ b/packages/core/src/web/hooks/use-host-info.ts @@ -0,0 +1,50 @@ +import { useMcpAppContext } from "../bridges/index.js"; + +/** + * Known host applications, as normalized slugs. Unrecognized hosts surface + * their raw `hostInfo.name` string instead. + */ +export type Host = + | "chatgpt" + | "claude" + | "cursor" + | "goose" + | "mistral-vibe" + | "alpic"; + +const HOST_BY_REPORTED_NAME: Record = { + chatgpt: "chatgpt", + Claude: "claude", + Cursor: "cursor", + "MCP-UI Host": "goose", + "Le Chat": "mistral-vibe", + "alpic-playground": "alpic", +}; + +export type HostInfo = { + name: Host | (string & {}) | undefined; + version: string | undefined; +}; + +/** + * Identity of the host application rendering the view, from the MCP Apps + * `ui/initialize` handshake. `name` is normalized to a {@link Host} slug when + * recognized, otherwise the raw string; both fields are `undefined` until the + * handshake resolves (the view renders first and re-renders once it lands). + * + * @example + * ```tsx + * const { name } = useHostInfo(); + * if (name === "claude") return ; + * ``` + */ +export function useHostInfo(): HostInfo { + const hostInfo = useMcpAppContext("hostInfo"); + const name = hostInfo?.name; + + return { + name: + name !== undefined ? (HOST_BY_REPORTED_NAME[name] ?? name) : undefined, + version: hostInfo?.version, + }; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b38990d7d..05a814243 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -164,6 +164,61 @@ importers: specifier: ^5.9.3 version: 5.9.3 + examples/auth-authplane: + dependencies: + '@alpic-ai/insights': + specifier: ^1.142.1 + version: 1.158.0(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(react@19.2.7)(skybridge@packages+core) + dotenv: + specifier: ^16.6.1 + version: 16.6.1 + react: + specifier: ^19.2.7 + version: 19.2.7 + react-dom: + specifier: ^19.2.7 + version: 19.2.7(react@19.2.7) + skybridge: + specifier: workspace:* + version: link:../../packages/core + vite: + specifier: ^8.1.5 + version: 8.1.5(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.44.1)(tsx@4.23.1)(yaml@2.9.0) + zod: + specifier: ^4.4.3 + version: 4.4.3 + devDependencies: + '@skybridge/devtools': + specifier: ^1.2.3 + version: 1.2.7(arktype@2.1.27)(typescript@5.9.3) + '@types/express': + specifier: ^5.0.6 + version: 5.0.6 + '@types/node': + specifier: ^22.20.0 + version: 22.20.1 + '@types/react': + specifier: ^19.2.17 + version: 19.2.17 + '@types/react-dom': + specifier: ^19.2.3 + version: 19.2.3(@types/react@19.2.17) + '@vitejs/plugin-react': + specifier: ^6.0.2 + version: 6.0.3(vite@8.1.5(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.44.1)(tsx@4.23.1)(yaml@2.9.0)) + alpic: + specifier: ^1.142.1 + version: 1.157.0(@opentelemetry/api@1.9.1)(@opentelemetry/core@2.9.0(@opentelemetry/api@1.9.1))(arktype@2.1.27)(rxjs@7.8.2)(typescript@5.9.3) + nodemon: + specifier: ^3.1.14 + version: 3.1.14 + tsx: + specifier: ^4.22.4 + version: 4.23.1 + typescript: + specifier: ^5.9.3 + version: 5.9.3 + examples/auth-clerk: dependencies: '@alpic-ai/insights': @@ -16363,7 +16418,7 @@ snapshots: sirv: 3.0.2 tinyglobby: 0.2.17 tinyrainbow: 3.1.0 - vitest: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@25.9.5)(@vitest/ui@4.1.10)(jsdom@29.1.1(@noble/hashes@1.8.0))(msw@2.12.4(@types/node@25.9.5)(typescript@6.0.3))(vite@8.1.5(@types/node@25.9.5)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.44.1)(tsx@4.23.1)(yaml@2.9.0)) + vitest: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(@vitest/ui@4.1.10)(jsdom@29.1.1(@noble/hashes@1.8.0))(msw@2.12.4(@types/node@24.13.3)(typescript@6.0.3))(vite@8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.44.1)(tsx@4.23.1)(yaml@2.9.0)) '@vitest/utils@4.1.10': dependencies: