diff --git a/docs/agent-skills.md b/docs/agent-skills.md
index 36e1f989e..d27ad719d 100644
--- a/docs/agent-skills.md
+++ b/docs/agent-skills.md
@@ -1,5 +1,7 @@
---
title: Agent Skills
+group: Getting Started
+description: Use Agent Skills to build, migrate, and extend MCP Apps with AI coding agents. Install skills that scaffold new apps, convert OpenAI Apps, and add UI to existing servers.
---
# Agent Skills
diff --git a/docs/authorization.md b/docs/authorization.md
new file mode 100644
index 000000000..59fc26cde
--- /dev/null
+++ b/docs/authorization.md
@@ -0,0 +1,211 @@
+---
+title: Authorization
+group: Security
+description: Learn how to protect MCP App tools with OAuth authorization, including per-server and per-tool auth patterns, token verification, and UI-initiated auth escalation.
+---
+
+# Authorization
+
+MCP Apps can protect tools behind OAuth-based authorization, as defined in the [MCP specification](https://modelcontextprotocol.io/specification/latest/basic/authorization). There are two approaches:
+
+- **Per-server authorization** — The entire MCP server requires authorization at connection time. Every request must include a valid token, regardless of which tool is being called. This is the simpler model when all tools are sensitive.
+- **Per-tool authorization** — Only specific tools require authorization. Public tools work without a token, and the OAuth flow is triggered only when the user calls a protected tool. This lets you mix public and protected tools in the same server.
+
+## Shared setup
+
+Regardless of which approach you choose, you need OAuth discovery metadata and token verification. These are the same for both.
+
+### OAuth discovery metadata
+
+The MCP specification requires servers to implement [authorization server discovery](https://modelcontextprotocol.io/specification/latest/basic/authorization#authorization-server-discovery) so clients know how to obtain authorization. Two well-known endpoints are needed:
+
+**[Protected Resource Metadata](https://datatracker.ietf.org/doc/html/rfc9728)** (`/.well-known/oauth-protected-resource`) — describes the resource server and identifies which authorization server(s) can issue tokens for it. The MCP SDK's `mcpAuthRouter` handles this automatically.
+
+**Authorization Server Metadata** (`/.well-known/oauth-authorization-server`) — advertises the authorization and token endpoints, supported scopes, and whether [Client ID Metadata Documents](https://modelcontextprotocol.io/specification/latest/basic/authorization#client-id-metadata-documents) (CIMD) is supported:
+
+```ts
+app.get("/.well-known/oauth-authorization-server", (_req, res) => {
+ res.json({
+ ...oauthMetadata,
+ client_id_metadata_document_supported: true,
+ });
+});
+```
+
+Setting `client_id_metadata_document_supported: true` tells MCP clients to use CIMD instead of [Dynamic Client Registration](https://modelcontextprotocol.io/specification/latest/basic/authorization#dynamic-client-registration) (DCR). With CIMD, the `client_id` is a URL that serves the client's metadata document, removing the need for a registration endpoint. See [Client Registration Approaches](https://modelcontextprotocol.io/specification/latest/basic/authorization#client-registration-approaches) in the spec for the full list of options and priority order.
+
+### Token verification
+
+Verify access tokens as JWTs against the identity provider's JWKS endpoint. The `jose` library handles key fetching and caching:
+
+```ts
+import { createRemoteJWKSet, jwtVerify } from "jose";
+
+const JWKS = createRemoteJWKSet(new URL(`${IDP_DOMAIN}/.well-known/jwks.json`));
+
+const { payload } = await jwtVerify(token, JWKS, {
+ issuer: IDP_DOMAIN,
+});
+```
+
+MCP servers must validate that tokens were issued specifically for them — see [Token Handling](https://modelcontextprotocol.io/specification/latest/basic/authorization#token-handling) and [Access Token Privilege Restriction](https://modelcontextprotocol.io/specification/latest/basic/authorization#access-token-privilege-restriction) in the spec for the full requirements.
+
+## Per-server authorization
+
+With per-server authorization, every request to the `/mcp` endpoint must include a valid Bearer token. Any unauthorized request receives HTTP `401`, and the host must complete the OAuth flow before the client can use any tools. This is the right choice when all tools are sensitive and there's no value in allowing unauthorized access.
+
+The TypeScript MCP SDK supports this out of the box via `mcpAuthRouter` and `ProxyOAuthServerProvider` — no custom HTTP handler logic is needed. See the [MCP SDK documentation](https://github.com/modelcontextprotocol/typescript-sdk) for setup details.
+
+## Per-tool authorization
+
+With per-tool authorization, the `/mcp` endpoint handler inspects the raw JSON-RPC request body, checks whether any message targets a protected tool, and only enforces authorization for those calls. Public tools pass through without a token.
+
+### How it works
+
+1. The server maintains a set of tool names that require authorization
+2. When a JSON-RPC request arrives at the `/mcp` endpoint, the server inspects the request body to determine if any message is a `tools/call` targeting a protected tool
+3. If a protected tool is being called and no valid Bearer token is present, the server returns HTTP `401` with a [`WWW-Authenticate` header](https://modelcontextprotocol.io/specification/latest/basic/authorization#protected-resource-metadata-discovery-requirements) pointing to its [Protected Resource Metadata](https://datatracker.ietf.org/doc/html/rfc9728)
+4. The MCP host (e.g., Claude Desktop) sees the `401`, discovers the authorization server via the metadata URL, runs the [OAuth flow](https://modelcontextprotocol.io/specification/latest/basic/authorization#authorization-flow-steps) with the user, and retries the request with the acquired token
+5. On retry, the server verifies the token, extracts the user identity, and creates a per-request MCP server instance with that auth context
+6. Unprotected tools pass through without any token check — they work for everyone
+
+This design means authorization is enforced at the HTTP boundary (as [required by the spec](https://modelcontextprotocol.io/specification/latest/basic/authorization#access-token-usage)), not as a tool-level error. The MCP server itself never sees unauthorized requests for protected tools.
+
+### Enforcing HTTP 401
+
+The [MCP auth specification](https://modelcontextprotocol.io/specification/latest/basic/authorization#access-token-usage) requires protected resources to return HTTP `401` responses — not tool-level errors.
+
+Start by defining which tools require authorization. Then, in the `/mcp` endpoint handler, inspect the raw JSON-RPC request body, check whether any message targets a protected tool, and either verify the Bearer token or return `401` before the request ever reaches the MCP server:
+
+```ts
+/** Tools that require a valid Bearer token — checked at the HTTP level for proper 401. */
+const PROTECTED_TOOLS = new Set(["get_account_balance", "manage_branch_admin"]);
+
+app.all("/mcp", async (req, res) => {
+ // Parse the JSON-RPC body — it may be a single message or a batch
+ const messages = Array.isArray(req.body) ? req.body : [req.body];
+
+ // Check if any message is a tools/call for a protected tool
+ const needsAuth = messages.some(
+ (msg: any) =>
+ msg?.method === "tools/call" && PROTECTED_TOOLS.has(msg.params?.name),
+ );
+
+ // Extract and verify the Bearer token
+ let authInfo: AuthInfo | undefined;
+ const authHeader = req.headers.authorization;
+
+ if (authHeader?.startsWith("Bearer ")) {
+ try {
+ const token = authHeader.slice(7);
+ const { payload } = await jwtVerify(token, JWKS, {
+ issuer: IDP_DOMAIN,
+ });
+ authInfo = { token, sub: payload.sub as string };
+ } catch {
+ if (needsAuth) {
+ return res
+ .status(401)
+ .set(
+ "WWW-Authenticate",
+ `Bearer resource_metadata="${resourceMetadataUrl}"`,
+ )
+ .json({
+ error: "invalid_token",
+ error_description: "The access token is invalid",
+ });
+ }
+ }
+ } else if (needsAuth) {
+ return res
+ .status(401)
+ .set(
+ "WWW-Authenticate",
+ `Bearer resource_metadata="${resourceMetadataUrl}"`,
+ )
+ .json({
+ error: "invalid_token",
+ error_description: "Authorization required",
+ });
+ }
+
+ // Create a per-request MCP server with the auth context.
+ // authInfo is undefined for public tool calls, populated for
+ // authenticated requests — tool handlers use it to scope data
+ // to the authenticated user.
+ const server = createServer(authInfo);
+ // ... handle the request with transport
+});
+```
+
+The `WWW-Authenticate` header includes the [Protected Resource Metadata](https://modelcontextprotocol.io/specification/latest/basic/authorization#authorization-server-location) URL, which tells the client where to discover the authorization server.
+
+### Defence-in-depth in tool handlers
+
+Even though the HTTP layer enforces authorization, protected tool handlers should also verify `authInfo` as a defence-in-depth measure. If the HTTP layer is misconfigured or bypassed, the tool handler catches unauthorized access:
+
+```ts
+registerAppTool(
+ server,
+ "get_account_balance",
+ {
+ description: "Get account balance",
+ inputSchema: { accountId: z.string() },
+ },
+ async ({ accountId }) => {
+ if (!authInfo) {
+ return {
+ isError: true,
+ content: [
+ {
+ type: "text",
+ text: "Authorization required to access account data.",
+ },
+ ],
+ };
+ }
+
+ const balance = await getBalance(authInfo.sub, accountId);
+ return {
+ content: [{ type: "text", text: `Balance: ${balance}` }],
+ };
+ },
+);
+```
+
+### UI-initiated auth escalation
+
+A powerful pattern is mixing public and protected tools in the same app. The app loads with public data (no authorization required), and the OAuth flow is triggered only when the user performs a protected action. This is a practical application of the [step-up authorization flow](https://modelcontextprotocol.io/specification/latest/basic/authorization#step-up-authorization-flow) described in the spec:
+
+1. A public tool (e.g., `manage_branch`) loads the UI without requiring authorization
+2. The user clicks a button that calls a protected tool via `app.callServerTool()`
+3. The MCP host receives HTTP `401` and automatically runs the OAuth flow
+4. After the user completes the OAuth flow, the host retries the tool call with the acquired token
+5. The protected data appears in the UI
+
+```tsx
+function BranchItem({ branch }: { branch: Branch }) {
+ const [adminData, setAdminData] = useState(null);
+
+ async function handleManage() {
+ // This call may trigger the OAuth flow if the user
+ // hasn't been authorized yet — the host handles it
+ // transparently.
+ const result = await app.callServerTool({
+ name: "manage_branch_admin",
+ arguments: { branch_id: branch.id },
+ });
+ setAdminData(result.structuredContent);
+ }
+
+ return (
+
+ {branch.name}
+
+ {adminData && }
+
+ );
+}
+```
+
+This pattern keeps the initial experience fast (no login wall) while securing sensitive operations behind authorization. The host manages the entire OAuth flow — the app code simply calls the tool and handles the result.
diff --git a/docs/csp-cors.md b/docs/csp-cors.md
new file mode 100644
index 000000000..8978ab615
--- /dev/null
+++ b/docs/csp-cors.md
@@ -0,0 +1,64 @@
+---
+title: CSP and CORS
+group: Security
+description: Configure Content Security Policy and CORS for MCP Apps that make network requests from sandboxed iframes, including connectDomains, resourceDomains, and stable origin setup.
+---
+
+# CSP & CORS
+
+Unlike regular web apps, MCP Apps HTML is served as an MCP resource and runs in a sandboxed iframe with no same-origin server. Any app that makes network requests must configure Content Security Policy (CSP) and possibly CORS.
+
+**CSP** controls what the _browser_ allows. You must declare _all_ origins in {@link types!McpUiResourceMeta.csp `_meta.ui.csp`} ({@link types!McpUiResourceCsp `McpUiResourceCsp`}) — including `localhost` during development. Declare `connectDomains` for fetch/XHR/WebSocket requests and `resourceDomains` for scripts, stylesheets, images, and fonts.
+
+**CORS** controls what the _API server_ allows. Public APIs that respond with `Access-Control-Allow-Origin: *` or use API key authentication work without CORS configuration. For APIs that allowlist specific origins, use {@link types!McpUiResourceMeta.domain `_meta.ui.domain`} to give the app a stable origin that the API server can allowlist. The format is host-specific, so check each host's documentation for its supported format.
+
+
+```ts source="../src/server/index.examples.ts#registerAppResource_withDomain"
+// Computes a stable origin from an MCP server URL for hosting in Claude.
+function computeAppDomainForClaude(mcpServerUrl: string): string {
+ const hash = crypto
+ .createHash("sha256")
+ .update(mcpServerUrl)
+ .digest("hex")
+ .slice(0, 32);
+ return `${hash}.claudemcpcontent.com`;
+}
+
+const APP_DOMAIN = computeAppDomainForClaude("https://example.com/mcp");
+
+registerAppResource(
+ server,
+ "Company Dashboard",
+ "ui://dashboard/view.html",
+ {
+ description: "Internal dashboard with company data",
+ },
+ async () => ({
+ contents: [
+ {
+ uri: "ui://dashboard/view.html",
+ mimeType: RESOURCE_MIME_TYPE,
+ text: dashboardHtml,
+ _meta: {
+ ui: {
+ // CSP: tell browser the app is allowed to make requests
+ csp: {
+ connectDomains: ["https://api.example.com"],
+ },
+ // CORS: give app a stable origin for the API server to allowlist
+ //
+ // (Public APIs that use `Access-Control-Allow-Origin: *` or API
+ // key auth don't need this.)
+ domain: APP_DOMAIN,
+ },
+ },
+ },
+ ],
+ }),
+);
+```
+
+Note that `_meta.ui.csp` and `_meta.ui.domain` are set in the `contents[]` objects returned by the resource read callback, not in `registerAppResource()`'s config object.
+
+> [!NOTE]
+> For full examples that configures CSP, see: [`examples/sheet-music-server/`](https://github.com/modelcontextprotocol/ext-apps/tree/main/examples/sheet-music-server) (`connectDomains`) and [`examples/map-server/`](https://github.com/modelcontextprotocol/ext-apps/tree/main/examples/map-server) (`connectDomains` and `resourceDomains`).
diff --git a/docs/favicons/apple-touch-icon.png b/docs/favicons/apple-touch-icon.png
new file mode 100644
index 000000000..2e10abc75
Binary files /dev/null and b/docs/favicons/apple-touch-icon.png differ
diff --git a/docs/favicons/favicon-16x16.png b/docs/favicons/favicon-16x16.png
new file mode 100644
index 000000000..1548f7e8f
Binary files /dev/null and b/docs/favicons/favicon-16x16.png differ
diff --git a/docs/favicons/favicon-32x32.png b/docs/favicons/favicon-32x32.png
new file mode 100644
index 000000000..88f78f67c
Binary files /dev/null and b/docs/favicons/favicon-32x32.png differ
diff --git a/docs/favicons/favicon-dark-16x16.png b/docs/favicons/favicon-dark-16x16.png
new file mode 100644
index 000000000..1548f7e8f
Binary files /dev/null and b/docs/favicons/favicon-dark-16x16.png differ
diff --git a/docs/favicons/favicon-dark-32x32.png b/docs/favicons/favicon-dark-32x32.png
new file mode 100644
index 000000000..b861b127b
Binary files /dev/null and b/docs/favicons/favicon-dark-32x32.png differ
diff --git a/docs/favicons/favicon-dark.ico b/docs/favicons/favicon-dark.ico
new file mode 100644
index 000000000..fd1f24d2a
Binary files /dev/null and b/docs/favicons/favicon-dark.ico differ
diff --git a/docs/favicons/favicon.ico b/docs/favicons/favicon.ico
new file mode 100644
index 000000000..b92e162a3
Binary files /dev/null and b/docs/favicons/favicon.ico differ
diff --git a/docs/mcp-theme.css b/docs/mcp-theme.css
new file mode 100644
index 000000000..8dd20410a
--- /dev/null
+++ b/docs/mcp-theme.css
@@ -0,0 +1,189 @@
+/*
+ * MCP Apps custom theme — aligns TypeDoc styling with modelcontextprotocol.io
+ */
+
+@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap');
+
+/* ------------------------------------------------------------------ */
+/* Fonts */
+/* ------------------------------------------------------------------ */
+
+:root {
+ --font-family-text: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
+ --font-family-code: 'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
+}
+
+body {
+ font-family: var(--font-family-text);
+ -webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale;
+}
+
+/* ------------------------------------------------------------------ */
+/* Light mode colors */
+/* ------------------------------------------------------------------ */
+
+:root {
+ --light-color-background: #ffffff;
+ --light-color-background-secondary: #f9fafb;
+ --light-color-background-navbar: #ffffff;
+ --light-color-accent: #e5e7eb;
+ --light-color-text: #111827;
+ --light-color-text-aside: #6b7280;
+}
+
+/* ------------------------------------------------------------------ */
+/* Dark mode colors */
+/* ------------------------------------------------------------------ */
+
+:root {
+ --dark-color-background: #0f1117;
+ --dark-color-background-secondary: #161b22;
+ --dark-color-background-navbar: #0f1117;
+ --dark-color-accent: #30363d;
+ --dark-color-text: #f0f6fc;
+ --dark-color-text-aside: #8b949e;
+}
+
+/* ------------------------------------------------------------------ */
+/* Header — taller, frosted glass */
+/* ------------------------------------------------------------------ */
+
+:root {
+ --dim-toolbar-contents-height: 3.5rem;
+}
+
+.tsd-page-toolbar {
+ backdrop-filter: blur(12px);
+ -webkit-backdrop-filter: blur(12px);
+}
+
+@media (prefers-color-scheme: light) {
+ .tsd-page-toolbar {
+ background-color: rgba(255, 255, 255, 0.85);
+ }
+}
+
+@media (prefers-color-scheme: dark) {
+ .tsd-page-toolbar {
+ background-color: rgba(15, 17, 23, 0.75);
+ }
+}
+
+:root[data-theme='light'] .tsd-page-toolbar {
+ background-color: rgba(255, 255, 255, 0.85);
+}
+
+:root[data-theme='dark'] .tsd-page-toolbar {
+ background-color: rgba(15, 17, 23, 0.75);
+}
+
+/* ------------------------------------------------------------------ */
+/* Sidebar navigation (matches modelcontextprotocol.io sidebar) */
+/* ------------------------------------------------------------------ */
+
+.tsd-navigation a {
+ padding: 0.375rem 0.75rem;
+ font-size: 0.875rem;
+ line-height: 1.5;
+ border-radius: 0.75rem;
+ color: var(--color-text-aside);
+ transition: background-color 0.15s ease, color 0.15s ease;
+}
+
+.tsd-navigation a:hover:not(.current) {
+ background-color: rgba(107, 114, 128, 0.05);
+ color: var(--color-text);
+}
+
+.tsd-navigation a.current {
+ background-color: rgba(107, 114, 128, 0.15);
+ color: var(--color-text);
+ font-weight: 700;
+}
+
+/* Group headers (Documents, Security, API Documentation) */
+.tsd-accordion-summary > h3 {
+ font-weight: 600;
+ font-size: 0.875rem;
+ letter-spacing: 0.01em;
+}
+
+.site-menu {
+ padding: 1.25rem 0;
+}
+
+@media (prefers-color-scheme: dark) {
+ .tsd-navigation a {
+ color: var(--color-text-aside);
+ }
+
+ .tsd-navigation a:hover:not(.current) {
+ background-color: rgba(229, 231, 235, 0.05);
+ color: var(--color-text);
+ }
+
+ .tsd-navigation a.current {
+ background-color: rgba(229, 231, 235, 0.15);
+ color: var(--color-text);
+ font-weight: 700;
+ }
+}
+
+:root[data-theme='dark'] .tsd-navigation a {
+ color: var(--color-text-aside);
+}
+
+:root[data-theme='dark'] .tsd-navigation a:hover:not(.current) {
+ background-color: rgba(229, 231, 235, 0.05);
+ color: var(--color-text);
+}
+
+:root[data-theme='dark'] .tsd-navigation a.current {
+ background-color: rgba(229, 231, 235, 0.15);
+ color: var(--color-text);
+ font-weight: 700;
+}
+
+/* ------------------------------------------------------------------ */
+/* Content area */
+/* ------------------------------------------------------------------ */
+
+.tsd-typography {
+ line-height: 1.6;
+}
+
+.tsd-panel.tsd-typography h1 {
+ margin-top: 0;
+ padding-bottom: 0.4em;
+}
+
+.tsd-panel.tsd-typography h2 {
+ margin-top: 2rem;
+ padding-bottom: 0.3em;
+}
+
+.tsd-panel.tsd-typography h3 {
+ margin-top: 1.75rem;
+}
+
+/* ------------------------------------------------------------------ */
+/* Code blocks */
+/* ------------------------------------------------------------------ */
+
+code,
+pre {
+ font-family: var(--font-family-code);
+ font-size: 1em;
+}
+
+.tsd-typography pre {
+ padding: 1rem 1.25rem;
+ border: 1px solid var(--color-accent);
+}
+
+/* Inline code */
+.tsd-typography code:not(pre code) {
+ padding: 0.15em 0.35em;
+ font-size: 1em;
+}
diff --git a/docs/migrate_from_openai_apps.md b/docs/migrate_from_openai_apps.md
index 6f5a1c323..d91281b41 100644
--- a/docs/migrate_from_openai_apps.md
+++ b/docs/migrate_from_openai_apps.md
@@ -1,5 +1,7 @@
---
title: Migrate OpenAI App
+group: Getting Started
+description: Migrate from the OpenAI Apps SDK to MCP Apps SDK with concept mapping tables, API equivalents, and complete before/after code examples.
---
# Migrating from OpenAI Apps SDK to MCP Apps SDK
diff --git a/docs/overview.md b/docs/overview.md
index 4de6a6860..28f5b2379 100644
--- a/docs/overview.md
+++ b/docs/overview.md
@@ -1,5 +1,7 @@
---
title: Overview
+group: Getting Started
+description: MCP Apps extends the Model Context Protocol to let MCP servers deliver interactive UIs — charts, forms, dashboards — rendered securely in iframes inside any compliant host.
---
# MCP Apps Overview
diff --git a/docs/patterns.md b/docs/patterns.md
index 3a6302c9a..87a6d8232 100644
--- a/docs/patterns.md
+++ b/docs/patterns.md
@@ -1,5 +1,7 @@
---
title: Patterns
+group: Getting Started
+description: Common patterns and recipes for building MCP Apps — polling, chunked data, binary resources, theming, fullscreen, model context, state persistence, and more.
---
# MCP Apps Patterns
@@ -298,62 +300,7 @@ videoEl.src = `data:${content.mimeType!};base64,${content.blob}`;
## Configuring CSP and CORS
-Unlike regular web apps, MCP Apps HTML is served as an MCP resource and runs in a sandboxed iframe with no same-origin server. Any app that makes network requests must configure Content Security Policy (CSP) and possibly CORS.
-
-**CSP** controls what the _browser_ allows. You must declare _all_ origins in {@link types!McpUiResourceMeta.csp `_meta.ui.csp`} ({@link types!McpUiResourceCsp `McpUiResourceCsp`}) — including `localhost` during development. Declare `connectDomains` for fetch/XHR/WebSocket requests and `resourceDomains` for scripts, stylesheets, images, and fonts.
-
-**CORS** controls what the _API server_ allows. Public APIs that respond with `Access-Control-Allow-Origin: *` or use API key authentication work without CORS configuration. For APIs that allowlist specific origins, use {@link types!McpUiResourceMeta.domain `_meta.ui.domain`} to give the app a stable origin that the API server can allowlist. The format is host-specific, so check each host's documentation for its supported format.
-
-
-```ts source="../src/server/index.examples.ts#registerAppResource_withDomain"
-// Computes a stable origin from an MCP server URL for hosting in Claude.
-function computeAppDomainForClaude(mcpServerUrl: string): string {
- const hash = crypto
- .createHash("sha256")
- .update(mcpServerUrl)
- .digest("hex")
- .slice(0, 32);
- return `${hash}.claudemcpcontent.com`;
-}
-
-const APP_DOMAIN = computeAppDomainForClaude("https://example.com/mcp");
-
-registerAppResource(
- server,
- "Company Dashboard",
- "ui://dashboard/view.html",
- {
- description: "Internal dashboard with company data",
- },
- async () => ({
- contents: [
- {
- uri: "ui://dashboard/view.html",
- mimeType: RESOURCE_MIME_TYPE,
- text: dashboardHtml,
- _meta: {
- ui: {
- // CSP: tell browser the app is allowed to make requests
- csp: {
- connectDomains: ["https://api.example.com"],
- },
- // CORS: give app a stable origin for the API server to allowlist
- //
- // (Public APIs that use `Access-Control-Allow-Origin: *` or API
- // key auth don't need this.)
- domain: APP_DOMAIN,
- },
- },
- },
- ],
- }),
-);
-```
-
-Note that `_meta.ui.csp` and `_meta.ui.domain` are set in the `contents[]` objects returned by the resource read callback, not in `registerAppResource()`'s config object.
-
-> [!NOTE]
-> For full examples that configures CSP, see: [`examples/sheet-music-server/`](https://github.com/modelcontextprotocol/ext-apps/tree/main/examples/sheet-music-server) (`connectDomains`) and [`examples/map-server/`](https://github.com/modelcontextprotocol/ext-apps/tree/main/examples/map-server) (`connectDomains` and `resourceDomains`).
+See the dedicated [CSP & CORS](./csp-cors.md) guide in the Security section.
## Adapting to host context (theme, styling, fonts, and safe areas)
diff --git a/docs/quickstart.md b/docs/quickstart.md
index c850f8c65..2714d4e5a 100644
--- a/docs/quickstart.md
+++ b/docs/quickstart.md
@@ -1,5 +1,7 @@
---
title: Quickstart
+group: Getting Started
+description: Build your first MCP App step by step — create an MCP server with an interactive View that renders inside Claude Desktop and other MCP hosts.
---
# Build Your First MCP App
diff --git a/docs/testing-mcp-apps.md b/docs/testing-mcp-apps.md
index 6bef13e2c..df1396c5f 100644
--- a/docs/testing-mcp-apps.md
+++ b/docs/testing-mcp-apps.md
@@ -1,5 +1,7 @@
---
title: Testing MCP Apps
+group: Getting Started
+description: Test MCP Apps locally with the basic-host reference implementation or in production hosts like Claude.ai and VS Code.
---
# Test Your MCP App
diff --git a/scripts/typedoc-plugin-mcpstyle.mjs b/scripts/typedoc-plugin-mcpstyle.mjs
new file mode 100644
index 000000000..bcd40f75c
--- /dev/null
+++ b/scripts/typedoc-plugin-mcpstyle.mjs
@@ -0,0 +1,53 @@
+/**
+ * TypeDoc plugin that applies MCP-specific styling tweaks.
+ *
+ * - Moves custom.css to load last so overrides win the cascade
+ * - Replaces breadcrumbs with the document group name (e.g. "Security")
+ * - Marks the current sidebar nav link for CSS highlighting
+ */
+
+import { Renderer } from "typedoc";
+
+/**
+ * TypeDoc plugin entry point.
+ * @param {import('typedoc').Application} app
+ */
+export function load(app) {
+ app.renderer.on(Renderer.EVENT_END_PAGE, (page) => {
+ if (!page.contents) return;
+
+ // Move custom.css to load after all theme stylesheets so overrides win the cascade
+ const customCssLink = page.contents.match(
+ //,
+ );
+ if (customCssLink) {
+ page.contents = page.contents.replace(customCssLink[0], "");
+ page.contents = page.contents.replace(
+ "",
+ customCssLink[0] + "\n",
+ );
+ }
+
+ // For document pages, replace the breadcrumb with the group name
+ // (e.g. "Security", "Getting Started"). The page title is in the H1.
+ if (page.model?.isDocument?.() && page.model.frontmatter?.group) {
+ const group = String(page.model.frontmatter.group);
+ page.contents = page.contents.replace(
+ /
]*>.*?<\/ul>/,
+ `
${group}
`,
+ );
+ }
+
+ // Inject script to mark the current sidebar nav link with a "current" class.
+ // TypeDoc does not natively add this class for document pages.
+ // The sidebar is populated asynchronously from compressed navigation data,
+ // so we use a MutationObserver to detect when links appear.
+ // Pathname comparison strips trailing slashes and .html extensions to handle
+ // servers with clean-URL mode (e.g. `serve` drops .html).
+ const currentNavScript = ``;
+ page.contents = page.contents.replace(
+ "