diff --git a/docs/assets/pr/ai-settings-mcp-connections.jpg b/docs/assets/pr/ai-settings-mcp-connections.jpg new file mode 100644 index 000000000..b8cc1b88e Binary files /dev/null and b/docs/assets/pr/ai-settings-mcp-connections.jpg differ diff --git a/docs/assets/pr/ai-settings-providers.jpg b/docs/assets/pr/ai-settings-providers.jpg new file mode 100644 index 000000000..4db233b41 Binary files /dev/null and b/docs/assets/pr/ai-settings-providers.jpg differ diff --git a/docs/features/mcp-connectors.md b/docs/features/mcp-connectors.md index 3b2ee54db..46baeee8e 100644 --- a/docs/features/mcp-connectors.md +++ b/docs/features/mcp-connectors.md @@ -1,170 +1,185 @@ -# MCP Connectors +# MCP connections -MCP connectors let **external AI clients drive this Instatic instance** over the [Model Context Protocol](https://modelcontextprotocol.io). Instatic acts as an **MCP server**: a local client (Claude Code, Codex, Cursor) or a remote agent connects, lists the available tools, and operates the CMS — reading the site, editing page structure, and managing content — exactly the way the built-in AI panel does. +MCP connections let external AI clients operate an Instatic instance through the [Model Context Protocol](https://modelcontextprotocol.io). Instatic is the MCP server: clients list its capability-filtered tools, run headless reads, relay editing tools to the connection owner's open workspace, and explicitly publish completed drafts. -This is the mirror image of the **Providers** tab (`server/ai/credentials/`), which points Instatic's *own* agent outward at LLM providers. MCP connectors point inward: they let outside agents reach in. +This is the inverse of the **Providers** tab. Providers let Instatic call model APIs; MCP connections let outside clients call Instatic. -The server is implemented with the official `@modelcontextprotocol/sdk`. That package is banned everywhere else in the tree (the AI drivers hand-roll provider REST); it is allowed **only under `server/ai/mcp/`**, scoped by `ai-driver-isolation.test.ts`. +The wire server uses `@modelcontextprotocol/sdk`. That dependency remains allowed only under `server/ai/mcp/`; provider drivers continue to use their direct REST implementations. ---- +## Connection modes -## TL;DR +The UI exposes two credential lifecycles instead of a cosmetic local/remote switch: -- **Instatic is an MCP server.** One Streamable-HTTP endpoint at `/_instatic/mcp` serves both local and remote clients (local is just `localhost`). -- **Thin adapter over the existing tool engine.** No tool logic is duplicated. MCP is a new *caller* alongside the built-in agent and the plugin host; tool dispatch reuses `executeAiTool`. -- **Tool surface = the full catalog.** Server-resolved tools (content reads, `site_list_documents`, `site_read_styles`, and explicit `site_publish`) run headless — no editor needed. Every browser-execution tool the agent panel has is exposed too, **relayed to the matching open Site or Content workspace** — the single source of truth for edits. If that workspace is not open, its tools return a clear, scope-specific error; headless tools still work. -- **Draft, then publish.** Browser writes save the draft and never leak intermediate work to visitors. A connector with `ai.tools.write` + `pages.publish` calls `site_publish` once after its edit sequence; that server-side tool runs the canonical full-site pipeline and atomically swaps the rebuilt static slot. -- **Bearer-token auth, one secret per connector.** The token is shown once on creation and stored only as a SHA-256 hash. New tokens expire after 90 days by default; admins can choose a custom TTL or explicitly create a non-expiring token. Revocable. -- **Capability-gated.** A connector carries a granted capability subset; the same gate the built-in agent uses (`toolAllowedForCapabilities`) filters the toolset. An MCP caller can never invoke a tool the granting capabilities couldn't authorize over HTTP. -- **Privilege floor.** An admin can only grant capabilities they themselves hold. -- **Managed from the admin UI:** AI workspace → **MCP** tab. Minting a long-lived connector secret is step-up authenticated. +| Mode | Intended client | Authentication | Lifecycle | +|---|---|---|---| +| Hosted OAuth | Claude custom connectors and other remote MCP clients | OAuth authorization code with S256 PKCE | The client discovers the authorization server, dynamically registers, and sends the user to Instatic for consent. Access tokens last up to one hour; refresh tokens rotate; the grant expires after 90 days. | +| Personal access token | Claude Code, Codex, Cursor, local bridges, and clients that accept an explicit header | `Authorization: Bearer imcp_pat_…` | Created after step-up authentication, shown once, scoped to selected capabilities, configurable expiry, independently revocable. | ---- +Both modes resolve to the same persistent connection grant and the same `toolAllowedForCapabilities` gate. OAuth does not create a wider tool path. -## Architecture +The MCP endpoint is always: -``` -MCP client (Claude Code / Codex / remote agent) - │ JSON-RPC over Streamable HTTP - ▼ -server/router.ts → /_instatic/mcp (tryServeMcp) - │ -server/ai/mcp/transports/http.ts WebStandardStreamableHTTPServerTransport (Web Request/Response) - │ -server/ai/mcp/auth.ts Bearer token → connector → capability set (401 + WWW-Authenticate otherwise) - │ -server/ai/mcp/server.ts low-level SDK Server; tools filtered by capabilities - │ -server/ai/mcp/registry.ts AiTool registry → MCP tools (TypeBox inputSchema sent verbatim as JSON Schema) - │ -executeAiTool(...) / live editor bridge - ▼ -repositories (headless reads) / live editor store (browser tools) +```text +https:///_instatic/mcp ``` -### Module layout — `server/ai/mcp/` - -| File | Responsibility | -|---|---| -| `transports/http.ts` | Mounts the SDK's Web-standard Streamable-HTTP transport; stateless per request (`enableJsonResponse`). | -| `auth.ts` | Bearer resolution → `{ connectorId, userId, capabilities }`; spec-correct 401 with an RFC 9728 `resource_metadata` pointer. | -| `server.ts` | Builds a capability-scoped low-level `Server` (`ListTools` / `CallTool` handlers). Uses the low-level `Server`, not `McpServer.registerTool`, because the latter needs Zod (banned) — this lets the TypeBox `inputSchema` pass through verbatim. | -| `registry.ts` | Headless reads plus the browser-relayed site/content catalog, deduped by name and filtered by `toolAllowedForCapabilities`. | -| `tools/documentTools.ts` | `site_list_documents` — pages, templates, and visual components, headless from the DB. | -| `contentAuthorization.ts` | Re-checks own-vs-any connector grants against the target content row before a browser-relayed mutation. | -| `tools/styleTools.ts` | `site_read_styles` — the design system as a CSS stylesheet, headless from the DB. | -| `tools/publishTool.ts` | `site_publish` — explicit server-side full-site publish through `publishDraftSite`, including the Layer-A static slot and MCP audit metadata. | -| `editorBridge.ts` | Per-user, per-scope live workspace bridge registry + `createEditorBridgeStream`; browser tools route to the owner's matching Site or Content workspace. | -| `handlers/editorBridge.ts` | `GET /admin/api/ai/editor-bridge?scope=site|content` — the capability-gated NDJSON stream each workspace holds open. | -| `connectors/` | `types.ts` (server-only record), `token.ts` (generate + SHA-256 hash), `store.ts` (CRUD + `toConnectorView`). | -| `handlers/connectors.ts` | `/admin/api/ai/mcp/connectors` CRUD, gated by `ai.providers.manage`. | +Hosted clients run in their provider's infrastructure. They cannot reach `localhost`, a private LAN address, or an HTTP-only deployment. The MCP tab detects this and warns until Instatic's canonical public origin is HTTPS. ---- +## Hosted OAuth flow -## Tool surface +Instatic implements the MCP authorization-server surface directly: -MCP exposes the **full tool catalog** (deduped by name), capability-filtered. Tools fall in two execution classes: +| Endpoint | Purpose | +|---|---| +| `/.well-known/oauth-protected-resource` | RFC 9728 protected-resource metadata for the MCP resource. | +| `/.well-known/oauth-protected-resource/_instatic/mcp` | Path-aware protected-resource discovery alias. | +| `/.well-known/oauth-authorization-server` | Authorization-server metadata. | +| `/_instatic/oauth/register` | Dynamic client registration for public clients. | +| `/admin/ai/oauth/authorize` | Signed-in consent screen. | +| `/_instatic/oauth/token` | Form-encoded authorization-code and refresh-token exchange. | + +The authorization sequence is: + +```text +Hosted MCP client + → reads protected-resource and authorization-server metadata + → dynamically registers its exact callback URI + → opens /admin/ai/oauth/authorize with resource + state + S256 challenge + → user signs in, reviews the client, selects capabilities, and completes step-up + → Instatic redirects a one-time code to the registered callback + → client exchanges code + verifier for opaque access and refresh tokens + → client calls /_instatic/mcp with the access token +``` -**Single source of truth.** All page *editing* goes through the **live editor store** (browser tools, relayed to the open editor). There is deliberately **no** headless DB-mutating page-tree tool: an earlier `read_page_tree`/`mutate_page_tree` pair edited the DB directly, creating a second copy of each page with identical node ids that desynced from the open editor and got clobbered by its autosave (data loss). They were removed — structure editing uses the editor's browser tools, which the existing save-flush persists. +Security properties: -**Server-resolved — work with no workspace open:** -- Content reads — list/read collections, entries, data rows, media. -- `get_context({ entryId? })` — orientation in one call: whether the Site and Content workspace bridges are connected, which "everywhere"/post-type templates wrap pages, and the site name. Call it first if a browser tool returns an "open the workspace" error. -- `site_list_documents` — editable pages, templates, and visual components with document references, root node ids, template metadata, and summaries. Nothing is marked active/current because headless calls have no editor focus. -- `site_read_styles({ format?, className?, includeTokens? })` — the design system as a **CSS stylesheet**: design tokens (CSS custom properties) + every class/ambient rule, read straight from the DB via the publisher's emitters. `format:"summary"` returns a compact class catalog (selector + referenced token vars, no declarations) to scan first. Symmetric with reading pages as HTML / writing CSS via `site_apply_css`. Replaces the old snapshot-dependent `list_tokens`. -- `site_list_breakpoints` — configured viewport ids/labels/widths (the first is the base), so `site_render_snapshot` can target one deliberately. Headless version replaces the snapshot-dependent one. -- `site_publish` — deploys the **saved** draft. It requires `ai.tools.write` + `pages.publish`, calls `publishDraftSite` with the server's real uploads directory, rebuilds HTML/CSS/runtime assets into the inactive static slot, swaps it atomically, bumps the publish cache version, and records `source: "mcp"` plus the connector id in the publish audit event. +- Public clients only: no generated client secret is required or stored. +- Redirect URIs are registered exactly. HTTPS is required except for HTTP loopback callbacks used by native clients. +- Authorization codes expire after five minutes, are stored only as SHA-256 hashes, and can be consumed once. +- S256 PKCE, exact client id, callback, and MCP `resource` binding are checked during exchange. +- OAuth access tokens are opaque, stored only as hashes, bound to the exact MCP resource, and expire after at most one hour. +- Refresh tokens rotate on every use. Reuse of a rotated token revokes the connection and all of its tokens. +- OAuth grants expire after 90 days. Disconnecting the connection immediately invalidates its access and refresh tokens. +- The consent API requires `ai.providers.manage`; approval also requires a fresh step-up window. The approver cannot delegate a capability they do not hold. +- Dynamic-registration client names are self-declared. The consent screen surfaces the exact callback and tells the approver to verify that they initiated the request and recognize the address. +- The client-supplied `state` value is round-tripped on success and denial. Redirects are generated only from the callback already registered for that client. -Site and content writes deliberately do **not** call `site_publish` automatically. A multi-step agent edit can involve many tool calls; publishing each intermediate call would expose incomplete work, bypass the user's explicit deployment intent, and repeatedly run the expensive full-site pipeline. The client should finish and verify its draft changes, then call `site_publish` once when publication was requested. +### Claude Desktop / Claude custom connector -**Browser-relayed (via the live workspace bridge) — require the matching workspace:** -- Structure editing — `site_insert_html`, `site_replace_node_html`, `site_delete_node`, `site_move_node`, `site_duplicate_node`, `site_rename_node`, `site_update_node_props`. -- HTML/CSS authoring (`site_apply_css`, `site_assign_class`, `site_remove_class`), page lifecycle (`site_add_page`, …), design tokens (`site_set_color_tokens`, …), content CRUD (`content_create_document`, `content_set_document_field`, …), code assets, structure reads (`site_read_document`), and live-DOM reads (`site_render_snapshot`, `site_get_node_html`). -- These have no server implementation — their logic runs in the browser against the live workspace state. Site tools route to `SitePage`; content tools route to `ContentPage`. Image attachments (e.g. `site_render_snapshot`'s PNG) come back as MCP image content blocks. No matching workspace connected → a clear error asking the operator to open that workspace. -- `content_create_document` always creates a draft. Publication is a separate `content_set_document_status` call, which carries the content-publish capability gate; scheduled status uses the same explicit status tool. +1. Deploy Instatic at a public HTTPS origin and configure that origin through the normal server public-origin configuration. +2. In Instatic, open **AI → MCP** and copy the **Remote MCP URL**. +3. In Claude, open **Settings → Connectors**, add a custom connector, choose a name, and paste the URL. +4. Leave **OAuth Client ID** and **OAuth Client Secret** empty. Claude can dynamically register as a public PKCE client. +5. Choose **Connect**. The browser returns to Instatic, where the user selects capabilities and approves the connection. -## Live editor bridge +The resulting OAuth connection appears automatically under **Authorized connections**. No Instatic token is copied into Claude. -`server/ai/mcp/editorBridge.ts` keeps one bridge per `(userId, scope)` (newest connection for that scope wins). A connector can only reach **its own owner's** workspaces, while the owner's Site and Content pages may stay connected at the same time. +## Personal access tokens -``` -MCP browser-tool call Matching workspace (open in a browser) - │ executeAiTool(browser) │ useMcpWorkspaceBridge(scope, dispatcher) - ▼ ▼ -buildMcpServer → getEditorBridgeForUser(userId, tool.scope) - │ bridge.callBrowser(tool, input) → emits toolRequest ─────────────▶ Site or Content dispatcher - │ │ (live workspace) - ◀───────────── POST /admin/api/ai/tool-result ◀── postToolResult ◀───────┘ -``` +In **AI → MCP**, choose **Create access token**, name the device/client, choose an expiry and capabilities, then complete step-up. The plaintext `imcp_pat_…` token is returned once; only its SHA-256 hash is stored. -- Browser side: `useMcpWorkspaceBridge` opens the scope-qualified NDJSON stream, runs each `toolRequest` through the SAME Site or Content dispatcher as the built-in agent panel, and POSTs the result back. It reconnects with backoff. `SitePage` flushes pending draft changes before reporting a successful tool result, so a follow-up headless read or `site_publish` sees the persisted edit immediately; a failed save makes the MCP tool fail instead of silently publishing stale data. `ContentPage` registers its bridge whenever the workspace is mounted, independent of whether the AI panel is visible. -- Server side: reuses the chat bridge machinery wholesale — `createBridge` issues the `AiBrowserBridge`, `resolveBridgeToolResult` settles it from the existing `/admin/api/ai/tool-result` endpoint. +Claude Code example: -This is why an open editor (yours, or one the agent opens) unlocks the full editing surface without reimplementing any tool. +```sh +claude mcp add instatic --transport http http://localhost:3000/_instatic/mcp \ + --header "Authorization: Bearer imcp_pat_…" +``` ---- +Claude Desktop can also reach a local server through a local stdio bridge. The UI generates a ready-to-copy `mcp-remote` configuration that keeps the token in an environment variable instead of embedding it in the command arguments. -## Authentication +Personal tokens are deliberately not the hosted-connector setup path. The hosted Claude connector form does not provide a general custom-header field, and a cloud-hosted connector cannot reach a local-only endpoint. -Each connector has a bearer secret (`imcp_…`). The client sends `Authorization: Bearer `. The server hashes the presented token and looks up a non-revoked, non-expired connector, yielding its capability set. Missing/invalid/expired tokens get a `401` with `WWW-Authenticate: Bearer resource_metadata="…/.well-known/oauth-protected-resource"`. +## Architecture -Works today with Claude Code, Cursor, Claude.ai custom connectors, and custom remote agents. +```text +MCP client + │ Streamable HTTP + OAuth/PAT bearer + ▼ +server/router.ts + ├─ OAuth metadata / registration / token endpoints + └─ /_instatic/mcp + ▼ +server/ai/mcp/auth.ts + bearer → OAuth access token or personal token → connection capability set + ▼ +server/ai/mcp/server.ts + registry.ts + capability-filtered MCP tools + ▼ +executeAiTool(...) / live editor bridge + ├─ repositories and publisher for headless tools + └─ connection owner's open Site or Content workspace for browser tools +``` -Managed connector UIs that require an OAuth flow are not compatible with the current bearer-token implementation. +### Module layout ---- +| Module | Responsibility | +|---|---| +| `paths.ts` | Canonical MCP, OAuth, metadata, and consent paths. | +| `oauth/protocol.ts` | Issuer/resource construction, supported scopes, redirect validation, PKCE validation, safe callback generation. | +| `oauth/schemas.ts` | TypeBox schemas for dynamic registration and form/query parsing. | +| `oauth/handler.ts` | Public metadata, dynamic registration, and token endpoints with protocol-shaped errors and rate limits. | +| `oauth/store.ts` | Registered clients, hashed one-time codes, token issue/rotation, resource-bound access lookup. | +| `handlers/oauthAuthorization.ts` | Admin-session consent read/decision API, capability floor, and step-up gate. | +| `handlers/management.ts` | Connection overview, personal-token creation, and revoke/disconnect. | +| `connectors/store.ts` | Persistent connection grant and the token-free `toConnectionView` projection. | +| `connectors/token.ts` | Opaque secret generation, hashing, and PKCE challenge calculation. | +| `auth.ts` | Resolves OAuth access tokens or personal tokens to `{ connectorId, userId, capabilities }`; returns a discovery-aware 401 otherwise. | +| `transports/http.ts` | Stateless Web-standard Streamable HTTP transport. | +| `server.ts` / `registry.ts` | Low-level SDK server, TypeBox input schemas, catalog deduplication, and capability filtering. | +| `editorBridge.ts` | Per-user, per-scope live workspace bridge. | +| `tools/publishTool.ts` | Explicit canonical full-site publish with MCP audit metadata. | -## Connecting a client +## Tool execution model -Create a connector in **AI → MCP**, complete the step-up prompt if the session is not already fresh, choose its type and capabilities, then copy the token (shown once). +MCP exposes the full deduplicated tool catalog, filtered by the connection's capabilities. -**Local (Claude Code / Codex / Cursor):** +Server-resolved tools work without an editor open. They include content reads, `get_context`, `site_list_documents`, `site_read_styles`, `site_list_breakpoints`, and explicit `site_publish`. Publishing requires `ai.tools.write` plus `pages.publish`, runs the canonical full-site pipeline, swaps the static slot atomically, and records the connection id in the publish audit event. -```sh -claude mcp add instatic --transport http http://localhost:3000/_instatic/mcp \ - --header "Authorization: Bearer imcp_…" -``` +Browser tools run against the connection owner's live workspace. Site structure, HTML/CSS, page lifecycle, design-token, content mutation, code-asset, and live-DOM tools route to the matching open Site or Content workspace. If that workspace is not open, the tool returns a scope-specific error while headless tools remain available. -**Remote:** point the client at `https:///_instatic/mcp` and send the token as an `Authorization: Bearer` header. +There is intentionally no headless page-tree mutation path. The open editor store is the single source of truth for draft edits; a second DB mutation path would desynchronize node state and risk autosave overwrites. Successful relayed edits flush the draft before returning, so a following headless read or explicit publish sees the saved result. ---- +Writes remain drafts. Clients should finish and verify an edit sequence, then call `site_publish` once only when deployment was requested. ## Data model -`ai_mcp_connectors` (migration `018` plus additive expiry migration `019`, PG + SQLite parity): +`ai_mcp_connectors` remains the persistent owner/capability grant (migrations `018` and `019`): -| column | notes | +| Column | Notes | |---|---| -| `id`, `user_id`, `label` | owner + display name | -| `type` | `local` \| `remote` | -| `auth_mode` | `bearer` for every connector created by the current UI/API. The schema also accepts `oauth` as a reserved storage value, but no OAuth flow creates or authenticates those rows today. | -| `token_hash` | SHA-256 of the secret; never the plaintext. Unique. | -| `capabilities_json` | granted capability subset | -| `created_at`, `last_used_at`, `revoked_at` | lifecycle; revoked tokens fail auth | -| `expires_at` | token expiry; new tokens default to 90 days, `NULL` means explicitly non-expiring or grandfathered | - -The wire-safe `McpConnectorView` (the only HTTP-returned shape) includes `expiresAt` but never includes the hash — gated by `ai-mcp-connectors-never-leak.test.ts`. Create and revoke are audited (`ai.mcp_connector.created` / `ai.mcp_connector.revoked`). +| `id`, `user_id`, `label` | Connection identity and owner. | +| `auth_mode` | `bearer` for personal access tokens, `oauth` for hosted grants. | +| `token_hash` | Personal-token hash; `NULL` for OAuth connections. | +| `capabilities_json` | Granted capability subset. | +| `created_at`, `last_used_at`, `revoked_at`, `expires_at` | Shared lifecycle. | +| `type` | Legacy storage discriminator retained in the existing live schema; it is no longer exposed as a product choice or wire field. | ---- +Migration `021_mcp_oauth` adds, in both dialects: -## Capabilities +- `ai_mcp_oauth_clients`: dynamically registered public clients and exact callback lists; +- `ai_mcp_oauth_codes`: hashed, short-lived, one-time PKCE authorization codes; +- `ai_mcp_oauth_tokens`: hashed access/refresh tokens with kind, client, scope, resource, expiry, and revocation state. -Connector management is gated by `ai.providers.manage` (the AI-integrations admin surface), and connector creation additionally requires a fresh step-up window because it mints a long-lived delegated secret. A connector's granted capabilities flow straight into the existing tool gate: +The wire-safe `McpConnectionView` never includes a token or hash. Personal-token creation is the only response that contains a plaintext personal token. OAuth codes and tokens stay on the protocol endpoints and never appear in list/read APIs. -- mutating tools require `ai.tools.write`; -- page-tree edits require any of `site.structure.edit` / `site.content.edit` / `site.style.edit` / `pages.edit`; -- full-site deployment additionally requires `pages.publish`; -- own-vs-any content grants are re-checked against the target row before browser relay, so the owner's broader admin cookie cannot widen a restricted connector token; -- reads require any site/content read grant. +Create and manual revoke actions retain the existing `ai.mcp_connector.created` and `ai.mcp_connector.revoked` audit events, with the authentication mode included in create metadata. -An admin cannot grant a capability they do not hold (enforced in `handlers/connectors.ts`). +## Capability enforcement ---- +- Management and consent require `ai.providers.manage`. +- Token creation and OAuth approval require step-up authentication. +- Mutating tools require `ai.tools.write`. +- Page-tree edits require the matching site/page edit capabilities. +- Full-site deployment additionally requires `pages.publish`. +- Own-vs-any content permissions are checked again against the target row before browser relay. +- The approver can grant only capabilities they hold. ## Tests -- `server/ai/mcp/connectors/{token,store}.test.ts` — token hashing, expiry, and store CRUD. -- `server/ai/mcp/{registry,auth,server,contentAuthorization,transports/http}.test.ts` and `server/ai/mcp/tools/documentTools.test.ts` — capability filtering, headless document listing, bearer auth + 401, row ownership, scoped workspace relay, full MCP round-trip, HTTP handshake. -- `server/ai/mcp/publishTool.test.ts` — explicit MCP publish rebuilds and swaps the real static CSS/HTML slot and records connector audit metadata. -- `src/__tests__/ai/mcpConnectorsHandler.test.ts` — CRUD, step-up, privilege floor, capability gating. -- `src/__tests__/architecture/ai-mcp-connectors-never-leak.test.ts` — token never serialized. +- `server/ai/mcp/oauth/handler.test.ts` covers discovery metadata, public dynamic registration, and callback policy. +- `server/ai/mcp/oauth/store.test.ts` covers PKCE exchange, exact binding, one-time codes, access lookup, refresh rotation/reuse, and revocation. +- `server/ai/mcp/auth.test.ts` covers personal and OAuth bearer resolution plus the protected-resource challenge. +- `src/__tests__/ai/mcpOAuthAuthorizationHandler.test.ts` covers signed-in consent, capability selection, exact callback redirects, denial, and privilege floors. +- `src/__tests__/ai/mcpConnectorsHandler.test.ts` covers connection listing, personal-token creation, step-up, revoke, and privilege floors. +- `server/ai/mcp/e2e.test.ts`, `transports/http.test.ts`, and `publishTool.test.ts` cover the real MCP request flow and publish path. +- `src/__tests__/architecture/ai-mcp-connectors-never-leak.test.ts` gates the token-free connection projection. diff --git a/docs/reference/architecture-tests.md b/docs/reference/architecture-tests.md index 2c8d56a8d..4ca6a489c 100644 --- a/docs/reference/architecture-tests.md +++ b/docs/reference/architecture-tests.md @@ -173,7 +173,7 @@ See [docs/features/plugin-system.md](../features/plugin-system.md). | `ai-tool-input-object.test.ts` | Every provider-facing site/content tool schema has a plain `type: object` root with no root-level `anyOf`, `oneOf`, or `allOf`, matching Anthropic's tool-schema contract while keeping one cross-provider registry. | | `ai-tool-schema-ssot.test.ts` | Site write-tool input schemas live once in `src/core/ai/toolSchemas.ts` (re-exported from `@core/ai`). Every registered tool's `inputSchema` is the exact object from `@core/ai` (referential identity check); consumer modules (`writeTools.ts`, `executor.ts`, `tokenRunners.ts`) import from `@core/ai` and do not redeclare local copies. | | `ai-driver-isolation.test.ts` | Provider SDKs (`@anthropic-ai/claude-agent-sdk`, `@openai/agents`, `@openrouter/agent`), `zod`, and `@anthropic-ai/sdk` are banned repo-wide with no allowed callers. `@modelcontextprotocol/sdk` is **scoped**: allowed only under `server/ai/mcp/` (the MCP server), banned everywhere else (drivers + browser). Drivers talk directly to each provider's REST API over HTTP/SSE and pass TypeBox schemas through as JSON Schema. `src/` and `server/` are both scanned. | -| `ai-mcp-connectors-never-leak.test.ts` | The MCP connector wire projection (`toConnectorView`) and `McpConnectorViewSchema` never expose the token or its hash. Mirrors `ai-credentials-never-leak.test.ts`. | +| `ai-mcp-connectors-never-leak.test.ts` | The MCP connection wire projection (`toConnectionView`) and `McpConnectionViewSchema` never expose personal access tokens, OAuth tokens, or their hashes. Mirrors `ai-credentials-never-leak.test.ts`. | | `ai-driver-shared-helpers.test.ts` | Two cross-provider helpers must have exactly one source: (1) `parseToolArguments` is exported only from `server/ai/drivers/http/toolArgs.ts` — every driver imports it; private copies (`parseJsonOrEmpty`, etc.) are banned. (2) `SYSTEM_PROMPT_DYNAMIC_BOUNDARY` is declared only in `server/ai/runtime/types.ts` — prompt builders and drivers import it. Divergent copies silently produce different error handling or break prompt caching per provider. | | `ai-handlers-capability-gated.test.ts` | Every handler under `server/ai/handlers/` calls `requireCapability` or `requireAnyCapability` before doing work. Prevents unauthenticated access to AI endpoints. | | `ai-credentials-never-leak.test.ts` | AI handler response bodies do not contain credential ciphertext or raw `apiKey` fields. Handlers must project through `toCredentialView()` before serialising a `CredentialRecord`. | diff --git a/server/ai/handlers/index.ts b/server/ai/handlers/index.ts index d8722f62c..51a28205a 100644 --- a/server/ai/handlers/index.ts +++ b/server/ai/handlers/index.ts @@ -17,7 +17,8 @@ import { tryHandleAiCredentials } from './credentials' import { tryHandleAiConversations } from './conversations' import { tryHandleAiDefaults } from './defaults' import { tryHandleAiModels } from './models' -import { tryHandleAiMcpConnectors } from '../mcp/handlers/connectors' +import { tryHandleAiMcpManagement } from '../mcp/handlers/management' +import { tryHandleMcpOAuthAuthorization } from '../mcp/handlers/oauthAuthorization' import { tryHandleAiEditorBridge } from '../mcp/handlers/editorBridge' export function tryHandleAi( @@ -39,7 +40,8 @@ export function tryHandleAi( // generic credentials/:id route — both live inside the credentials // handler so the order is handled there. return ( - tryHandleAiMcpConnectors(req, db, pathname) ?? + tryHandleMcpOAuthAuthorization(req, db, url, pathname) ?? + tryHandleAiMcpManagement(req, db, pathname) ?? tryHandleAiEditorBridge(req, db, pathname) ?? tryHandleAiAudit(req, db, url, pathname) ?? tryHandleAiChat(req, db, pathname) ?? diff --git a/server/ai/mcp/auth.test.ts b/server/ai/mcp/auth.test.ts index 57842a30b..f6fe8f7c2 100644 --- a/server/ai/mcp/auth.test.ts +++ b/server/ai/mcp/auth.test.ts @@ -3,9 +3,18 @@ import { createSqliteClient } from '../../db/sqlite' import { sqliteMigrations } from '../../db/migrations-sqlite' import { runMigrations } from '../../db/runMigrations' import type { DbClient } from '../../db/client' -import { createConnector } from './connectors/store' -import { generateConnectorToken, hashConnectorToken } from './connectors/token' +import { createBearerConnection } from './connectors/store' +import { + generatePersonalAccessToken, + hashMcpSecret, + pkceChallengeForVerifier, +} from './connectors/token' import { resolveMcpAuth, unauthorizedResponse } from './auth' +import { + createOAuthAuthorizationGrant, + exchangeAuthorizationCode, + registerOAuthClient, +} from './oauth/store' async function freshDb(): Promise { const db = createSqliteClient(':memory:') @@ -22,10 +31,10 @@ beforeEach(async () => { db = await freshDb() }) describe('mcp auth', () => { it('resolves a valid bearer token to a connector + capabilities', async () => { - const token = generateConnectorToken() - await createConnector(db, { - userId: 'u1', label: 'L', type: 'remote', - capabilities: ['ai.chat', 'content.manage'], tokenHash: await hashConnectorToken(token), + const token = generatePersonalAccessToken() + await createBearerConnection(db, { + userId: 'u1', label: 'L', + capabilities: ['ai.chat', 'content.manage'], tokenHash: await hashMcpSecret(token), }) const req = new Request('http://x/_instatic/mcp', { headers: { Authorization: `Bearer ${token}` } }) const res = await resolveMcpAuth(req, db) @@ -47,9 +56,9 @@ describe('mcp auth', () => { }) it('rejects a revoked connector token', async () => { - const token = generateConnectorToken() - const rec = await createConnector(db, { - userId: 'u1', label: 'L', type: 'remote', capabilities: ['ai.chat'], tokenHash: await hashConnectorToken(token), + const token = generatePersonalAccessToken() + const rec = await createBearerConnection(db, { + userId: 'u1', label: 'L', capabilities: ['ai.chat'], tokenHash: await hashMcpSecret(token), }) await db`update ai_mcp_connectors set revoked_at = current_timestamp where id = ${rec.id}` const req = new Request('http://x/_instatic/mcp', { headers: { Authorization: `Bearer ${token}` } }) @@ -57,11 +66,11 @@ describe('mcp auth', () => { }) it('rejects an expired connector token', async () => { - const token = generateConnectorToken() + const token = generatePersonalAccessToken() // Create a connector that expired 1 second ago. const pastExpiry = new Date(Date.now() - 1000).toISOString() - const rec = await createConnector(db, { - userId: 'u1', label: 'L', type: 'remote', capabilities: ['ai.chat'], tokenHash: await hashConnectorToken(token), + const rec = await createBearerConnection(db, { + userId: 'u1', label: 'L', capabilities: ['ai.chat'], tokenHash: await hashMcpSecret(token), ttlDays: 90, }) // Backdate expires_at to a past timestamp to simulate expiry. @@ -71,9 +80,9 @@ describe('mcp auth', () => { }) it('accepts a grandfathered connector with NULL expires_at (non-expiring)', async () => { - const token = generateConnectorToken() - const rec = await createConnector(db, { - userId: 'u1', label: 'Legacy', type: 'remote', capabilities: ['ai.chat'], tokenHash: await hashConnectorToken(token), + const token = generatePersonalAccessToken() + const rec = await createBearerConnection(db, { + userId: 'u1', label: 'Legacy', capabilities: ['ai.chat'], tokenHash: await hashMcpSecret(token), }) // Simulate a pre-migration 019 row with no expiry set. await db`update ai_mcp_connectors set expires_at = null where id = ${rec.id}` @@ -82,8 +91,43 @@ describe('mcp auth', () => { expect(res.ok).toBe(true) }) + it('resolves a resource-bound OAuth access token through the same capability gate', async () => { + const verifier = 'p'.repeat(64) + const client = await registerOAuthClient(db, { + clientName: 'Claude', + redirectUris: ['https://claude.ai/api/mcp/auth_callback'], + }) + const { code } = await createOAuthAuthorizationGrant(db, { + userId: 'u1', + clientName: client.clientName, + capabilities: ['ai.chat', 'site.read'], + request: { + responseType: 'code', + clientId: client.clientId, + redirectUri: client.redirectUris[0]!, + codeChallenge: await pkceChallengeForVerifier(verifier), + codeChallengeMethod: 'S256', + scope: 'mcp offline_access', + resource: 'http://x/_instatic/mcp', + }, + }) + const tokens = await exchangeAuthorizationCode(db, { + code, + clientId: client.clientId, + redirectUri: client.redirectUris[0]!, + codeVerifier: verifier, + resource: 'http://x/_instatic/mcp', + }) + const req = new Request('http://x/_instatic/mcp', { + headers: { Authorization: `Bearer ${tokens!.accessToken}` }, + }) + const result = await resolveMcpAuth(req, db) + expect(result.ok).toBe(true) + if (result.ok) expect(result.capabilities).toEqual(['ai.chat', 'site.read']) + }) + it('builds a spec-correct 401 with a resource_metadata pointer', () => { - const r = unauthorizedResponse(new URL('http://x/_instatic/mcp')) + const r = unauthorizedResponse(new Request('http://x/_instatic/mcp')) expect(r.status).toBe(401) const wwwAuth = r.headers.get('WWW-Authenticate') ?? '' expect(wwwAuth).toContain('Bearer') diff --git a/server/ai/mcp/auth.ts b/server/ai/mcp/auth.ts index 1985e207c..8f01e0f69 100644 --- a/server/ai/mcp/auth.ts +++ b/server/ai/mcp/auth.ts @@ -1,16 +1,14 @@ /** - * MCP bearer auth. Resolves an incoming request to the connector that owns the - * presented token, yielding its user id + granted capabilities — which flow - * straight into the existing tool capability gate. - * - * Current auth is static bearer tokens. The 401 advertises a Protected - * Resource Metadata pointer (`WWW-Authenticate`) so MCP-aware clients receive - * a spec-shaped challenge even when the bearer token is missing or invalid. + * MCP bearer resolution. The bearer value may be a personal access token or + * a short-lived OAuth access token; both resolve to the same connector grant + * and capability set consumed by the tool gate. */ import type { DbClient } from '../../db/client' import type { CoreCapability } from '@core/capabilities' -import { findConnectorByTokenHash, touchConnectorLastUsed } from './connectors/store' -import { hashConnectorToken } from './connectors/token' +import { findConnectionByTokenHash, touchConnectorLastUsed } from './connectors/store' +import { hashMcpSecret } from './connectors/token' +import { findOAuthAccessGrant } from './oauth/store' +import { mcpProtectedResourceMetadataUrl, mcpResource, MCP_OAUTH_SCOPE } from './oauth/protocol' export type McpAuthResult = | { ok: true; connectorId: string; userId: string; capabilities: readonly CoreCapability[] } @@ -21,12 +19,18 @@ const BEARER_RE = /^Bearer\s+(.+)$/i export async function resolveMcpAuth(req: Request, db: DbClient): Promise { const match = BEARER_RE.exec((req.headers.get('Authorization') ?? '').trim()) if (!match) return { ok: false } - const connector = await findConnectorByTokenHash(db, await hashConnectorToken(match[1]!.trim())) + const token = match[1]!.trim() + const oauthGrant = token.startsWith('imcp_at_') + ? await findOAuthAccessGrant(db, token, mcpResource(req)) + : null + if (oauthGrant) { + stampLastUsed(db, oauthGrant.connectorId) + return { ok: true, ...oauthGrant } + } + + const connector = await findConnectionByTokenHash(db, await hashMcpSecret(token)) if (!connector || !connector.tokenHash) return { ok: false } - // Best-effort last-used stamp; never block the request on it. - void touchConnectorLastUsed(db, connector.id).catch((err) => { - console.error('[ai:mcp] failed to stamp connector last_used_at:', err) - }) + stampLastUsed(db, connector.id) return { ok: true, connectorId: connector.id, @@ -37,15 +41,21 @@ export async function resolveMcpAuth(req: Request, db: DbClient): Promise { + console.error('[ai:mcp] failed to stamp connector last_used_at:', err) + }) +} diff --git a/server/ai/mcp/connectors/store.test.ts b/server/ai/mcp/connectors/store.test.ts index ae5dd4b71..ab1362fbb 100644 --- a/server/ai/mcp/connectors/store.test.ts +++ b/server/ai/mcp/connectors/store.test.ts @@ -4,14 +4,14 @@ import { sqliteMigrations } from '../../../db/migrations-sqlite' import { runMigrations } from '../../../db/runMigrations' import type { DbClient } from '../../../db/client' import { - createConnector, + createBearerConnection, listConnectorsForUser, - findConnectorByTokenHash, + findConnectionByTokenHash, revokeConnector, touchConnectorLastUsed, - toConnectorView, + toConnectionView, } from './store' -import { hashConnectorToken } from './token' +import { hashMcpSecret } from './token' async function freshDb(): Promise { const db = createSqliteClient(':memory:') @@ -29,12 +29,11 @@ beforeEach(async () => { db = await freshDb() }) describe('connector store', () => { it('creates, lists, and projects to a token-free view', async () => { - const rec = await createConnector(db, { + const rec = await createBearerConnection(db, { userId: 'u1', label: 'Claude Code', - type: 'local', capabilities: ['ai.chat', 'content.manage'], - tokenHash: await hashConnectorToken('imcp_x'), + tokenHash: await hashMcpSecret('imcp_x'), }) expect(rec.label).toBe('Claude Code') expect(rec.capabilities).toEqual(['ai.chat', 'content.manage']) @@ -43,7 +42,7 @@ describe('connector store', () => { const list = await listConnectorsForUser(db, 'u1') expect(list).toHaveLength(1) - const view = toConnectorView(rec) + const view = toConnectionView(rec) expect(JSON.stringify(view)).not.toContain('tokenHash') expect(JSON.stringify(view)).not.toContain('token_hash') expect(view.revoked).toBe(false) @@ -51,30 +50,30 @@ describe('connector store', () => { }) it('finds an active connector by token hash and skips revoked', async () => { - const hash = await hashConnectorToken('imcp_y') - const rec = await createConnector(db, { - userId: 'u1', label: 'L', type: 'remote', capabilities: ['ai.chat'], tokenHash: hash, + const hash = await hashMcpSecret('imcp_y') + const rec = await createBearerConnection(db, { + userId: 'u1', label: 'L', capabilities: ['ai.chat'], tokenHash: hash, }) - const found = await findConnectorByTokenHash(db, hash) + const found = await findConnectionByTokenHash(db, hash) expect(found?.id).toBe(rec.id) expect(await revokeConnector(db, rec.id, 'u1')).toBe(true) - expect(await findConnectorByTokenHash(db, hash)).toBeNull() + expect(await findConnectionByTokenHash(db, hash)).toBeNull() // A second revoke is a no-op (already revoked). expect(await revokeConnector(db, rec.id, 'u1')).toBe(false) }) it('does not revoke another user\'s connector', async () => { - const rec = await createConnector(db, { - userId: 'u1', label: 'L', type: 'local', capabilities: ['ai.chat'], tokenHash: await hashConnectorToken('imcp_z'), + const rec = await createBearerConnection(db, { + userId: 'u1', label: 'L', capabilities: ['ai.chat'], tokenHash: await hashMcpSecret('imcp_z'), }) expect(await revokeConnector(db, rec.id, 'someone-else')).toBe(false) }) it('touches last_used_at', async () => { - const rec = await createConnector(db, { - userId: 'u1', label: 'L', type: 'local', capabilities: ['ai.chat'], tokenHash: await hashConnectorToken('imcp_w'), + const rec = await createBearerConnection(db, { + userId: 'u1', label: 'L', capabilities: ['ai.chat'], tokenHash: await hashMcpSecret('imcp_w'), }) expect(rec.lastUsedAt).toBeNull() await touchConnectorLastUsed(db, rec.id) @@ -84,45 +83,45 @@ describe('connector store', () => { // ── Expiry tests ──────────────────────────────────────────────────────── - it('a freshly created token is accepted by findConnectorByTokenHash (not yet expired)', async () => { - const hash = await hashConnectorToken('imcp_fresh') - await createConnector(db, { - userId: 'u1', label: 'Fresh', type: 'local', capabilities: ['ai.chat'], tokenHash: hash, + it('a freshly created token is accepted by findConnectionByTokenHash (not yet expired)', async () => { + const hash = await hashMcpSecret('imcp_fresh') + await createBearerConnection(db, { + userId: 'u1', label: 'Fresh', capabilities: ['ai.chat'], tokenHash: hash, }) // Default now = new Date() — the token expires 90 days from creation, so it is valid. - const found = await findConnectorByTokenHash(db, hash) + const found = await findConnectionByTokenHash(db, hash) expect(found).not.toBeNull() expect(found?.label).toBe('Fresh') }) - it('an expired token is rejected by findConnectorByTokenHash', async () => { - const hash = await hashConnectorToken('imcp_expired') - await createConnector(db, { - userId: 'u1', label: 'Expired', type: 'local', capabilities: ['ai.chat'], tokenHash: hash, + it('an expired token is rejected by findConnectionByTokenHash', async () => { + const hash = await hashMcpSecret('imcp_expired') + await createBearerConnection(db, { + userId: 'u1', label: 'Expired', capabilities: ['ai.chat'], tokenHash: hash, ttlDays: 30, }) // Inject a `now` 31 days in the future — past the 30-day TTL. const future = new Date(Date.now() + 31 * 24 * 60 * 60 * 1000) - const found = await findConnectorByTokenHash(db, hash, future) + const found = await findConnectionByTokenHash(db, hash, future) expect(found).toBeNull() }) it('a non-expired token is still accepted when now is before expires_at', async () => { - const hash = await hashConnectorToken('imcp_valid') - await createConnector(db, { - userId: 'u1', label: 'Valid', type: 'local', capabilities: ['ai.chat'], tokenHash: hash, + const hash = await hashMcpSecret('imcp_valid') + await createBearerConnection(db, { + userId: 'u1', label: 'Valid', capabilities: ['ai.chat'], tokenHash: hash, ttlDays: 30, }) // Inject a `now` 29 days in the future — still within the 30-day TTL. const soon = new Date(Date.now() + 29 * 24 * 60 * 60 * 1000) - const found = await findConnectorByTokenHash(db, hash, soon) + const found = await findConnectionByTokenHash(db, hash, soon) expect(found).not.toBeNull() }) - it('createConnector always sets a non-null expiresAt on the returned record', async () => { - const rec = await createConnector(db, { - userId: 'u1', label: 'E', type: 'local', capabilities: ['ai.chat'], - tokenHash: await hashConnectorToken('imcp_ttl'), + it('createBearerConnection always sets a non-null expiresAt on the returned record', async () => { + const rec = await createBearerConnection(db, { + userId: 'u1', label: 'E', capabilities: ['ai.chat'], + tokenHash: await hashMcpSecret('imcp_ttl'), }) expect(rec.expiresAt).not.toBeNull() // expiresAt should be approximately 90 days from now (within ±2 minutes). @@ -133,12 +132,12 @@ describe('connector store', () => { expect(delta).toBeLessThan(ninetyDays + 2 * 60 * 1000) }) - it('toConnectorView includes expiresAt (non-null for new tokens) and never tokenHash', async () => { - const rec = await createConnector(db, { - userId: 'u1', label: 'V', type: 'local', capabilities: ['ai.chat'], - tokenHash: await hashConnectorToken('imcp_view'), + it('toConnectionView includes expiresAt (non-null for new tokens) and never tokenHash', async () => { + const rec = await createBearerConnection(db, { + userId: 'u1', label: 'V', capabilities: ['ai.chat'], + tokenHash: await hashMcpSecret('imcp_view'), }) - const view = toConnectorView(rec) + const view = toConnectionView(rec) // expiresAt must be a non-null string for a freshly created token. expect(view.expiresAt).not.toBeNull() expect(typeof view.expiresAt).toBe('string') @@ -149,9 +148,9 @@ describe('connector store', () => { }) it('custom ttlDays is honoured', async () => { - const rec = await createConnector(db, { - userId: 'u1', label: 'Custom TTL', type: 'local', capabilities: ['ai.chat'], - tokenHash: await hashConnectorToken('imcp_custom'), + const rec = await createBearerConnection(db, { + userId: 'u1', label: 'Custom TTL', capabilities: ['ai.chat'], + tokenHash: await hashMcpSecret('imcp_custom'), ttlDays: 7, }) const delta = new Date(rec.expiresAt!).getTime() - Date.now() @@ -160,32 +159,32 @@ describe('connector store', () => { expect(delta).toBeLessThan(sevenDays + 2 * 60 * 1000) }) - it('createConnector with ttlDays: null creates a non-expiring token', async () => { - const hash = await hashConnectorToken('imcp_no_expiry') - const rec = await createConnector(db, { - userId: 'u1', label: 'No Expiry', type: 'local', capabilities: ['ai.chat'], + it('createBearerConnection with ttlDays: null creates a non-expiring token', async () => { + const hash = await hashMcpSecret('imcp_no_expiry') + const rec = await createBearerConnection(db, { + userId: 'u1', label: 'No Expiry', capabilities: ['ai.chat'], tokenHash: hash, ttlDays: null, }) // expiresAt must be null for an explicitly non-expiring token. expect(rec.expiresAt).toBeNull() - // findConnectorByTokenHash must accept the token even with `now` far in the future. + // findConnectionByTokenHash must accept the token even with `now` far in the future. const farFuture = new Date(Date.now() + 10 * 365 * 24 * 60 * 60 * 1000) // 10 years - const found = await findConnectorByTokenHash(db, hash, farFuture) + const found = await findConnectionByTokenHash(db, hash, farFuture) expect(found).not.toBeNull() expect(found?.expiresAt).toBeNull() }) it('a connector row with NULL expires_at (grandfathered) is accepted as non-expiring', async () => { - const hash = await hashConnectorToken('imcp_null_expiry') - const rec = await createConnector(db, { - userId: 'u1', label: 'Legacy', type: 'local', capabilities: ['ai.chat'], tokenHash: hash, + const hash = await hashMcpSecret('imcp_null_expiry') + const rec = await createBearerConnection(db, { + userId: 'u1', label: 'Legacy', capabilities: ['ai.chat'], tokenHash: hash, }) // Simulate a pre-migration 019 row by clearing expires_at. await db`update ai_mcp_connectors set expires_at = null where id = ${rec.id}` // NULL expires_at → non-expiring; the connector must still be accepted. - const found = await findConnectorByTokenHash(db, hash) + const found = await findConnectionByTokenHash(db, hash) expect(found).not.toBeNull() expect(found?.label).toBe('Legacy') expect(found?.expiresAt).toBeNull() diff --git a/server/ai/mcp/connectors/store.ts b/server/ai/mcp/connectors/store.ts index 204abdf9a..e116a8880 100644 --- a/server/ai/mcp/connectors/store.ts +++ b/server/ai/mcp/connectors/store.ts @@ -4,23 +4,23 @@ * as a JSON string (parsed back automatically on read — SQLite by the adapter, * Postgres by jsonb), matching the `writeJson` convention in other repos. * - * `toConnectorView` is the ONLY projection the HTTP layer may serialise: it + * `toConnectionView` is the ONLY projection the HTTP layer may serialise: it * drops `tokenHash` entirely. Gated by `ai-mcp-connectors-never-leak.test.ts`. */ import { nanoid } from 'nanoid' import type { DbClient } from '../../../db/client' import type { CoreCapability } from '@core/capabilities' -import type { McpConnectorView, McpConnectorType, McpAuthMode } from '@core/ai' +import type { McpConnectionView, McpAuthMode } from '@core/ai' import type { McpConnectorRecord } from './types' const DEFAULT_TTL_DAYS = 90 +export const OAUTH_GRANT_TTL_DAYS = 90 interface ConnectorRow { id: string user_id: string label: string - type: string - auth_mode: string + auth_mode: McpAuthMode token_hash: string | null // `_json` column → auto-parsed to an array on read (both dialects). capabilities_json: CoreCapability[] @@ -35,27 +35,25 @@ function rowToRecord(row: ConnectorRow): McpConnectorRecord { id: row.id, userId: row.user_id, label: row.label, - type: row.type as McpConnectorType, - authMode: row.auth_mode as McpAuthMode, + authMode: row.auth_mode, tokenHash: row.token_hash, capabilities: Array.isArray(row.capabilities_json) ? row.capabilities_json : [], createdAt: row.created_at, lastUsedAt: row.last_used_at, revokedAt: row.revoked_at, - // expires_at is set by createConnector for every new bearer token. + // expires_at is set for every new bearer token and OAuth grant. // A null value (pre-migration 019 rows / grandfathered connectors) means - // non-expiring — findConnectorByTokenHash already accepts NULL via + // non-expiring — findConnectionByTokenHash already accepts NULL via // `(expires_at is null or expires_at > ${now})`. expiresAt: row.expires_at, } } /** Wire-safe projection. Never exposes the token hash. */ -export function toConnectorView(rec: McpConnectorRecord): McpConnectorView { +export function toConnectionView(rec: McpConnectorRecord): McpConnectionView { return { id: rec.id, label: rec.label, - type: rec.type, authMode: rec.authMode, capabilities: [...rec.capabilities], createdAt: rec.createdAt, @@ -65,12 +63,11 @@ export function toConnectorView(rec: McpConnectorRecord): McpConnectorView { } } -export async function createConnector( +export async function createBearerConnection( db: DbClient, input: { userId: string label: string - type: McpConnectorType capabilities: readonly CoreCapability[] tokenHash: string /** @@ -96,20 +93,53 @@ export async function createConnector( created_at, expires_at ) values ( - ${id}, ${input.userId}, ${input.label}, ${input.type}, 'bearer', + ${id}, ${input.userId}, ${input.label}, 'local', 'bearer', ${input.tokenHash}, ${capabilitiesJson}, ${createdAt}, ${expiresAt} ) - returning id, user_id, label, type, auth_mode, token_hash, + returning id, user_id, label, auth_mode, token_hash, capabilities_json, created_at, last_used_at, revoked_at, expires_at ` if (!rows[0]) throw new Error('Connector insert did not persist') return rowToRecord(rows[0]) } +/** Create the persistent user/capability grant behind an OAuth connection. */ +export async function createOAuthConnection( + db: DbClient, + input: { + userId: string + label: string + capabilities: readonly CoreCapability[] + expiresAt?: Date + }, +): Promise { + const id = nanoid() + const capabilitiesJson = JSON.stringify(input.capabilities) + const createdAt = new Date() + const expiresAt = input.expiresAt ?? new Date( + createdAt.getTime() + OAUTH_GRANT_TTL_DAYS * 24 * 60 * 60 * 1000, + ) + + const { rows } = await db` + insert into ai_mcp_connectors ( + id, user_id, label, type, auth_mode, token_hash, capabilities_json, + created_at, expires_at + ) + values ( + ${id}, ${input.userId}, ${input.label}, 'remote', 'oauth', null, + ${capabilitiesJson}, ${createdAt}, ${expiresAt} + ) + returning id, user_id, label, auth_mode, token_hash, + capabilities_json, created_at, last_used_at, revoked_at, expires_at + ` + if (!rows[0]) throw new Error('OAuth connection insert did not persist') + return rowToRecord(rows[0]) +} + export async function listConnectorsForUser(db: DbClient, userId: string): Promise { const { rows } = await db` - select id, user_id, label, type, auth_mode, token_hash, + select id, user_id, label, auth_mode, token_hash, capabilities_json, created_at, last_used_at, revoked_at, expires_at from ai_mcp_connectors where user_id = ${userId} @@ -124,13 +154,13 @@ export async function listConnectorsForUser(db: DbClient, userId: string): Promi * @param now - Injection point for the current time; defaults to `new Date()`. * Pass a fixed Date in tests to simulate future/past times without waiting. */ -export async function findConnectorByTokenHash( +export async function findConnectionByTokenHash( db: DbClient, tokenHash: string, now: Date = new Date(), ): Promise { const { rows } = await db` - select id, user_id, label, type, auth_mode, token_hash, + select id, user_id, label, auth_mode, token_hash, capabilities_json, created_at, last_used_at, revoked_at, expires_at from ai_mcp_connectors where token_hash = ${tokenHash} diff --git a/server/ai/mcp/connectors/token.test.ts b/server/ai/mcp/connectors/token.test.ts index 744037b4c..efc852a4d 100644 --- a/server/ai/mcp/connectors/token.test.ts +++ b/server/ai/mcp/connectors/token.test.ts @@ -1,24 +1,24 @@ import { describe, expect, it } from 'bun:test' -import { generateConnectorToken, hashConnectorToken } from './token' +import { generatePersonalAccessToken, hashMcpSecret } from './token' describe('connector token', () => { it('generates a prefixed, url-safe token', () => { - const t = generateConnectorToken() - expect(t).toMatch(/^imcp_[A-Za-z0-9_-]{43}$/) + const t = generatePersonalAccessToken() + expect(t).toMatch(/^imcp_pat_[A-Za-z0-9_-]{43}$/) }) it('generates distinct tokens', () => { - expect(generateConnectorToken()).not.toBe(generateConnectorToken()) + expect(generatePersonalAccessToken()).not.toBe(generatePersonalAccessToken()) }) it('hashes deterministically and differs per token', async () => { - const a = generateConnectorToken() - expect(await hashConnectorToken(a)).toBe(await hashConnectorToken(a)) - expect(await hashConnectorToken(a)).not.toBe(await hashConnectorToken(generateConnectorToken())) + const a = generatePersonalAccessToken() + expect(await hashMcpSecret(a)).toBe(await hashMcpSecret(a)) + expect(await hashMcpSecret(a)).not.toBe(await hashMcpSecret(generatePersonalAccessToken())) }) it('produces a url-safe hash with no padding', async () => { - const h = await hashConnectorToken('imcp_example') + const h = await hashMcpSecret('imcp_example') expect(h).toMatch(/^[A-Za-z0-9_-]+$/) }) }) diff --git a/server/ai/mcp/connectors/token.ts b/server/ai/mcp/connectors/token.ts index db234c247..6ae247bb5 100644 --- a/server/ai/mcp/connectors/token.ts +++ b/server/ai/mcp/connectors/token.ts @@ -1,27 +1,39 @@ -/** - * Connector secrets. The plaintext is shown to the operator exactly once at - * creation; only its SHA-256 hash is persisted, so a database read can never - * yield a usable token. Auth (`../auth.ts`) hashes the presented bearer token - * and looks it up by hash. - * - * Uses Web Crypto (`crypto.getRandomValues` / `crypto.subtle`) — available in - * Bun without imports — so there is no Node `crypto` dependency. - */ -const TOKEN_BYTES = 32 // 32 random bytes → 43 base64url chars - -export function generateConnectorToken(): string { - const bytes = crypto.getRandomValues(new Uint8Array(TOKEN_BYTES)) - return `imcp_${toBase64Url(bytes)}` +/** Opaque MCP credentials. Only one-way SHA-256 hashes are persisted. */ +const TOKEN_BYTES = 32 + +export function generatePersonalAccessToken(): string { + return generateSecret('imcp_pat_') +} + +export function generateOAuthAuthorizationCode(): string { + return generateSecret('imcp_ac_') } -export async function hashConnectorToken(token: string): Promise { - const data = new TextEncoder().encode(token) +export function generateOAuthAccessToken(): string { + return generateSecret('imcp_at_') +} + +export function generateOAuthRefreshToken(): string { + return generateSecret('imcp_rt_') +} + +export async function hashMcpSecret(secret: string): Promise { + const data = new TextEncoder().encode(secret) const digest = await crypto.subtle.digest('SHA-256', data) return toBase64Url(new Uint8Array(digest)) } +export async function pkceChallengeForVerifier(verifier: string): Promise { + return hashMcpSecret(verifier) +} + +function generateSecret(prefix: string): string { + const bytes = crypto.getRandomValues(new Uint8Array(TOKEN_BYTES)) + return `${prefix}${toBase64Url(bytes)}` +} + function toBase64Url(bytes: Uint8Array): string { - let bin = '' - for (const b of bytes) bin += String.fromCharCode(b) - return btoa(bin).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '') + let binary = '' + for (const byte of bytes) binary += String.fromCharCode(byte) + return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '') } diff --git a/server/ai/mcp/connectors/types.ts b/server/ai/mcp/connectors/types.ts index 8d620196d..a81e5dbf5 100644 --- a/server/ai/mcp/connectors/types.ts +++ b/server/ai/mcp/connectors/types.ts @@ -1,19 +1,18 @@ /** * Server-only connector record. Mirrors the credentials store's split: this * type carries `tokenHash` and is NEVER serialised over HTTP. The wire-safe - * projection is `McpConnectorView` (from `@core/ai`), produced by - * `toConnectorView` in `./store`. + * projection is `McpConnectionView` (from `@core/ai`), produced by + * `toConnectionView` in `./store`. */ import type { CoreCapability } from '@core/capabilities' -import type { McpAuthMode, McpConnectorType } from '@core/ai' +import type { McpAuthMode } from '@core/ai' export interface McpConnectorRecord { readonly id: string readonly userId: string readonly label: string - readonly type: McpConnectorType readonly authMode: McpAuthMode - /** One-way hash of the secret. Null is reserved for future OAuth rows. */ + /** One-way hash of a personal access token. OAuth rows keep this null. */ readonly tokenHash: string | null readonly capabilities: readonly CoreCapability[] readonly createdAt: string @@ -21,7 +20,7 @@ export interface McpConnectorRecord { readonly revokedAt: string | null /** * ISO 8601 UTC timestamp when this token expires. - * Always non-null for tokens created via createConnector. + * Always non-null for personal access tokens created via createBearerConnection. * Null for grandfathered rows (pre-migration 019): treated as non-expiring. */ readonly expiresAt: string | null diff --git a/server/ai/mcp/e2e.test.ts b/server/ai/mcp/e2e.test.ts index 66b71c575..ebd0ebf63 100644 --- a/server/ai/mcp/e2e.test.ts +++ b/server/ai/mcp/e2e.test.ts @@ -15,8 +15,8 @@ import { sqliteMigrations } from '../../db/migrations-sqlite' import { runMigrations } from '../../db/runMigrations' import type { DbClient } from '../../db/client' import { handleMcpHttp } from './index' -import { createConnector } from './connectors/store' -import { generateConnectorToken, hashConnectorToken } from './connectors/token' +import { createBearerConnection } from './connectors/store' +import { generatePersonalAccessToken, hashMcpSecret } from './connectors/token' let db: DbClient let token: string @@ -28,11 +28,11 @@ beforeEach(async () => { insert into users (id, email, email_normalized, display_name, password_hash, role_id) values ('u1', 'u1@example.com', 'u1@example.com', 'User One', 'x', 'owner') ` - token = generateConnectorToken() - await createConnector(db, { - userId: 'u1', label: 'Claude Code', type: 'local', + token = generatePersonalAccessToken() + await createBearerConnection(db, { + userId: 'u1', label: 'Claude Code', capabilities: ['ai.chat', 'ai.tools.write', 'site.read', 'site.structure.edit', 'content.manage', 'data.system.tables.read'], - tokenHash: await hashConnectorToken(token), + tokenHash: await hashMcpSecret(token), }) }) @@ -90,11 +90,11 @@ describe('MCP end-to-end (stateless multi-request, real handler)', () => { }) it('a read-only connector sees reads but no write tools', async () => { - const readToken = generateConnectorToken() - await createConnector(db, { - userId: 'u1', label: 'RO', type: 'remote', + const readToken = generatePersonalAccessToken() + await createBearerConnection(db, { + userId: 'u1', label: 'RO', capabilities: ['ai.chat', 'site.read', 'content.manage', 'data.system.tables.read'], - tokenHash: await hashConnectorToken(readToken), + tokenHash: await hashMcpSecret(readToken), }) const req = (method: string, params: unknown) => new Request('http://localhost/_instatic/mcp', { diff --git a/server/ai/mcp/handlers/connectors.ts b/server/ai/mcp/handlers/connectors.ts deleted file mode 100644 index 8e89fa34b..000000000 --- a/server/ai/mcp/handlers/connectors.ts +++ /dev/null @@ -1,121 +0,0 @@ -/** - * MCP connector CRUD — `/admin/api/ai/mcp/connectors[/:id]`. - * - * GET /admin/api/ai/mcp/connectors → list (token-free views) - * POST /admin/api/ai/mcp/connectors → create (returns token ONCE) - * DELETE /admin/api/ai/mcp/connectors/:id → revoke - * - * Gated by `ai.providers.manage` — the same capability that governs the AI - * Providers tab; managing MCP connectors is the same "manage AI integrations" - * admin surface. The plaintext token is surfaced exactly once, in the create - * response; every other shape is the wire-safe `McpConnectorView`. - * - * A connector can only grant capabilities the creating admin actually holds — - * an admin cannot mint a connector more powerful than themselves. - */ -import { getErrorMessage } from '@core/utils/errorMessage' -import { CreateMcpConnectorBodySchema } from '@core/ai' -import { jsonResponse, readValidatedBody, badRequest } from '../../../http' -import { requireCapability, requireStepUp, userHasCapability } from '../../../auth/authz' -import type { DbClient } from '../../../db/client' -import { createAuditEvent } from '../../../repositories/audit' -import { - createConnector, - listConnectorsForUser, - revokeConnector, - toConnectorView, -} from '../connectors/store' -import { generateConnectorToken, hashConnectorToken } from '../connectors/token' - -const BASE = '/admin/api/ai/mcp/connectors' - -export function tryHandleAiMcpConnectors( - req: Request, - db: DbClient, - pathname: string, -): Promise | null { - if (pathname !== BASE && !pathname.startsWith(`${BASE}/`)) return null - return handle(req, db, pathname) -} - -async function handle(req: Request, db: DbClient, pathname: string): Promise { - if (pathname === BASE) { - if (req.method === 'GET') return handleList(req, db) - if (req.method === 'POST') return handleCreate(req, db) - return jsonResponse({ error: 'Method not allowed' }, { status: 405 }) - } - // /:id - if (req.method === 'DELETE') return handleRevoke(req, db, pathname.slice(`${BASE}/`.length)) - return jsonResponse({ error: 'Method not allowed' }, { status: 405 }) -} - -async function handleList(req: Request, db: DbClient): Promise { - const userOrResponse = await requireCapability(req, db, 'ai.providers.manage') - if (userOrResponse instanceof Response) return userOrResponse - const records = await listConnectorsForUser(db, userOrResponse.id) - return jsonResponse({ connectors: records.map(toConnectorView) }) -} - -async function handleCreate(req: Request, db: DbClient): Promise { - const userOrResponse = await requireCapability(req, db, 'ai.providers.manage') - if (userOrResponse instanceof Response) return userOrResponse - - const body = await readValidatedBody(req, CreateMcpConnectorBodySchema) - if (!body) return badRequest('Invalid request body.') - - // Privilege floor: a connector can only grant capabilities the creator holds. - const overreach = body.capabilities.filter((cap) => !userHasCapability(userOrResponse, cap)) - if (overreach.length > 0) { - return jsonResponse( - { error: `You cannot grant capabilities you don't hold: ${overreach.join(', ')}` }, - { status: 403 }, - ) - } - - // Connector tokens are long-lived delegated credentials. Creating one can - // grant the same high-impact capabilities as the current admin (including - // non-interactive full-site publish), so minting the secret requires the - // same fresh authentication window as other sensitive admin operations. - const stepUp = await requireStepUp(req, db, userOrResponse) - if (stepUp) return stepUp - - try { - const token = generateConnectorToken() - const record = await createConnector(db, { - userId: userOrResponse.id, - label: body.label, - type: body.type, - capabilities: body.capabilities, - tokenHash: await hashConnectorToken(token), - ttlDays: body.ttlDays, - }) - await createAuditEvent(db, { - actorUserId: userOrResponse.id, - action: 'ai.mcp_connector.created', - targetType: 'ai_mcp_connector', - targetId: record.id, - metadata: { label: record.label, type: record.type, capabilities: [...record.capabilities] }, - }) - return jsonResponse({ connector: toConnectorView(record), token }, { status: 201 }) - } catch (err) { - console.error('[ai:mcp] failed to create connector:', err) - return jsonResponse({ error: getErrorMessage(err, 'Failed to create connector.') }, { status: 500 }) - } -} - -async function handleRevoke(req: Request, db: DbClient, id: string): Promise { - const userOrResponse = await requireCapability(req, db, 'ai.providers.manage') - if (userOrResponse instanceof Response) return userOrResponse - if (!id) return badRequest('Missing connector id.') - - const revoked = await revokeConnector(db, id, userOrResponse.id) - if (!revoked) return jsonResponse({ error: 'Connector not found.' }, { status: 404 }) - - await createAuditEvent(db, { - actorUserId: userOrResponse.id, - action: 'ai.mcp_connector.revoked', - targetType: 'ai_mcp_connector', - targetId: id, - }) - return jsonResponse({ revoked: true }) -} diff --git a/server/ai/mcp/handlers/management.ts b/server/ai/mcp/handlers/management.ts new file mode 100644 index 000000000..5e1f20a06 --- /dev/null +++ b/server/ai/mcp/handlers/management.ts @@ -0,0 +1,132 @@ +/** Admin management API for MCP connections and personal access tokens. */ +import { getErrorMessage } from '@core/utils/errorMessage' +import { CreateMcpAccessTokenBodySchema } from '@core/ai' +import { jsonResponse, readValidatedBody, badRequest } from '../../../http' +import { requireCapability, requireStepUp, userHasCapability } from '../../../auth/authz' +import { expectedOrigin } from '../../../auth/security' +import type { DbClient } from '../../../db/client' +import { createAuditEvent } from '../../../repositories/audit' +import { + createBearerConnection, + listConnectorsForUser, + revokeConnector, + toConnectionView, +} from '../connectors/store' +import { generatePersonalAccessToken, hashMcpSecret } from '../connectors/token' +import { MCP_ENDPOINT_PATH } from '../paths' +import { isRemoteMcpEndpoint } from '../oauth/protocol' + +const CONNECTIONS_BASE = '/admin/api/ai/mcp/connections' +const ACCESS_TOKENS_PATH = '/admin/api/ai/mcp/access-tokens' + +export function tryHandleAiMcpManagement( + req: Request, + db: DbClient, + pathname: string, +): Promise | null { + if ( + pathname !== CONNECTIONS_BASE && + !pathname.startsWith(`${CONNECTIONS_BASE}/`) && + pathname !== ACCESS_TOKENS_PATH + ) { + return null + } + return handle(req, db, pathname) +} + +async function handle(req: Request, db: DbClient, pathname: string): Promise { + if (pathname === CONNECTIONS_BASE) { + return req.method === 'GET' + ? handleList(req, db) + : jsonResponse({ error: 'Method not allowed' }, { status: 405 }) + } + if (pathname === ACCESS_TOKENS_PATH) { + return req.method === 'POST' + ? handleCreateAccessToken(req, db) + : jsonResponse({ error: 'Method not allowed' }, { status: 405 }) + } + if (req.method === 'DELETE') { + return handleRevoke(req, db, pathname.slice(`${CONNECTIONS_BASE}/`.length)) + } + return jsonResponse({ error: 'Method not allowed' }, { status: 405 }) +} + +async function handleList(req: Request, db: DbClient): Promise { + const userOrResponse = await requireCapability(req, db, 'ai.providers.manage') + if (userOrResponse instanceof Response) return userOrResponse + const records = await listConnectorsForUser(db, userOrResponse.id) + const endpoint = `${expectedOrigin(req)}${MCP_ENDPOINT_PATH}` + return jsonResponse({ + connections: records.map(toConnectionView), + endpoint, + remoteAccess: isRemoteMcpEndpoint(endpoint) ? 'public-https' : 'local-only', + }) +} + +async function handleCreateAccessToken(req: Request, db: DbClient): Promise { + const userOrResponse = await requireCapability(req, db, 'ai.providers.manage') + if (userOrResponse instanceof Response) return userOrResponse + + const body = await readValidatedBody(req, CreateMcpAccessTokenBodySchema) + if (!body) return badRequest('Invalid request body.') + const label = body.label.trim() + if (!label) return badRequest('Label is required.') + + const capabilities = [...new Set(body.capabilities)] + if (userHasCapability(userOrResponse, 'ai.chat') && !capabilities.includes('ai.chat')) { + capabilities.push('ai.chat') + } + const overreach = capabilities.filter((capability) => !userHasCapability(userOrResponse, capability)) + if (overreach.length > 0) { + return jsonResponse( + { error: `You cannot grant capabilities you don't hold: ${overreach.join(', ')}` }, + { status: 403 }, + ) + } + + const stepUp = await requireStepUp(req, db, userOrResponse) + if (stepUp) return stepUp + + try { + const accessToken = generatePersonalAccessToken() + const record = await createBearerConnection(db, { + userId: userOrResponse.id, + label, + capabilities, + tokenHash: await hashMcpSecret(accessToken), + ttlDays: body.ttlDays, + }) + await createAuditEvent(db, { + actorUserId: userOrResponse.id, + action: 'ai.mcp_connector.created', + targetType: 'ai_mcp_connector', + targetId: record.id, + metadata: { + label: record.label, + authMode: record.authMode, + capabilities: [...record.capabilities], + }, + }) + return jsonResponse({ connection: toConnectionView(record), accessToken }, { status: 201 }) + } catch (err) { + console.error('[ai:mcp] failed to create access token:', err) + return jsonResponse({ error: getErrorMessage(err, 'Failed to create access token.') }, { status: 500 }) + } +} + +async function handleRevoke(req: Request, db: DbClient, id: string): Promise { + const userOrResponse = await requireCapability(req, db, 'ai.providers.manage') + if (userOrResponse instanceof Response) return userOrResponse + if (!id) return badRequest('Missing connection id.') + + const revoked = await revokeConnector(db, id, userOrResponse.id) + if (!revoked) return jsonResponse({ error: 'Connection not found.' }, { status: 404 }) + + await createAuditEvent(db, { + actorUserId: userOrResponse.id, + action: 'ai.mcp_connector.revoked', + targetType: 'ai_mcp_connector', + targetId: id, + }) + return jsonResponse({ revoked: true }) +} diff --git a/server/ai/mcp/handlers/oauthAuthorization.ts b/server/ai/mcp/handlers/oauthAuthorization.ts new file mode 100644 index 000000000..c56394aee --- /dev/null +++ b/server/ai/mcp/handlers/oauthAuthorization.ts @@ -0,0 +1,132 @@ +/** Admin-session consent surface for the MCP OAuth authorization-code flow. */ +import { getErrorMessage } from '@core/utils/errorMessage' +import { + DecideMcpOAuthAuthorizationBodySchema, + type McpOAuthAuthorizationRequest, +} from '@core/ai' +import { badRequest, jsonResponse, readValidatedBody } from '../../../http' +import { requireCapability, requireStepUp, userHasCapability } from '../../../auth/authz' +import type { DbClient } from '../../../db/client' +import { createAuditEvent } from '../../../repositories/audit' +import { createOAuthAuthorizationGrant, findOAuthClient } from '../oauth/store' +import { OAUTH_GRANT_TTL_DAYS } from '../connectors/store' +import { + isValidPkceChallenge, + mcpResource, + normalizeMcpScope, + oauthRedirect, +} from '../oauth/protocol' +import { parseOAuthAuthorizationRequest } from '../oauth/schemas' +import { MCP_OAUTH_AUTHORIZATION_API_PATH } from '../paths' + +export function tryHandleMcpOAuthAuthorization( + req: Request, + db: DbClient, + url: URL, + pathname: string, +): Promise | null { + if (pathname !== MCP_OAUTH_AUTHORIZATION_API_PATH) return null + if (req.method === 'GET') return handleRead(req, db, url) + if (req.method === 'POST') return handleDecision(req, db) + return Promise.resolve(jsonResponse({ error: 'Method not allowed' }, { status: 405 })) +} + +async function handleRead(req: Request, db: DbClient, url: URL): Promise { + const userOrResponse = await requireCapability(req, db, 'ai.providers.manage') + if (userOrResponse instanceof Response) return userOrResponse + const request = parseOAuthAuthorizationRequest(url.searchParams) + if (!request) return badRequest('Invalid OAuth authorization request.') + const resolved = await resolveAuthorizationRequest(req, db, request) + if (!resolved) return badRequest('The OAuth client, callback, scope, or PKCE challenge is invalid.') + return jsonResponse({ + clientName: resolved.clientName, + callbackUrl: request.redirectUri, + grantExpiresInDays: OAUTH_GRANT_TTL_DAYS, + request: resolved.request, + }) +} + +async function handleDecision(req: Request, db: DbClient): Promise { + const userOrResponse = await requireCapability(req, db, 'ai.providers.manage') + if (userOrResponse instanceof Response) return userOrResponse + const body = await readValidatedBody(req, DecideMcpOAuthAuthorizationBodySchema) + if (!body) return badRequest('Invalid authorization decision.') + const resolved = await resolveAuthorizationRequest(req, db, body.request) + if (!resolved) return badRequest('The OAuth authorization request is no longer valid.') + + if (body.decision === 'deny') { + return jsonResponse({ + redirectUrl: oauthRedirect(body.request.redirectUri, { + error: 'access_denied', + error_description: 'The resource owner denied the request.', + state: body.request.state, + }), + }) + } + + const capabilities = [...new Set(body.capabilities ?? [])] + if ( + userHasCapability(userOrResponse, 'ai.chat') && + !capabilities.includes('ai.chat') + ) { + capabilities.push('ai.chat') + } + if (capabilities.length === 0) return badRequest('Select at least one capability.') + const overreach = capabilities.filter((capability) => !userHasCapability(userOrResponse, capability)) + if (overreach.length > 0) { + return jsonResponse( + { error: `You cannot grant capabilities you don't hold: ${overreach.join(', ')}` }, + { status: 403 }, + ) + } + + const stepUp = await requireStepUp(req, db, userOrResponse) + if (stepUp) return stepUp + + try { + const { connection, code } = await createOAuthAuthorizationGrant(db, { + userId: userOrResponse.id, + clientName: resolved.clientName, + capabilities, + request: resolved.request, + }) + await createAuditEvent(db, { + actorUserId: userOrResponse.id, + action: 'ai.mcp_connector.created', + targetType: 'ai_mcp_connector', + targetId: connection.id, + metadata: { + label: connection.label, + authMode: connection.authMode, + clientId: resolved.request.clientId, + capabilities: [...connection.capabilities], + }, + }) + return jsonResponse({ + redirectUrl: oauthRedirect(resolved.request.redirectUri, { + code, + state: resolved.request.state, + }), + }) + } catch (err) { + console.error('[ai:mcp:oauth] authorization failed:', err) + return jsonResponse({ error: getErrorMessage(err, 'Failed to authorize MCP connection.') }, { status: 500 }) + } +} + +async function resolveAuthorizationRequest( + req: Request, + db: DbClient, + request: McpOAuthAuthorizationRequest, +): Promise<{ clientName: string; request: McpOAuthAuthorizationRequest } | null> { + const client = await findOAuthClient(db, request.clientId) + const scope = normalizeMcpScope(request.scope) + if ( + !client || !scope || !client.redirectUris.includes(request.redirectUri) || + !isValidPkceChallenge(request.codeChallenge) || + request.resource !== mcpResource(req) + ) { + return null + } + return { clientName: client.clientName, request: { ...request, scope } } +} diff --git a/server/ai/mcp/index.ts b/server/ai/mcp/index.ts index c88f6c51c..1fcc66c28 100644 --- a/server/ai/mcp/index.ts +++ b/server/ai/mcp/index.ts @@ -2,4 +2,5 @@ * MCP server module — Instatic as an MCP server. External MCP clients (Claude * Code, Codex, remote agents) connect here and drive the CMS tools. */ -export { handleMcpHttp, MCP_ENDPOINT_PATH } from './transports/http' +export { handleMcpHttp } from './transports/http' +export { MCP_ENDPOINT_PATH } from './paths' diff --git a/server/ai/mcp/oauth/handler.test.ts b/server/ai/mcp/oauth/handler.test.ts new file mode 100644 index 000000000..ec621354c --- /dev/null +++ b/server/ai/mcp/oauth/handler.test.ts @@ -0,0 +1,156 @@ +import { beforeEach, describe, expect, it } from 'bun:test' +import type { DbClient } from '../../../db/client' +import { sqliteMigrations } from '../../../db/migrations-sqlite' +import { runMigrations } from '../../../db/runMigrations' +import { createSqliteClient } from '../../../db/sqlite' +import { pkceChallengeForVerifier } from '../connectors/token' +import { + MCP_AUTHORIZATION_SERVER_METADATA_PATH, + MCP_OAUTH_REGISTER_PATH, + MCP_OAUTH_TOKEN_PATH, + MCP_PROTECTED_RESOURCE_METADATA_PATH, +} from '../paths' +import { tryHandleMcpOAuth } from './handler' +import { isRemoteMcpEndpoint } from './protocol' +import { + createOAuthAuthorizationGrant, + registerOAuthClient, +} from './store' + +async function freshDb(): Promise { + const db = createSqliteClient(':memory:') + await runMigrations(db, sqliteMigrations) + await db` + insert into users (id, email, email_normalized, display_name, password_hash, role_id) + values ('u1', 'u1@example.com', 'u1@example.com', 'User One', 'x', 'owner') + ` + return db +} + +function request(path: string, init?: RequestInit): Request { + return new Request(`https://cms.example.com${path}`, init) +} + +async function handle(req: Request, db: DbClient): Promise { + const response = await tryHandleMcpOAuth(req, db, new URL(req.url).pathname) + if (!response) throw new Error('OAuth handler returned null') + return response +} + +let db: DbClient +beforeEach(async () => { db = await freshDb() }) + +describe('MCP OAuth protocol endpoints', () => { + it('only marks non-local HTTPS endpoints as ready for hosted clients', () => { + expect(isRemoteMcpEndpoint('https://cms.example.com/_instatic/mcp')).toBe(true) + expect(isRemoteMcpEndpoint('http://cms.example.com/_instatic/mcp')).toBe(false) + expect(isRemoteMcpEndpoint('https://localhost:3000/_instatic/mcp')).toBe(false) + expect(isRemoteMcpEndpoint('https://192.168.1.5/_instatic/mcp')).toBe(false) + expect(isRemoteMcpEndpoint('https://instatic.internal/_instatic/mcp')).toBe(false) + }) + + it('advertises protected-resource and authorization-server metadata', async () => { + const protectedResource = await handle(request(MCP_PROTECTED_RESOURCE_METADATA_PATH), db) + expect(protectedResource.status).toBe(200) + expect(protectedResource.headers.get('Cache-Control')).toContain('max-age=300') + const resourceBody = await protectedResource.json() as Record + expect(resourceBody.resource).toBe('https://cms.example.com/_instatic/mcp') + expect(resourceBody.authorization_servers).toEqual(['https://cms.example.com']) + + const authorizationServer = await handle(request(MCP_AUTHORIZATION_SERVER_METADATA_PATH), db) + const serverBody = await authorizationServer.json() as Record + expect(serverBody.authorization_endpoint).toBe('https://cms.example.com/admin/ai/oauth/authorize') + expect(serverBody.token_endpoint).toBe('https://cms.example.com/_instatic/oauth/token') + expect(serverBody.registration_endpoint).toBe('https://cms.example.com/_instatic/oauth/register') + expect(serverBody.code_challenge_methods_supported).toEqual(['S256']) + expect(serverBody.token_endpoint_auth_methods_supported).toEqual(['none']) + }) + + it('dynamically registers a public Claude client', async () => { + const response = await handle(request(MCP_OAUTH_REGISTER_PATH, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + client_name: 'Claude', + redirect_uris: ['https://claude.ai/api/mcp/auth_callback'], + token_endpoint_auth_method: 'none', + grant_types: ['authorization_code', 'refresh_token'], + response_types: ['code'], + }), + }), db) + expect(response.status).toBe(201) + expect(response.headers.get('Cache-Control')).toBe('no-store') + const body = await response.json() as Record + expect(body.client_id).toMatch(/^imcp_client_/) + expect(body.token_endpoint_auth_method).toBe('none') + expect(body.redirect_uris).toEqual(['https://claude.ai/api/mcp/auth_callback']) + }) + + it('rejects non-HTTPS redirects outside a loopback host', async () => { + const response = await handle(request(MCP_OAUTH_REGISTER_PATH, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + client_name: 'Unsafe client', + redirect_uris: ['http://attacker.example/callback'], + }), + }), db) + expect(response.status).toBe(400) + expect(await response.json()).toMatchObject({ error: 'invalid_redirect_uri' }) + }) + + it('exchanges a form-encoded authorization code without exposing cacheable credentials', async () => { + const verifier = 'k'.repeat(64) + const callback = 'https://claude.ai/api/mcp/auth_callback' + const resource = 'https://cms.example.com/_instatic/mcp' + const client = await registerOAuthClient(db, { + clientName: 'Claude', + redirectUris: [callback], + }) + const { code } = await createOAuthAuthorizationGrant(db, { + userId: 'u1', + clientName: client.clientName, + capabilities: ['site.read'], + request: { + responseType: 'code', + clientId: client.clientId, + redirectUri: callback, + codeChallenge: await pkceChallengeForVerifier(verifier), + codeChallengeMethod: 'S256', + scope: 'mcp offline_access', + resource, + }, + }) + const form = new URLSearchParams({ + grant_type: 'authorization_code', + code, + redirect_uri: callback, + client_id: client.clientId, + code_verifier: verifier, + resource, + }) + const response = await handle(request(MCP_OAUTH_TOKEN_PATH, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: form, + }), db) + expect(response.status).toBe(200) + expect(response.headers.get('Cache-Control')).toBe('no-store') + expect(response.headers.get('Pragma')).toBe('no-cache') + expect(await response.json()).toMatchObject({ + token_type: 'Bearer', + expires_in: 3600, + scope: 'mcp offline_access', + }) + }) + + it('rejects duplicate token parameters instead of resolving an ambiguous request', async () => { + const response = await handle(request(MCP_OAUTH_TOKEN_PATH, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: 'grant_type=refresh_token&grant_type=authorization_code', + }), db) + expect(response.status).toBe(400) + expect(await response.json()).toMatchObject({ error: 'invalid_request' }) + }) +}) diff --git a/server/ai/mcp/oauth/handler.ts b/server/ai/mcp/oauth/handler.ts new file mode 100644 index 000000000..14e7c69ef --- /dev/null +++ b/server/ai/mcp/oauth/handler.ts @@ -0,0 +1,231 @@ +import type { DbClient } from '../../../db/client' +import { clientIp } from '../../../auth/security' +import { + jsonResponse, + methodNotAllowed, + readTextBodyWithLimit, + readValidatedBody, + RequestBodyTooLargeError, +} from '../../../http' +import { + MCP_AUTHORIZATION_SERVER_METADATA_PATH, + MCP_PATH_PROTECTED_RESOURCE_METADATA_PATH, + MCP_OAUTH_REGISTER_PATH, + MCP_OAUTH_TOKEN_PATH, + MCP_PROTECTED_RESOURCE_METADATA_PATH, +} from '../paths' +import { + mcpOAuthEndpoints, + mcpResource, + MCP_OAUTH_SUPPORTED_SCOPES, + parseAllowedRedirectUri, +} from './protocol' +import { + OAuthClientRegistrationRequestSchema, + parseOAuthTokenRequest, + type OAuthClientRegistrationRequest, +} from './schemas' +import { + exchangeAuthorizationCode, + registerOAuthClient, + rotateRefreshToken, +} from './store' +import { oauthRegistrationRateLimit, oauthTokenRateLimit } from './rateLimit' + +const OAUTH_REQUEST_MAX_BYTES = 32 * 1024 + +export function tryHandleMcpOAuth( + req: Request, + db: DbClient, + pathname: string, +): Promise | Response | null { + if ( + pathname === MCP_PROTECTED_RESOURCE_METADATA_PATH || + pathname === MCP_PATH_PROTECTED_RESOURCE_METADATA_PATH + ) { + return req.method === 'GET' ? protectedResourceMetadata(req) : methodNotAllowed() + } + if (pathname === MCP_AUTHORIZATION_SERVER_METADATA_PATH) { + return req.method === 'GET' ? authorizationServerMetadata(req) : methodNotAllowed() + } + if (pathname === MCP_OAUTH_REGISTER_PATH) { + return req.method === 'POST' ? handleClientRegistration(req, db) : methodNotAllowed() + } + if (pathname === MCP_OAUTH_TOKEN_PATH) { + return req.method === 'POST' ? handleTokenRequest(req, db) : methodNotAllowed() + } + return null +} + +function protectedResourceMetadata(req: Request): Response { + const endpoints = mcpOAuthEndpoints(req) + return metadataResponse({ + resource: mcpResource(req), + resource_name: 'Instatic MCP', + authorization_servers: [endpoints.issuer], + scopes_supported: [...MCP_OAUTH_SUPPORTED_SCOPES], + bearer_methods_supported: ['header'], + }) +} + +function authorizationServerMetadata(req: Request): Response { + const endpoints = mcpOAuthEndpoints(req) + return metadataResponse({ + issuer: endpoints.issuer, + authorization_endpoint: endpoints.authorizationEndpoint, + token_endpoint: endpoints.tokenEndpoint, + registration_endpoint: endpoints.registrationEndpoint, + response_types_supported: ['code'], + grant_types_supported: ['authorization_code', 'refresh_token'], + code_challenge_methods_supported: ['S256'], + token_endpoint_auth_methods_supported: ['none'], + scopes_supported: [...MCP_OAUTH_SUPPORTED_SCOPES], + }) +} + +async function handleClientRegistration(req: Request, db: DbClient): Promise { + const limited = consumeRateLimit(req, oauthRegistrationRateLimit) + if (limited) return limited + + let body: OAuthClientRegistrationRequest | null + try { + body = await readValidatedBody(req, OAuthClientRegistrationRequestSchema, { + maxBytes: OAUTH_REQUEST_MAX_BYTES, + }) + } catch (err) { + if (err instanceof RequestBodyTooLargeError) { + return oauthError('invalid_client_metadata', 'Client registration is too large.', 400) + } + throw err + } + if (!body) return oauthError('invalid_client_metadata', 'Invalid client registration.', 400) + const clientName = body.client_name.trim() + if ( + !clientName || + (body.grant_types !== undefined && !body.grant_types.includes('authorization_code')) || + (body.response_types !== undefined && !body.response_types.includes('code')) || + body.grant_types?.some((grant) => grant !== 'authorization_code' && grant !== 'refresh_token') || + body.response_types?.some((responseType) => responseType !== 'code') + ) { + return oauthError('invalid_client_metadata', 'Only authorization_code with PKCE is supported.', 400) + } + + const redirectUris = [...new Set(body.redirect_uris)] + if (redirectUris.some((uri) => !parseAllowedRedirectUri(uri))) { + return oauthError( + 'invalid_redirect_uri', + 'Redirect URIs must use HTTPS, or HTTP on a loopback host.', + 400, + ) + } + + try { + const client = await registerOAuthClient(db, { + clientName, + redirectUris, + }) + return privateJsonResponse({ + client_id: client.clientId, + client_id_issued_at: client.clientIdIssuedAt, + client_name: client.clientName, + redirect_uris: [...client.redirectUris], + token_endpoint_auth_method: 'none', + grant_types: ['authorization_code', 'refresh_token'], + response_types: ['code'], + }, { status: 201 }) + } catch (err) { + console.error('[ai:mcp:oauth] client registration failed:', err) + return oauthError('server_error', 'Client registration failed.', 500) + } +} + +async function handleTokenRequest(req: Request, db: DbClient): Promise { + const limited = consumeRateLimit(req, oauthTokenRateLimit) + if (limited) return limited + + const contentType = req.headers.get('content-type')?.toLowerCase() ?? '' + if (!contentType.startsWith('application/x-www-form-urlencoded')) { + return oauthError('invalid_request', 'The token endpoint requires form-encoded input.', 400) + } + if (req.headers.has('authorization')) { + return oauthError('invalid_client', 'This authorization server accepts public clients only.', 401) + } + + let params: URLSearchParams + try { + params = new URLSearchParams(await readTextBodyWithLimit(req, OAUTH_REQUEST_MAX_BYTES)) + } catch (err) { + if (err instanceof RequestBodyTooLargeError) { + return oauthError('invalid_request', 'The token request is too large.', 400) + } + return oauthError('invalid_request', 'Invalid token request.', 400) + } + const body = parseOAuthTokenRequest(params) + if (!body) { + const grantType = params.get('grant_type') + return grantType === 'authorization_code' || grantType === 'refresh_token' + ? oauthError('invalid_request', 'The token request is invalid.', 400) + : oauthError('unsupported_grant_type', 'The token grant is not supported.', 400) + } + if (body.resource !== mcpResource(req)) { + return oauthError('invalid_target', 'The requested resource does not match this MCP server.', 400) + } + + try { + const tokens = body.grant_type === 'authorization_code' + ? await exchangeAuthorizationCode(db, { + code: body.code, + clientId: body.client_id, + redirectUri: body.redirect_uri, + codeVerifier: body.code_verifier, + resource: body.resource, + }) + : await rotateRefreshToken(db, { + refreshToken: body.refresh_token, + clientId: body.client_id, + resource: body.resource, + scope: body.scope, + }) + + if (!tokens) return oauthError('invalid_grant', 'The authorization grant is invalid or expired.', 400) + return privateJsonResponse({ + access_token: tokens.accessToken, + token_type: 'Bearer', + expires_in: tokens.expiresIn, + refresh_token: tokens.refreshToken, + scope: tokens.scope, + }) + } catch (err) { + console.error('[ai:mcp:oauth] token exchange failed:', err) + return oauthError('server_error', 'Token exchange failed.', 500) + } +} + +function metadataResponse(body: unknown): Response { + return jsonResponse(body, { + headers: { 'Cache-Control': 'public, max-age=300' }, + }) +} + +function oauthError(error: string, description: string, status: number): Response { + return privateJsonResponse({ error, error_description: description }, { status }) +} + +function privateJsonResponse(body: unknown, init: ResponseInit = {}): Response { + const headers = new Headers(init.headers) + headers.set('Cache-Control', 'no-store') + headers.set('Pragma', 'no-cache') + return jsonResponse(body, { ...init, headers }) +} + +function consumeRateLimit( + req: Request, + limiter: { consume: (key: string) => { ok: boolean; retryAfterMs: number } }, +): Response | null { + const decision = limiter.consume(clientIp(req) ?? 'unknown') + if (decision.ok) return null + return privateJsonResponse({ error: 'temporarily_unavailable', error_description: 'Too many requests.' }, { + status: 429, + headers: { 'Retry-After': String(Math.max(1, Math.ceil(decision.retryAfterMs / 1000))) }, + }) +} diff --git a/server/ai/mcp/oauth/protocol.ts b/server/ai/mcp/oauth/protocol.ts new file mode 100644 index 000000000..6fa6b67aa --- /dev/null +++ b/server/ai/mcp/oauth/protocol.ts @@ -0,0 +1,123 @@ +import { expectedOrigin } from '../../../auth/security' +import { + MCP_AUTHORIZATION_SERVER_METADATA_PATH, + MCP_ENDPOINT_PATH, + MCP_OAUTH_AUTHORIZE_PATH, + MCP_OAUTH_REGISTER_PATH, + MCP_OAUTH_TOKEN_PATH, + MCP_PROTECTED_RESOURCE_METADATA_PATH, +} from '../paths' + +export const MCP_OAUTH_SCOPE = 'mcp' +export const MCP_OAUTH_OFFLINE_SCOPE = 'offline_access' +export const MCP_OAUTH_SUPPORTED_SCOPES = [MCP_OAUTH_SCOPE, MCP_OAUTH_OFFLINE_SCOPE] as const + +export function mcpIssuer(req: Request): string { + return expectedOrigin(req) +} + +export function mcpResource(req: Request): string { + return `${mcpIssuer(req)}${MCP_ENDPOINT_PATH}` +} + +export function mcpProtectedResourceMetadataUrl(req: Request): string { + return `${mcpIssuer(req)}${MCP_PROTECTED_RESOURCE_METADATA_PATH}` +} + +export function mcpOAuthEndpoints(req: Request) { + const issuer = mcpIssuer(req) + return { + issuer, + authorizationEndpoint: `${issuer}${MCP_OAUTH_AUTHORIZE_PATH}`, + tokenEndpoint: `${issuer}${MCP_OAUTH_TOKEN_PATH}`, + registrationEndpoint: `${issuer}${MCP_OAUTH_REGISTER_PATH}`, + authorizationServerMetadata: `${issuer}${MCP_AUTHORIZATION_SERVER_METADATA_PATH}`, + } +} + +export function normalizeMcpScope(raw: string | undefined): string | null { + const requested = new Set((raw ?? MCP_OAUTH_SCOPE).split(/\s+/).filter(Boolean)) + if (!requested.has(MCP_OAUTH_SCOPE)) return null + for (const scope of requested) { + if (!MCP_OAUTH_SUPPORTED_SCOPES.includes(scope as typeof MCP_OAUTH_SUPPORTED_SCOPES[number])) { + return null + } + } + return MCP_OAUTH_SUPPORTED_SCOPES.filter((scope) => requested.has(scope)).join(' ') +} + +export function isValidPkceChallenge(value: string): boolean { + return /^[A-Za-z0-9_-]{43}$/.test(value) +} + +export function isValidPkceVerifier(value: string): boolean { + return /^[A-Za-z0-9._~-]{43,128}$/.test(value) +} + +export function parseAllowedRedirectUri(raw: string): URL | null { + let url: URL + try { + url = new URL(raw) + } catch { + return null + } + if (url.username || url.password || url.hash) return null + if (url.protocol === 'https:') return url + if (url.protocol !== 'http:') return null + return isLoopbackHostname(url.hostname) ? url : null +} + +export function isRemoteMcpEndpoint(endpoint: string): boolean { + const url = new URL(endpoint) + return url.protocol === 'https:' && !isPrivateNetworkHostname(url.hostname) +} + +export function oauthRedirect( + redirectUri: string, + params: Record, +): string { + const url = new URL(redirectUri) + for (const [key, value] of Object.entries(params)) { + if (value !== undefined) url.searchParams.set(key, value) + } + return url.toString() +} + +function isLoopbackHostname(hostname: string): boolean { + const normalized = hostname.toLowerCase().replace(/^\[|\]$/g, '') + return normalized === 'localhost' || normalized.endsWith('.localhost') || + normalized.startsWith('127.') || normalized === '::1' +} + +function isPrivateNetworkHostname(hostname: string): boolean { + const normalized = hostname.toLowerCase().replace(/^\[|\]$/g, '') + if (isLoopbackHostname(normalized)) return true + if ( + (!normalized.includes('.') && !normalized.includes(':')) || normalized.endsWith('.local') || + normalized.endsWith('.internal') || normalized.endsWith('.lan') || + normalized.endsWith('.test') || normalized.endsWith('.example') || + normalized.endsWith('.invalid') + ) { + return true + } + + const octets = normalized.split('.').map(Number) + if (octets.length === 4 && octets.every((octet) => Number.isInteger(octet) && octet >= 0 && octet <= 255)) { + const [first, second] = octets as [number, number, number, number] + return first === 0 || first === 10 || first === 127 || first >= 224 || + (first === 100 && second >= 64 && second <= 127) || + (first === 169 && second === 254) || + (first === 172 && second >= 16 && second <= 31) || + (first === 192 && (second === 0 || second === 168)) || + (first === 198 && (second === 18 || second === 19 || second === 51)) || + (first === 203 && second === 0) + } + + if (normalized.includes(':')) { + if (normalized === '::' || normalized.startsWith('::ffff:')) return true + const firstHextet = parseInt(normalized.split(':')[0] ?? '', 16) + return (firstHextet >= 0xfc00 && firstHextet <= 0xfdff) || + (firstHextet >= 0xfe80 && firstHextet <= 0xfebf) + } + return false +} diff --git a/server/ai/mcp/oauth/rateLimit.ts b/server/ai/mcp/oauth/rateLimit.ts new file mode 100644 index 000000000..03c32796a --- /dev/null +++ b/server/ai/mcp/oauth/rateLimit.ts @@ -0,0 +1,11 @@ +import { RateLimiter } from '../../../auth/rateLimit' + +export const oauthRegistrationRateLimit = new RateLimiter({ + limit: 30, + windowMs: 60 * 60 * 1000, +}) + +export const oauthTokenRateLimit = new RateLimiter({ + limit: 60, + windowMs: 10 * 60 * 1000, +}) diff --git a/server/ai/mcp/oauth/schemas.ts b/server/ai/mcp/oauth/schemas.ts new file mode 100644 index 000000000..10e39ef54 --- /dev/null +++ b/server/ai/mcp/oauth/schemas.ts @@ -0,0 +1,78 @@ +import { Type, safeParseValue, type Static } from '@core/utils/typeboxHelpers' +import { + McpOAuthAuthorizationRequestSchema, + type McpOAuthAuthorizationRequest, +} from '@core/ai' + +export const OAuthClientRegistrationRequestSchema = Type.Object({ + client_name: Type.String({ minLength: 1, maxLength: 120 }), + redirect_uris: Type.Array(Type.String({ minLength: 1, maxLength: 4096 }), { + minItems: 1, + maxItems: 10, + }), + token_endpoint_auth_method: Type.Optional(Type.Literal('none')), + grant_types: Type.Optional(Type.Array(Type.String())), + response_types: Type.Optional(Type.Array(Type.String())), +}, { additionalProperties: true }) +export type OAuthClientRegistrationRequest = Static + +const AuthorizationCodeTokenRequestSchema = Type.Object({ + grant_type: Type.Literal('authorization_code'), + code: Type.String({ minLength: 1, maxLength: 2048 }), + redirect_uri: Type.String({ minLength: 1, maxLength: 4096 }), + client_id: Type.String({ minLength: 1, maxLength: 2048 }), + code_verifier: Type.String({ minLength: 43, maxLength: 128 }), + resource: Type.String({ minLength: 1, maxLength: 4096 }), +}, { additionalProperties: true }) +export type AuthorizationCodeTokenRequest = Static + +const RefreshTokenRequestSchema = Type.Object({ + grant_type: Type.Literal('refresh_token'), + refresh_token: Type.String({ minLength: 1, maxLength: 2048 }), + client_id: Type.String({ minLength: 1, maxLength: 2048 }), + resource: Type.String({ minLength: 1, maxLength: 4096 }), + scope: Type.Optional(Type.String({ minLength: 1, maxLength: 256 })), +}, { additionalProperties: true }) +export type RefreshTokenRequest = Static + +export type OAuthTokenRequest = AuthorizationCodeTokenRequest | RefreshTokenRequest + +export function parseOAuthTokenRequest(params: URLSearchParams): OAuthTokenRequest | null { + if (hasDuplicateParameters(params)) return null + const raw = Object.fromEntries(params.entries()) + const schema = raw.grant_type === 'authorization_code' + ? AuthorizationCodeTokenRequestSchema + : raw.grant_type === 'refresh_token' + ? RefreshTokenRequestSchema + : null + if (!schema) return null + const parsed = safeParseValue(schema, raw) + return parsed.ok ? parsed.value : null +} + +export function parseOAuthAuthorizationRequest( + params: URLSearchParams, +): McpOAuthAuthorizationRequest | null { + if (hasDuplicateParameters(params)) return null + const raw = { + responseType: params.get('response_type'), + clientId: params.get('client_id'), + redirectUri: params.get('redirect_uri'), + codeChallenge: params.get('code_challenge'), + codeChallengeMethod: params.get('code_challenge_method'), + scope: params.get('scope') ?? 'mcp', + resource: params.get('resource'), + ...(params.has('state') ? { state: params.get('state') } : {}), + } + const parsed = safeParseValue(McpOAuthAuthorizationRequestSchema, raw) + return parsed.ok ? parsed.value : null +} + +function hasDuplicateParameters(params: URLSearchParams): boolean { + const names = new Set() + for (const name of params.keys()) { + if (names.has(name)) return true + names.add(name) + } + return false +} diff --git a/server/ai/mcp/oauth/store.test.ts b/server/ai/mcp/oauth/store.test.ts new file mode 100644 index 000000000..fe6cc8830 --- /dev/null +++ b/server/ai/mcp/oauth/store.test.ts @@ -0,0 +1,181 @@ +import { beforeEach, describe, expect, it, spyOn } from 'bun:test' +import type { McpOAuthAuthorizationRequest } from '@core/ai' +import type { DbClient } from '../../../db/client' +import { sqliteMigrations } from '../../../db/migrations-sqlite' +import { runMigrations } from '../../../db/runMigrations' +import { createSqliteClient } from '../../../db/sqlite' +import { revokeConnector } from '../connectors/store' +import { pkceChallengeForVerifier } from '../connectors/token' +import { + createOAuthAuthorizationGrant, + exchangeAuthorizationCode, + findOAuthAccessGrant, + registerOAuthClient, + rotateRefreshToken, +} from './store' + +const RESOURCE = 'https://cms.example.com/_instatic/mcp' +const REDIRECT_URI = 'https://claude.ai/api/mcp/auth_callback' +const VERIFIER = 'a'.repeat(64) + +async function freshDb(): Promise { + const db = createSqliteClient(':memory:') + await runMigrations(db, sqliteMigrations) + await db` + insert into users (id, email, email_normalized, display_name, password_hash, role_id) + values ('u1', 'u1@example.com', 'u1@example.com', 'User One', 'x', 'owner') + ` + return db +} + +let db: DbClient +let request: McpOAuthAuthorizationRequest + +beforeEach(async () => { + db = await freshDb() + const client = await registerOAuthClient(db, { + clientName: 'Claude', + redirectUris: [REDIRECT_URI], + }) + request = { + responseType: 'code', + clientId: client.clientId, + redirectUri: REDIRECT_URI, + codeChallenge: await pkceChallengeForVerifier(VERIFIER), + codeChallengeMethod: 'S256', + scope: 'mcp offline_access', + resource: RESOURCE, + state: 'opaque-state', + } +}) + +describe('MCP OAuth grant store', () => { + it('exchanges a one-time PKCE code for resource-bound access and refresh tokens', async () => { + const { connection, code } = await createOAuthAuthorizationGrant(db, { + userId: 'u1', + clientName: 'Claude', + capabilities: ['ai.chat', 'site.read'], + request, + }) + + const tokens = await exchangeAuthorizationCode(db, { + code, + clientId: request.clientId, + redirectUri: request.redirectUri, + codeVerifier: VERIFIER, + resource: RESOURCE, + }) + expect(tokens?.accessToken).toMatch(/^imcp_at_/) + expect(tokens?.refreshToken).toMatch(/^imcp_rt_/) + expect(tokens?.expiresIn).toBeLessThanOrEqual(3600) + + const access = await findOAuthAccessGrant(db, tokens!.accessToken, RESOURCE) + expect(access).toEqual({ + connectorId: connection.id, + userId: 'u1', + capabilities: ['ai.chat', 'site.read'], + }) + expect(await findOAuthAccessGrant(db, tokens!.accessToken, 'https://other.example/mcp')).toBeNull() + + expect(await exchangeAuthorizationCode(db, { + code, + clientId: request.clientId, + redirectUri: request.redirectUri, + codeVerifier: VERIFIER, + resource: RESOURCE, + })).toBeNull() + }) + + it('rejects a code when the verifier, callback, client, or resource differs', async () => { + const { code } = await createOAuthAuthorizationGrant(db, { + userId: 'u1', + clientName: 'Claude', + capabilities: ['site.read'], + request, + }) + + expect(await exchangeAuthorizationCode(db, { + code, + clientId: request.clientId, + redirectUri: request.redirectUri, + codeVerifier: 'b'.repeat(64), + resource: RESOURCE, + })).toBeNull() + expect(await exchangeAuthorizationCode(db, { + code, + clientId: 'different-client', + redirectUri: request.redirectUri, + codeVerifier: VERIFIER, + resource: RESOURCE, + })).toBeNull() + expect(await exchangeAuthorizationCode(db, { + code, + clientId: request.clientId, + redirectUri: 'https://attacker.example/callback', + codeVerifier: VERIFIER, + resource: RESOURCE, + })).toBeNull() + expect(await exchangeAuthorizationCode(db, { + code, + clientId: request.clientId, + redirectUri: request.redirectUri, + codeVerifier: VERIFIER, + resource: 'https://attacker.example/mcp', + })).toBeNull() + }) + + it('rotates refresh tokens and invalidates the previous refresh token', async () => { + const warn = spyOn(console, 'warn').mockImplementation(() => {}) + const { code } = await createOAuthAuthorizationGrant(db, { + userId: 'u1', + clientName: 'Claude', + capabilities: ['site.read'], + request, + }) + const initial = await exchangeAuthorizationCode(db, { + code, + clientId: request.clientId, + redirectUri: request.redirectUri, + codeVerifier: VERIFIER, + resource: RESOURCE, + }) + const rotated = await rotateRefreshToken(db, { + refreshToken: initial!.refreshToken, + clientId: request.clientId, + resource: RESOURCE, + scope: 'mcp', + }) + expect(rotated?.accessToken).not.toBe(initial?.accessToken) + expect(rotated?.refreshToken).not.toBe(initial?.refreshToken) + expect(rotated?.scope).toBe('mcp') + + expect(await rotateRefreshToken(db, { + refreshToken: initial!.refreshToken, + clientId: request.clientId, + resource: RESOURCE, + })).toBeNull() + expect(await findOAuthAccessGrant(db, rotated!.accessToken, RESOURCE)).toBeNull() + expect(warn).toHaveBeenCalledTimes(1) + warn.mockRestore() + }) + + it('invalidates OAuth access when the connection is revoked', async () => { + const { connection, code } = await createOAuthAuthorizationGrant(db, { + userId: 'u1', + clientName: 'Claude', + capabilities: ['site.read'], + request, + }) + const tokens = await exchangeAuthorizationCode(db, { + code, + clientId: request.clientId, + redirectUri: request.redirectUri, + codeVerifier: VERIFIER, + resource: RESOURCE, + }) + expect(await findOAuthAccessGrant(db, tokens!.accessToken, RESOURCE)).not.toBeNull() + + expect(await revokeConnector(db, connection.id, 'u1')).toBe(true) + expect(await findOAuthAccessGrant(db, tokens!.accessToken, RESOURCE)).toBeNull() + }) +}) diff --git a/server/ai/mcp/oauth/store.ts b/server/ai/mcp/oauth/store.ts new file mode 100644 index 000000000..986894539 --- /dev/null +++ b/server/ai/mcp/oauth/store.ts @@ -0,0 +1,349 @@ +import { nanoid } from 'nanoid' +import type { CoreCapability } from '@core/capabilities' +import type { McpOAuthAuthorizationRequest } from '@core/ai' +import type { DbClient } from '../../../db/client' +import { createOAuthConnection } from '../connectors/store' +import type { McpConnectorRecord } from '../connectors/types' +import { + generateOAuthAccessToken, + generateOAuthAuthorizationCode, + generateOAuthRefreshToken, + hashMcpSecret, + pkceChallengeForVerifier, +} from '../connectors/token' +import { isValidPkceVerifier, normalizeMcpScope } from './protocol' + +const AUTHORIZATION_CODE_TTL_MS = 5 * 60 * 1000 +export const OAUTH_ACCESS_TOKEN_TTL_SECONDS = 60 * 60 + +interface OAuthClientRow { + client_id: string + client_name: string + redirect_uris_json: string[] + client_id_issued_at: number +} + +export interface OAuthClientRecord { + clientId: string + clientName: string + redirectUris: readonly string[] + clientIdIssuedAt: number +} + +interface AuthorizationCodeRow { + code_hash: string + connector_id: string + client_id: string + redirect_uri: string + code_challenge: string + scope: string + resource: string + expires_at: string + consumed_at: string | null + connector_expires_at: string | null + connector_revoked_at: string | null +} + +interface OAuthTokenRow { + id: string + connector_id: string + client_id: string + scope: string + resource: string + expires_at: string + revoked_at: string | null + connector_expires_at: string | null + connector_revoked_at: string | null +} + +interface OAuthAccessGrantRow { + connector_id: string + user_id: string + capabilities_json: CoreCapability[] +} + +export interface OAuthAccessGrant { + connectorId: string + userId: string + capabilities: readonly CoreCapability[] +} + +export interface OAuthTokenPair { + accessToken: string + refreshToken: string + scope: string + expiresIn: number +} + +function rowToClient(row: OAuthClientRow): OAuthClientRecord { + return { + clientId: row.client_id, + clientName: row.client_name, + redirectUris: Array.isArray(row.redirect_uris_json) ? row.redirect_uris_json : [], + clientIdIssuedAt: row.client_id_issued_at, + } +} + +export async function registerOAuthClient( + db: DbClient, + input: { clientName: string; redirectUris: readonly string[] }, +): Promise { + const clientId = `imcp_client_${nanoid(32)}` + const issuedAt = Math.floor(Date.now() / 1000) + const redirectUrisJson = JSON.stringify(input.redirectUris) + const { rows } = await db` + insert into ai_mcp_oauth_clients ( + client_id, client_name, redirect_uris_json, client_id_issued_at + ) + values (${clientId}, ${input.clientName}, ${redirectUrisJson}, ${issuedAt}) + returning client_id, client_name, redirect_uris_json, client_id_issued_at + ` + if (!rows[0]) throw new Error('OAuth client registration did not persist') + return rowToClient(rows[0]) +} + +export async function findOAuthClient(db: DbClient, clientId: string): Promise { + const { rows } = await db` + select client_id, client_name, redirect_uris_json, client_id_issued_at + from ai_mcp_oauth_clients + where client_id = ${clientId} + limit 1 + ` + return rows[0] ? rowToClient(rows[0]) : null +} + +export async function createOAuthAuthorizationGrant( + db: DbClient, + input: { + userId: string + clientName: string + capabilities: readonly CoreCapability[] + request: McpOAuthAuthorizationRequest + }, +): Promise<{ connection: McpConnectorRecord; code: string }> { + const code = generateOAuthAuthorizationCode() + const codeHash = await hashMcpSecret(code) + const expiresAt = new Date(Date.now() + AUTHORIZATION_CODE_TTL_MS) + + return db.transaction(async (tx) => { + const connection = await createOAuthConnection(tx, { + userId: input.userId, + label: input.clientName, + capabilities: input.capabilities, + }) + await tx` + insert into ai_mcp_oauth_codes ( + code_hash, connector_id, client_id, redirect_uri, code_challenge, + scope, resource, expires_at + ) + values ( + ${codeHash}, ${connection.id}, ${input.request.clientId}, + ${input.request.redirectUri}, ${input.request.codeChallenge}, + ${input.request.scope}, ${input.request.resource}, ${expiresAt} + ) + ` + return { connection, code } + }) +} + +export async function exchangeAuthorizationCode( + db: DbClient, + input: { + code: string + clientId: string + redirectUri: string + codeVerifier: string + resource: string + }, + now: Date = new Date(), +): Promise { + if (!isValidPkceVerifier(input.codeVerifier)) return null + const codeHash = await hashMcpSecret(input.code) + const challenge = await pkceChallengeForVerifier(input.codeVerifier) + + return db.transaction(async (tx) => { + const { rows } = await tx` + select c.code_hash, c.connector_id, c.client_id, c.redirect_uri, + c.code_challenge, c.scope, c.resource, c.expires_at, c.consumed_at, + g.expires_at as connector_expires_at, + g.revoked_at as connector_revoked_at + from ai_mcp_oauth_codes c + join ai_mcp_connectors g on g.id = c.connector_id + where c.code_hash = ${codeHash} + limit 1 + ` + const code = rows[0] + if ( + !code || code.consumed_at || code.connector_revoked_at || + new Date(code.expires_at).getTime() <= now.getTime() || + !code.connector_expires_at || new Date(code.connector_expires_at).getTime() <= now.getTime() || + code.client_id !== input.clientId || code.redirect_uri !== input.redirectUri || + code.resource !== input.resource || code.code_challenge !== challenge + ) { + return null + } + + const consumed = await tx` + update ai_mcp_oauth_codes + set consumed_at = current_timestamp + where code_hash = ${codeHash} and consumed_at is null + ` + if (consumed.rowCount !== 1) return null + + return issueTokenPair(tx, { + connectorId: code.connector_id, + clientId: code.client_id, + scope: code.scope, + resource: code.resource, + connectorExpiresAt: new Date(code.connector_expires_at), + now, + }) + }) +} + +export async function rotateRefreshToken( + db: DbClient, + input: { + refreshToken: string + clientId: string + resource: string + scope?: string + }, + now: Date = new Date(), +): Promise { + const tokenHash = await hashMcpSecret(input.refreshToken) + + return db.transaction(async (tx) => { + const { rows } = await tx` + select t.id, t.connector_id, t.client_id, t.scope, t.resource, + t.expires_at, t.revoked_at, + g.expires_at as connector_expires_at, + g.revoked_at as connector_revoked_at + from ai_mcp_oauth_tokens t + join ai_mcp_connectors g on g.id = t.connector_id + where t.token_hash = ${tokenHash} and t.kind = 'refresh' + limit 1 + ` + const token = rows[0] + const requestedScope = normalizeMcpScope(input.scope ?? token?.scope) + + // A rotated refresh token should never be presented again. Treat reuse as + // credential theft and revoke the entire grant, including access tokens + // minted from the replacement token. The client must re-authorize. + if (token?.revoked_at && !token.connector_revoked_at) { + await tx` + update ai_mcp_connectors + set revoked_at = current_timestamp + where id = ${token.connector_id} and revoked_at is null + ` + await tx` + update ai_mcp_oauth_tokens + set revoked_at = current_timestamp + where connector_id = ${token.connector_id} and revoked_at is null + ` + console.warn(`[ai:mcp:oauth] refresh-token reuse revoked connection ${token.connector_id}`) + return null + } + if ( + !token || token.revoked_at || token.connector_revoked_at || !requestedScope || + new Date(token.expires_at).getTime() <= now.getTime() || + !token.connector_expires_at || new Date(token.connector_expires_at).getTime() <= now.getTime() || + token.client_id !== input.clientId || token.resource !== input.resource || + !scopeIsSubset(requestedScope, token.scope) + ) { + return null + } + + const revoked = await tx` + update ai_mcp_oauth_tokens + set revoked_at = current_timestamp + where id = ${token.id} and revoked_at is null + ` + if (revoked.rowCount !== 1) return null + + return issueTokenPair(tx, { + connectorId: token.connector_id, + clientId: token.client_id, + scope: requestedScope, + resource: token.resource, + connectorExpiresAt: new Date(token.connector_expires_at), + now, + }) + }) +} + +export async function findOAuthAccessGrant( + db: DbClient, + token: string, + resource: string, + now: Date = new Date(), +): Promise { + const tokenHash = await hashMcpSecret(token) + const { rows } = await db` + select t.connector_id, g.user_id, g.capabilities_json + from ai_mcp_oauth_tokens t + join ai_mcp_connectors g on g.id = t.connector_id + where t.token_hash = ${tokenHash} + and t.kind = 'access' + and t.resource = ${resource} + and t.revoked_at is null + and t.expires_at > ${now} + and g.revoked_at is null + and g.expires_at > ${now} + limit 1 + ` + const row = rows[0] + if (!row) return null + return { + connectorId: row.connector_id, + userId: row.user_id, + capabilities: Array.isArray(row.capabilities_json) ? row.capabilities_json : [], + } +} + +async function issueTokenPair( + db: DbClient, + input: { + connectorId: string + clientId: string + scope: string + resource: string + connectorExpiresAt: Date + now: Date + }, +): Promise { + const accessToken = generateOAuthAccessToken() + const refreshToken = generateOAuthRefreshToken() + const accessExpiresAt = new Date(Math.min( + input.now.getTime() + OAUTH_ACCESS_TOKEN_TTL_SECONDS * 1000, + input.connectorExpiresAt.getTime(), + )) + const expiresIn = Math.max(1, Math.floor((accessExpiresAt.getTime() - input.now.getTime()) / 1000)) + + await db` + insert into ai_mcp_oauth_tokens ( + id, connector_id, client_id, kind, token_hash, scope, resource, expires_at + ) + values ( + ${nanoid()}, ${input.connectorId}, ${input.clientId}, 'access', + ${await hashMcpSecret(accessToken)}, ${input.scope}, ${input.resource}, ${accessExpiresAt} + ) + ` + await db` + insert into ai_mcp_oauth_tokens ( + id, connector_id, client_id, kind, token_hash, scope, resource, expires_at + ) + values ( + ${nanoid()}, ${input.connectorId}, ${input.clientId}, 'refresh', + ${await hashMcpSecret(refreshToken)}, ${input.scope}, ${input.resource}, + ${input.connectorExpiresAt} + ) + ` + + return { accessToken, refreshToken, scope: input.scope, expiresIn } +} + +function scopeIsSubset(requested: string, granted: string): boolean { + const grantedScopes = new Set(granted.split(/\s+/).filter(Boolean)) + return requested.split(/\s+/).filter(Boolean).every((scope) => grantedScopes.has(scope)) +} diff --git a/server/ai/mcp/paths.ts b/server/ai/mcp/paths.ts new file mode 100644 index 000000000..745982f9f --- /dev/null +++ b/server/ai/mcp/paths.ts @@ -0,0 +1,9 @@ +export const MCP_ENDPOINT_PATH = '/_instatic/mcp' +export const MCP_PROTECTED_RESOURCE_METADATA_PATH = '/.well-known/oauth-protected-resource' +export const MCP_PATH_PROTECTED_RESOURCE_METADATA_PATH = + '/.well-known/oauth-protected-resource/_instatic/mcp' +export const MCP_AUTHORIZATION_SERVER_METADATA_PATH = '/.well-known/oauth-authorization-server' +export const MCP_OAUTH_REGISTER_PATH = '/_instatic/oauth/register' +export const MCP_OAUTH_TOKEN_PATH = '/_instatic/oauth/token' +export const MCP_OAUTH_AUTHORIZE_PATH = '/admin/ai/oauth/authorize' +export const MCP_OAUTH_AUTHORIZATION_API_PATH = '/admin/api/ai/mcp/oauth/authorization' diff --git a/server/ai/mcp/publishTool.test.ts b/server/ai/mcp/publishTool.test.ts index 07ca62783..8c4e040b3 100644 --- a/server/ai/mcp/publishTool.test.ts +++ b/server/ai/mcp/publishTool.test.ts @@ -5,8 +5,8 @@ import { join } from 'node:path' import { createCapabilityTestHarness, type CapabilityTestHarness } from '../../../src/__tests__/helpers/capabilityHarness' import { getDraftSite, saveDraftSite } from '../../repositories/site' import { readArtefact, readStaticAsset } from '../../publish/staticArtefact' -import { createConnector } from './connectors/store' -import { generateConnectorToken, hashConnectorToken } from './connectors/token' +import { createBearerConnection } from './connectors/store' +import { generatePersonalAccessToken, hashMcpSecret } from './connectors/token' import { handleMcpHttp } from './transports/http' interface AuditRow { @@ -89,13 +89,12 @@ describe('site_publish MCP tool', () => { const userId = users[0]?.id if (!userId) throw new Error('owner user was not seeded') - const token = generateConnectorToken() - const connector = await createConnector(harness.db, { + const token = generatePersonalAccessToken() + const connector = await createBearerConnection(harness.db, { userId, label: 'Publish regression', - type: 'local', capabilities: ['ai.chat', 'ai.tools.write', 'pages.publish'], - tokenHash: await hashConnectorToken(token), + tokenHash: await hashMcpSecret(token), }) await callMcp(harness, uploadsDir, token, 'initialize', { protocolVersion: '2025-06-18', diff --git a/server/ai/mcp/transports/http.test.ts b/server/ai/mcp/transports/http.test.ts index fcba6e261..832c06a2f 100644 --- a/server/ai/mcp/transports/http.test.ts +++ b/server/ai/mcp/transports/http.test.ts @@ -3,8 +3,8 @@ import { createSqliteClient } from '../../../db/sqlite' import { sqliteMigrations } from '../../../db/migrations-sqlite' import { runMigrations } from '../../../db/runMigrations' import type { DbClient } from '../../../db/client' -import { createConnector } from '../connectors/store' -import { generateConnectorToken, hashConnectorToken } from '../connectors/token' +import { createBearerConnection } from '../connectors/store' +import { generatePersonalAccessToken, hashMcpSecret } from '../connectors/token' import { handleMcpHttp } from './http' function initBody() { @@ -38,10 +38,10 @@ beforeEach(async () => { insert into users (id, email, email_normalized, display_name, password_hash, role_id) values ('u1', 'u1@example.com', 'u1@example.com', 'User One', 'x', 'owner') ` - token = generateConnectorToken() - await createConnector(db, { - userId: 'u1', label: 'L', type: 'local', - capabilities: ['ai.chat', 'content.manage', 'site.read'], tokenHash: await hashConnectorToken(token), + token = generatePersonalAccessToken() + await createBearerConnection(db, { + userId: 'u1', label: 'L', + capabilities: ['ai.chat', 'content.manage', 'site.read'], tokenHash: await hashMcpSecret(token), }) }) diff --git a/server/ai/mcp/transports/http.ts b/server/ai/mcp/transports/http.ts index f3d5f11e9..b5c147740 100644 --- a/server/ai/mcp/transports/http.ts +++ b/server/ai/mcp/transports/http.ts @@ -16,8 +16,7 @@ import { WebStandardStreamableHTTPServerTransport } from '@modelcontextprotocol/ import type { DbClient } from '../../../db/client' import { resolveMcpAuth, unauthorizedResponse } from '../auth' import { buildMcpServer } from '../server' - -export const MCP_ENDPOINT_PATH = '/_instatic/mcp' +import { MCP_ENDPOINT_PATH } from '../paths' interface McpHttpOptions { uploadsDir?: string @@ -32,7 +31,7 @@ export async function handleMcpHttp( if (url.pathname !== MCP_ENDPOINT_PATH) return null const auth = await resolveMcpAuth(req, db) - if (!auth.ok) return unauthorizedResponse(url) + if (!auth.ok) return unauthorizedResponse(req) const server = buildMcpServer({ db, diff --git a/server/db/migrations-pg.ts b/server/db/migrations-pg.ts index ba5397ec2..6fad59d68 100644 --- a/server/db/migrations-pg.ts +++ b/server/db/migrations-pg.ts @@ -1071,4 +1071,53 @@ export const pgMigrations: Migration[] = [ insert into site_sync_state (id, seq) values (1, 0); `, }, + { + // OAuth 2.1 authorization-code + PKCE support for hosted MCP clients. + // Connector rows remain the user/capability grant; these tables hold the + // dynamically registered public client, one-time authorization codes, and + // opaque access/refresh credentials for that grant. + id: '021_mcp_oauth', + sql: ` + create table if not exists ai_mcp_oauth_clients ( + client_id text primary key, + client_name text not null, + redirect_uris_json jsonb not null, + client_id_issued_at bigint not null, + created_at timestamptz not null default current_timestamp + ); + + create table if not exists ai_mcp_oauth_codes ( + code_hash text primary key, + connector_id text not null references ai_mcp_connectors(id) on delete cascade, + client_id text not null references ai_mcp_oauth_clients(client_id) on delete cascade, + redirect_uri text not null, + code_challenge text not null, + scope text not null, + resource text not null, + created_at timestamptz not null default current_timestamp, + expires_at timestamptz not null, + consumed_at timestamptz + ); + + create index if not exists ai_mcp_oauth_codes_connector_idx + on ai_mcp_oauth_codes (connector_id); + + create table if not exists ai_mcp_oauth_tokens ( + id text primary key, + connector_id text not null references ai_mcp_connectors(id) on delete cascade, + client_id text not null references ai_mcp_oauth_clients(client_id) on delete cascade, + kind text not null, + token_hash text not null unique, + scope text not null, + resource text not null, + created_at timestamptz not null default current_timestamp, + expires_at timestamptz not null, + revoked_at timestamptz, + constraint ai_mcp_oauth_tokens_kind_check check (kind in ('access', 'refresh')) + ); + + create index if not exists ai_mcp_oauth_tokens_connector_idx + on ai_mcp_oauth_tokens (connector_id); + `, + }, ] diff --git a/server/db/migrations-sqlite.ts b/server/db/migrations-sqlite.ts index f723e3d7a..40d5ba57b 100644 --- a/server/db/migrations-sqlite.ts +++ b/server/db/migrations-sqlite.ts @@ -1135,4 +1135,53 @@ export const sqliteMigrations: Migration[] = [ insert into site_sync_state (id, seq) values (1, 0); `, }, + { + // OAuth 2.1 authorization-code + PKCE support for hosted MCP clients. + // Connector rows remain the user/capability grant; these tables hold the + // dynamically registered public client, one-time authorization codes, and + // opaque access/refresh credentials for that grant. + id: '021_mcp_oauth', + sql: ` + create table if not exists ai_mcp_oauth_clients ( + client_id text primary key, + client_name text not null, + redirect_uris_json text not null, + client_id_issued_at integer not null, + created_at text not null default (strftime('%Y-%m-%dT%H:%M:%fZ','now')) + ); + + create table if not exists ai_mcp_oauth_codes ( + code_hash text primary key, + connector_id text not null references ai_mcp_connectors(id) on delete cascade, + client_id text not null references ai_mcp_oauth_clients(client_id) on delete cascade, + redirect_uri text not null, + code_challenge text not null, + scope text not null, + resource text not null, + created_at text not null default (strftime('%Y-%m-%dT%H:%M:%fZ','now')), + expires_at text not null, + consumed_at text + ); + + create index if not exists ai_mcp_oauth_codes_connector_idx + on ai_mcp_oauth_codes (connector_id); + + create table if not exists ai_mcp_oauth_tokens ( + id text primary key, + connector_id text not null references ai_mcp_connectors(id) on delete cascade, + client_id text not null references ai_mcp_oauth_clients(client_id) on delete cascade, + kind text not null, + token_hash text not null unique, + scope text not null, + resource text not null, + created_at text not null default (strftime('%Y-%m-%dT%H:%M:%fZ','now')), + expires_at text not null, + revoked_at text, + constraint ai_mcp_oauth_tokens_kind_check check (kind in ('access', 'refresh')) + ); + + create index if not exists ai_mcp_oauth_tokens_connector_idx + on ai_mcp_oauth_tokens (connector_id); + `, + }, ] diff --git a/server/http.ts b/server/http.ts index 4ce7a6e8f..a8e658554 100644 --- a/server/http.ts +++ b/server/http.ts @@ -84,7 +84,12 @@ async function readFormJsonField(req: Request, fieldName: string): Promise { - if (maxBytes < 1) throw new Error('readValidatedBody: maxBytes must be >= 1') + return JSON.parse(await readTextBodyWithLimit(req, maxBytes)) +} + +/** Read a UTF-8 request body with a hard streaming byte limit. */ +export async function readTextBodyWithLimit(req: Request, maxBytes: number): Promise { + if (maxBytes < 1) throw new Error('readTextBodyWithLimit: maxBytes must be >= 1') const contentLength = req.headers.get('content-length') if (contentLength) { const parsed = Number(contentLength) @@ -94,7 +99,7 @@ async function readJsonWithLimit(req: Request, maxBytes: number): Promise | Response | null { + return tryHandleMcpOAuth(req, runtime.db, pathname) +} + /** * AI runtime — provider-agnostic stack at `/admin/api/ai/*`. Handles chat * streams, browser bridge, credentials CRUD, conversation history, @@ -138,8 +152,8 @@ function tryServeAi(req: Request, runtime: ServerRuntime, url: URL, _pathname: s /** * MCP server endpoint (`/_instatic/mcp`). Authenticates per-connector via a - * bearer token (NOT the admin session cookie) and exposes the capability-gated - * CMS tool surface over the Model Context Protocol. Returns `null` for any + * OAuth or personal bearer token (NOT the admin session cookie) and exposes + * the capability-gated CMS tool surface over MCP. Returns `null` for any * other path so the dispatcher keeps walking. */ function tryServeMcp(req: Request, runtime: ServerRuntime, _url: URL, pathname: string): Promise | null { diff --git a/src/__tests__/ai/mcpConnectorsHandler.test.ts b/src/__tests__/ai/mcpConnectorsHandler.test.ts index 85a3afa4e..cda2b5dae 100644 --- a/src/__tests__/ai/mcpConnectorsHandler.test.ts +++ b/src/__tests__/ai/mcpConnectorsHandler.test.ts @@ -5,11 +5,12 @@ import { readJson, type CapabilityTestHarness, } from '../helpers/capabilityHarness' -import type { CreateMcpConnectorResult, McpConnectorList } from '@core/ai' +import type { CreateMcpAccessTokenResult, McpConnectionOverview } from '@core/ai' -const BASE = '/admin/api/ai/mcp/connectors' +const CONNECTIONS = '/admin/api/ai/mcp/connections' +const ACCESS_TOKENS = '/admin/api/ai/mcp/access-tokens' -describe('MCP connector handler', () => { +describe('MCP connection management handler', () => { let harness: CapabilityTestHarness let originalError: typeof console.error @@ -23,97 +24,98 @@ describe('MCP connector handler', () => { console.error = originalError }) - it('creates a connector and returns the token exactly once', async () => { + it('creates a personal access token and returns its plaintext exactly once', async () => { const cookie = await harness.setupOwner() - const res = await harness.ai(BASE, { + const res = await harness.ai(ACCESS_TOKENS, { method: 'POST', cookie, - json: { label: 'Claude Code', type: 'local', capabilities: ['ai.chat', 'content.manage'] }, + json: { label: 'Claude Code', capabilities: ['ai.chat', 'content.manage'] }, }) expect(res.status).toBe(201) - const created = await readJson(res) - expect(created.token).toMatch(/^imcp_/) - expect(created.connector.id).toBeTruthy() - expect(created.connector.revoked).toBe(false) + const created = await readJson(res) + expect(created.accessToken).toMatch(/^imcp_pat_/) + expect(created.connection.id).toBeTruthy() + expect(created.connection.authMode).toBe('bearer') + expect(created.connection.revoked).toBe(false) - // The token is never returned by the list endpoint. - const listRes = await harness.ai(BASE, { cookie }) + const listRes = await harness.ai(CONNECTIONS, { cookie }) expect(listRes.status).toBe(200) - const list = await readJson(listRes) - expect(list.connectors).toHaveLength(1) - expect(JSON.stringify(list)).not.toContain(created.token) + const overview = await readJson(listRes) + expect(overview.connections).toHaveLength(1) + expect(overview.endpoint).toBe('http://localhost/_instatic/mcp') + expect(overview.remoteAccess).toBe('local-only') + expect(JSON.stringify(overview)).not.toContain(created.accessToken) }) - it('requires fresh step-up authentication before minting a connector token', async () => { + it('requires fresh step-up authentication before minting a personal access token', async () => { await harness.setupOwner() const { cookie } = await harness.createRoleUser({ - name: 'Connector Manager', - slug: 'connector-manager', + name: 'Connection Manager', + slug: 'connection-manager', capabilities: ['ai.providers.manage', 'ai.chat'], }) - const res = await harness.ai(BASE, { + const res = await harness.ai(ACCESS_TOKENS, { method: 'POST', cookie, - json: { label: 'Sensitive token', type: 'local', capabilities: ['ai.chat'] }, + json: { label: 'Sensitive token', capabilities: ['ai.chat'] }, }) await expectStepUpRequired(res) }) - it('revokes a connector', async () => { + it('revokes a connection', async () => { const cookie = await harness.setupOwner() - const created = await readJson( - await harness.ai(BASE, { + const created = await readJson( + await harness.ai(ACCESS_TOKENS, { method: 'POST', cookie, - json: { label: 'L', type: 'remote', capabilities: ['ai.chat'] }, + json: { label: 'L', capabilities: ['ai.chat'] }, }), ) - const del = await harness.ai(`${BASE}/${created.connector.id}`, { method: 'DELETE', cookie }) + const del = await harness.ai(`${CONNECTIONS}/${created.connection.id}`, { method: 'DELETE', cookie }) expect(del.status).toBe(200) - const list = await readJson(await harness.ai(BASE, { cookie })) - expect(list.connectors[0].revoked).toBe(true) + const overview = await readJson(await harness.ai(CONNECTIONS, { cookie })) + expect(overview.connections[0].revoked).toBe(true) }) - it('404s revoking an unknown connector', async () => { + it('404s revoking an unknown connection', async () => { const cookie = await harness.setupOwner() - const del = await harness.ai(`${BASE}/does-not-exist`, { method: 'DELETE', cookie }) + const del = await harness.ai(`${CONNECTIONS}/does-not-exist`, { method: 'DELETE', cookie }) expect(del.status).toBe(404) }) - it('forbids connector management without ai.providers.manage', async () => { + it('forbids connection management without ai.providers.manage', async () => { await harness.setupOwner() const { cookie } = await harness.createRoleUser({ name: 'Editor', slug: 'editor', capabilities: ['ai.chat', 'content.manage'], }) - const res = await harness.ai(BASE, { + const res = await harness.ai(ACCESS_TOKENS, { method: 'POST', cookie, - json: { label: 'x', type: 'local', capabilities: ['ai.chat'] }, + json: { label: 'x', capabilities: ['ai.chat'] }, }) expect(res.status).toBe(403) }) it('refuses to grant capabilities the creator does not hold', async () => { await harness.setupOwner() - // A manager who can configure AI but holds no site-edit capability. const { cookie } = await harness.createRoleUser({ name: 'AI Manager', slug: 'ai-manager', capabilities: ['ai.providers.manage', 'ai.chat'], }) - const res = await harness.ai(BASE, { + const res = await harness.ai(ACCESS_TOKENS, { method: 'POST', cookie, - json: { label: 'overreach', type: 'remote', capabilities: ['site.structure.edit'] }, + json: { label: 'overreach', capabilities: ['site.structure.edit'] }, }) expect(res.status).toBe(403) }) - it('rejects an invalid body', async () => { + it('rejects an invalid access-token body', async () => { const cookie = await harness.setupOwner() - const res = await harness.ai(BASE, { + const res = await harness.ai(ACCESS_TOKENS, { method: 'POST', cookie, - json: { label: '', type: 'nope', capabilities: [] }, + json: { label: '', capabilities: [] }, }) expect(res.status).toBe(400) }) diff --git a/src/__tests__/ai/mcpOAuthAuthorizationHandler.test.ts b/src/__tests__/ai/mcpOAuthAuthorizationHandler.test.ts new file mode 100644 index 000000000..cf4eb379a --- /dev/null +++ b/src/__tests__/ai/mcpOAuthAuthorizationHandler.test.ts @@ -0,0 +1,145 @@ +import { beforeEach, describe, expect, it } from 'bun:test' +import type { + DecideMcpOAuthAuthorizationResult, + McpConnectionOverview, + McpOAuthAuthorizationRequest, + McpOAuthAuthorizationView, +} from '@core/ai' +import { pkceChallengeForVerifier } from '../../../server/ai/mcp/connectors/token' +import { registerOAuthClient } from '../../../server/ai/mcp/oauth/store' +import { + createCapabilityTestHarness, + readJson, + type CapabilityTestHarness, +} from '../helpers/capabilityHarness' + +const AUTHORIZATION = '/admin/api/ai/mcp/oauth/authorization' +const CONNECTIONS = '/admin/api/ai/mcp/connections' +const CALLBACK = 'https://claude.ai/api/mcp/auth_callback' +const VERIFIER = 'v'.repeat(64) + +function searchFor(request: McpOAuthAuthorizationRequest): string { + const params = new URLSearchParams({ + response_type: request.responseType, + client_id: request.clientId, + redirect_uri: request.redirectUri, + code_challenge: request.codeChallenge, + code_challenge_method: request.codeChallengeMethod, + scope: request.scope, + resource: request.resource, + }) + if (request.state) params.set('state', request.state) + return params.toString() +} + +describe('MCP OAuth authorization consent handler', () => { + let harness: CapabilityTestHarness + let request: McpOAuthAuthorizationRequest + + beforeEach(async () => { + harness = await createCapabilityTestHarness() + const client = await registerOAuthClient(harness.db, { + clientName: 'Claude Desktop', + redirectUris: [CALLBACK], + }) + request = { + responseType: 'code', + clientId: client.clientId, + redirectUri: CALLBACK, + codeChallenge: await pkceChallengeForVerifier(VERIFIER), + codeChallengeMethod: 'S256', + scope: 'mcp offline_access', + resource: 'http://localhost/_instatic/mcp', + state: 'client-state', + } + }) + + it('loads consent details and creates an OAuth connection after explicit approval', async () => { + const cookie = await harness.setupOwner() + const read = await harness.ai(`${AUTHORIZATION}?${searchFor(request)}`, { cookie }) + expect(read.status).toBe(200) + const view = await readJson(read) + expect(view.clientName).toBe('Claude Desktop') + expect(view.callbackUrl).toBe(CALLBACK) + expect(view.grantExpiresInDays).toBe(90) + + const approve = await harness.ai(AUTHORIZATION, { + method: 'POST', + cookie, + json: { + decision: 'approve', + request: view.request, + capabilities: ['site.read'], + }, + }) + expect(approve.status).toBe(200) + const result = await readJson(approve) + const redirect = new URL(result.redirectUrl) + expect(`${redirect.origin}${redirect.pathname}`).toBe(CALLBACK) + expect(redirect.searchParams.get('code')).toMatch(/^imcp_ac_/) + expect(redirect.searchParams.get('state')).toBe('client-state') + + const overview = await readJson(await harness.ai(CONNECTIONS, { cookie })) + expect(overview.connections).toHaveLength(1) + expect(overview.connections[0]).toMatchObject({ + label: 'Claude Desktop', + authMode: 'oauth', + capabilities: ['site.read', 'ai.chat'], + revoked: false, + }) + expect(JSON.stringify(overview)).not.toContain('imcp_ac_') + }) + + it('returns access_denied to the exact registered callback without creating a connection', async () => { + const cookie = await harness.setupOwner() + const denied = await harness.ai(AUTHORIZATION, { + method: 'POST', + cookie, + json: { decision: 'deny', request }, + }) + expect(denied.status).toBe(200) + const result = await readJson(denied) + const redirect = new URL(result.redirectUrl) + expect(`${redirect.origin}${redirect.pathname}`).toBe(CALLBACK) + expect(redirect.searchParams.get('error')).toBe('access_denied') + expect(redirect.searchParams.get('state')).toBe('client-state') + + const overview = await readJson(await harness.ai(CONNECTIONS, { cookie })) + expect(overview.connections).toHaveLength(0) + }) + + it('rejects a callback that was not registered for the client', async () => { + const cookie = await harness.setupOwner() + const tampered = { ...request, redirectUri: 'https://attacker.example/callback' } + const response = await harness.ai(AUTHORIZATION, { + method: 'POST', + cookie, + json: { decision: 'approve', request: tampered, capabilities: ['site.read'] }, + }) + expect(response.status).toBe(400) + }) + + it('rejects duplicate authorization parameters instead of choosing one', async () => { + const cookie = await harness.setupOwner() + const response = await harness.ai( + `${AUTHORIZATION}?${searchFor(request)}&state=other-state`, + { cookie }, + ) + expect(response.status).toBe(400) + }) + + it('refuses OAuth capabilities the approving user does not hold', async () => { + await harness.setupOwner() + const { cookie } = await harness.createRoleUser({ + name: 'AI Manager', + slug: 'oauth-manager', + capabilities: ['ai.providers.manage', 'ai.chat'], + }) + const response = await harness.ai(AUTHORIZATION, { + method: 'POST', + cookie, + json: { decision: 'approve', request, capabilities: ['site.structure.edit'] }, + }) + expect(response.status).toBe(403) + }) +}) diff --git a/src/__tests__/ai/providersTab.test.tsx b/src/__tests__/ai/providersTab.test.tsx index 1a3d2eca0..b95eee102 100644 --- a/src/__tests__/ai/providersTab.test.tsx +++ b/src/__tests__/ai/providersTab.test.tsx @@ -1,5 +1,5 @@ import { afterEach, describe, expect, it, mock } from 'bun:test' -import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react' +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' import { ProvidersTab } from '@admin/pages/ai/tabs/ProvidersTab' const originalFetch = globalThis.fetch @@ -28,33 +28,73 @@ describe('ProvidersTab', () => { it('derives credential authentication from the selected provider', async () => { mockEmptyCredentials() - render() - await waitFor(() => expect(screen.getByText('No credentials yet. Add one to start using AI features.')).toBeDefined()) + render( {}} />) + await waitFor(() => expect(screen.getByRole('heading', { name: 'Connect Anthropic' })).toBeDefined()) - fireEvent.click(screen.getByRole('button', { name: 'Add credential' })) - - const dialog = screen.getByRole('dialog', { name: 'Add AI credential' }) - expect(within(dialog).getByRole('combobox', { name: 'Provider' })).toBeDefined() - expect(within(dialog).queryByLabelText('Authentication')).toBeNull() - expect(within(dialog).getByLabelText('API key')).toBeDefined() + expect(screen.queryByRole('combobox', { name: 'Provider' })).toBeNull() + expect(screen.queryByLabelText('Authentication')).toBeNull() + expect(screen.getByLabelText('API key')).toBeDefined() + expect(screen.queryByRole('button', { name: 'Add' })).toBeNull() + expect(screen.queryByRole('heading', { name: 'Credentials' })).toBeNull() + expect(screen.queryByText('Secrets are encrypted at rest and never returned to the browser.')).toBeNull() }) it('keeps Ollama on the endpoint credential shape without an auth-mode choice', async () => { mockEmptyCredentials() - render() - await waitFor(() => expect(screen.getByText('No credentials yet. Add one to start using AI features.')).toBeDefined()) + render( {}} />) + await waitFor(() => expect(screen.getByRole('heading', { name: 'Connect Anthropic' })).toBeDefined()) + + fireEvent.click(screen.getByRole('button', { name: /Ollama Local models/ })) + + expect(screen.getByRole('heading', { name: 'Connect Ollama' })).toBeDefined() + expect(screen.queryByLabelText('Authentication')).toBeNull() + expect(screen.getByLabelText('Base URL')).toBeDefined() + expect(screen.getByLabelText(/Bearer token/)).toBeDefined() + expect(screen.queryByLabelText('API key')).toBeNull() + }) - fireEvent.click(screen.getByRole('button', { name: 'Add credential' })) + it('opens configured credentials in the detail inspector', async () => { + globalThis.fetch = mock(async (input: RequestInfo | URL) => { + const url = typeof input === 'string' ? input : input.toString() + if (url.endsWith('/admin/api/ai/credentials')) { + return json({ + credentials: [{ + id: 'cred-1', + providerId: 'anthropic', + authMode: 'apiKey', + displayLabel: 'Production Claude', + baseUrl: null, + keyFingerprintCurrent: true, + createdAt: '2026-07-13T10:00:00.000Z', + lastUsedAt: null, + }], + }) + } + if (url.includes('/admin/api/ai/providers/anthropic/models')) { + return json({ + models: [{ + id: 'claude-sonnet-4', + label: 'Claude Sonnet 4', + capabilities: { + toolCalling: true, + visionInput: true, + toolResultImages: true, + promptCache: true, + streaming: true, + }, + contextWindow: 200000, + }], + }) + } + throw new Error(`Unexpected fetch: ${url}`) + }) as typeof fetch - const dialog = screen.getByRole('dialog', { name: 'Add AI credential' }) - const provider = within(dialog).getByRole('combobox', { name: 'Provider' }) - fireEvent.click(provider.nextElementSibling as HTMLElement) - fireEvent.click(screen.getByRole('option', { name: 'Ollama (local)' })) + render( {}} />) - expect(within(dialog).queryByLabelText('Authentication')).toBeNull() - expect(within(dialog).getByLabelText('Base URL')).toBeDefined() - expect(within(dialog).getByLabelText('Bearer token (optional)')).toBeDefined() - expect(within(dialog).queryByLabelText('API key')).toBeNull() + await waitFor(() => expect(screen.getByRole('heading', { name: 'Production Claude' })).toBeDefined()) + await waitFor(() => expect(screen.getByText('Claude Sonnet 4')).toBeDefined()) + expect(screen.getByText('200K tokens')).toBeDefined() + expect(screen.getByRole('button', { name: 'Test connection' })).toBeDefined() }) }) diff --git a/src/__tests__/architecture/ai-mcp-connectors-never-leak.test.ts b/src/__tests__/architecture/ai-mcp-connectors-never-leak.test.ts index 0a4f2e09a..35ac47edf 100644 --- a/src/__tests__/architecture/ai-mcp-connectors-never-leak.test.ts +++ b/src/__tests__/architecture/ai-mcp-connectors-never-leak.test.ts @@ -2,7 +2,7 @@ * Architecture gate — MCP connector secrets never leak. * * Mirrors `ai-credentials-never-leak.test.ts`: the wire-safe projection - * (`toConnectorView`) and the wire schema (`McpConnectorViewSchema`) must not + * (`toConnectionView`) and the wire schema (`McpConnectionViewSchema`) must not * expose the token or its hash. A connector token is shown to the operator * exactly once, in the create response — never in any list/read shape. */ @@ -13,9 +13,9 @@ import { join } from 'node:path' const STORE = join(import.meta.dir, '../../../server/ai/mcp/connectors/store.ts') describe('MCP connector secrets never leak', () => { - it('toConnectorView does not reference the token hash', () => { + it('toConnectionView does not reference the token hash', () => { const src = readFileSync(STORE, 'utf8') - const start = src.indexOf('export function toConnectorView') + const start = src.indexOf('export function toConnectionView') expect(start).toBeGreaterThan(-1) const body = src.slice(start, src.indexOf('\n}', start) + 2) expect(body).not.toContain('tokenHash') @@ -23,8 +23,8 @@ describe('MCP connector secrets never leak', () => { }) it('the wire view schema has no token / tokenHash field', async () => { - const { McpConnectorViewSchema } = await import('@core/ai') - const keys = Object.keys(McpConnectorViewSchema.properties) + const { McpConnectionViewSchema } = await import('@core/ai') + const keys = Object.keys(McpConnectionViewSchema.properties) expect(keys).not.toContain('token') expect(keys).not.toContain('tokenHash') expect(keys).not.toContain('token_hash') diff --git a/src/__tests__/architecture/no-plugin-tab-shells.test.ts b/src/__tests__/architecture/no-plugin-tab-shells.test.ts index 026866f00..96b73e633 100644 --- a/src/__tests__/architecture/no-plugin-tab-shells.test.ts +++ b/src/__tests__/architecture/no-plugin-tab-shells.test.ts @@ -21,9 +21,7 @@ * §T.3 src/admin/pages/site/canvas/CanvasModeToggle.tsx — design/preview * segmented-control with icon-only tab buttons; predates the Tabs * primitive and uses its own compact CSS layout. - * §T.4 src/admin/pages/ai/AiPage.tsx — capability-gated Button row pattern - * mirroring §T.1. - * §T.5 src/admin/pages/content/components/ContentModeToggle/ContentModeToggle.tsx + * §T.4 src/admin/pages/content/components/ContentModeToggle/ContentModeToggle.tsx * — Write/Live segmented-control for the content workspace, mirroring * the §T.3 icon-only segmented-pill pattern. * @@ -74,7 +72,7 @@ function collectAllFiles(): string[] { /** §T.0 — every file under the Tabs primitive directory is allowed. */ const TABS_PRIMITIVE_DIR = join(PROJECT_ROOT, 'src/ui/components/Tabs') -/** §T.1–§T.5 — pre-existing custom tablist implementations. */ +/** §T.1–§T.4 — pre-existing custom tablist implementations. */ const EXACT_ALLOWLIST = new Set([ // §T.1 — capability-gated Button row in UsersPage predates the Tabs primitive. join(PROJECT_ROOT, 'src/admin/pages/users/UsersPage.tsx'), @@ -83,9 +81,7 @@ const EXACT_ALLOWLIST = new Set([ // §T.3 — icon-only design/preview toggle; compact fixed layout incompatible // with the full-width underline-indicator Tabs style. join(PROJECT_ROOT, 'src/admin/pages/site/canvas/CanvasModeToggle.tsx'), - // §T.4 — AiPage uses the same capability-gated Button row pattern as UsersPage. - join(PROJECT_ROOT, 'src/admin/pages/ai/AiPage.tsx'), - // §T.5 — Content workspace's Write/Live mode switch mirrors the §T.3 + // §T.4 — Content workspace's Write/Live mode switch mirrors the §T.3 // pattern: icon-only, compact fixed layout that the full-width Tabs // style cannot represent. join(PROJECT_ROOT, 'src/admin/pages/content/components/ContentModeToggle/ContentModeToggle.tsx'), @@ -182,8 +178,7 @@ describe('No-plugin-tab-shells — role="tablist" must come from the Tabs primit ` src/admin/pages/users/UsersPage.tsx (§T.1)\n` + ` src/admin/pages/account/AccountPage.tsx (§T.2)\n` + ` src/admin/pages/site/canvas/CanvasModeToggle.tsx (§T.3)\n` + - ` src/admin/pages/ai/AiPage.tsx (§T.4)\n` + - ` src/admin/pages/content/components/ContentModeToggle/ContentModeToggle.tsx (§T.5)`, + ` src/admin/pages/content/components/ContentModeToggle/ContentModeToggle.tsx (§T.4)`, ) }) }) diff --git a/src/admin/ai/api.ts b/src/admin/ai/api.ts index f567fc7c5..0eb0c165a 100644 --- a/src/admin/ai/api.ts +++ b/src/admin/ai/api.ts @@ -15,11 +15,15 @@ import { Type, type Static } from '@core/utils/typeboxHelpers' import { apiRequest, ApiError } from '@core/http' import { AiContentViewBlockSchema, - McpConnectorListSchema, - CreateMcpConnectorResultSchema, - type McpConnectorView, - type CreateMcpConnectorBody, - type CreateMcpConnectorResult, + McpConnectionOverviewSchema, + CreateMcpAccessTokenResultSchema, + McpOAuthAuthorizationViewSchema, + DecideMcpOAuthAuthorizationResultSchema, + type McpConnectionOverview, + type CreateMcpAccessTokenBody, + type CreateMcpAccessTokenResult, + type McpOAuthAuthorizationView, + type DecideMcpOAuthAuthorizationBody, } from '@core/ai' // --------------------------------------------------------------------------- @@ -421,26 +425,48 @@ export async function listAiAudit( } // --------------------------------------------------------------------------- -// MCP connectors — `/admin/api/ai/mcp/connectors`. Wire shapes are the shared -// TypeBox schemas from `@core/ai`; the plaintext token is returned only by -// `createMcpConnector` and is never persisted client-side. +// MCP connections + OAuth consent. Wire shapes are the shared TypeBox schemas +// from `@core/ai`; the plaintext personal access token is returned only by +// `createMcpAccessToken` and is never persisted client-side. // --------------------------------------------------------------------------- -const MCP_CONNECTORS_BASE = '/admin/api/ai/mcp/connectors' +const MCP_CONNECTIONS_BASE = '/admin/api/ai/mcp/connections' +const MCP_ACCESS_TOKENS_PATH = '/admin/api/ai/mcp/access-tokens' +const MCP_OAUTH_AUTHORIZATION_PATH = '/admin/api/ai/mcp/oauth/authorization' -export async function listMcpConnectors(signal?: AbortSignal): Promise { - const body = await apiRequest(MCP_CONNECTORS_BASE, { schema: McpConnectorListSchema, signal }) - return body.connectors +export async function getMcpConnectionOverview(signal?: AbortSignal): Promise { + return apiRequest(MCP_CONNECTIONS_BASE, { schema: McpConnectionOverviewSchema, signal }) } -export async function createMcpConnector(body: CreateMcpConnectorBody): Promise { - return apiRequest(MCP_CONNECTORS_BASE, { +export async function createMcpAccessToken( + body: CreateMcpAccessTokenBody, +): Promise { + return apiRequest(MCP_ACCESS_TOKENS_PATH, { method: 'POST', body, - schema: CreateMcpConnectorResultSchema, + schema: CreateMcpAccessTokenResultSchema, }) } -export async function revokeMcpConnector(id: string): Promise { - await apiRequest(`${MCP_CONNECTORS_BASE}/${encodeURIComponent(id)}`, { method: 'DELETE' }) +export async function revokeMcpConnection(id: string): Promise { + await apiRequest(`${MCP_CONNECTIONS_BASE}/${encodeURIComponent(id)}`, { method: 'DELETE' }) +} + +export async function getMcpOAuthAuthorization( + search: string, +): Promise { + return apiRequest(`${MCP_OAUTH_AUTHORIZATION_PATH}${search}`, { + schema: McpOAuthAuthorizationViewSchema, + }) +} + +export async function decideMcpOAuthAuthorization( + body: DecideMcpOAuthAuthorizationBody, +): Promise { + const result = await apiRequest(MCP_OAUTH_AUTHORIZATION_PATH, { + method: 'POST', + body, + schema: DecideMcpOAuthAuthorizationResultSchema, + }) + return result.redirectUrl } diff --git a/src/admin/layouts/AdminPageLayout/AdminPageLayout.module.css b/src/admin/layouts/AdminPageLayout/AdminPageLayout.module.css index ac1a5f062..65416a5cc 100644 --- a/src/admin/layouts/AdminPageLayout/AdminPageLayout.module.css +++ b/src/admin/layouts/AdminPageLayout/AdminPageLayout.module.css @@ -26,6 +26,10 @@ border-top-right-radius: 16px; } +.body[data-page-mode="workspace"] { + padding: var(--space-5xl) var(--space-6xl) var(--space-7xl); +} + .container { display: grid; gap: var(--space-6xl); @@ -33,6 +37,12 @@ margin: 0 auto; } +.container[data-page-mode="workspace"] { + display: block; + width: 100%; + min-height: 100%; +} + .header { display: grid; grid-template-columns: minmax(0, 1fr) auto; @@ -88,6 +98,10 @@ padding: var(--space-10xl) var(--space-3xl) var(--space-7xl); } + .body[data-page-mode="workspace"] { + padding: var(--space-2xl); + } + .header { grid-template-columns: 1fr; align-items: start; diff --git a/src/admin/layouts/AdminPageLayout/AdminPageLayout.tsx b/src/admin/layouts/AdminPageLayout/AdminPageLayout.tsx index b2b3c37a0..791d3dd18 100644 --- a/src/admin/layouts/AdminPageLayout/AdminPageLayout.tsx +++ b/src/admin/layouts/AdminPageLayout/AdminPageLayout.tsx @@ -6,10 +6,9 @@ * editor panels, the page canvas, and the SiteExplorer DnD context. * - AdminWorkspaceCanvasLayout — used by Content / Data / Media. Keeps the * canvas chrome without importing Site-only editor panels. - * - AdminPageLayout (this file) — used by Plugins, Users, Account, and - * plugin admin pages. Strips the canvas / sidebar / DnD chrome and - * renders a simple centered page body with a unified header (title, - * description, optional tabs and actions slots). + * - AdminPageLayout (this file) — used by Plugins, Users, Account, AI, and + * plugin admin pages. Strips the canvas / sidebar / DnD chrome and renders + * either a centered page with a unified header or a full-width workspace. * * Pick AdminWorkspaceCanvasLayout for non-site canvas workspaces. Pick this * layout when the page is a regular admin page (lists, forms, settings). @@ -53,8 +52,8 @@ const SettingsModal = lazy(() => interface AdminPageLayoutProps { /** Active section — drives the toolbar's nav highlight. */ workspace: AdminWorkspace - /** Page title rendered in the H1 of the page header. */ - title: string + /** Page title rendered in the H1 of the page header. Omit when children own the page heading. */ + title?: string /** Optional description shown under the title. */ description?: string /** @@ -86,6 +85,11 @@ interface AdminPageLayoutProps { * prop, no per-page skeleton markup. */ loading?: boolean + /** + * `workspace` removes the centered document header and uses the available + * canvas for a self-contained settings workspace with its own navigation. + */ + mode?: 'page' | 'workspace' /** Page body. */ children?: ReactNode } @@ -99,6 +103,7 @@ export function AdminPageLayout({ toolbarRightSlot, titleId, loading = false, + mode = 'page', children, }: AdminPageLayoutProps) { const currentUser = useCurrentAdminUser() @@ -141,24 +146,26 @@ export function AdminPageLayout({ rightSlot={toolbarRightSlot} /> -
-
-
-
-

{title}

- {description &&

{description}

} -
- {(tabs || actions) && ( -
- {tabs && ( -
{tabs}
- )} - {actions && ( -
{actions}
- )} +
+
+ {(title || description || tabs || actions) && ( +
+
+ {title &&

{title}

} + {description &&

{description}

}
- )} -
+ {(tabs || actions) && ( +
+ {tabs && ( +
{tabs}
+ )} + {actions && ( +
{actions}
+ )} +
+ )} +
+ )} {loading ? : children}
diff --git a/src/admin/pages/ai/AiPage.module.css b/src/admin/pages/ai/AiPage.module.css index f67a0a61c..992329750 100644 --- a/src/admin/pages/ai/AiPage.module.css +++ b/src/admin/pages/ai/AiPage.module.css @@ -1,221 +1,865 @@ -/* AI workspace page — Providers / Defaults / Audit tabs. - Same visual conventions as UsersPage: stacked sections, 28px gutters, - `--text-bright` for headings, `--text-subtle` for descriptions. */ +/* AI workspace shell */ -.body { +.workspace { display: grid; - gap: var(--space-6xl); + grid-template-columns: 184px minmax(0, 1fr); + gap: var(--space-7xl); + min-height: calc(100vh - 116px); } -.tabsRow { +.workspaceSidebar { display: flex; - flex-wrap: wrap; + flex-direction: column; + min-width: 0; +} + +.workspaceIdentity { + display: grid; gap: var(--space-s); + padding: 0 0 var(--space-4xl); } -.section { +.workspaceIdentity h1 { + margin: 0; + color: var(--text-bright); + font-size: var(--text-4xl); + line-height: 1; +} + +.workspaceIdentity p { + color: var(--text-subtle); + font-size: var(--text-m); + line-height: 1.5; +} + +.workspaceNavigation { display: grid; - gap: var(--space-2xl); + gap: var(--space-xs); } -.sectionHeader { +.workspaceNavigationButton { + min-width: 0; +} + +.workspaceContent { + min-width: 0; +} + +/* Provider identity assets */ + +.providerMark { + --provider-accent: var(--accent-3); + + display: inline-flex; + flex: none; + align-items: center; + justify-content: center; + width: 40px; + height: 40px; + border-radius: var(--panel-radius); + background: linear-gradient( + to top, + color-mix(in srgb, var(--provider-accent) 24%, transparent), + color-mix(in srgb, var(--provider-accent) 8%, transparent) + ); + color: var(--provider-accent); +} + +.providerMark[data-size="sm"] { + width: 36px; + height: 36px; +} + +.providerMark[data-size="lg"] { + width: 60px; + height: 60px; + border-radius: var(--card-radius); +} + +.providerLogo { + width: 54%; + height: 54%; + background: var(--provider-accent); + mask-position: center; + mask-repeat: no-repeat; + mask-size: contain; +} + +.logoAnthropic { + mask-image: url("./assets/anthropic.svg"); +} + +.logoOpenai { + mask-image: url("./assets/openai.svg"); +} + +.logoOpenrouter { + mask-image: url("./assets/openrouter.svg"); +} + +.logoOllama { + mask-image: url("./assets/ollama.svg"); +} + +.providerMark[data-provider="anthropic"] { + --provider-accent: var(--accent-4); +} + +.providerMark[data-provider="openai"] { + --provider-accent: var(--accent-1); +} + +.providerMark[data-provider="openrouter"] { + --provider-accent: var(--accent-2); +} + +.providerMark[data-provider="ollama"], +.providerMark[data-provider="openai-compatible"] { + --provider-accent: var(--accent-3); +} + +/* Shared nested master-detail layout */ + +.settingsWorkspace { display: grid; - grid-template-columns: minmax(0, 1fr) auto; + grid-template-columns: 280px minmax(0, 1fr); + gap: var(--space-7xl); + min-height: 100%; +} + +.settingsBrowser { + display: flex; + flex-direction: column; + gap: var(--space-l); + min-width: 0; +} + +.settingsBrowserHeader { + display: flex; align-items: center; - gap: var(--space-3xl); + justify-content: space-between; + gap: var(--space-l); } -.sectionHeader h2 { +.settingsBrowserHeader h2 { margin: 0; color: var(--text-bright); - letter-spacing: 0; font-size: var(--text-2xl); - line-height: 1.25; + line-height: 1.2; } -.sectionHeader p, -.secondaryText { +.settingsBrowserError, +.settingsBrowserEmpty { margin: 0; color: var(--text-subtle); font-size: var(--text-s); line-height: 1.45; } -.emptyState { - padding: var(--space-7xl) var(--space-2xl); - text-align: center; +.settingsBrowserError { + color: var(--danger); +} + +.settingsBrowserSections { + display: grid; + gap: var(--space-3xl); +} + +.settingsListSection { + display: grid; + gap: var(--space-s); +} + +.settingsListSection h3, +.detailEyebrow { + margin: 0; color: var(--text-subtle); - background: var(--bg-surface-2); - border-radius: 16px; + font-size: var(--text-s); + font-weight: 700; + letter-spacing: 0.05em; + line-height: 1.3; + text-transform: uppercase; } -.credentialGrid { +.settingsList { display: grid; - grid-template-columns: 1fr; gap: var(--space-px); - background: var(--bg-surface); - border-radius: 16px; +} + +.settingsListItem { + display: grid; + grid-template-columns: auto minmax(0, 1fr) auto; + height: auto; + min-height: 44px; + padding: var(--space-3xs); + gap: var(--space-m); + border-radius: var(--card-radius); +} + +.settingsListItem:hover { + background: var(--overlay-5); +} + +.settingsListItem[data-active="true"] { + background: var(--overlay-10); +} + +.settingsListIdentity { + display: grid; + gap: var(--space-4xs); + min-width: 0; +} + +.settingsListLabel { overflow: hidden; + color: var(--text-bright); + font-size: var(--text-m); + font-weight: 600; + text-overflow: ellipsis; } -.credentialCard { +.settingsListMeta { + display: flex; + align-items: center; + gap: var(--space-xs); + overflow: hidden; + color: var(--text-subtle); + font-size: var(--text-s); + font-weight: 400; + text-overflow: ellipsis; +} + +.healthDot, +.warningDot { + display: inline-block; + flex: none; + width: 6px; + height: 6px; + border-radius: 50%; + background: var(--success); +} + +.warningDot { + background: var(--warning); +} + +.settingsDetailCanvas { + min-width: 0; +} + +.settingsItemIcon, +.settingsHeroIcon { + display: inline-flex; + flex: none; + align-items: center; + justify-content: center; + background: var(--bg-surface-2); + color: var(--text-muted); +} + +.settingsItemIcon { + width: 36px; + height: 36px; + border-radius: var(--panel-radius); +} + +.settingsHeroIcon { + width: 60px; + height: 60px; + border-radius: var(--card-radius); +} + +.settingsDetail { display: grid; - grid-template-columns: minmax(0, 1fr) auto; + gap: var(--space-5xl); + max-width: 780px; + margin: 0 auto; +} + +.settingsDetailHeader { + display: grid; + grid-template-columns: auto minmax(0, 1fr) auto; align-items: center; gap: var(--space-2xl); - padding: var(--space-2xl); - background: var(--bg-surface-2); + padding-bottom: var(--space-4xl); + border-bottom: 1px solid var(--overlay-10); } -.credentialIdentity { +.settingsDetailIdentity { display: grid; - gap: var(--space-3xs); + gap: var(--space-s); min-width: 0; } -.credentialLabel { +.settingsDetailIdentity h2 { + margin: 0; color: var(--text-bright); - font-size: var(--text-l); - font-weight: 500; - line-height: 1.3; + font-size: var(--text-4xl); + line-height: 1.1; } -.credentialMeta { +.settingsDetailIdentity p { + margin: 0; color: var(--text-subtle); - font-size: var(--text-s); - line-height: 1.4; + font-size: var(--text-m); + line-height: 1.5; +} + +.settingsDetailSection { + display: grid; + gap: var(--space-3xl); + padding-bottom: var(--space-4xl); + border-bottom: 1px solid var(--overlay-10); +} + +.settingsDetailSection:last-child { + border-bottom: 0; +} + +.settingsFormRow { + display: grid; + grid-template-columns: 150px minmax(0, 1fr); + align-items: start; + gap: var(--space-2xl); +} + +.settingsFormRow > label { + display: flex; + align-items: center; + min-height: 32px; + color: var(--text-muted); + font-size: var(--text-m); + font-weight: 600; +} + +.settingsFormRow > div { + display: grid; + gap: var(--space-xs); +} + +.settingsDetailActions { display: flex; flex-wrap: wrap; - gap: var(--space-s); align-items: center; + justify-content: flex-end; + gap: var(--space-xs); } -.credentialActions { +.credentialDetail, +.providerSetup { + display: grid; + gap: var(--space-5xl); + max-width: 980px; + margin: 0 auto; +} + +.credentialDetailHeader, +.credentialDetailIdentity, +.credentialTitleRow, +.detailSectionHeader { display: flex; + align-items: center; + gap: var(--space-l); +} + +.credentialDetailHeader { + justify-content: space-between; + padding-bottom: var(--space-4xl); + border-bottom: 1px solid var(--overlay-10); +} + +.credentialDetailIdentity { + min-width: 0; +} + +.credentialTitleRow { + flex-wrap: wrap; gap: var(--space-s); } -.statusBadge { +.credentialTitleRow h2, +.providerSetupHeader h2 { + margin: 0; + color: var(--text-bright); + font-size: var(--text-4xl); + line-height: 1.1; +} + +.credentialDetailIdentity p, +.providerSetupHeader p, +.detailSectionHeader p, +.credentialDangerZone p, +.providerSetupField p, +.removeDialogCopy { + margin: 0; + color: var(--text-subtle); + font-size: var(--text-m); + line-height: 1.5; +} + +.connectionStatus, +.connectionStatusWarning { display: inline-flex; align-items: center; - padding: var(--space-4xs) var(--space-s); + gap: var(--space-xs); + padding: var(--space-xs) var(--space-s); border-radius: var(--radius-sm); + background: var(--success-10); + color: var(--success-text); font-size: var(--text-xs); - font-weight: 500; - background: var(--bg-surface); - color: var(--text-subtle); + font-weight: 600; +} + +.connectionStatusWarning { + background: var(--warning-10); + color: var(--warning-text); } -.statusBadge.success { +.connectionResultSuccess, +.connectionResultError, +.detailInlineError { + margin: 0; + padding: var(--space-s) var(--space-l); + border-radius: var(--radius); + background: var(--success-10); color: var(--success-text); + font-size: var(--text-s); } -.statusBadge.warning { - color: var(--warning-text); +.connectionResultError, +.detailInlineError { + background: var(--danger-10); + color: var(--danger-text); } -.statusBadge.danger { - color: var(--danger); +.detailSection { + display: grid; + gap: var(--space-2xl); + padding-bottom: var(--space-4xl); + border-bottom: 1px solid var(--overlay-10); } -.testResult { - margin: 0; - font-size: var(--text-s); - color: var(--text-subtle); +.detailSectionHeader { + justify-content: space-between; + align-items: flex-start; } -.testResult.success { +.detailSectionHeader h3, +.providerSetupFormSection h3, +.defaultsSetupEmpty h3 { + margin: 0; color: var(--text-bright); + font-size: var(--text-xl); + font-weight: 600; + line-height: 1.3; } -.testResult.danger { - color: var(--danger); +.connectionDetails { + display: grid; + gap: var(--space-px); + margin: 0; } -.errorAlert { - padding: var(--space-l) var(--space-2xl); - border-radius: var(--radius); - background: var(--bg-surface-2); - color: var(--danger); +.connectionDetailRow { + display: grid; + grid-template-columns: minmax(140px, 1fr) minmax(0, 3fr); + gap: var(--space-2xl); + padding: var(--space-s) 0; +} + +.connectionDetailRow dt { + color: var(--text-subtle); font-size: var(--text-m); +} + +.connectionDetailRow dd { + min-width: 0; margin: 0; + overflow-wrap: anywhere; + color: var(--text-bright); + font-size: var(--text-m); +} + +.connectionDetailRow code, +.modelIdentity code { + font-family: var(--font-mono); + color: var(--text-muted); + font-size: var(--text-s); } -.defaultsGrid { +.modelsTable { display: grid; - grid-template-columns: 1fr; - gap: var(--space-l); } -.defaultRow { +.modelsTableHeader, +.modelsTableRow { display: grid; - grid-template-columns: minmax(120px, 1fr) minmax(0, 4fr) auto; + grid-template-columns: minmax(0, 2fr) minmax(120px, 1fr) minmax(100px, auto); align-items: center; - gap: var(--space-l); - padding: var(--space-2xl); - background: var(--bg-surface-2); - border-radius: 16px; + gap: var(--space-2xl); + min-height: 54px; + border-bottom: 1px solid var(--overlay-10); +} + +.modelsTableHeader { + min-height: 32px; + color: var(--text-subtle); + font-size: var(--text-s); + font-weight: 600; + letter-spacing: 0.03em; + text-transform: uppercase; } -.defaultScopeLabel { +.modelsTableRow { + color: var(--text); + font-size: var(--text-m); +} + +.modelsTableRow:last-child { + border-bottom: 0; +} + +.modelIdentity { + display: grid; + gap: var(--space-4xs); + min-width: 0; +} + +.modelIdentity strong { color: var(--text-bright); - font-size: var(--text-l); font-weight: 500; - text-transform: capitalize; } -.defaultActions { +.modelIdentity code { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.modelAvailability { + display: inline-flex; + align-items: center; + gap: var(--space-xs); + color: var(--text-muted); +} + +.detailEmpty { + margin: 0; + padding: var(--space-3xl) 0; + color: var(--text-subtle); + font-size: var(--text-m); +} + +.credentialDangerZone { + display: grid; + justify-items: start; + gap: var(--space-xs); +} + +.providerSetup { + max-width: 780px; +} + +.providerSetupHeader { display: flex; align-items: center; - justify-content: flex-end; - flex-wrap: wrap; - gap: var(--space-m); + gap: var(--space-2xl); + padding-bottom: var(--space-4xl); + border-bottom: 1px solid var(--overlay-10); } -.defaultActions .testResult { - flex-basis: 100%; - text-align: right; +.providerSetupHeader > div { + display: grid; + gap: var(--space-s); +} + +.providerSetupFormSection { + display: grid; + gap: var(--space-3xl); } -.dialogBody { +.providerSetupForm { display: grid; gap: var(--space-2xl); } -.dialogActions { +.providerSetupField { + display: grid; + grid-template-columns: 150px minmax(0, 1fr); + align-items: start; + gap: var(--space-2xl); +} + +.providerSetupField > label { + display: flex; + align-items: center; + gap: var(--space-xs); + min-height: 32px; + color: var(--text-muted); + font-size: var(--text-m); + font-weight: 600; +} + +.providerSetupField > label span { + color: var(--text-subtle); + font-size: var(--text-s); + font-weight: 400; +} + +.providerSetupField > div { + display: grid; + gap: var(--space-xs); +} + +.providerSetupActions { display: flex; justify-content: flex-end; - gap: var(--space-s); + padding-top: var(--space-s); } -/* Row layout for the credentials dialog — label on left, input on right. - Matches the existing form pattern in other admin dialogs that want a - compact, two-column read. */ -.dialogForm { +/* Shared section surfaces for Defaults, MCP, and Audit */ + +.section { display: grid; - gap: var(--space-m); + gap: var(--space-4xl); } -.dialogField { +.sectionHeader { display: grid; - grid-template-columns: 110px minmax(0, 1fr); + grid-template-columns: minmax(0, 1fr) auto; align-items: center; - gap: var(--space-l); + gap: var(--space-3xl); + padding-bottom: var(--space-4xl); + border-bottom: 1px solid var(--overlay-10); } -.dialogFieldLabel { +.sectionHeader > div { + display: grid; + gap: var(--space-xs); +} + +.sectionHeader h2 { + margin: 0; + color: var(--text-bright); + font-size: var(--text-4xl); + line-height: 1.1; +} + +.sectionHeader p { + margin: 0; color: var(--text-subtle); - font-size: var(--text-xs); - font-weight: 700; - line-height: 1.3; + font-size: var(--text-m); + line-height: 1.45; } -.dialogError { - grid-column: 1 / -1; +.emptyState { + padding: var(--space-7xl) var(--space-2xl); + text-align: center; + color: var(--text-subtle); + background: var(--bg-surface-2); + border-radius: var(--card-radius); +} + +.errorAlert { margin: 0; - color: var(--danger); + padding: var(--space-l) var(--space-2xl); + border-radius: var(--radius); + background: var(--danger-10); + color: var(--danger-text); + font-size: var(--text-m); +} + +.defaultsSetupEmpty { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-3xl); + padding: var(--space-4xl); + border-radius: var(--card-radius); + background: var(--bg-surface-2); +} + +.defaultsSetupEmpty > div { + display: grid; + gap: var(--space-xs); +} + +.defaultsSetupEmpty p { + margin: 0; + color: var(--text-subtle); + font-size: var(--text-m); +} + +.defaultCurrentChoice { + display: flex; + align-items: center; + gap: var(--space-xs); + max-width: 100%; + margin-top: var(--space-s); + overflow: hidden; + color: var(--text-muted); font-size: var(--text-xs); - line-height: 1.3; + text-overflow: ellipsis; + white-space: nowrap; +} + +.defaultCurrentChoice .providerMark { + width: 20px; + height: 20px; +} + +.defaultStaleMessage { + margin: var(--space-s) 0 0; + color: var(--warning-text); + font-size: var(--text-xs); + line-height: 1.4; +} + +.defaultSaveStatus { + flex-basis: 100%; + color: var(--success-text); + font-size: var(--text-xs); + text-align: right; +} + +@media (max-width: 1180px) { + .workspace { + grid-template-columns: 168px minmax(0, 1fr); + gap: var(--space-5xl); + } + + .settingsWorkspace { + grid-template-columns: 248px minmax(0, 1fr); + gap: var(--space-5xl); + } + +} + +@media (max-width: 900px) { + .workspace { + grid-template-columns: 48px minmax(0, 1fr); + gap: var(--space-3xl); + } + + .workspaceIdentity { + padding: 0 0 var(--space-3xl); + text-align: center; + } + + .workspaceIdentity p, + .workspaceNavigationButton span { + display: none; + } + + .workspaceNavigationButton { + justify-content: center; + width: 32px; + padding: 0; + } + + .settingsWorkspace { + grid-template-columns: 240px minmax(0, 1fr); + gap: var(--space-3xl); + } + + .settingsBrowser { + padding: 0; + } + + .credentialDetailHeader, + .settingsDetailHeader, + .providerSetupHeader { + align-items: flex-start; + } + + .modelsTableHeader, + .modelsTableRow { + grid-template-columns: minmax(0, 2fr) minmax(100px, 1fr); + } + + .modelsTableHeader > :last-child, + .modelsTableRow > :last-child { + display: none; + } +} + +@media (max-width: 700px) { + .workspace { + grid-template-columns: 1fr; + min-height: 0; + } + + .workspaceSidebar { + display: grid; + grid-template-columns: auto minmax(0, 1fr); + align-items: center; + gap: var(--space-2xl); + padding-bottom: var(--space-3xl); + } + + .workspaceIdentity { + padding: var(--space-l); + } + + .workspaceNavigation { + display: flex; + justify-content: flex-end; + overflow-x: auto; + } + + .workspaceNavigationButton { + width: 32px; + } + + .settingsWorkspace { + grid-template-columns: 1fr; + } + + .settingsBrowser { + padding-bottom: var(--space-5xl); + } + + .credentialDetailHeader, + .settingsDetailHeader, + .sectionHeader, + .defaultsSetupEmpty { + grid-template-columns: 1fr; + flex-direction: column; + align-items: flex-start; + } + + .credentialTitleRow h2, + .settingsDetailIdentity h2, + .providerSetupHeader h2, + .sectionHeader h2 { + font-size: var(--text-3xl); + } + + .connectionDetailRow, + .settingsFormRow, + .providerSetupField, + .settingsDetailHeader { + grid-template-columns: 1fr; + } + + .settingsDetailActions { + justify-content: flex-start; + } + + .defaultSaveStatus { + text-align: left; + } + + .modelsTableHeader, + .modelsTableRow { + grid-template-columns: 1fr; + gap: var(--space-xs); + padding: var(--space-s) 0; + } + + .modelsTableHeader { + display: none; + } } /* ── Audit tab ───────────────────────────────────────────────────────── */ @@ -226,12 +870,6 @@ gap: var(--space-l); } -.auditSinceLabel { - color: var(--text-subtle); - font-size: var(--text-xs); - white-space: nowrap; -} - .auditTotalsRow { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); diff --git a/src/admin/pages/ai/AiPage.tsx b/src/admin/pages/ai/AiPage.tsx index e73b54871..03e768290 100644 --- a/src/admin/pages/ai/AiPage.tsx +++ b/src/admin/pages/ai/AiPage.tsx @@ -2,82 +2,111 @@ * AiPage — `/admin/ai`. * * Capability-gated workspace for managing AI provider credentials, per-scope - * defaults, and (Phase 6) the AI usage audit log. - * - * Layout mirrors UsersPage — three tabs, each owning its own state. The - * page itself is thin: it figures out which tabs the current admin can - * see, loads minimal data, and delegates rendering to per-tab components. + * defaults, MCP connections, and the AI usage audit log. * * Capabilities consulted: - * - `ai.providers.manage` → Providers + Defaults tabs (CRUD) + * - `ai.providers.manage` → Providers, Defaults, and MCP connection management * - `ai.audit.read` → Audit tab (read site-wide usage) */ import { useState } from 'react' import { Button } from '@ui/components/Button' import { AdminPageLayout } from '@admin/layouts/AdminPageLayout' +import { useLocation } from '@admin/lib/routing' import { hasCapability } from '@admin/access' import { useCurrentAdminUser } from '@admin/sessionContext' +import { DatabaseSolidIcon } from 'pixel-art-icons/icons/database-solid' +import { Settings2SolidIcon } from 'pixel-art-icons/icons/settings-2-solid' +import { LinkIcon } from 'pixel-art-icons/icons/link' +import { ChartSolidIcon } from 'pixel-art-icons/icons/chart-solid' import { ProvidersTab } from './tabs/ProvidersTab' import { DefaultsTab } from './tabs/DefaultsTab' import { AuditTab } from './tabs/AuditTab' import { McpTab } from './tabs/McpTab' +import { McpOAuthAuthorizePage } from './McpOAuthAuthorizePage' import styles from './AiPage.module.css' -type Tab = 'providers' | 'defaults' | 'mcp' | 'audit' +type Section = 'providers' | 'defaults' | 'mcp' | 'audit' -const TAB_LABELS: Record = { +const SECTION_LABELS: Record = { providers: 'Providers', defaults: 'Defaults', - mcp: 'MCP', + mcp: 'MCP connections', audit: 'Audit', } +const SECTION_ICONS = { + providers: DatabaseSolidIcon, + defaults: Settings2SolidIcon, + mcp: LinkIcon, + audit: ChartSolidIcon, +} satisfies Record + export function AiPage() { + const location = useLocation() const currentUser = useCurrentAdminUser() const unrestricted = !currentUser const canManage = unrestricted || hasCapability(currentUser, 'ai.providers.manage') const canReadAudit = unrestricted || hasCapability(currentUser, 'ai.audit.read') - const availableTabs: Tab[] = [] - if (canManage) availableTabs.push('providers', 'defaults', 'mcp') - if (canReadAudit) availableTabs.push('audit') + const availableSections: Section[] = [] + if (canManage) availableSections.push('providers', 'defaults', 'mcp') + if (canReadAudit) availableSections.push('audit') - const [tab, setTab] = useState('providers') - const activeTab = availableTabs.includes(tab) ? tab : availableTabs[0] ?? 'providers' + const [section, setSection] = useState
('providers') + const activeSection = availableSections.includes(section) + ? section + : availableSections[0] ?? 'providers' - const tabs = ( -
- {availableTabs.map((item) => ( - - ))} -
- ) + if (location.pathname === '/admin/ai/oauth/authorize') { + return + } return ( - -
- {activeTab === 'providers' && } - {activeTab === 'defaults' && } - {activeTab === 'mcp' && } - {activeTab === 'audit' && } + +
+ + +
+ {activeSection === 'providers' && ( + setSection('defaults')} /> + )} + {activeSection === 'defaults' && ( + setSection('providers')} /> + )} + {activeSection === 'mcp' && } + {activeSection === 'audit' && } +
) diff --git a/src/admin/pages/ai/AiSettingsListSection.tsx b/src/admin/pages/ai/AiSettingsListSection.tsx new file mode 100644 index 000000000..6bc839df8 --- /dev/null +++ b/src/admin/pages/ai/AiSettingsListSection.tsx @@ -0,0 +1,17 @@ +import type { ReactNode } from 'react' +import styles from './AiPage.module.css' + +export function AiSettingsListSection({ + label, + children, +}: { + label: string + children: ReactNode +}) { + return ( +
+

{label}

+
{children}
+
+ ) +} diff --git a/src/admin/pages/ai/McpOAuthAuthorizePage.module.css b/src/admin/pages/ai/McpOAuthAuthorizePage.module.css new file mode 100644 index 000000000..481a82bbd --- /dev/null +++ b/src/admin/pages/ai/McpOAuthAuthorizePage.module.css @@ -0,0 +1,71 @@ +.pageBody { + display: grid; + gap: var(--space-2xl); + max-width: 880px; +} + +.clientCard, +.permissionsCard, +.stateCard { + display: grid; + gap: var(--space-l); + padding: var(--space-2xl); + border-radius: var(--panel-radius); + background: var(--bg-surface-2); +} + +.clientCard h2, +.permissionsCard h2, +.stateCard h2 { + margin: var(--space-3xs) 0 0; + color: var(--text-bright); + font-size: var(--text-2xl); + line-height: 1.3; +} + +.clientCard p, +.permissionsCard p, +.stateCard p { + margin: var(--space-s) 0 0; + color: var(--text-subtle); + font-size: var(--text-s); + line-height: 1.5; +} + +.clientCard code { + overflow-wrap: anywhere; + padding: var(--space-s) var(--space-m); + border-radius: var(--radius); + background: var(--bg-surface); + color: var(--text-bright); + font-family: var(--font-mono); + font-size: var(--text-xs); +} + +.eyebrow { + color: var(--text-subtle); + font-size: var(--text-xs); + font-weight: 700; + letter-spacing: 0.04em; + text-transform: uppercase; +} + +.clientCard .expiryNote { + padding: var(--space-s) var(--space-m); + border-radius: var(--radius); + background: var(--success-10); + color: var(--success-text); +} + +.clientCard .trustNote { + padding: var(--space-s) var(--space-m); + border-radius: var(--radius); + background: var(--warning-10); + color: var(--warning-text); +} + +.actions { + display: flex; + justify-content: flex-end; + gap: var(--space-s); +} diff --git a/src/admin/pages/ai/McpOAuthAuthorizePage.tsx b/src/admin/pages/ai/McpOAuthAuthorizePage.tsx new file mode 100644 index 000000000..9bd8bcb66 --- /dev/null +++ b/src/admin/pages/ai/McpOAuthAuthorizePage.tsx @@ -0,0 +1,131 @@ +import { useState } from 'react' +import { AdminPageLayout } from '@admin/layouts/AdminPageLayout' +import { useAsyncResource } from '@admin/lib/useAsyncResource' +import { useLocation, useNavigate } from '@admin/lib/routing' +import { useAuthenticatedAdminUser } from '@admin/sessionContext' +import { CapabilityPicker } from '@admin/shared/CapabilityPicker' +import { StepUpCancelledMessage, useStepUp } from '@admin/shared/StepUp' +import { Button } from '@ui/components/Button' +import { pushToast } from '@ui/components/Toast' +import { getErrorMessage } from '@core/utils/errorMessage' +import type { CoreCapability } from '@core/capabilities' +import { + decideMcpOAuthAuthorization, + getMcpOAuthAuthorization, +} from '../../ai/api' +import { + availableMcpCapabilityGroups, + defaultMcpReadCapabilities, +} from './tabs/mcpCapabilities' +import styles from './McpOAuthAuthorizePage.module.css' + +export function McpOAuthAuthorizePage() { + const location = useLocation() + const navigate = useNavigate() + const currentUser = useAuthenticatedAdminUser() + const { runStepUp } = useStepUp() + const groups = availableMcpCapabilityGroups(currentUser) + const [selected, setSelected] = useState>( + () => defaultMcpReadCapabilities(groups), + ) + const [busy, setBusy] = useState(false) + const { data, loading, error } = useAsyncResource( + () => getMcpOAuthAuthorization(location.search), + [location.search], + { fallbackError: 'This OAuth authorization request is invalid or has expired.' }, + ) + + async function decide(decision: 'approve' | 'deny') { + if (!data) return + setBusy(true) + try { + const body = { + decision, + request: data.request, + ...(decision === 'approve' ? { capabilities: [...selected] } : {}), + } as const + const redirectUrl = decision === 'approve' + ? await runStepUp(() => decideMcpOAuthAuthorization(body)) + : await decideMcpOAuthAuthorization(body) + window.location.assign(redirectUrl) + } catch (err) { + if (err instanceof Error && err.message === StepUpCancelledMessage) return + pushToast({ + kind: 'error', + title: decision === 'approve' ? 'Could not authorize connection' : 'Could not deny connection', + body: getErrorMessage(err, 'Unknown OAuth authorization error'), + }) + } finally { + setBusy(false) + } + } + + return ( + +
+ {loading &&
Loading authorization request…
} + {error && ( +
+

Connection request unavailable

+

{error}

+ +
+ )} + {data && ( + <> +
+
+ Requesting client +

{data.clientName}

+

+ After approval, Instatic sends a one-time authorization code to this registered callback: +

+
+ {data.callbackUrl} +

+ Client names are self-declared. Approve only if you started this connection and + recognize the callback address above. +

+

+ The connection expires after {data.grantExpiresInDays} days and can be disconnected at any time. +

+
+ +
+
+ Permissions +

Choose allowed capabilities

+

+ Read access is selected by default. Writes and publishing stay off until you explicitly enable them. +

+
+ +
+ +
+ + +
+ + )} +
+
+ ) +} diff --git a/src/admin/pages/ai/ProviderMark.tsx b/src/admin/pages/ai/ProviderMark.tsx new file mode 100644 index 000000000..83b9dcb02 --- /dev/null +++ b/src/admin/pages/ai/ProviderMark.tsx @@ -0,0 +1,36 @@ +import { CodeIcon } from 'pixel-art-icons/icons/code' +import type { ProviderId } from './providerCatalog' +import { cn } from '@ui/cn' +import styles from './AiPage.module.css' + +const LOGO_CLASS: Partial> = { + anthropic: styles.logoAnthropic, + openai: styles.logoOpenai, + openrouter: styles.logoOpenrouter, + ollama: styles.logoOllama, +} + +export function ProviderMark({ + providerId, + size = 'md', +}: { + providerId: ProviderId + size?: 'sm' | 'md' | 'lg' +}) { + const logoClass = LOGO_CLASS[providerId] + + return ( +
- {effectiveAuthMode === 'apiKey' && ( -
- + {provider.authMode === 'baseUrl' && ( +
+ +
setApiKey(e.currentTarget.value)} - placeholder={API_KEY_PLACEHOLDER[providerId] ?? 'sk-...'} - // Browsers ignore autoComplete="off" on password fields and - // inject the saved admin login. "new-password" suppresses that; - // the data-* attributes opt out of password-manager overlays. - autoComplete="new-password" - data-1p-ignore="true" - data-lpignore="true" - data-bwignore="true" - data-form-type="other" + id={baseUrlInputId} + value={baseUrl} + onChange={(event) => setBaseUrl(event.currentTarget.value)} + placeholder={baseUrlPlaceholder} required /> +

The root URL of the compatible API.

- )} +
+ )} - {effectiveAuthMode === 'baseUrl' && ( - <> -
- - setBaseUrl(e.currentTarget.value)} - placeholder={baseUrlPlaceholder} - required - /> -
-
- - setApiKey(e.currentTarget.value)} - placeholder="Leave blank if no auth" - // See the API key field above: "new-password" + data-* opt-outs - // stop the browser/password manager autofilling the admin login. - autoComplete="new-password" - data-1p-ignore="true" - data-lpignore="true" - data-bwignore="true" - data-form-type="other" - /> -
- - )} +
+ +
+ setApiKey(event.currentTarget.value)} + placeholder={API_KEY_PLACEHOLDER[provider.id] ?? 'Leave blank if no auth'} + autoComplete="new-password" + data-1p-ignore="true" + data-lpignore="true" + data-bwignore="true" + data-form-type="other" + required={provider.authMode === 'apiKey'} + /> +

{provider.authMode === 'apiKey' ? 'Stored encrypted and never displayed again.' : 'Leave blank when the endpoint does not require authentication.'}

+
+
- {error &&

{error}

} - - +
+ +
+ ) } diff --git a/src/admin/pages/ai/tabs/mcpCapabilities.ts b/src/admin/pages/ai/tabs/mcpCapabilities.ts new file mode 100644 index 000000000..e3dd98514 --- /dev/null +++ b/src/admin/pages/ai/tabs/mcpCapabilities.ts @@ -0,0 +1,54 @@ +import type { CmsCurrentUser } from '@core/persistence' +import type { CoreCapability } from '@core/capabilities' +import { hasCapability } from '@admin/access' +import type { CapabilityPickerGroup } from '@admin/shared/CapabilityPicker' + +export const MCP_CAPABILITY_GROUPS: readonly CapabilityPickerGroup[] = [ + { + title: 'Read', + capabilities: ['site.read', 'content.manage', 'data.custom.tables.read', 'data.system.tables.read', 'media.read'], + }, + { + title: 'Allow writes', + capabilities: ['ai.tools.write'], + }, + { + title: 'Site editing', + capabilities: ['site.structure.edit', 'site.content.edit', 'site.style.edit'], + }, + { + title: 'Pages', + capabilities: ['pages.edit', 'pages.publish'], + }, + { + title: 'Content', + capabilities: ['content.create', 'content.edit.own', 'content.edit.any', 'content.publish.own', 'content.publish.any'], + }, + { + title: 'Media', + capabilities: ['media.write', 'media.replace', 'media.delete'], + }, +] + +const READ_CAPABILITIES = new Set(MCP_CAPABILITY_GROUPS[0].capabilities) + +export function availableMcpCapabilityGroups( + currentUser: CmsCurrentUser | null, +): CapabilityPickerGroup[] { + return MCP_CAPABILITY_GROUPS + .map((group) => ({ + title: group.title, + capabilities: group.capabilities.filter( + (capability) => !currentUser || hasCapability(currentUser, capability), + ), + })) + .filter((group) => group.capabilities.length > 0) +} + +export function defaultMcpReadCapabilities( + groups: readonly CapabilityPickerGroup[], +): Set { + return new Set( + groups.flatMap((group) => group.capabilities).filter((capability) => READ_CAPABILITIES.has(capability)), + ) +} diff --git a/src/admin/router.tsx b/src/admin/router.tsx index b2834f987..b187bcbc4 100644 --- a/src/admin/router.tsx +++ b/src/admin/router.tsx @@ -61,6 +61,7 @@ export function AdminRoutes() { )} /> )} /> )} /> + )} /> )} /> - -export const McpAuthModeSchema = Type.Union([ +const McpAuthModeSchema = Type.Union([ Type.Literal('bearer'), Type.Literal('oauth'), ]) export type McpAuthMode = Static /** Closed enum over the capability vocabulary — bodies are validated, not free text. */ -const CapabilitySchema = Type.Union(CORE_CAPABILITIES.map((c) => Type.Literal(c))) +const CapabilitySchema = Type.Union(CORE_CAPABILITIES.map((capability) => Type.Literal(capability))) -/** Wire-safe projection — the only connector shape the HTTP layer returns. No token. */ -export const McpConnectorViewSchema = Type.Object({ +/** Wire-safe projection — the only persisted connection shape returned to the browser. */ +export const McpConnectionViewSchema = Type.Object({ id: Type.String(), label: Type.String(), - type: McpConnectorTypeSchema, authMode: McpAuthModeSchema, capabilities: Type.Array(CapabilitySchema), createdAt: Type.String(), @@ -38,35 +32,69 @@ export const McpConnectorViewSchema = Type.Object({ revoked: Type.Boolean(), expiresAt: Type.Union([Type.String(), Type.Null()]), }) -export type McpConnectorView = Static +export type McpConnectionView = Static + +const McpRemoteAccessSchema = Type.Union([ + Type.Literal('public-https'), + Type.Literal('local-only'), +]) +export type McpRemoteAccess = Static + +export const McpConnectionOverviewSchema = Type.Object({ + connections: Type.Array(McpConnectionViewSchema), + endpoint: Type.String(), + remoteAccess: McpRemoteAccessSchema, +}) +export type McpConnectionOverview = Static -export const CreateMcpConnectorBodySchema = Type.Object({ +export const CreateMcpAccessTokenBodySchema = Type.Object({ label: Type.String({ minLength: 1, maxLength: 120 }), - type: McpConnectorTypeSchema, capabilities: Type.Array(CapabilitySchema, { minItems: 1 }), /** * Token lifetime: * number → token expires that many days after creation (1–3650) - * null → no expiry (token never expires, explicit opt-in) + * null → no expiry (explicit opt-in) * omitted → server default (90 days) */ ttlDays: Type.Optional(Type.Union([Type.Integer({ minimum: 1, maximum: 3650 }), Type.Null()])), }) -export type CreateMcpConnectorBody = Static +export type CreateMcpAccessTokenBody = Static -/** Response to a successful create — carries the plaintext token EXACTLY ONCE. */ -export const CreateMcpConnectorResultSchema = Type.Object({ - connector: McpConnectorViewSchema, - token: Type.String(), +/** Response to a successful token creation — carries the plaintext token exactly once. */ +export const CreateMcpAccessTokenResultSchema = Type.Object({ + connection: McpConnectionViewSchema, + accessToken: Type.String(), }) -export type CreateMcpConnectorResult = Static +export type CreateMcpAccessTokenResult = Static -export const McpConnectorListSchema = Type.Object({ - connectors: Type.Array(McpConnectorViewSchema), +export const McpOAuthAuthorizationRequestSchema = Type.Object({ + responseType: Type.Literal('code'), + clientId: Type.String({ minLength: 1, maxLength: 2048 }), + redirectUri: Type.String({ minLength: 1, maxLength: 4096 }), + codeChallenge: Type.String({ minLength: 43, maxLength: 43 }), + codeChallengeMethod: Type.Literal('S256'), + scope: Type.String({ minLength: 1, maxLength: 256 }), + resource: Type.String({ minLength: 1, maxLength: 4096 }), + state: Type.Optional(Type.String({ maxLength: 2048 })), }) -export type McpConnectorList = Static +export type McpOAuthAuthorizationRequest = Static -export const RevokeMcpConnectorResultSchema = Type.Object({ - revoked: Type.Boolean(), +export const McpOAuthAuthorizationViewSchema = Type.Object({ + clientName: Type.String(), + callbackUrl: Type.String(), + grantExpiresInDays: Type.Integer(), + request: McpOAuthAuthorizationRequestSchema, +}) +export type McpOAuthAuthorizationView = Static + +export const DecideMcpOAuthAuthorizationBodySchema = Type.Object({ + decision: Type.Union([Type.Literal('approve'), Type.Literal('deny')]), + request: McpOAuthAuthorizationRequestSchema, + capabilities: Type.Optional(Type.Array(CapabilitySchema)), +}) +export type DecideMcpOAuthAuthorizationBody = Static + +export const DecideMcpOAuthAuthorizationResultSchema = Type.Object({ + redirectUrl: Type.String(), }) -export type RevokeMcpConnectorResult = Static +export type DecideMcpOAuthAuthorizationResult = Static