From ad91245aabb146c8f8e8bfd609f34be496450a70 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Wed, 29 Jul 2026 14:33:45 +0200 Subject: [PATCH 1/4] chore(deps): update actions/setup-node action to v7 (#978) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- .github/workflows/ci.yml | 8 ++++---- .github/workflows/deploy-infra.yml | 2 +- .github/workflows/deploy-landing.yml | 2 +- .github/workflows/publish.yml | 2 +- 4 files changed, 7 insertions(+), 7 deletions(-) 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" From ddcca065f32e4f53ed8d4d7536b1ad6e845433b9 Mon Sep 17 00:00:00 2001 From: Quentin Churet <45853629+qchuchu@users.noreply.github.com> Date: Wed, 29 Jul 2026 16:50:19 +0200 Subject: [PATCH 2/4] feat(web): add useHostInfo hook to identify the rendering host (#1011) Co-authored-by: Claude Opus 4.8 (1M context) --- docs/api-reference/overview.mdx | 1 + docs/api-reference/use-host-info.mdx | 81 +++++++++++++++++++ docs/docs.json | 1 + .../core/src/web/bridges/mcp-app/bridge.ts | 8 +- .../core/src/web/bridges/mcp-app/types.ts | 2 + packages/core/src/web/hooks/index.ts | 1 + packages/core/src/web/hooks/test/utils.ts | 3 +- .../core/src/web/hooks/use-host-info.test.ts | 71 ++++++++++++++++ packages/core/src/web/hooks/use-host-info.ts | 50 ++++++++++++ 9 files changed, 214 insertions(+), 4 deletions(-) create mode 100644 docs/api-reference/use-host-info.mdx create mode 100644 packages/core/src/web/hooks/use-host-info.test.ts create mode 100644 packages/core/src/web/hooks/use-host-info.ts 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..2bafa52ff 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -133,6 +133,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", 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, + }; +} From e7c4b2f7cfeee5f303f43d7f64eb547a47da31c5 Mon Sep 17 00:00:00 2001 From: muralx Date: Tue, 28 Jul 2026 13:41:06 +0100 Subject: [PATCH 3/4] feat(auth): add authplaneProvider Branded Authplane provider, with a runnable example, docs and a showcase entry. --- README.md | 1 + docs/api-reference/authplane-provider.mdx | 85 +++++++ docs/docs.json | 2 + docs/examples/auth-authplane.mdx | 22 ++ docs/guides/auth-providers.mdx | 22 +- docs/images/showcase-authplane.png | Bin 0 -> 30727 bytes examples/auth-authplane/.env.example | 16 ++ examples/auth-authplane/README.md | 144 +++++++++++ examples/auth-authplane/alpic.json | 5 + examples/auth-authplane/dev-authserver.sh | 146 +++++++++++ examples/auth-authplane/nodemon.json | 5 + examples/auth-authplane/package.json | 39 +++ examples/auth-authplane/src/coffee-data.ts | 137 ++++++++++ examples/auth-authplane/src/env.ts | 24 ++ examples/auth-authplane/src/helpers.ts | 4 + examples/auth-authplane/src/index.css | 239 ++++++++++++++++++ examples/auth-authplane/src/server.ts | 116 +++++++++ .../src/views/search-coffee-paris.tsx | 72 ++++++ examples/auth-authplane/tsconfig.json | 11 + examples/auth-authplane/vite.config.ts | 20 ++ .../server/auth/providers/authplane.test.ts | 178 +++++++++++++ .../src/server/auth/providers/authplane.ts | 108 ++++++++ packages/core/src/server/index.ts | 1 + pnpm-lock.yaml | 57 ++++- 24 files changed, 1452 insertions(+), 2 deletions(-) create mode 100644 docs/api-reference/authplane-provider.mdx create mode 100644 docs/examples/auth-authplane.mdx create mode 100644 docs/images/showcase-authplane.png create mode 100644 examples/auth-authplane/.env.example create mode 100644 examples/auth-authplane/README.md create mode 100644 examples/auth-authplane/alpic.json create mode 100755 examples/auth-authplane/dev-authserver.sh create mode 100644 examples/auth-authplane/nodemon.json create mode 100644 examples/auth-authplane/package.json create mode 100644 examples/auth-authplane/src/coffee-data.ts create mode 100644 examples/auth-authplane/src/env.ts create mode 100644 examples/auth-authplane/src/helpers.ts create mode 100644 examples/auth-authplane/src/index.css create mode 100644 examples/auth-authplane/src/server.ts create mode 100644 examples/auth-authplane/src/views/search-coffee-paris.tsx create mode 100644 examples/auth-authplane/tsconfig.json create mode 100644 examples/auth-authplane/vite.config.ts create mode 100644 packages/core/src/server/auth/providers/authplane.test.ts create mode 100644 packages/core/src/server/auth/providers/authplane.ts 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/docs.json b/docs/docs.json index 2bafa52ff..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", @@ -204,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 0000000000000000000000000000000000000000..445d2386adc09ff0f764c9bf07255fd289631ad9 GIT binary patch literal 30727 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 O6o;q2dB18c#xqXanmqCK0c>Z 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 **`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. + +#### 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 +├── dev-authserver.sh # Optional: local Authplane server in Docker +├── 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/dev-authserver.sh b/examples/auth-authplane/dev-authserver.sh new file mode 100755 index 000000000..369229ece --- /dev/null +++ b/examples/auth-authplane/dev-authserver.sh @@ -0,0 +1,146 @@ +#!/usr/bin/env bash +# +# Optional helper: run an Authplane authorization server locally in Docker and +# configure it for this example. +# +# Nothing in the example depends on this script — if you already have an +# Authplane deployment, set AUTHPLANE_ISSUER and SERVER_URL in .env and ignore +# it. It exists so `npm run dev` has something to talk to on a fresh checkout. +# +# ./dev-authserver.sh start the server and configure it +# ./dev-authserver.sh stop stop and remove the container +# +# Overridable: +# PORT=9000 ADMIN_PORT=9001 SERVER_URL=http://localhost:3000/mcp ./dev-authserver.sh +# +# The secrets below are fixed demo values so runs are reproducible. They are +# not suitable for anything but local development. + +set -euo pipefail + +CONTAINER="${CONTAINER:-authplane-demo}" +IMAGE="${IMAGE:-authplane/authserver:latest}" +PORT="${PORT:-9000}" +ADMIN_PORT="${ADMIN_PORT:-9001}" +SERVER_URL="${SERVER_URL:-http://localhost:3000/mcp}" +DEMO_EMAIL="${DEMO_EMAIL:-demo@example.com}" +DEMO_PASSWORD="${DEMO_PASSWORD:-demo-password}" +RESOURCE_SLUG="${RESOURCE_SLUG:-coffee-mcp}" +SCOPE="tools/search-coffee-paris" + +ISSUER="http://localhost:${PORT}" +ADMIN="http://localhost:${ADMIN_PORT}" +ADMIN_KEY="demo0000000000000000000000000000000000000000000000000000000000ab" +ENCRYPTION_KEY="bed8eb204ebfe0bc38750d871e048051129f69c3ea85389c423a54e5b01b0e7f" + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ENV_FILE="${SCRIPT_DIR}/.env" + +die() { echo "error: $*" >&2; exit 1; } +port_in_use() { (echo >"/dev/tcp/127.0.0.1/$1") >/dev/null 2>&1; } + +command -v docker >/dev/null 2>&1 || die "docker is required but not installed." + +if [[ "${1:-}" == "stop" ]]; then + docker rm -f "${CONTAINER}" >/dev/null 2>&1 && echo "Removed ${CONTAINER}." \ + || echo "No container named ${CONTAINER}." + exit 0 +fi + +if [[ "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then + sed -n '3,18p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//' + exit 0 +fi + +# ── start ──────────────────────────────────────────────────────────────────── + +if [ -n "$(docker ps -q -f "name=^${CONTAINER}$")" ]; then + echo "==> ${CONTAINER} is already running; reusing it." +else + docker rm -f "${CONTAINER}" >/dev/null 2>&1 || true + for p in "${PORT}" "${ADMIN_PORT}"; do + if port_in_use "${p}"; then + die "port ${p} is already in use. Re-run with PORT=... ADMIN_PORT=... to pick others." + fi + done + + echo "==> Starting ${IMAGE} on ${ISSUER} (admin ${ADMIN})" + docker run -d --name "${CONTAINER}" \ + -p "${PORT}:9000" -p "${ADMIN_PORT}:9001" \ + -e "AUTHPLANE_SERVER_ISSUER=${ISSUER}" \ + -e "AUTHPLANE_SERVER_ALLOWED_ORIGINS=*" \ + -e "AUTHPLANE_ADMIN_ENABLED=true" \ + -e "AUTHPLANE_ADMIN_API_KEY=${ADMIN_KEY}" \ + -e "AUTHPLANE_ENCRYPTION_KEY=${ENCRYPTION_KEY}" \ + -e "AUTHPLANE_DCR_MODE=open" \ + "${IMAGE}" >/dev/null +fi + +printf '==> Waiting for the authorization server' +for _ in $(seq 1 60); do + if curl -sf -o /dev/null "${ISSUER}/.well-known/oauth-authorization-server"; then + echo " ready." + break + fi + printf '.' + sleep 1 +done +curl -sf -o /dev/null "${ISSUER}/.well-known/oauth-authorization-server" \ + || die "server did not become ready. Try: docker logs ${CONTAINER}" + +# ── configure ──────────────────────────────────────────────────────────────── +# 201 on first run, 409 when it already exists — both are success here. + +provision() { + local what="$1" path="$2" body="$3" code + code=$(curl -s -o /tmp/authplane-demo-provision.json -w '%{http_code}' \ + -X POST "${ADMIN}${path}" \ + -H "Authorization: Bearer ${ADMIN_KEY}" \ + -H "Content-Type: application/json" \ + -d "${body}") + case "${code}" in + 201) echo " created ${what}" ;; + 409) echo " ${what} already exists" ;; + *) cat /tmp/authplane-demo-provision.json >&2; die "creating ${what} returned HTTP ${code}" ;; + esac +} + +echo "==> Configuring" +provision "resource ${SERVER_URL}" /admin/resources \ + "{\"slug\":\"${RESOURCE_SLUG}\",\"uri\":\"${SERVER_URL}\",\"backend_kind\":\"mint\",\"display_name\":\"Coffee MCP example\",\"scopes\":[{\"name\":\"${SCOPE}\",\"description\":\"Search coffee shops\"}]}" +provision "user ${DEMO_EMAIL}" /admin/users \ + "{\"email\":\"${DEMO_EMAIL}\",\"name\":\"Demo User\",\"password\":\"${DEMO_PASSWORD}\",\"role\":\"user\"}" + +# ── .env ───────────────────────────────────────────────────────────────────── +# Never overwrite: an existing file may point at a real deployment. + +if [ -f "${ENV_FILE}" ]; then + echo "==> .env already exists, leaving it untouched" + if ! grep -q "^AUTHPLANE_ISSUER=${ISSUER}$" "${ENV_FILE}" 2>/dev/null; then + echo " note: its AUTHPLANE_ISSUER does not point at ${ISSUER}" + fi +else + cat > "${ENV_FILE}" < Wrote .env" +fi + +cat <=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/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: From 01601481d9492b6fdefc1e63fa2502e073c77811 Mon Sep 17 00:00:00 2001 From: muralx Date: Wed, 29 Jul 2026 15:44:44 +0100 Subject: [PATCH 4/4] Address review comments: remove the local auth server script Mentioned in the README instead. --- examples/auth-authplane/README.md | 17 +-- examples/auth-authplane/dev-authserver.sh | 146 ---------------------- 2 files changed, 4 insertions(+), 159 deletions(-) delete mode 100755 examples/auth-authplane/dev-authserver.sh diff --git a/examples/auth-authplane/README.md b/examples/auth-authplane/README.md index 7cc0d42d6..22d152bcc 100644 --- a/examples/auth-authplane/README.md +++ b/examples/auth-authplane/README.md @@ -21,7 +21,7 @@ An example MCP app built with [Skybridge](https://docs.skybridge.tech/home): a p ### Prerequisites - Node.js 24+ -- An Authplane authorization server reachable from this app — or Docker, to run one locally with the optional helper below +- An Authplane authorization server reachable from this app ### Local Development @@ -39,17 +39,6 @@ bun install #### 2. Configure Authplane -If you do not already have an authorization server to point at, an optional helper runs one locally in Docker and configures it for this example: - -```bash -./dev-authserver.sh # start and configure -./dev-authserver.sh stop # stop and remove -``` - -It starts [`authplane/authserver`](https://hub.docker.com/r/authplane/authserver), registers this server as a protected resource, creates a demo user, and writes `.env` if you do not have one. Re-running it is safe, and it never overwrites an existing `.env`. Skip it entirely if you have your own deployment — nothing else in the example depends on it. - -To configure by hand instead: - 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: @@ -67,6 +56,9 @@ SERVER_URL=http://localhost:3000/mcp > > 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 @@ -95,7 +87,6 @@ This command starts: │ │ └── search-coffee-paris.tsx # Coffee shop widget │ ├── helpers.ts # Type-safe Skybridge hooks │ └── index.css # Parisian theme styles -├── dev-authserver.sh # Optional: local Authplane server in Docker ├── nodemon.json # Dev server config └── package.json ``` diff --git a/examples/auth-authplane/dev-authserver.sh b/examples/auth-authplane/dev-authserver.sh deleted file mode 100755 index 369229ece..000000000 --- a/examples/auth-authplane/dev-authserver.sh +++ /dev/null @@ -1,146 +0,0 @@ -#!/usr/bin/env bash -# -# Optional helper: run an Authplane authorization server locally in Docker and -# configure it for this example. -# -# Nothing in the example depends on this script — if you already have an -# Authplane deployment, set AUTHPLANE_ISSUER and SERVER_URL in .env and ignore -# it. It exists so `npm run dev` has something to talk to on a fresh checkout. -# -# ./dev-authserver.sh start the server and configure it -# ./dev-authserver.sh stop stop and remove the container -# -# Overridable: -# PORT=9000 ADMIN_PORT=9001 SERVER_URL=http://localhost:3000/mcp ./dev-authserver.sh -# -# The secrets below are fixed demo values so runs are reproducible. They are -# not suitable for anything but local development. - -set -euo pipefail - -CONTAINER="${CONTAINER:-authplane-demo}" -IMAGE="${IMAGE:-authplane/authserver:latest}" -PORT="${PORT:-9000}" -ADMIN_PORT="${ADMIN_PORT:-9001}" -SERVER_URL="${SERVER_URL:-http://localhost:3000/mcp}" -DEMO_EMAIL="${DEMO_EMAIL:-demo@example.com}" -DEMO_PASSWORD="${DEMO_PASSWORD:-demo-password}" -RESOURCE_SLUG="${RESOURCE_SLUG:-coffee-mcp}" -SCOPE="tools/search-coffee-paris" - -ISSUER="http://localhost:${PORT}" -ADMIN="http://localhost:${ADMIN_PORT}" -ADMIN_KEY="demo0000000000000000000000000000000000000000000000000000000000ab" -ENCRYPTION_KEY="bed8eb204ebfe0bc38750d871e048051129f69c3ea85389c423a54e5b01b0e7f" - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -ENV_FILE="${SCRIPT_DIR}/.env" - -die() { echo "error: $*" >&2; exit 1; } -port_in_use() { (echo >"/dev/tcp/127.0.0.1/$1") >/dev/null 2>&1; } - -command -v docker >/dev/null 2>&1 || die "docker is required but not installed." - -if [[ "${1:-}" == "stop" ]]; then - docker rm -f "${CONTAINER}" >/dev/null 2>&1 && echo "Removed ${CONTAINER}." \ - || echo "No container named ${CONTAINER}." - exit 0 -fi - -if [[ "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then - sed -n '3,18p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//' - exit 0 -fi - -# ── start ──────────────────────────────────────────────────────────────────── - -if [ -n "$(docker ps -q -f "name=^${CONTAINER}$")" ]; then - echo "==> ${CONTAINER} is already running; reusing it." -else - docker rm -f "${CONTAINER}" >/dev/null 2>&1 || true - for p in "${PORT}" "${ADMIN_PORT}"; do - if port_in_use "${p}"; then - die "port ${p} is already in use. Re-run with PORT=... ADMIN_PORT=... to pick others." - fi - done - - echo "==> Starting ${IMAGE} on ${ISSUER} (admin ${ADMIN})" - docker run -d --name "${CONTAINER}" \ - -p "${PORT}:9000" -p "${ADMIN_PORT}:9001" \ - -e "AUTHPLANE_SERVER_ISSUER=${ISSUER}" \ - -e "AUTHPLANE_SERVER_ALLOWED_ORIGINS=*" \ - -e "AUTHPLANE_ADMIN_ENABLED=true" \ - -e "AUTHPLANE_ADMIN_API_KEY=${ADMIN_KEY}" \ - -e "AUTHPLANE_ENCRYPTION_KEY=${ENCRYPTION_KEY}" \ - -e "AUTHPLANE_DCR_MODE=open" \ - "${IMAGE}" >/dev/null -fi - -printf '==> Waiting for the authorization server' -for _ in $(seq 1 60); do - if curl -sf -o /dev/null "${ISSUER}/.well-known/oauth-authorization-server"; then - echo " ready." - break - fi - printf '.' - sleep 1 -done -curl -sf -o /dev/null "${ISSUER}/.well-known/oauth-authorization-server" \ - || die "server did not become ready. Try: docker logs ${CONTAINER}" - -# ── configure ──────────────────────────────────────────────────────────────── -# 201 on first run, 409 when it already exists — both are success here. - -provision() { - local what="$1" path="$2" body="$3" code - code=$(curl -s -o /tmp/authplane-demo-provision.json -w '%{http_code}' \ - -X POST "${ADMIN}${path}" \ - -H "Authorization: Bearer ${ADMIN_KEY}" \ - -H "Content-Type: application/json" \ - -d "${body}") - case "${code}" in - 201) echo " created ${what}" ;; - 409) echo " ${what} already exists" ;; - *) cat /tmp/authplane-demo-provision.json >&2; die "creating ${what} returned HTTP ${code}" ;; - esac -} - -echo "==> Configuring" -provision "resource ${SERVER_URL}" /admin/resources \ - "{\"slug\":\"${RESOURCE_SLUG}\",\"uri\":\"${SERVER_URL}\",\"backend_kind\":\"mint\",\"display_name\":\"Coffee MCP example\",\"scopes\":[{\"name\":\"${SCOPE}\",\"description\":\"Search coffee shops\"}]}" -provision "user ${DEMO_EMAIL}" /admin/users \ - "{\"email\":\"${DEMO_EMAIL}\",\"name\":\"Demo User\",\"password\":\"${DEMO_PASSWORD}\",\"role\":\"user\"}" - -# ── .env ───────────────────────────────────────────────────────────────────── -# Never overwrite: an existing file may point at a real deployment. - -if [ -f "${ENV_FILE}" ]; then - echo "==> .env already exists, leaving it untouched" - if ! grep -q "^AUTHPLANE_ISSUER=${ISSUER}$" "${ENV_FILE}" 2>/dev/null; then - echo " note: its AUTHPLANE_ISSUER does not point at ${ISSUER}" - fi -else - cat > "${ENV_FILE}" < Wrote .env" -fi - -cat <