Findings
Custom apps: registering external MCP servers per user
Feature: register an external MCP server (HTTP streamable, OAuth 2.1) as a
per-user custom app, modeled as an App beside the native apps. Its tools
surface through this app's own search_tools / execute_tools, never as a
passthrough endpoint.
Per-user tools can't live in the static registry#
The plugin registry is a module-level singleton loaded once at boot. Custom
apps are per-user, dynamic, DB-backed, so they need a parallel resolution path
(src/custom-apps/index.ts) keyed by userId, which search_tools,
get_tool_schema, executeSingle and list_integrations all consult after
the static registry misses. Tool names are appName__remoteTool; the __
separator is what keeps them out of the flat built-in namespace.
OAuth is MCP's spec flow, not the plugin env-cred flow#
Built-in plugins read a static client id/secret from env. A custom app pastes
only a base URL; the server follows the 401 WWW-Authenticate challenge to the
protected-resource metadata, then its authorization_servers to the AS metadata
(authorize/token/registration endpoints + scopes), then does RFC 7591 dynamic
client registration. Well-known paths are host-rooted when there's no
challenge — build them from new URL(baseUrl).origin, not the (possibly
path-qualified) base URL.
Token auth honours the registration response's token_endpoint_auth_method
(client_secret_basic → Basic header, otherwise client_secret_post, defaulting
to client_secret_basic per RFC 7591), and resource is sent on both the
authorize and token/refresh requests. The client id/secret live on the
custom_apps row (secret encrypted); the OAuth access and refresh tokens reuse
the existing connections table under integration = custom:<id>.
Real-provider gotcha (Notion https://mcp.notion.com/mcp): with
client_secret_basic the credentials go only in the Basic header — sending
client_id in the body too is rejected as invalid_request: "Client must not use multiple authentication methods".
No local arg validation (Claude Desktop parity)#
Custom app tools carry JSON Schema from the remote server, not Zod. Args pass
through unvalidated and the remote server rejects bad ones — matching Claude
Desktop, and avoiding a lossy JSON Schema → Zod conversion. Image content blocks
are dropped to a marker (the base64 would bloat context and the _mcpImage
renderer only surfaces one image per result node anyway).
SSRF: every discovered endpoint is attacker-influenced#
The AS/resource metadata endpoints (registration, token, authorization) and each
redirect hop are validated with assertSafeUrl/safeFetch — http(s) only, no
embedded creds, non-private host. safeFetch uses redirect: "manual" and
rejects a 3xx rather than following it to an internal host. isPrivateHost
handles IPv4-mapped IPv6 ([::ffff:a9fe:a9fe] = 169.254.169.254). DNS names are
resolved and rejected if any IP is private (blocks 169.254.169.254.nip.io and
internal names), and plain-HTTP fetches are pinned to the resolved IP so a
second lookup can't rebind. Loopback is allowed only when NODE_ENV is explicitly
development.
encryption.ts load-time side effect broke config-mocked tests#
src/auth/encryption.ts computed Buffer.from(config.ENCRYPTION_KEY, "hex") at
module load. Importing it through a new graph (custom-apps/store → encryption)
reached two suites that vi.mock("../src/config", …) without an
ENCRYPTION_KEY field, and Buffer.from(undefined) threw at import time. Fix =
compute the key lazily inside encrypt/decrypt (memoized), so importing the
module is always safe. General lesson: keep config reads out of module-load
side effects.
Vite dev proxy raw-prefix matches#
The portal's dev proxy forwards server-owned paths with raw-prefix matching, so
/c swallowed every SPA route starting with c (e.g. /connect/:integration,
fresh loads 404'd all along), and /authorize swallowed the portal's
/authorize/choose page (breaking the MCP OAuth flow). Fix = /c/ and
^/authorize(?:/resume)?$ (exact matches only).
Still open#
- Smoke-tested the full loop against a mock MCP+OAuth server (discovery → registration → PKCE → token → tools/list → tools/call), green; a real provider (Notion) connected and ran tools.
- HTTPS rebinding is not pinned (cert/SNI are bound to the hostname, so the fetch keeps the name); resolve+check still blocks the common nip.io case.
- No-auth MCP servers and non-DCR (manual client id/secret) are unsupported — OAuth + dynamic client registration only.