diff --git a/doc/connections/CONNECTOR-PERMISSION-AUDIT.md b/doc/connections/CONNECTOR-PERMISSION-AUDIT.md index 07942d7504..af6b592458 100644 --- a/doc/connections/CONNECTOR-PERMISSION-AUDIT.md +++ b/doc/connections/CONNECTOR-PERMISSION-AUDIT.md @@ -6,6 +6,10 @@ method's exact requested scopes, supported actions, restrictions, sources and verification limits. AI runtime authentication and chat/channel setup are separate contracts and were not changed. +Methods reviewed after this audit carry their own `reviewedAt` date in the same +ledger; the counts above are not restated. Later additions: [Neon](./NEON.md) +(`mcp-oauth`, `mcp-api-key`; 2026-10-02). + ## Shared credential failure Zapier's secret URL exposed a shared ownership/resolution problem. The same diff --git a/doc/connections/NEON.md b/doc/connections/NEON.md new file mode 100644 index 0000000000..253745f41f --- /dev/null +++ b/doc/connections/NEON.md @@ -0,0 +1,237 @@ +# Neon + +Updated: 2026-10-02. Status: catalog definition reviewed against official +documentation and live provider metadata; live account qualification +outstanding. + +Neon appears in Apps and uses Paperclip's shared remote-MCP OAuth connection, +vault, catalog, grants, policies, gateway, and audit trail. It is a resource +connection, not Paperclip sign-in. No plugin, provider-specific runtime code, +or database migration is required. + +Paperclip connects to Neon's hosted MCP server at `https://mcp.neon.tech/mcp`. +The connection supports two explicit methods: + +- browser OAuth, recommended for Neon accounts; or +- a Neon API key stored as a Paperclip secret and sent as an + `Authorization: Bearer ...` header. + +Paperclip does not silently fall back from OAuth to an API key. The selected +method is saved on the connection and reused for reconnects. + +This curated connection is the polished route: it provides branding, optional +project pinning and read-only controls, field validation, and tailored +guidance. None of it is *required* to reach Neon's server. Neon can also be +connected generically from **Connect your own MCP server** by pasting +`https://mcp.neon.tech/mcp`, with no Paperclip-specific code involved. See +[Connecting any remote MCP server](./GENERIC-REMOTE-MCP.md). + +## Service involvement + +Neon hosts both the MCP resource and its OAuth authorization service on the +same origin. Paperclip discovers the OAuth endpoints, dynamically registers the +client, stores returned credentials as secret references, and handles the +callback at `/api/tools/oauth/callback`. No Paperclip-operated vendor relay is +involved; cloud and self-hosted instances use the same path. + +```mermaid +sequenceDiagram + actor A as Administrator + participant P as Paperclip + participant M as mcp.neon.tech + + A->>P: Choose Sign in with Neon + P->>M: GET /.well-known/oauth-protected-resource/mcp + M-->>P: Authorization server: https://mcp.neon.tech + P->>M: GET /.well-known/oauth-authorization-server + M-->>P: authorize, token, register, revoke endpoints; scopes read, write + P->>M: POST /api/register (dynamic client registration) + M-->>P: Client registration + P-->>A: Open browser authorization (scope: read write, PKCE S256) + A->>M: Approve access + M-->>P: Redirect to /api/tools/oauth/callback + P->>M: POST /api/token (authorization code) + M-->>P: Access and refresh tokens + P->>M: tools/list on /mcp with optional projectId and readonly query + M-->>P: Neon tool catalog +``` + +The hosted endpoints retrieved on 2026-10-02: + +| Purpose | Endpoint | +| --- | --- | +| MCP resource (Streamable HTTP) | `https://mcp.neon.tech/mcp` | +| Protected-resource metadata | `https://mcp.neon.tech/.well-known/oauth-protected-resource/mcp` | +| Authorization-server metadata | `https://mcp.neon.tech/.well-known/oauth-authorization-server` | +| Authorize | `https://mcp.neon.tech/api/authorize` | +| Token | `https://mcp.neon.tech/api/token` | +| Dynamic client registration | `https://mcp.neon.tech/api/register` | +| Revoke | `https://mcp.neon.tech/api/revoke` | +| Paperclip callback | `/api/tools/oauth/callback` | + +The authorization server advertises `code` responses, PKCE `S256`, +`authorization_code` and `refresh_token` grants, `none` client authentication +for registered public clients, and exactly two scopes: `read` and `write`. +Paperclip requests both so the connection has the write surface described +below; the read-only switch narrows the server itself rather than the token. +Caller widening beyond the reviewed scopes is rejected. + +Neon's older SSE endpoint (`https://mcp.neon.tech/sse`) is deprecated and +returns `410 Gone` on or after 2026-10-01. Paperclip does not offer it. + +## Administrator setup + +1. In **Apps → Browse**, choose **Neon**. +2. Explicitly choose **Sign in with Neon** or **Use an API key**. +3. Continue directly with Neon's defaults. No project ID is required. +4. Open **Advanced** only when you need to pin the connection to one + **project ID** or force **Read-only mode**. +5. For OAuth, continue through browser consent. For API-key setup, create a + key in Neon Console → **Settings → API keys** and paste it into Paperclip. + Prefer a project-scoped key, which Neon limits to one project with Editor + rights; personal and organization keys reach every project the account can + access. Never put the key in connection configuration or a URL. +6. Review discovered actions on the connection's **Permissions** screen. Every + discovered action starts **Allowed**, including writes and destructive + actions. Set deletion, branch reset, and compute mutations to **Ask first** + where operator review is wanted. + +Use a public HTTPS Paperclip origin or a loopback HTTP origin such as +`http://localhost:3100`. A plain HTTP tailnet hostname is not loopback; use +HTTPS or change the local canonical origin before connecting. + +When configured, Paperclip appends `projectId=` and `readonly=true` as +query parameters on the server URL, exactly as Neon documents. The same URL is +used for catalog discovery and tool execution, and a caller cannot override it. +Neon documents a repeatable `category=` filter as well; Paperclip's +tenant fields serialize lists as one comma-joined value, so that filter is not +offered. Use the per-action Off / Ask first / Allowed controls to narrow the +catalog instead. + +## Capabilities and policy + +The catalog is discovered live from the actual provider schemas. Neon groups +its tools into these categories: + +| Category | What the tools do | Classification | +| --- | --- | --- | +| `docs` | Look up Neon documentation | Read | +| `schema`, `observability` | Inspect tables and columns, compare schemas, query logs, check availability | Read; may expose application data | +| `projects`, `branches`, `endpoints` | List, create, describe, delete projects and branches; manage roles, databases and computes | Write or destructive | +| `snapshots` | Create, restore and schedule snapshots | Write or destructive | +| `querying` | Execute SQL, apply schema changes, run diagnostics | Write; read-only mode limits SQL to `SELECT` | +| `neon_auth`, `data_api` | Provision Neon Auth, manage OAuth providers, enable or disable the Data API | Write | +| `functions`, `storage` | Deploy functions, manage buckets and objects | Write or destructive | + +Neon enforces the account, organization, and project permissions behind the +credential. The optional project pin and read-only switch are enforced by +Neon's server, not by Paperclip. `requiredResourceFilters: ["project"]` is +reviewed policy metadata that names the boundary operators should set; it is +not a local allowlist. + +Neon's own guidance: the hosted server grants broad database management +capabilities, so always review and authorize actions before execution, and +prefer development or testing projects over production data. + +## Vendor + +- App key: `neon` +- App name: Neon +- Reuse classification: MCP-direct +- Reason for classification: official hosted Streamable HTTP server with + RFC 7591 dynamic registration and documented bearer API keys; common fields + represent every documented option Paperclip can serialize. +- Security tier: S4 +- Plugin needed? No. + +## Transport and auth + +- Transport: `mcp_remote` +- Endpoint: `https://mcp.neon.tech/mcp` +- Auth modes: OAuth (DCR, PKCE) or API key +- OAuth scopes: `read`, `write` (explicit, reviewed) +- Key scope: whatever the Neon key carries; Paperclip cannot widen it +- Credential owner: company or user grant through the standard access screen +- Secret storage: `company_secrets` refs only +- Revocation: Neon publishes `/api/revoke`; Paperclip removal revokes the + grant and deletes stored secrets + +## Resource filters + +- Required filters: none on the default path +- Optional filters: `projectId` (query `projectId`), `readOnly` (query + `readonly=true`, omitted when off) +- Write-enabling filters: none; writes depend on the credential and on + read-only mode being off +- Filters enforced by: Neon's hosted server + +## Manifest + +- slug: `neon`; name: Neon; categories: `data` +- branding: `/brands/apps/neon.png` (official touch icon, both themes) +- docsUrl: `https://neon.com/docs/ai/neon-mcp-server` +- methods: `mcp-oauth` (Sign in with Neon, `dcr`), `mcp-api-key` (Use an API + key, `customer`, `Authorization: Bearer` header) +- tenantFields: `projectId` (text, advanced, `^[a-z0-9-]+$`, max 64), + `readOnly` (checkbox, advanced, default off) +- riskTier: S4; requiredResourceFilters: `project` +- urlPatterns: `https://mcp.neon.tech/*`; redirectConstraints: + `https-or-loopback-http` + +Source of truth: the `neon` block in `scripts/ingest-app-definitions.mjs`, the +ledger row in `packages/shared/src/self-serve-mcp-research.json`, and the two +reviews in `doc/connections/tool-method-permission-reviews.json`. Regenerate +with `pnpm connections:ingest-app-definitions` (or `--definitions-only` +without the research corpus). + +## Wizard path + +- User path: Apps → Browse → Neon → method → Access → Connect. +- Configuration steps: none required; Advanced holds project pin and + read-only mode. +- Error states: discovery or OAuth failures stay inline on Connect with a + retry of the same saved connection; an invalid project ID is rejected before + the provider is contacted. +- Redacted metadata shown: method, pinned project ID, read-only flag. Tokens + and keys are never shown. + +## Governance defaults + +- Default profile and bindings: the standard connection profile; every + discovered action starts Allowed under the current product default. +- Policies: operators narrow destructive categories to Ask first or Off on the + Permissions screen. +- Quarantine rules: the shared defaults; no Neon-specific exceptions. + +## Brand provenance + +Neon's own app icon, the black tile with the green logomark, is used because the +bare logomark from the brand kit is a thin outline that reads weakly at 24–36px +on the light frame. The file is Neon's official touch icon, copied byte-for-byte +on 2026-10-02 and used unchanged in both themes (Neon's brand colours are +black and `#34D59A`). + +| File | Source | SHA-256 | +| --- | --- | --- | +| `ui/public/brands/apps/neon.png` (180×180) | `https://neon.com/apple-touch-icon.png`, the icon linked from `https://neon.com/` | `a6cf4b0772b06a5a64ccfefbfb8b7a1af56e0876eb10c9052f43c8624f4a0b61` | + +The brand kit at `https://neon.com/brand` publishes the bare logomark as SVG +(light and dark colour variants) but no vector of the tile; the raster official +icon is preferred over a locally composed SVG so the artwork stays the +vendor's own. + +## Validation hook + +- Environment: definition review on 2026-10-02 against `origin/master`; + no Neon account was used. +- Metadata probe: both `.well-known` documents above returned HTTP 200 with the + endpoints and scopes recorded here. An unauthenticated `initialize` on + `/mcp` returned HTTP 401 with + `WWW-Authenticate: Bearer ... resource_metadata="https://mcp.neon.tech/.well-known/oauth-protected-resource/mcp"`. +- Deterministic tests: manifest shape, store visibility, artwork, reviewed + scopes, scope-widening rejection, URL projection of the project pin and + read-only flag, and invalid project ID rejection. +- Connect evidence, catalog evidence, allowed read, governed write, + denied case, revoke, audit: not run. Each exposed method still needs the + full live lifecycle with a Neon development project before this connection + is considered qualified. diff --git a/doc/connections/README.md b/doc/connections/README.md index 7e1bfe8f05..9677c400bd 100644 --- a/doc/connections/README.md +++ b/doc/connections/README.md @@ -13,7 +13,7 @@ Runtime authentication: [AI Connections](./AI-CONNECTIONS.md). Long-term memory: [Experimental memory connectors](./MEMORY.md). Provider notes: [Google Workspace](./GOOGLE-WORKSPACE.md), -[Gmail](./GMAIL.md), [Asana](./ASANA.md), [PostHog](./POSTHOG.md), +[Gmail](./GMAIL.md), [Asana](./ASANA.md), [PostHog](./POSTHOG.md), [Neon](./NEON.md), [AgentMail](./AGENTMAIL.md), and [iMessage Photon](./IMESSAGE-PHOTON.md). Optional credential custody: [Vercel Connect](./VERCEL-CONNECT.md). diff --git a/doc/connections/tool-method-permission-reviews.json b/doc/connections/tool-method-permission-reviews.json index 2ef0bc115f..1a7f785211 100644 --- a/doc/connections/tool-method-permission-reviews.json +++ b/doc/connections/tool-method-permission-reviews.json @@ -1368,6 +1368,42 @@ "reviewedAt": "2026-09-30", "liveProof": "not-run" }, + { + "app": "neon", + "method": "mcp-oauth", + "auth": "oauth", + "policy": "explicit", + "requestedScopes": [ + "read", + "write" + ], + "capability": "write", + "supportedActions": "Create, describe and delete projects and branches; manage computes, roles, databases and snapshots; run SQL and apply schema changes; configure Neon Auth and the Data API; read logs. Optional project pinning and read-only mode narrow the hosted server.", + "evidence": [ + "https://neon.com/docs/ai/neon-mcp-server", + "https://mcp.neon.tech/.well-known/oauth-protected-resource/mcp", + "https://mcp.neon.tech/.well-known/oauth-authorization-server" + ], + "reviewedAt": "2026-10-02", + "liveProof": "not-run" + }, + { + "app": "neon", + "method": "mcp-api-key", + "auth": "api_key", + "policy": "provider-key", + "requestedScopes": [], + "capability": "provider-controlled", + "supportedActions": "Create, describe and delete projects and branches; manage computes, roles, databases and snapshots; run SQL and apply schema changes; configure Neon Auth and the Data API; read logs. Optional project pinning and read-only mode narrow the hosted server.", + "evidence": [ + "https://neon.com/docs/ai/neon-mcp-server", + "https://neon.com/docs/manage/api-keys", + "https://mcp.neon.tech/.well-known/oauth-protected-resource/mcp" + ], + "reviewedAt": "2026-10-02", + "liveProof": "not-run", + "keyPermissions": "Project, branch, compute, snapshot, SQL and schema changes within the key\u2019s reach. A project-scoped key limits access to one project with Editor rights; personal and organization keys reach every project they can access. Paperclip cannot increase an existing key\u2019s permissions." + }, { "app": "notion", "method": "mcp-oauth", diff --git a/packages/shared/src/app-definitions-url.test.ts b/packages/shared/src/app-definitions-url.test.ts index c6a5d1a548..5c93e8c2f8 100644 --- a/packages/shared/src/app-definitions-url.test.ts +++ b/packages/shared/src/app-definitions-url.test.ts @@ -11,6 +11,7 @@ describe("tool app gallery URL matching", () => { expect(getAppDefinitionForUrl("https://github.com/paperclipai/paperclip/pull/1")?.slug).toBe("github"); expect(getAppDefinitionForUrl("https://docs.google.com/spreadsheets/d/sheet_123/edit")?.slug).toBe("google-sheets"); expect(getAppDefinitionForUrl("https://gmailmcp.googleapis.com/mcp/v1")?.slug).toBe("gmail"); + expect(getAppDefinitionForUrl("https://mcp.neon.tech/mcp")?.slug).toBe("neon"); }); it("returns null for invalid or unknown links", () => { diff --git a/packages/shared/src/app-definitions.generated.ts b/packages/shared/src/app-definitions.generated.ts index bfe14f543e..d85f7b9b36 100644 --- a/packages/shared/src/app-definitions.generated.ts +++ b/packages/shared/src/app-definitions.generated.ts @@ -67,17 +67,18 @@ import a65 from "./app-definitions/fireflies.json" with { type: "json" }; import a66 from "./app-definitions/zep.json" with { type: "json" }; import a67 from "./app-definitions/supermemory.json" with { type: "json" }; import a68 from "./app-definitions/honcho.json" with { type: "json" }; -import a69 from "./app-definitions/gmail.json" with { type: "json" }; -import a70 from "./app-definitions/google-drive.json" with { type: "json" }; -import a71 from "./app-definitions/google-docs.json" with { type: "json" }; -import a72 from "./app-definitions/google-sheets.json" with { type: "json" }; -import a73 from "./app-definitions/google-slides.json" with { type: "json" }; -import a74 from "./app-definitions/google-calendar.json" with { type: "json" }; -import a75 from "./app-definitions/google-chat.json" with { type: "json" }; -import a76 from "./app-definitions/google-people.json" with { type: "json" }; -import a77 from "./app-definitions/google-workspace-search.json" with { type: "json" }; -import a78 from "./app-definitions/openai.json" with { type: "json" }; -import a79 from "./app-definitions/openrouter.json" with { type: "json" }; -import a80 from "./app-definitions/xai.json" with { type: "json" }; +import a69 from "./app-definitions/neon.json" with { type: "json" }; +import a70 from "./app-definitions/gmail.json" with { type: "json" }; +import a71 from "./app-definitions/google-drive.json" with { type: "json" }; +import a72 from "./app-definitions/google-docs.json" with { type: "json" }; +import a73 from "./app-definitions/google-sheets.json" with { type: "json" }; +import a74 from "./app-definitions/google-slides.json" with { type: "json" }; +import a75 from "./app-definitions/google-calendar.json" with { type: "json" }; +import a76 from "./app-definitions/google-chat.json" with { type: "json" }; +import a77 from "./app-definitions/google-people.json" with { type: "json" }; +import a78 from "./app-definitions/google-workspace-search.json" with { type: "json" }; +import a79 from "./app-definitions/openai.json" with { type: "json" }; +import a80 from "./app-definitions/openrouter.json" with { type: "json" }; +import a81 from "./app-definitions/xai.json" with { type: "json" }; import type { AppDefinition } from "./types/app-definition.js"; -export const APP_DEFINITIONS=[a0,a1,a2,a3,a4,a5,a6,a7,a8,a9,a10,a11,a12,a13,a14,a15,a16,a17,a18,a19,a20,a21,a22,a23,a24,a25,a26,a27,a28,a29,a30,a31,a32,a33,a34,a35,a36,a37,a38,a39,a40,a41,a42,a43,a44,a45,a46,a47,a48,a49,a50,a51,a52,a53,a54,a55,a56,a57,a58,a59,a60,a61,a62,a63,a64,a65,a66,a67,a68,a69,a70,a71,a72,a73,a74,a75,a76,a77,a78,a79,a80] as AppDefinition[]; +export const APP_DEFINITIONS=[a0,a1,a2,a3,a4,a5,a6,a7,a8,a9,a10,a11,a12,a13,a14,a15,a16,a17,a18,a19,a20,a21,a22,a23,a24,a25,a26,a27,a28,a29,a30,a31,a32,a33,a34,a35,a36,a37,a38,a39,a40,a41,a42,a43,a44,a45,a46,a47,a48,a49,a50,a51,a52,a53,a54,a55,a56,a57,a58,a59,a60,a61,a62,a63,a64,a65,a66,a67,a68,a69,a70,a71,a72,a73,a74,a75,a76,a77,a78,a79,a80,a81] as AppDefinition[]; diff --git a/packages/shared/src/app-definitions.test.ts b/packages/shared/src/app-definitions.test.ts index da32aedf63..aa038c32f6 100644 --- a/packages/shared/src/app-definitions.test.ts +++ b/packages/shared/src/app-definitions.test.ts @@ -262,7 +262,7 @@ describe("AppDefinition catalog", () => { "google-workspace-search", ]), ); - expect(SELF_SERVE_MCP_CANDIDATES).toHaveLength(49); + expect(SELF_SERVE_MCP_CANDIDATES).toHaveLength(50); expect(BLOCKED_MCP_PROVIDERS.map((entry) => entry.slug)).toEqual([ "g2", "vercel", @@ -426,15 +426,15 @@ describe("AppDefinition catalog", () => { expect(channel("slack")?.guidanceMd).toContain("reactions"); expect(channel("slack")?.guidanceMd).toContain("direct messages"); }); - it("keeps a complete, unique, dated evidence ledger for all 52 researched MCP providers", () => { + it("keeps a complete, unique, dated evidence ledger for all 53 researched MCP providers", () => { // Ledger-wide date reflects the last full re-verification (2026-08-26); // later provider additions carry their own research evidence, but // bumping the shared date would overstate freshness for the other providers. expect(SELF_SERVE_MCP_RESEARCH.verifiedAt).toBe("2026-08-26"); - expect(SELF_SERVE_MCP_RESEARCH.entries).toHaveLength(52); + expect(SELF_SERVE_MCP_RESEARCH.entries).toHaveLength(53); expect( new Set(SELF_SERVE_MCP_RESEARCH.entries.map((entry) => entry.slug)), - ).toHaveProperty("size", 52); + ).toHaveProperty("size", 53); for (const entry of SELF_SERVE_MCP_RESEARCH.entries) { expect(new URL(entry.docsUrl).protocol).toBe("https:"); expect(new URL(entry.serverUrl).protocol).toBe("https:"); @@ -728,7 +728,7 @@ describe("AppDefinition catalog", () => { "ticktick", "xero", ]); - expect(APP_STORE_DEFINITIONS).toHaveLength(58); + expect(APP_STORE_DEFINITIONS).toHaveLength(59); const connectableSlugs = new Set( CONNECTABLE_APP_DEFINITIONS.map((entry) => entry.slug), ); @@ -938,6 +938,81 @@ describe("AppDefinition catalog", () => { expect(method.guidanceMd).toContain("optional advanced controls"); } }); + it("connects Neon's hosted server with optional project pinning and read-only mode", () => { + const neon = APP_DEFINITIONS.find((app) => app.slug === "neon")!; + expect(neon).toMatchObject({ + name: "Neon", + categories: ["data"], + urlPatterns: ["https://mcp.neon.tech/*"], + docsUrl: "https://neon.com/docs/ai/neon-mcp-server", + redirectConstraints: "https-or-loopback-http", + branding: { logoUrl: "/brands/apps/neon.png" }, + }); + expect(neon.branding.darkLogoUrl).toBeUndefined(); + expect(APP_STORE_DEFINITIONS.some((app) => app.slug === "neon")).toBe(true); + expect(neon.methods.map((candidate) => candidate.key)).toEqual([ + "mcp-oauth", + "mcp-api-key", + ]); + const [oauth, apiKey] = neon.methods; + expect(oauth).toMatchObject({ + auth: "oauth", + ownershipModes: ["dcr"], + riskTier: "S4", + requiredResourceFilters: ["project"], + defaults: { + serverUrl: "https://mcp.neon.tech/mcp", + scopesHint: ["read", "write"], + }, + }); + expect(apiKey).toMatchObject({ + auth: "api_key", + ownershipModes: ["customer"], + riskTier: "S4", + defaults: { serverUrl: "https://mcp.neon.tech/mcp" }, + keyPlacement: { + location: "header", + name: "Authorization", + prefix: "Bearer ", + }, + consoleLinks: { + keys: "https://console.neon.tech/app/settings/api-keys", + }, + }); + expect(apiKey!.credentialFields).toEqual([ + expect.objectContaining({ + key: "authorization", + type: "password", + secret: true, + required: true, + }), + ]); + for (const method of neon.methods) { + // Nothing is required on the default path: both narrowing controls are + // optional and folded under Advanced, and the provider enforces them. + expect(method.tenantFields?.map((field) => field.key)).toEqual([ + "projectId", + "readOnly", + ]); + expect(method.tenantFields?.every((field) => field.advanced && !field.required)).toBe(true); + expect(method.tenantFields?.[0]).toMatchObject({ + type: "text", + validation: { pattern: "^[a-z0-9-]+$", maxLength: 64 }, + transport: { location: "query", name: "projectId" }, + }); + expect(method.tenantFields?.[1]).toMatchObject({ + type: "checkbox", + defaultValue: false, + transport: { + location: "query", + name: "readonly", + format: "boolean", + omitFalse: true, + }, + }); + expect(method.warnings?.length).toBe(2); + } + }); it("requires only reviewed provider or safety-boundary configuration on the default path", () => { const required = APP_DEFINITIONS.flatMap((app) => app.methods.flatMap((method) => diff --git a/packages/shared/src/app-definitions/neon.json b/packages/shared/src/app-definitions/neon.json new file mode 100644 index 0000000000..8e9f20eaa1 --- /dev/null +++ b/packages/shared/src/app-definitions/neon.json @@ -0,0 +1,155 @@ +{ + "schemaVersion": 1, + "slug": "neon", + "name": "Neon", + "description": "Manage Postgres projects and branches, run SQL, and inspect schemas in Neon.", + "categories": [ + "data" + ], + "featured": false, + "branding": { + "logoUrl": "/brands/apps/neon.png" + }, + "urlPatterns": [ + "https://mcp.neon.tech/*" + ], + "docsUrl": "https://neon.com/docs/ai/neon-mcp-server", + "redirectConstraints": "https-or-loopback-http", + "methods": [ + { + "key": "mcp-oauth", + "transport": "mcp_remote", + "auth": "oauth", + "ownershipModes": [ + "dcr" + ], + "whenToUse": "Use browser sign-in for the provider-hosted MCP server.", + "defaults": { + "serverUrl": "https://mcp.neon.tech/mcp", + "scopesHint": [ + "read", + "write" + ] + }, + "guidanceMd": "Connect Neon in the browser. Open Advanced to pin one project or enable read-only mode. Write tools start enabled and remain governed by Paperclip's action policies.", + "riskTier": "S4", + "label": "Sign in with Neon", + "consoleLinks": { + "docs": "https://neon.com/docs/ai/neon-mcp-server" + }, + "warnings": [ + "A Neon account. The hosted server grants broad project and database management, so use a development project and review write actions before connecting production data.", + "Neon recommends its hosted server for development and testing. Review write and destructive actions before execution." + ], + "tenantFields": [ + { + "key": "projectId", + "label": "Pin to project ID", + "type": "text", + "advanced": true, + "placeholder": "Optional Neon project ID", + "helperMd": "Optional. Restrict this connection to one project. Copy the project ID from Neon Console → Project settings → General.", + "validation": { + "pattern": "^[a-z0-9-]+$", + "maxLength": 64 + }, + "transport": { + "location": "query", + "name": "projectId" + } + }, + { + "key": "readOnly", + "label": "Read-only mode", + "type": "checkbox", + "defaultValue": false, + "helperMd": "Enable this to limit SQL to SELECT queries and schema inspection.", + "transport": { + "location": "query", + "name": "readonly", + "format": "boolean", + "omitFalse": true + }, + "advanced": true + } + ], + "requiredResourceFilters": [ + "project" + ] + }, + { + "key": "mcp-api-key", + "transport": "mcp_remote", + "auth": "api_key", + "ownershipModes": [ + "customer" + ], + "whenToUse": "Use a restricted customer-owned key when browser sign-in is not suitable.", + "defaults": { + "serverUrl": "https://mcp.neon.tech/mcp" + }, + "guidanceMd": "Use a customer-created Neon API key. Prefer a project-scoped key for one development project; personal and organization keys reach every project they can access. Write tools start enabled and remain governed by Paperclip's action policies.", + "riskTier": "S4", + "label": "Use an API key", + "credentialFields": [ + { + "key": "authorization", + "label": "Neon API key", + "type": "password", + "required": true, + "placeholder": "napi_... or neon_project_key_...", + "secret": true, + "helperMd": "Project, branch, compute, snapshot, SQL and schema changes within the key’s reach. A project-scoped key limits access to one project with Editor rights; personal and organization keys reach every project they can access. Paperclip cannot increase an existing key’s permissions." + } + ], + "keyPlacement": { + "location": "header", + "name": "Authorization", + "prefix": "Bearer " + }, + "consoleLinks": { + "keys": "https://console.neon.tech/app/settings/api-keys", + "docs": "https://neon.com/docs/ai/neon-mcp-server" + }, + "warnings": [ + "A Neon account. The hosted server grants broad project and database management, so use a development project and review write actions before connecting production data.", + "Neon recommends its hosted server for development and testing. Review write and destructive actions before execution." + ], + "tenantFields": [ + { + "key": "projectId", + "label": "Pin to project ID", + "type": "text", + "advanced": true, + "placeholder": "Optional Neon project ID", + "helperMd": "Optional. Restrict this connection to one project. Copy the project ID from Neon Console → Project settings → General.", + "validation": { + "pattern": "^[a-z0-9-]+$", + "maxLength": 64 + }, + "transport": { + "location": "query", + "name": "projectId" + } + }, + { + "key": "readOnly", + "label": "Read-only mode", + "type": "checkbox", + "defaultValue": false, + "helperMd": "Enable this to limit SQL to SELECT queries and schema inspection.", + "transport": { + "location": "query", + "name": "readonly", + "format": "boolean", + "omitFalse": true + }, + "advanced": true + } + ], + "requiredResourceFilters": [ + "project" + ] + } + ] +} diff --git a/packages/shared/src/self-serve-mcp-research.json b/packages/shared/src/self-serve-mcp-research.json index 366d6d763f..ff278480b8 100644 --- a/packages/shared/src/self-serve-mcp-research.json +++ b/packages/shared/src/self-serve-mcp-research.json @@ -57,6 +57,7 @@ { "slug": "zomato", "name": "Zomato", "wave": "blocked", "status": "blocked", "docsUrl": "https://github.com/Zomato/mcp-server-manifest", "serverUrl": "https://mcp-server.zomato.com/mcp", "authMode": "provider_approval", "prerequisite": "Zomato currently limits third-party clients and requires redirect-URI allowlisting.", "riskTier": "S3" }, {"slug": "zep", "name": "Zep", "wave": 3, "status": "self_serve", "docsUrl": "https://help.getzep.com/memory-mcp-server", "serverUrl": "https://api.getzep.com/mcp", "authMode": "dcr_cimd", "prerequisite": "A Zep project with Memory MCP enabled, available MCP seats, and Google Workspace or enterprise OIDC configured by its administrator. Project API keys do not authenticate Memory MCP.", "riskTier": "S3"}, {"slug": "supermemory", "name": "Supermemory", "wave": 3, "status": "self_serve", "docsUrl": "https://supermemory.ai/docs/supermemory-mcp/mcp", "serverUrl": "https://mcp.supermemory.ai/mcp", "authMode": "dcr", "prerequisite": "Sign in to Supermemory and select the spaces this connection may access. The hosted MCP uses OAuth, not a developer API key.", "riskTier": "S3"}, - {"slug": "honcho", "name": "Honcho", "wave": 3, "status": "self_serve", "docsUrl": "https://honcho.dev/docs/v3/guides/integrations/mcp", "serverUrl": "https://mcp.honcho.dev", "authMode": "api_key", "prerequisite": "An API key from the Honcho dashboard. Memory is stored in your Honcho account; select workspace and peer identifiers when calling tools.", "riskTier": "S3"} + {"slug": "honcho", "name": "Honcho", "wave": 3, "status": "self_serve", "docsUrl": "https://honcho.dev/docs/v3/guides/integrations/mcp", "serverUrl": "https://mcp.honcho.dev", "authMode": "api_key", "prerequisite": "An API key from the Honcho dashboard. Memory is stored in your Honcho account; select workspace and peer identifiers when calling tools.", "riskTier": "S3"}, + { "slug": "neon", "name": "Neon", "wave": 4, "status": "self_serve", "docsUrl": "https://neon.com/docs/ai/neon-mcp-server", "serverUrl": "https://mcp.neon.tech/mcp", "authMode": "dcr_or_api_key", "prerequisite": "A Neon account. The hosted server grants broad project and database management, so use a development project and review write actions before connecting production data.", "riskTier": "S4" } ] } diff --git a/scripts/ingest-app-definitions.mjs b/scripts/ingest-app-definitions.mjs index 1ecfeb2e19..928085347b 100644 --- a/scripts/ingest-app-definitions.mjs +++ b/scripts/ingest-app-definitions.mjs @@ -945,6 +945,7 @@ const categoryBySlug = { miro: "productivity", mixpanel: "analytics", netlify: "developer", + neon: "data", notion: "content", oreilly: "content", pagerduty: "developer", @@ -1018,6 +1019,11 @@ const apiKeySpec = { placeholder: "Paste your Kernel API key", }, mem0: { name: "Authorization", prefix: "Bearer ", placeholder: "Paste your Mem0 API key" }, + neon: { + name: "Authorization", + prefix: "Bearer ", + placeholder: "napi_... or neon_project_key_...", + }, oreilly: { name: "Authorization", prefix: "Bearer ", @@ -1414,6 +1420,61 @@ const specialMethodsFor = (entry) => { }), ]; } + if (entry.slug === "neon") { + // Neon's hosted server narrows itself with documented query options: + // `projectId` pins one project and `readonly=true` limits SQL to SELECT + // and schema inspection. Its repeatable `category` filter has no + // comma-joined form, so catalog narrowing stays with per-action policies. + const tenantFields = [ + { + key: "projectId", + label: "Pin to project ID", + type: "text", + advanced: true, + placeholder: "Optional Neon project ID", + helperMd: + "Optional. Restrict this connection to one project. Copy the project ID from Neon Console → Project settings → General.", + validation: { pattern: "^[a-z0-9-]+$", maxLength: 64 }, + transport: { location: "query", name: "projectId" }, + }, + { + key: "readOnly", + label: "Read-only mode", + type: "checkbox", + defaultValue: false, + helperMd: + "Enable this to limit SQL to SELECT queries and schema inspection.", + transport: { + location: "query", + name: "readonly", + format: "boolean", + omitFalse: true, + }, + }, + ]; + const warning = + "Neon recommends its hosted server for development and testing. Review write and destructive actions before execution."; + return [ + oauthMethodFor(entry, "mcp-oauth", entry.serverUrl, { + guidanceMd: + "Connect Neon in the browser. Open Advanced to pin one project or enable read-only mode. Write tools start enabled and remain governed by Paperclip's action policies.", + tenantFields, + warnings: [entry.prerequisite, warning], + requiredResourceFilters: ["project"], + }), + apiKeyMethodFor(entry, "mcp-api-key", entry.serverUrl, { + guidanceMd: + "Use a customer-created Neon API key. Prefer a project-scoped key for one development project; personal and organization keys reach every project they can access. Write tools start enabled and remain governed by Paperclip's action policies.", + consoleLinks: { + keys: "https://console.neon.tech/app/settings/api-keys", + docs: entry.docsUrl, + }, + tenantFields, + warnings: [entry.prerequisite, warning], + requiredResourceFilters: ["project"], + }), + ]; + } if (entry.slug === "youcom") { // You.com also serves a documented keyless profile at ?profile=free with a // reduced read-only tool set. That is a real user choice: try web search @@ -1515,7 +1576,7 @@ for (const entry of researchManifest.entries) { schemaVersion: 1, slug: entry.slug, name: entry.name, - description: ({ mem0: "Remember preferences, conversations, events, and agent state.", zep: "Retrieve temporal graph memory and authorized business context.", supermemory: "Search and save shared memories, documents, and profiles.", honcho: "Remember conversations and retrieve context about peers." })[entry.slug] ?? (entry.slug === "fireflies" + description: ({ neon: "Manage Postgres projects and branches, run SQL, and inspect schemas in Neon.", mem0: "Remember preferences, conversations, events, and agent state.", zep: "Retrieve temporal graph memory and authorized business context.", supermemory: "Search and save shared memories, documents, and profiles.", honcho: "Remember conversations and retrieve context about peers." })[entry.slug] ?? (entry.slug === "fireflies" ? "Search meeting transcripts, read summaries and action items, and connect meeting-ready routines." : `Connect ${entry.name}'s provider-hosted MCP server.`), categories: [categoryBySlug[entry.slug] ?? "other"], diff --git a/server/src/__tests__/tool-access-service.test.ts b/server/src/__tests__/tool-access-service.test.ts index 6aee6ee0ec..4d14318cc5 100644 --- a/server/src/__tests__/tool-access-service.test.ts +++ b/server/src/__tests__/tool-access-service.test.ts @@ -2441,7 +2441,7 @@ describeEmbeddedPostgres("tool access service", () => { } }); - it.each(["airtable", "beehiiv", "miro", "netlify", "sentry", "supabase", "todoist", "ticktick", "hugging-face"])( + it.each(["airtable", "beehiiv", "miro", "neon", "netlify", "sentry", "supabase", "todoist", "ticktick", "hugging-face"])( "requests the reviewed read/write scopes for %s without adopting advertised admin scopes", async (slug) => { const company = await createCompany(db); @@ -5123,7 +5123,7 @@ describeEmbeddedPostgres("tool access service", () => { "youcom", ]), ); - expect(res.body.apps).toHaveLength(58); + expect(res.body.apps).toHaveLength(59); expect( res.body.apps.find((app: { slug: string }) => app.slug === "gmail") .ownershipAvailability, @@ -6132,6 +6132,52 @@ describeEmbeddedPostgres("tool access service", () => { ).rejects.toMatchObject({ status: 400 }); }); + it("projects Neon's optional project pin and read-only mode into the hosted server URL", async () => { + const company = await createCompany(db); + const service = createTestToolAccessService(db); + + const pinned = await service.connectGalleryApp( + company.id, + { + galleryKey: "neon", + connectionMethodKey: "mcp-oauth", + name: "Neon pinned", + configValues: { projectId: "shy-sun-12345678", readOnly: true }, + }, + { actorType: "user", actorId: "board" }, + ); + expect(pinned.connection.config).toMatchObject({ + url: "https://mcp.neon.tech/mcp?projectId=shy-sun-12345678&readonly=true", + sourceTemplateKey: "neon", + connectionMethodKey: "mcp-oauth", + methodConfig: { projectId: "shy-sun-12345678", readOnly: true }, + }); + + // The default path sends Neon's own defaults: no pin, no readonly flag. + const unpinned = await service.connectGalleryApp( + company.id, + { galleryKey: "neon", connectionMethodKey: "mcp-oauth", name: "Neon unpinned" }, + { actorType: "user", actorId: "board" }, + ); + expect(unpinned.connection.config).toMatchObject({ + url: "https://mcp.neon.tech/mcp", + methodConfig: { readOnly: false }, + }); + + await expect( + service.connectGalleryApp( + company.id, + { + galleryKey: "neon", + connectionMethodKey: "mcp-oauth", + name: "Neon invalid", + configValues: { projectId: "Shy Sun!" }, + }, + { actorType: "user", actorId: "board" }, + ), + ).rejects.toMatchObject({ status: 400 }); + }); + it("resumes an interrupted configured OAuth draft instead of conflicting on its generated name", async () => { const company = await createCompany(db); const service = createTestToolAccessService(db); diff --git a/ui/public/brands/apps/manifest.json b/ui/public/brands/apps/manifest.json index 6b55457f13..ed490bfc21 100644 --- a/ui/public/brands/apps/manifest.json +++ b/ui/public/brands/apps/manifest.json @@ -288,6 +288,12 @@ "localAsset": "/brands/apps/netlify.svg", "darkAsset": "/brands/apps/netlify-dark.svg" }, + { + "slug": "neon", + "provider": "Neon", + "catalogVisible": true, + "localAsset": "/brands/apps/neon.png" + }, { "slug": "notion", "provider": "Notion", diff --git a/ui/public/brands/apps/neon.png b/ui/public/brands/apps/neon.png new file mode 100644 index 0000000000..f2eb817ad4 Binary files /dev/null and b/ui/public/brands/apps/neon.png differ diff --git a/ui/src/lib/app-gallery-copy.ts b/ui/src/lib/app-gallery-copy.ts index fb253b23c0..cfb964f727 100644 --- a/ui/src/lib/app-gallery-copy.ts +++ b/ui/src/lib/app-gallery-copy.ts @@ -74,6 +74,10 @@ const APP_COPY: Record = { tagline: "Explore product usage, errors, flags, and experiments.", short: "Sign in with PostHog. Project pinning and access controls are optional.", }, + neon: { + tagline: "Manage Postgres projects, branches, and queries.", + short: "Sign in with Neon. Project pinning and read-only mode are optional.", + }, linear: { tagline: "Create, update and read tickets.", short: "Create, update and read tickets.", diff --git a/ui/src/pages/apps/AppsConnect.test.tsx b/ui/src/pages/apps/AppsConnect.test.tsx index 82309bf53e..2d306338dd 100644 --- a/ui/src/pages/apps/AppsConnect.test.tsx +++ b/ui/src/pages/apps/AppsConnect.test.tsx @@ -59,6 +59,7 @@ const ASANA_MANAGED = { }; const BOX = CONNECTABLE_APP_DEFINITIONS.find((app) => app.slug === "box")!; const POSTHOG = CONNECTABLE_APP_DEFINITIONS.find((app) => app.slug === "posthog")!; +const NEON = CONNECTABLE_APP_DEFINITIONS.find((app) => app.slug === "neon")!; const POSTMAN = CONNECTABLE_APP_DEFINITIONS.find((app) => app.slug === "postman")!; const SHOPIFY = CONNECTABLE_APP_DEFINITIONS.find((app) => app.slug === "shopify")!; const GOOGLE_SHEETS = CONNECTABLE_APP_DEFINITIONS.find((app) => app.slug === "google-sheets")!; @@ -1842,6 +1843,48 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => { expect(container.textContent).not.toContain("Pick the app you want your agents to use."); }); + it("enables Neon's Connect button only once the API key is entered, with pin and read-only optional", async () => { + mockParams.appKey = "neon"; + listGalleryMock.mockResolvedValueOnce({ apps: [NEON] }); + await render(); + await openAccessAdvanced(); + + expect(radioContaining("Sign in with Neon")?.getAttribute("aria-checked")).toBe("true"); + expect(buttonByText("Continue to sign in")?.disabled).toBe(false); + + await act(async () => { + radioContaining("Use an API key")?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + await flushReact(); + + const keyInput = container.querySelector('input[type="password"]'); + expect(keyInput).toBeTruthy(); + expect(container.textContent).toContain("Pin to project ID"); + expect(container.querySelector('input[placeholder="Optional Neon project ID"]')).toBeTruthy(); + expect(container.querySelector('[role="switch"]')?.getAttribute("aria-checked")).toBe("false"); + // The key is the only required input on this method: Connect waits for it + // and for nothing else, since both narrowing controls are optional. + expect(buttonByText("Connect")?.disabled).toBe(true); + + await act(async () => { + setInputValue(keyInput!, "napi_test-key"); + }); + await flushReact(); + const submit = buttonByText("Connect"); + expect(submit?.disabled).toBe(false); + await act(async () => { + submit?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + await flushReact(); + + expect(connectAppMock).toHaveBeenCalledWith("company-1", expect.objectContaining({ + galleryKey: "neon", + connectionMethodKey: "mcp-api-key", + credentialValues: { "credentials.authorization": "napi_test-key" }, + configValues: { readOnly: false }, + })); + }); + it("connects PostHog without a project ID and keeps optional controls advanced", async () => { mockParams.appKey = "posthog"; listGalleryMock.mockResolvedValueOnce({ apps: [POSTHOG] });