feat(apps): connect Notion through MCP OAuth (#11009)

## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - Apps give agents governed access to external tools.
> - The Apps gallery lists Notion, but the server required manually
configured OAuth credentials.
> - Notion's hosted MCP server supports OAuth discovery and dynamic
client registration.
> - Notion also requires HTTPS or a loopback HTTP redirect URI.
> - This pull request adds a direct Notion MCP OAuth path with PKCE and
reusable dynamic clients.
> - It also adds the current Apps UI states for connect and
reauthorization.
> - The benefit is a secure Notion connection with no manual client
credential setup.

## Linked Issues or Issue Description

**What existing behavior does this improve?**

The Apps gallery, Apps connect route, OAuth token lifecycle, and managed
MCP gateway.

**Subsystem affected**

`server/`, `packages/shared/`, `scripts/`, and `ui/`.

**Current behavior**

The Notion gallery cards are disabled. The server uses the classic
Notion OAuth endpoints and requires operator-supplied client
credentials. It does not register an OAuth client from provider
metadata. Concurrent refreshes can also replay a rotating refresh token.

**Proposed behavior**

Enable the Notion Apps flow. Discover OAuth metadata from
`https://mcp.notion.com/mcp`. Register and reuse a public RFC 7591
client with PKCE. Require HTTPS or loopback HTTP callbacks. Serialize
refreshes, store each rotated refresh token before the new access token
can be used, and show a reconnect state for `invalid_grant`.

**Reason and benefit**

Operators can connect the built-in Notion MCP app without creating or
copying OAuth credentials. Paperclip keeps dynamic clients and rotating
tokens in the company secret store.

**Breaking changes**

None. Explicit environment client credentials still take priority.
Existing Slack and Linear OAuth endpoint hints remain unchanged. Other
OAuth apps remain disabled unless they are allowlisted.

**Additional context**

PR #10910 is a related, broader Connections v3 wizard replacement. This
PR is the focused current Apps flow. The MCP Tool Gateway and Connected
Apps items in `ROADMAP.md` cover this planned capability.

## What Changed

- Classify all 20 reviewed Notion MCP tools with provider-scoped read
and write defaults.
- Require approval for selected Notion mutations, including move,
duplicate, and convert actions that generic verb matching missed.
- Preserve company-scoped connection and catalog resolution for Notion
profiles and policies.
- Add RFC 7591 dynamic client registration with
`token_endpoint_auth_method=none` and mandatory PKCE.
- Store the dynamic client ID on the connection and store any returned
client secret in the company secret store.
- Reuse the registered client for later connects and keep explicit
environment credentials as the first choice.
- Discover protected-resource and authorization-server metadata from the
Notion MCP endpoint.
- Add `redirectConstraints: "https-or-loopback-http"` to the generated
Notion app definition and shared contract.
- Reject non-loopback plain HTTP callbacks before network access with a
TLS setup error.
- Serialize client registration and token refresh operations within the
server process.
- Store a rotated refresh token before publishing the refreshed access
token.
- Treat `invalid_grant` as terminal and move the connection to a clear
reauthorization state.
- Add focused coverage for registration reuse, callback constraints,
refresh rotation, and terminal grants.
- Enable the Notion Apps route and add connect, redirect, success,
error, and reconnect UI states.
- Keep non-allowlisted OAuth apps blocked and cover the UI policy with
regression tests.

## Verification

- The focused Notion policy integration test passed with embedded
PostgreSQL.
- The focused 20-tool classification test passed.
- The server typecheck passed on the governance head.
- `pnpm -r typecheck` passed on the rebased head.
- `pnpm --filter @paperclipai/server typecheck` passed after the
security follow-up.
- `pnpm exec vitest run server/src/__tests__/tool-access-service.test.ts
-t \u0027DCR|refresh tokens|invalid_grant|abandoned lease\u0027` passed
10 focused security tests.
- `pnpm build` passed on the rebased head.
- `pnpm exec vitest run server/src/__tests__/tool-access-service.test.ts
-t 'OAuth|oauth'` passed 14 tests.
- `pnpm exec vitest run packages/shared/src/app-definitions.test.ts`
passed 5 tests.
- The complete server group passed 3,686 tests with 4 skipped.
- The complete UI group passed 3,656 tests.
- The full local runner found one environment-only CLI failure because
this agent runtime injects static AWS credentials into a test that
expects `AWS_PROFILE` only. `env -u AWS_ACCESS_KEY_ID -u
AWS_SECRET_ACCESS_KEY pnpm exec vitest run
cli/src/__tests__/secrets.test.ts` passed all 8 tests.
- The prior UI verification passed 55 focused tests, `pnpm
check:token-gates`, the Storybook build, and review of six 1440 x 1000
screenshots.
- OAuth request sequence: protected-resource metadata `GET
https://mcp.notion.com/.well-known/oauth-protected-resource/mcp`;
authorization metadata `GET
https://mcp.notion.com/.well-known/oauth-authorization-server`; dynamic
registration `POST https://mcp.notion.com/register`; authorization `GET
https://mcp.notion.com/authorize`; token exchange and refresh `POST
https://mcp.notion.com/token`; MCP traffic `POST
https://mcp.notion.com/mcp`.
- The live metadata and registration probe confirmed that Notion accepts
HTTPS and loopback HTTP redirects. It rejects a plain HTTP private
hostname.
- A later QA task owns the full browser consent and managed gateway
tool-list dry run against a configured HTTPS deployment.

## Risks

- Notion can add tools. Unrecognized names use the generic classifier,
and new or changed risky tools stay quarantined after connection
activation.
- A deployment that uses a private non-loopback hostname must configure
HTTPS before it can connect Notion.
- Dynamic registration creates a provider-side client. Paperclip reuses
it because registration does not provide a standard delete operation.
- Refresh coordination uses a database CAS lease across service
instances. An unclean crash leaves an uncertain lease and requires
reconnect instead of risking refresh-token replay.
- The current Apps surface overlaps with PR #10910. Merge order can
require a small conflict resolution if that PR lands first.

> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR. Feature PRs that overlap
with planned core work may need to be redirected — check the roadmap
first. See `CONTRIBUTING.md`.

## Model Used

- OpenAI Codex on a GPT-5 runtime. The exact deployment ID and context
window are not exposed. The runtime used reasoning, repository tools,
code execution, and network tools.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Dotta 2026-08-06 22:18:08 -05:00 committed by GitHub
parent ea83c5c822
commit 03cfad7ceb
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
24 changed files with 2820 additions and 158 deletions

View File

@ -183,6 +183,93 @@ Recommended defaults for a new catalog entry:
If a gallery card cannot pass this path against a real vendor, de-list it or mark it unavailable until the missing auth, transport, or governance dependency is fixed.
## MCP-Direct Connections (Hosted MCP + OAuth)
Many vendors now expose an official hosted MCP server whose authorization
server is discovered from the MCP endpoint itself, instead of documenting fixed
OAuth URLs. For these connectors the manifest's `oauth` block is a hint at
most; the broker resolves endpoints at connect time:
1. `GET <serverUrl>` unauthenticated returns `401` with a `WWW-Authenticate`
header naming the protected-resource metadata URL (RFC 9728).
2. `GET /.well-known/oauth-protected-resource[/<path>]` names the
authorization server(s).
3. `GET /.well-known/oauth-authorization-server` (RFC 8414) yields
`authorization_endpoint`, `token_endpoint`, and — when the vendor supports
dynamic registration — `registration_endpoint`.
The broker implements this in `discoverOAuthEndpoints`
(`server/src/services/tool-access.ts`); connections with transport
`mcp_remote` and `auth: "oauth"` prefer discovered endpoints over manifest
hints. Keep manifest hints current anyway so reviewers can read the expected
endpoints without running discovery.
### Dynamic client registration (RFC 7591)
Vendors whose authorization server advertises a `registration_endpoint` and
supports public clients (`token_endpoint_auth_method: "none"` plus PKCE S256)
need **no pre-provisioned OAuth app at all**. At first connect the broker
registers a client on the fly and stores it on the connection:
- Registration request: `client_name` `Paperclip (<instance host>)`,
`redirect_uris` = the instance's own callback, `grant_types`
`["authorization_code", "refresh_token"]`, `response_types` `["code"]`,
`token_endpoint_auth_method` `"none"`.
- The issued `client_id` is persisted in the connection's OAuth config and any
issued `client_secret` becomes a `company_secrets` ref. The registered
client is **reused** for every later authorize/refresh on that connection —
re-registering orphans prior grants on providers that bind grants to the
client.
- Env-registered clients always win: when
`PAPERCLIP_TOOL_OAUTH_<PROVIDER>_CLIENT_ID/_SECRET` are configured, the
broker uses them (`customer` ownership) and skips registration. List both
`customer` and `dcr` in the method's `ownershipModes` when the vendor
supports both.
**DCR needs neither Paperclip ID nor Paperclip Connect.** DCR is always
instance-local (ratified in the PAP-14828 connector-service spec, section 10
item 8.4: "DCR is always instance-local; the service has no DCR involvement").
Each instance registers its own public client with the vendor and uses its own
`/api/tools/oauth/callback` redirect. **Cloud-hosted and self-hosted instances
use the SAME path** — the only per-instance difference is the hostname inside
the redirect URI. `id.paperclip.ing` authenticates operators only and never
holds resource tokens; `connect.paperclip.ing` is a fallback only for
providers that genuinely require a pre-registered public redirect, which a DCR
provider by definition does not.
### Redirect-URI constraints
Vendors restrict what `redirect_uris` a dynamic client may register. Record
the probed constraint in the `AppDefinition` `redirectConstraints` field and
enforce it before starting OAuth. The first supported value is
`https-or-loopback-http` (Notion's rule): HTTPS on any host — public or
private — or plain HTTP only on loopback (`localhost`, `*.localhost`, `::1`,
`127.0.0.0/8`). A plain-HTTP non-loopback origin fails fast with
`oauth_redirect_origin_unsupported` ("This provider requires an HTTPS or
loopback origin. Configure TLS before connecting.") and a pointer to the TLS
deployment docs, instead of a confusing vendor-side `invalid_redirect_uri`.
Probe the constraint with real registration attempts before writing the
manifest — the redirect-URI rule and browser-reachability are independent
axes; a private HTTPS host can be fine even when plain HTTP is not.
### Documentation standards for every connection doc
Every connection doc — playbook appendix, proposal, or user-facing doc —
must include all three of the following (they are part of the template below):
1. **Service involvement statement.** Say explicitly whether Paperclip ID or
Paperclip Connect participates in the flow. For RFC 7591 DCR providers the
answer is always: neither — DCR is instance-local and cloud vs self-hosted
use the same path.
2. **Sequence diagram + exact endpoints.** A sequence diagram of how the
connection works, and the exact paths/endpoints used for auth: authorize,
token, registration (if DCR), and the Paperclip callback. Keep mermaid
sources next to the doc; do not put semicolons inside mermaid message text
(they parse as statement separators).
3. **Administrator setup instructions.** Step-by-step: what (if anything) an
admin must register — callback URLs? client credentials? nothing, for DCR? —
where to register it, and how to verify the connection works end to end.
## Template
Copy this section into a connector proposal or implementation issue.
@ -208,6 +295,25 @@ Copy this section into a connector proposal or implementation issue.
- Secret storage: company_secrets refs only
- Revocation behavior:
## Connection Flow (mandatory)
- Sequence diagram: <mermaid source or rendered image REQUIRED for every connection doc>
- Auth endpoints (exact paths):
- Authorize:
- Token:
- Registration (if DCR):
- Discovery (.well-known), if any:
- Paperclip callback: `/api/tools/oauth/callback` (or n/a)
- Redirect constraints (probed): none / https-or-loopback-http / requires-public-redirect
- Paperclip ID / Paperclip Connect involvement: <"none — DCR is instance-local; cloud and self-hosted use the same path" for RFC 7591 providers; otherwise name the role>
## Administrator Setup (mandatory)
- What the admin must register (callback URLs? client credentials? nothing for DCR?):
- Where to register it:
- Instance prerequisites (TLS, base URL, feature flags):
- How to verify the connection works:
## Resource Filters
- Required filters:
@ -363,3 +469,261 @@ Linear's real-vendor evidence belongs in [PAP-12373](/PAP/issues/PAP-12373). The
Connector proposals now target the versioned `AppDefinition` contract in `packages/shared/src/types/app-definition.ts`. Seed data is one JSON file per provider under `packages/shared/src/app-definitions/`; regenerate Wave 1 with `pnpm connections:ingest-app-definitions`. The generator parses all 99 captured templates, validates required placeholders, OAuth ownership modes, and API-key placement, and produces deterministic output for review. FIRST-30 remains authoritative for `riskTier` and `requiredResourceFilters`; managed ownership modes stay data-visible but runtime-hidden until availability is injected.
## Appendix: Notion Dry Run (MCP-Direct With DCR)
This dry run applies the template to Notion, the first MCP-direct connector to
ship with RFC 7591 dynamic client registration (PAP-16637; server
implementation PAP-16649, PR #11009). Unlike the Linear appendix, every
endpoint and constraint below comes from a live request log, not vendor docs
alone.
### Vendor
- App key: `notion`
- App name: Notion
- First-30 classification: MCP-direct. Notion ships an official hosted MCP
server; its ~20 `notion-*` tools map directly to Paperclip grants.
- Reason for classification: no shim or wrapper needed — the hosted server
speaks Streamable HTTP, which `server/src/services/mcp-http.ts` already
handles. The FIRST-30 matrix's "thin wrapper for block/database policy" is
explicitly deferred; v1 enforcement is gateway policy plus filters-as-config.
- Security tier: S3 — workspace content read/write, but no payments, tenant
admin, or production infrastructure.
- Plugin needed: No. Gallery card, OAuth connect, filters, catalog, profiles,
policies, and audit cover the UX.
### Transport And Auth
- Transport: `mcp_remote`
- Endpoint: `https://mcp.notion.com/mcp` (Streamable HTTP; `/sse` fallback exists)
- Auth mode: OAuth, endpoints resolved by discovery (RFC 9728 → RFC 8414),
public client via RFC 7591 DCR with PKCE S256 mandatory.
- Ownership modes: `dcr` (default, zero setup) and `customer`
(env-registered classic integration via
`PAPERCLIP_TOOL_OAUTH_NOTION_CLIENT_ID/_SECRET`, which always wins when set).
- Token behavior: access tokens last ~8 h (`expires_in` authoritative).
Refresh tokens **rotate on every refresh** — the old token is invalidated
(at most 2 valid per grant) and replaying a stale one can revoke the whole
grant, so the broker persists the rotated token before publishing the new
access token and serializes refresh per connection. Absolute expiry 180
days, inactivity expiry 30 days. `invalid_grant` on refresh is terminal:
clear tokens, require re-auth, never retry.
- Secret storage: access/refresh tokens and any DCR `client_secret` are
`company_secrets` refs; the DCR `client_id` persists on the connection and
is reused — re-registering would orphan prior grants.
- Revocation behavior: disabling or revoking the connection removes
`notion-*` tools from agent sessions and denies brokered execution on the
next gateway check.
### Connection Flow (mandatory)
Paperclip ID / Paperclip Connect involvement: **none — DCR is instance-local**
(PAP-14828 spec section 10 item 8.4); **cloud-hosted and self-hosted use the
same path**. The only per-instance difference is the hostname in the redirect
URI.
Auth endpoints (exact paths, from the live discovery chain):
| Role | Endpoint |
| --- | --- |
| MCP server | `https://mcp.notion.com/mcp` |
| Protected-resource metadata (RFC 9728) | `https://mcp.notion.com/.well-known/oauth-protected-resource/mcp` |
| AS metadata (RFC 8414) | `https://mcp.notion.com/.well-known/oauth-authorization-server` |
| Authorize | `https://mcp.notion.com/authorize` |
| Token (exchange + refresh) | `https://mcp.notion.com/token` |
| Registration (RFC 7591 DCR) | `https://mcp.notion.com/register` |
| Paperclip connect (wizard) | `POST /api/companies/:companyId/tools/apps/connect` |
| Paperclip OAuth start | `POST /api/tools/oauth/:connectionId/start` |
| Paperclip callback | `GET /api/tools/oauth/callback` |
Redirect constraints (probed): `https-or-loopback-http`.
```mermaid
sequenceDiagram
autonumber
actor U as User's browser
participant UI as Paperclip UI<br/>/PAP/apps/connect?source=notion
participant S as Paperclip instance server<br/>(cloud or self-hosted — same path)
participant M as mcp.notion.com<br/>(MCP server + OAuth AS)
participant N as Notion web<br/>(app.notion.com, notion.com)
U->>UI: Click "Connect" (deep link ?source=notion)
UI->>S: POST /companies/:id/tools/apps/connect { appKey: "notion" }
S->>M: GET /.well-known/oauth-protected-resource (RFC 9728)
M-->>S: authorization_servers → mcp.notion.com
S->>M: GET /.well-known/oauth-authorization-server (RFC 8414)
M-->>S: authorize / token / registration endpoints
alt First connect on this instance (no stored client, no env client)
S->>M: POST registration_endpoint (RFC 7591 DCR, public client, PKCE-only)
M-->>S: client_id (persisted, REUSED for every later connect)
else Client already known
S->>S: Reuse stored DCR client_id (or env-registered client if configured)
end
S-->>UI: auth.startUrl (authorize URL + PKCE S256 challenge + state)
UI->>U: Redirect browser to startUrl
U->>M: GET /authorize?client_id + code_challenge + state
M->>N: 302 to app.notion.com/install-integration
N->>N: notion.com/login (only if signed out)
N-->>U: Consent page: pick workspace, approve integration
U->>S: 302 to GET /api/tools/oauth/callback?code&state (instance's OWN callback)
S->>M: POST token_endpoint (code + code_verifier)
M-->>S: access_token (~8 h) + rotating refresh_token
S->>S: Store tokens as company_secrets refs (server-side only)
S-->>U: Redirect to wizard actions/review step (?oauth=connected)
Note over S,M: Later: agent runs reach notion-* tools via the managed MCP gateway.<br/>Server refreshes ahead of use — each refresh ROTATES the refresh token.
```
### Dry-Run Request Log (PAP-16649, 2026-08-06/07)
The verified request sequence for a first connect:
1. `GET https://mcp.notion.com/mcp``401` with `WWW-Authenticate` naming
`https://mcp.notion.com/.well-known/oauth-protected-resource/mcp`.
2. `GET https://mcp.notion.com/.well-known/oauth-protected-resource/mcp`
`200`; authorization server `https://mcp.notion.com`, scope `default`.
3. `GET https://mcp.notion.com/.well-known/oauth-authorization-server`
`200`; `/authorize`, `/token`, `/register`; `token_endpoint_auth_method`
`none` supported; PKCE `S256` supported.
4. `POST https://mcp.notion.com/register` (RFC 7591).
5. Browser `GET https://mcp.notion.com/authorize` → Notion consent
(`app.notion.com/install-integration`, `notion.com/login` if signed out).
6. `POST https://mcp.notion.com/token` for code exchange and every refresh.
7. `POST https://mcp.notion.com/mcp` for MCP traffic.
Redirect-URI probes against `/register`:
| Probed `redirect_uris` value | Result |
| --- | --- |
| `http://paperclip-dev:3100/api/tools/oauth/callback` | 400 `invalid_redirect_uri` — "Redirect URI must use HTTPS unless it is a loopback HTTP URI" |
| `https://paperclip-dev:3100/api/tools/oauth/callback` | Accepted — private host is fine over HTTPS |
| `http://localhost:3100/api/tools/oauth/callback` | Accepted |
| `http://127.0.0.1:3100/api/tools/oauth/callback` | Accepted |
Hence `redirectConstraints: "https-or-loopback-http"` in `notion.json`, and
the broker's fail-fast `oauth_redirect_origin_unsupported` error for
plain-HTTP non-loopback origins.
### Administrator Setup (mandatory)
- What the admin must register: **nothing**. Notion's authorization server
supports RFC 7591 DCR, so the instance registers its own public client on
first connect. No Notion integration, no client credentials, no callback
registration, no Paperclip ID or Paperclip Connect involvement.
- Optional escape hatch: to use a pre-registered classic Notion integration
instead, set `PAPERCLIP_TOOL_OAUTH_NOTION_CLIENT_ID` and
`PAPERCLIP_TOOL_OAUTH_NOTION_CLIENT_SECRET`; the env client always takes
precedence (`customer` ownership).
- Instance prerequisites: the instance base URL must be HTTPS on any host or
loopback HTTP (Notion's redirect-URI rule). A plain-HTTP non-loopback origin
gets "This provider requires an HTTPS or loopback origin. Configure TLS
before connecting." — add TLS first (e.g. a tailscale cert, as
paperclip-dev did). The `enableApps` experimental setting must be on for
`/apps/*` routes. The connecting user must be allowed to install
integrations in their Notion workspace.
- How to verify: visit `/PAP/apps/connect?source=notion`, complete the Notion
consent flow, and land on the wizard's actions step listing `notion-*`
tools. Then confirm an agent run sees Notion tools through the runtime MCP
gateway and that a write call (e.g. `notion-create-pages`) opens an
ask-first action request.
### Resource Filters
- Required filters: workspace, page, database (per FIRST-30).
- Optional filters: object type, database/data-source scope.
- Write-enabling filters: workspace plus page/database scope for
create/update.
- Enforced by: gateway policy plus filters-as-config in v1; the FIRST-30
"thin wrapper for block/database policy" is explicitly deferred. Notion-side
scoping also applies — the consent step lets the user share only selected
pages/databases with the integration.
### Manifest Sketch
The shipped `packages/shared/src/app-definitions/notion.json` (regenerate via
`pnpm connections:ingest-app-definitions`):
```json
{
"schemaVersion": 1,
"slug": "notion",
"name": "Notion",
"description": "Read and update pages in your Notion workspace.",
"urlPatterns": ["https://mcp.notion.com/*"],
"methods": [
{
"key": "mcp-oauth",
"transport": "mcp_remote",
"auth": "oauth",
"ownershipModes": ["customer", "dcr"],
"defaults": { "serverUrl": "https://mcp.notion.com/mcp" },
"riskTier": "S3",
"requiredResourceFilters": ["workspace", "page", "database"]
}
],
"redirectConstraints": "https-or-loopback-http"
}
```
### Actions
Notion's hosted server exposes ~20 `notion-*` tools. Representative risk
classes below; the full catalog review with per-tool defaults is PAP-16652
(P4), and changed-action quarantine applies as usual.
| Tool | Risk | Default status | Filters | Approval default | Audit fields | Negative case |
| --- | --- | --- | --- | --- | --- | --- |
| `notion-search` | read | active after catalog review; plan-gated by Notion (needs Notion AI) — may list but fail at call time | workspace | allow when profile includes Notion reads | query summary, result count | Ungranted agent cannot invoke. |
| `notion-fetch` | read | active after catalog review | workspace, page, database | allow when profile includes Notion reads | page/database id | Fetch outside shared pages fails Notion-side and is audited. |
| `notion-create-pages` | write | active only after review; changed versions quarantined | workspace, page, database | ask-first by default | parent id, title hash, created page id | Missing workspace/page filter denies. |
| `notion-update-page` | write | active only after review; changed versions quarantined | workspace, page | ask-first by default | page id, redaction summary | Revoked connection blocks retry. |
| `notion-query-data-sources` | read | active after catalog review | workspace, database | allow when profile includes Notion reads | data-source id, result count | Granted agent cannot query a disallowed database. |
No destructive Notion action ships in the first pass; any future
delete/archive/bulk action starts quarantined pending SecurityEngineer review.
### Wizard Path
1. Operator opens `/PAP/apps/connect?source=notion` (or the Notion gallery
card → Connect). The deep link POSTs connect immediately and redirects the
browser to `auth.startUrl`.
2. Operator completes Notion consent (workspace picker → approve).
3. Notion redirects to the instance's own `GET /api/tools/oauth/callback`;
Paperclip exchanges the code, stores token material in `company_secrets`,
and returns the operator to the wizard (`?oauth=connected`).
4. Operator confirms resource filters and default ask-first writes.
5. Paperclip runs health check and catalog refresh; `notion-*` tools appear
on the actions step.
6. Write actions stay ask-first until the operator approves calls or creates
narrow trust rules.
Error state: on a plain-HTTP non-loopback instance, step 1 fails fast with
the TLS guidance error above — the operator never reaches Notion.
### Governance Defaults
- Default profile: Notion read actions for the selected scope; writes opt-in.
- Policy defaults: ask-first for `notion-create-pages`, `notion-update-page`,
and comment writes; block any unreviewed destructive action.
- Quarantine: new or schema-changed write actions receive
`quarantineReason: "pending_review"` and are hidden from agent tool lists.
- Rate limits: per-connection search/fetch budget to protect vendor quota.
- Audit: log connect, DCR registration, config/filter changes, grant changes,
action requests, allowed/denied calls, token refresh failures, revoke, and
catalog quarantine events.
### Validation Hook
End-to-end evidence belongs to PAP-16654 (P6) and the PAP-12373 matrix:
- Zero-setup OAuth connect succeeds on
`https://paperclip-dev.tail29c1aa.ts.net/PAP/apps/connect?source=notion`
with no pre-provisioned OAuth env vars (proves DCR).
- Catalog discovery lists `notion-*` tools; new/changed risky actions are
quarantined.
- An agent run sees Notion tools through the managed runtime MCP gateway.
- `notion-create-pages` opens ask-first review and executes only after
approval.
- Revocation removes Notion tools and blocks execution.
- Audit rows prove actor, run/issue context, connection, tool, decision,
reason code, and outcome.

View File

@ -4,9 +4,11 @@ import { appDefinitionsSchema } from "./validators/app-definition.js";
describe("AppDefinition catalog",()=>{
it("validates all Wave 1 definitions",()=>expect(()=>appDefinitionsSchema.parse(APP_DEFINITIONS)).not.toThrow());
it("contains twelve reviewed providers",()=>expect(APP_DEFINITIONS.map((app)=>app.slug)).toEqual(["zapier","github","slack","notion","linear","google-sheets","context7","oauth-generic","api-key-generic","sentry","vercel","anthropic"]));
it.each([
["notion",["read_content","update_content"]],
["linear",["read","write"]],
])("preserves required OAuth scopes for %s",(slug,scopes)=>expect(APP_DEFINITIONS.find((app)=>app.slug===slug)?.methods[0]?.defaults?.scopesHint).toEqual(scopes));
it("uses discovery-first Notion MCP OAuth metadata",()=>{
const notion=APP_DEFINITIONS.find((app)=>app.slug==="notion");
expect(notion?.redirectConstraints).toBe("https-or-loopback-http");
expect(notion?.methods[0]?.defaults).toEqual({serverUrl:"https://mcp.notion.com/mcp"});
});
it("preserves required Linear OAuth scopes",()=>expect(APP_DEFINITIONS.find((app)=>app.slug==="linear")?.methods[0]?.defaults?.scopesHint).toEqual(["read","write"]));
it("enforces method and field invariants",()=>{for(const app of APP_DEFINITIONS)for(const method of app.methods){if(method.auth==="api_key")expect(method.keyPlacement).toBeTruthy();if(method.auth==="oauth")expect(method.ownershipModes.length).toBeGreaterThan(0);for(const field of method.credentialFields??[])if(field.required&&field.type!=="checkbox")expect(field.placeholder).toBeTruthy()}});
});

View File

@ -24,13 +24,7 @@
],
"whenToUse": "Use the provider-hosted connection for the quickest setup.",
"defaults": {
"serverUrl": "https://mcp.notion.com/mcp",
"authorizationEndpoint": "https://api.notion.com/v1/oauth/authorize",
"tokenEndpoint": "https://api.notion.com/v1/oauth/token",
"scopesHint": [
"read_content",
"update_content"
]
"serverUrl": "https://mcp.notion.com/mcp"
},
"guidanceMd": "Connect Notion for workspace content. Share only the pages and databases agents should use.",
"riskTier": "S3",
@ -40,5 +34,6 @@
"database"
]
}
]
],
"redirectConstraints": "https-or-loopback-http"
}

View File

@ -1393,6 +1393,7 @@ export type {
AppDefinition,
ConnectionMethodDef,
FieldDef,
OAuthRedirectConstraints,
QuotaWindow,
ProviderQuotaResult,
} from "./types/index.js";

View File

@ -1,5 +1,6 @@
import type { ToolConnectionOwnership, ToolConnectionTransport } from "./tool-access.js";
export type AppCategory = "ai"|"analytics"|"commerce"|"communication"|"content"|"data"|"developer"|"productivity"|"other";
export type OAuthRedirectConstraints = "https-or-loopback-http";
export interface FieldDef { key:string; label:string; type:"text"|"password"|"textarea"|"datetime"|"select"|"checkbox"; required?:boolean; placeholder?:string; helperMd?:string; secret?:boolean; prefix?:string; validation?:{pattern?:string;maxLength?:number}; options?:Array<{value:string;label:string}> }
export interface ConnectionMethodDef { key:string; transport:ToolConnectionTransport; auth:"oauth"|"api_key"|"none"; ownershipModes:ToolConnectionOwnership[]; whenToUse:string; defaults?:{serverUrl?:string;discoveryUrl?:string|null;serviceHost?:string;templateKey?:string;authorizationEndpoint?:string;tokenEndpoint?:string;metadataUrl?:string;scopesHint?:string[]}; tenantFields?:FieldDef[]; extensionFields?:FieldDef[]; credentialFields?:FieldDef[]; keyPlacement?:{location:"header"|"query"|"body_json"|"env";name:string;prefix?:string|null}; guidanceMd:string; consoleLinks?:{register?:string;keys?:string;settings?:string;docs?:string}; warnings?:string[]; variants?:Array<{key:string;label:string;whenToUse:string;tenantFields?:FieldDef[]}>; riskTier:"S1"|"S2"|"S3"|"S4"; requiredResourceFilters?:string[] }
export interface AppDefinition { schemaVersion:1; slug:string; name:string; description:string; categories:AppCategory[]; featured?:boolean; branding:{logoUrl:string;darkLogoUrl?:string;backgroundColor?:string;accentColor?:string}; urlPatterns:string[]; docsUrl?:string; methods:ConnectionMethodDef[]; suggestable?:boolean; availability?:{available:boolean;reason?:string;robotEmail?:string}; ownershipAvailability?:Partial<Record<ToolConnectionOwnership,boolean>> }
export interface AppDefinition { schemaVersion:1; slug:string; name:string; description:string; categories:AppCategory[]; featured?:boolean; branding:{logoUrl:string;darkLogoUrl?:string;backgroundColor?:string;accentColor?:string}; urlPatterns:string[]; docsUrl?:string; redirectConstraints?:OAuthRedirectConstraints; methods:ConnectionMethodDef[]; suggestable?:boolean; availability?:{available:boolean;reason?:string;robotEmail?:string}; ownershipAvailability?:Partial<Record<ToolConnectionOwnership,boolean>> }

View File

@ -2,5 +2,5 @@ import { z } from "zod";
import { toolConnectionOwnershipSchema, toolConnectionTransportSchema } from "./tool-access.js";
const field=z.object({key:z.string().min(1),label:z.string().min(1),type:z.enum(["text","password","textarea","datetime","select","checkbox"]),required:z.boolean().optional(),placeholder:z.string().optional(),helperMd:z.string().optional(),secret:z.boolean().optional(),prefix:z.string().optional()}).superRefine((v,c)=>{if(v.required&&v.type!=="checkbox"&&!v.placeholder)c.addIssue({code:"custom",message:"Required fields need placeholders",path:["placeholder"]})});
export const connectionMethodDefSchema=z.object({key:z.string().min(1),transport:toolConnectionTransportSchema,auth:z.enum(["oauth","api_key","none"]),ownershipModes:z.array(toolConnectionOwnershipSchema).min(1),whenToUse:z.string().min(1),defaults:z.object({serverUrl:z.string().url().optional(),discoveryUrl:z.string().url().nullable().optional(),serviceHost:z.string().optional(),templateKey:z.string().optional(),authorizationEndpoint:z.string().url().optional(),tokenEndpoint:z.string().url().optional(),metadataUrl:z.string().url().optional(),scopesHint:z.array(z.string()).optional()}).optional(),tenantFields:z.array(field).optional(),extensionFields:z.array(field).optional(),credentialFields:z.array(field).optional(),keyPlacement:z.object({location:z.enum(["header","query","body_json","env"]),name:z.string().min(1),prefix:z.string().nullable().optional()}).optional(),guidanceMd:z.string().min(1),consoleLinks:z.object({register:z.string().url().optional(),keys:z.string().url().optional(),settings:z.string().url().optional(),docs:z.string().url().optional()}).optional(),warnings:z.array(z.string()).optional(),variants:z.array(z.object({key:z.string(),label:z.string(),whenToUse:z.string(),tenantFields:z.array(field).optional()})).optional(),riskTier:z.enum(["S1","S2","S3","S4"]),requiredResourceFilters:z.array(z.string()).optional()}).superRefine((v,c)=>{if(v.auth==="api_key"&&!v.keyPlacement)c.addIssue({code:"custom",message:"API-key methods require keyPlacement",path:["keyPlacement"]})});
export const appDefinitionSchema=z.object({schemaVersion:z.literal(1),slug:z.string().regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/),name:z.string().min(1),description:z.string().min(1),categories:z.array(z.enum(["ai","analytics","commerce","communication","content","data","developer","productivity","other"])).min(1),featured:z.boolean().optional(),branding:z.object({logoUrl:z.string().url(),darkLogoUrl:z.string().url().optional(),backgroundColor:z.string().optional(),accentColor:z.string().optional()}),urlPatterns:z.array(z.string()),docsUrl:z.string().url().optional(),methods:z.array(connectionMethodDefSchema).min(1),suggestable:z.boolean().optional(),availability:z.object({available:z.boolean(),reason:z.string().optional(),robotEmail:z.string().optional()}).optional(),ownershipAvailability:z.object({platform_shared:z.boolean().optional(),platform_provisioned:z.boolean().optional(),customer:z.boolean().optional(),dcr:z.boolean().optional()}).optional()});
export const appDefinitionSchema=z.object({schemaVersion:z.literal(1),slug:z.string().regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/),name:z.string().min(1),description:z.string().min(1),categories:z.array(z.enum(["ai","analytics","commerce","communication","content","data","developer","productivity","other"])).min(1),featured:z.boolean().optional(),branding:z.object({logoUrl:z.string().url(),darkLogoUrl:z.string().url().optional(),backgroundColor:z.string().optional(),accentColor:z.string().optional()}),urlPatterns:z.array(z.string()),docsUrl:z.string().url().optional(),redirectConstraints:z.enum(["https-or-loopback-http"]).optional(),methods:z.array(connectionMethodDefSchema).min(1),suggestable:z.boolean().optional(),availability:z.object({available:z.boolean(),reason:z.string().optional(),robotEmail:z.string().optional()}).optional(),ownershipAvailability:z.object({platform_shared:z.boolean().optional(),platform_provisioned:z.boolean().optional(),customer:z.boolean().optional(),dcr:z.boolean().optional()}).optional()});
export const appDefinitionsSchema=z.array(appDefinitionSchema).superRefine((v,c)=>{const s=new Set<string>();v.forEach((a,i)=>{if(s.has(a.slug))c.addIssue({code:"custom",message:"Duplicate slug",path:[i,"slug"]});s.add(a.slug)})});

View File

@ -7,7 +7,7 @@ const apps=[
["zapier","Zapier","Reach thousands of apps through your Zapier account.","productivity","zapier.com",["https://mcp.zapier.com/*"],method("mcp-key","mcp_remote","api_key",{serverUrl:"https://mcp.zapier.com/api/mcp"},"S3","Create a Zapier MCP connection, then paste its token here.",{credentialFields:[field("authorization","Zapier MCP token","Paste your Zapier token")],keyPlacement:{location:"header",name:"Authorization",prefix:"Bearer "}})],
["github","GitHub","Read code and pull requests, and coordinate repository work.","developer","github.com",["https://api.githubcopilot.com/mcp/*"],method("mcp-key","mcp_remote","api_key",{serverUrl:"https://api.githubcopilot.com/mcp/"},"S3","Create a fine-grained token limited to the repositories agents should use.",{credentialFields:[field("authorization","GitHub token","github_pat_...")],keyPlacement:{location:"header",name:"Authorization",prefix:"Bearer "},requiredResourceFilters:["organization","repository"]})],
["slack","Slack","Search channels and coordinate team communication.","communication","slack.com",["https://mcp.slack.com/*"],method("mcp-oauth","mcp_remote","oauth",{serverUrl:"https://mcp.slack.com/mcp",authorizationEndpoint:"https://slack.com/oauth/v2/authorize",tokenEndpoint:"https://slack.com/api/oauth.v2.access",scopesHint:["channels:read","chat:write","search:read"]},"S3","Connect a Slack workspace and limit access to the channels agents need.",{requiredResourceFilters:["workspace","channel"]})],
["notion","Notion","Read and update pages in your Notion workspace.","content","notion.so",["https://mcp.notion.com/*"],method("mcp-oauth","mcp_remote","oauth",{serverUrl:"https://mcp.notion.com/mcp",authorizationEndpoint:"https://api.notion.com/v1/oauth/authorize",tokenEndpoint:"https://api.notion.com/v1/oauth/token",scopesHint:["read_content","update_content"]},"S3","Connect Notion for workspace content. Share only the pages and databases agents should use.",{requiredResourceFilters:["workspace","page","database"]})],
["notion","Notion","Read and update pages in your Notion workspace.","content","notion.so",["https://mcp.notion.com/*"],method("mcp-oauth","mcp_remote","oauth",{serverUrl:"https://mcp.notion.com/mcp"},"S3","Connect Notion for workspace content. Share only the pages and databases agents should use.",{requiredResourceFilters:["workspace","page","database"]}),{redirectConstraints:"https-or-loopback-http"}],
["linear","Linear","Create, update, and read Linear issues.","productivity","linear.app",["https://mcp.linear.app/*"],method("mcp-oauth","mcp_remote","oauth",{serverUrl:"https://mcp.linear.app/mcp",authorizationEndpoint:"https://linear.app/oauth/authorize",tokenEndpoint:"https://api.linear.app/oauth/token",scopesHint:["read","write"]},"S2","Register a Linear OAuth app and add Paperclip's redirect URI before connecting.",{requiredResourceFilters:["workspace","team","project"]})],
["google-sheets","Google Sheets","Read and update selected spreadsheets.","data","sheets.google.com",["https://docs.google.com/spreadsheets/*","https://sheets.google.com/*"],method("local","local_stdio","none",{templateKey:"paperclip.google-sheets"},"S3","Share each spreadsheet with the Paperclip robot email, then paste the sheet links.",{requiredResourceFilters:["spreadsheet"]})],
["context7","Context7","Look up current documentation for software libraries.","developer","context7.com",["https://mcp.context7.com/*"],method("mcp","mcp_remote","none",{serverUrl:"https://mcp.context7.com/mcp"},"S1","Connect Context7 to give agents current library documentation.")],
@ -16,7 +16,7 @@ const apps=[
["sentry","Sentry","Investigate errors, releases, and production issues.","developer","sentry.io",["https://mcp.sentry.dev/*"],method("mcp-oauth","mcp_remote","oauth",{serverUrl:"https://mcp.sentry.dev/mcp",discoveryUrl:"https://sentry.io/.well-known/oauth-authorization-server"},"S2","Connect the Sentry organization and projects agents need for incident work.",{requiredResourceFilters:["organization","project","environment"]})],
["vercel","Vercel","Inspect projects, deployments, and runtime logs.","developer","vercel.com",["https://mcp.vercel.com/*"],method("mcp-oauth","mcp_remote","oauth",{serverUrl:"https://mcp.vercel.com/mcp"},"S3","Connect the Vercel team and projects agents should operate.",{requiredResourceFilters:["team","project","environment"]})],
["anthropic","Anthropic","Use Anthropic APIs with a restricted key.","ai","anthropic.com",["https://api.anthropic.com/*"],method("api-key","rest_api","api_key",{serviceHost:"api.anthropic.com"},"S3","Create a key in the Anthropic Console and rotate it if it has been exposed.",{credentialFields:[field("apiKey","API key","sk-ant-api03-...")],keyPlacement:{location:"header",name:"x-api-key"}})],
].map(([slug,name,description,category,domain,urlPatterns,m])=>({schemaVersion:1,slug,name,description,categories:[category],featured:["zapier","github","slack","notion","linear"].includes(slug),branding:{logoUrl:favicon(domain)},urlPatterns,methods:[m]}));
].map(([slug,name,description,category,domain,urlPatterns,m,extra={}])=>({schemaVersion:1,slug,name,description,categories:[category],featured:["zapier","github","slack","notion","linear"].includes(slug),branding:{logoUrl:favicon(domain)},urlPatterns,methods:[m],...extra}));
const parseTableRow=(line)=>line.slice(1,-1).split("|").map((cell)=>cell.trim());
const parseCapture=(fileName)=>{
const markdown=fs.readFileSync(path.join(corpus,fileName),"utf8");

View File

@ -0,0 +1,96 @@
#!/usr/bin/env node
// Capture the PAP-16650 Notion connection-flow Storybook stories.
// Usage: node scripts/screenshot-notion-connect-flow.mjs <storybook-static-dir> <output-dir>
import http from "node:http";
import path from "node:path";
import fs from "node:fs/promises";
import { chromium } from "@playwright/test";
async function main() {
const [, , staticDir, outDir] = process.argv;
if (!staticDir || !outDir) {
console.error(
"usage: node scripts/screenshot-notion-connect-flow.mjs <storybook-static-dir> <output-dir>",
);
process.exit(1);
}
await fs.mkdir(outDir, { recursive: true });
const absStaticDir = path.resolve(staticDir);
const server = http.createServer(async (req, res) => {
try {
let urlPath = decodeURIComponent((req.url || "/").split("?")[0]);
if (urlPath.endsWith("/")) urlPath += "iframe.html";
const filePath = path.resolve(absStaticDir, `.${urlPath}`);
if (!filePath.startsWith(absStaticDir + path.sep) && filePath !== absStaticDir) {
res.writeHead(403);
res.end("Forbidden");
return;
}
const buf = await fs.readFile(filePath);
const ext = path.extname(filePath).toLowerCase();
const mime =
{
".html": "text/html; charset=utf-8",
".js": "application/javascript",
".css": "text/css",
".json": "application/json",
".svg": "image/svg+xml",
".png": "image/png",
".woff": "font/woff",
".woff2": "font/woff2",
".map": "application/json",
}[ext] || "application/octet-stream";
res.writeHead(200, { "content-type": mime });
res.end(buf);
} catch (error) {
res.writeHead(404);
res.end(String(error));
}
});
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
const address = server.address();
if (!address || typeof address === "string") throw new Error("Failed to start screenshot server");
const baseUrl = `http://127.0.0.1:${address.port}/iframe.html`;
const stories = [
["browse-entry", "01-browse-entry.png"],
["connect-entry", "02-connect-entry.png"],
["in-flight", "03-in-flight.png"],
["connected", "04-connected.png"],
["connect-error", "05-connect-error.png"],
["reconnect-required", "06-reconnect-required.png"],
];
const browser = await chromium.launch();
try {
for (const [story, file] of stories) {
const context = await browser.newContext({
viewport: { width: 1440, height: 1000 },
deviceScaleFactor: 2,
colorScheme: "light",
});
const page = await context.newPage();
await page.goto(
`${baseUrl}?id=apps-notion-mcp-connect-flow-pap-16650--${story}&viewMode=story`,
{ waitUntil: "networkidle" },
);
await page.waitForTimeout(500);
const out = path.join(outDir, file);
await page.screenshot({ path: out, fullPage: true });
console.log("wrote", out);
await context.close();
}
} finally {
await browser.close();
server.close();
}
}
main().catch((error) => {
console.error(error);
process.exit(1);
});

View File

@ -3240,11 +3240,214 @@ describeEmbeddedPostgres("tool access service", () => {
await expect(db.select().from(toolOauthStates)).resolves.toHaveLength(0);
});
it("refreshes expired OAuth access tokens before remote app calls", async () => {
it("discovers Notion MCP OAuth metadata, registers one public client, and reuses it", async () => {
vi.stubEnv("PAPERCLIP_TOOL_OAUTH_NOTION_CLIENT_ID", "");
vi.stubEnv("PAPERCLIP_TOOL_OAUTH_NOTION_CLIENT_SECRET", "");
vi.stubEnv("PAPERCLIP_TOOL_OAUTH_CLIENT_ID", "");
vi.stubEnv("PAPERCLIP_TOOL_OAUTH_CLIENT_SECRET", "");
const company = await createCompany(db);
const service = toolAccessService(db);
const connected = await service.connectGalleryApp(company.id, {
galleryKey: "notion",
name: "Notion DCR",
});
const redirectUri = "https://paperclip-dev.tail29c1aa.ts.net/api/tools/oauth/callback";
const registrationBodies: Array<Record<string, unknown>> = [];
const fetchMock = vi.spyOn(globalThis, "fetch").mockImplementation(async (url, init) => {
const href = String(url);
if (href === "https://mcp.notion.com/.well-known/oauth-protected-resource/mcp") {
return mcpHttpResponse({
authorization_servers: ["https://mcp.notion.com"],
scopes_supported: ["default"],
});
}
if (href === "https://mcp.notion.com/.well-known/oauth-authorization-server") {
return mcpHttpResponse({
issuer: "https://mcp.notion.com",
authorization_endpoint: "https://mcp.notion.com/authorize",
token_endpoint: "https://mcp.notion.com/token",
registration_endpoint: "https://mcp.notion.com/register",
code_challenge_methods_supported: ["S256"],
token_endpoint_auth_methods_supported: ["none"],
});
}
if (href === "https://mcp.notion.com/register") {
expect(init?.method).toBe("POST");
registrationBodies.push(JSON.parse(String(init?.body)) as Record<string, unknown>);
return mcpHttpResponse({
client_id: "notion-dcr-client",
client_secret: "notion-dcr-secret",
redirect_uris: [redirectUri],
grant_types: ["authorization_code", "refresh_token"],
response_types: ["code"],
token_endpoint_auth_method: "none",
});
}
throw new Error(`unexpected fetch ${href}`);
});
const [first, concurrent] = await Promise.all([
service.startOAuth(company.id, connected.connectionId, {
redirectUri,
actor: { actorType: "user", actorId: "board" },
}),
service.startOAuth(company.id, connected.connectionId, {
redirectUri,
actor: { actorType: "user", actorId: "board" },
}),
]);
expect(new URL(first.authorizationUrl).origin).toBe("https://mcp.notion.com");
expect(new URL(concurrent.authorizationUrl).searchParams.get("client_id")).toBe("notion-dcr-client");
expect(registrationBodies).toEqual([{
client_name: "Paperclip (paperclip-dev.tail29c1aa.ts.net)",
redirect_uris: [redirectUri],
grant_types: ["authorization_code", "refresh_token"],
response_types: ["code"],
token_endpoint_auth_method: "none",
}]);
fetchMock.mockClear();
const reused = await service.startOAuth(company.id, connected.connectionId, {
redirectUri,
actor: { actorType: "user", actorId: "board" },
});
expect(new URL(reused.authorizationUrl).searchParams.get("client_id")).toBe("notion-dcr-client");
expect(fetchMock).not.toHaveBeenCalled();
const [connection] = await db.select().from(toolConnections).where(eq(toolConnections.id, connected.connectionId));
expect(connection).toMatchObject({ ownership: "dcr" });
expect(connection.config).toMatchObject({
oauth: {
provider: "notion",
clientId: "notion-dcr-client",
clientRegistrationSource: "dcr",
clientTokenEndpointAuthMethod: "none",
clientRedirectUri: redirectUri,
registrationUrl: "https://mcp.notion.com/register",
},
});
expect(connection.credentialSecretRefs).toEqual([
expect.objectContaining({ configPath: "oauth.client_secret", required: false }),
]);
expect(JSON.stringify(connection.config)).not.toContain("notion-dcr-secret");
});
it.each([
[
"a confidential token endpoint auth method",
{ client_id: "notion-dcr-client", token_endpoint_auth_method: "client_secret_basic" },
"token_endpoint_auth_method",
],
[
"a different redirect URI",
{ client_id: "notion-dcr-client", redirect_uris: ["https://attacker.example/callback"] },
"redirect_uris",
],
[
"a reduced grant set",
{ client_id: "notion-dcr-client", grant_types: ["authorization_code"] },
"grant_types",
],
[
"missing response types",
{ client_id: "notion-dcr-client", response_types: undefined },
"response_types",
],
[
"an oversized client id",
{ client_id: "x".repeat(4_097) },
"client_id",
],
])("rejects DCR responses that return %s", async (_label, registrationResponse, field) => {
vi.stubEnv("PAPERCLIP_TOOL_OAUTH_NOTION_CLIENT_ID", "");
vi.stubEnv("PAPERCLIP_TOOL_OAUTH_NOTION_CLIENT_SECRET", "");
vi.stubEnv("PAPERCLIP_TOOL_OAUTH_CLIENT_ID", "");
vi.stubEnv("PAPERCLIP_TOOL_OAUTH_CLIENT_SECRET", "");
const company = await createCompany(db);
const service = toolAccessService(db);
const connected = await service.connectGalleryApp(company.id, {
galleryKey: "notion",
name: `Notion invalid DCR ${field}`,
});
const redirectUri = "https://paperclip-dev.tail29c1aa.ts.net/api/tools/oauth/callback";
vi.spyOn(globalThis, "fetch").mockImplementation(async (url) => {
const href = String(url);
if (href === "https://mcp.notion.com/.well-known/oauth-protected-resource/mcp") {
return mcpHttpResponse({ authorization_servers: ["https://mcp.notion.com"] });
}
if (href === "https://mcp.notion.com/.well-known/oauth-authorization-server") {
return mcpHttpResponse({
authorization_endpoint: "https://mcp.notion.com/authorize",
token_endpoint: "https://mcp.notion.com/token",
registration_endpoint: "https://mcp.notion.com/register",
code_challenge_methods_supported: ["S256"],
token_endpoint_auth_methods_supported: ["none"],
});
}
if (href === "https://mcp.notion.com/register") {
return mcpHttpResponse({
client_id: "notion-dcr-client",
redirect_uris: [redirectUri],
grant_types: ["authorization_code", "refresh_token"],
response_types: ["code"],
token_endpoint_auth_method: "none",
...registrationResponse,
});
}
throw new Error(`unexpected fetch ${href}`);
});
await expect(service.startOAuth(company.id, connected.connectionId, {
redirectUri,
actor: { actorType: "user", actorId: "board" },
})).rejects.toMatchObject({
status: 502,
details: {
code: "oauth_dcr_response_invalid",
field,
},
});
const [connection] = await db
.select()
.from(toolConnections)
.where(eq(toolConnections.id, connected.connectionId));
expect(connection.ownership).not.toBe("dcr");
expect((connection.config.oauth as Record<string, unknown> | undefined)?.clientId).toBeUndefined();
});
it("fails fast when Notion DCR is attempted from a non-loopback HTTP origin", async () => {
vi.stubEnv("PAPERCLIP_TOOL_OAUTH_NOTION_CLIENT_ID", "");
vi.stubEnv("PAPERCLIP_TOOL_OAUTH_CLIENT_ID", "");
const company = await createCompany(db);
const service = toolAccessService(db);
const connected = await service.connectGalleryApp(company.id, {
galleryKey: "notion",
name: "Notion invalid origin",
});
const fetchMock = vi.spyOn(globalThis, "fetch");
await expect(service.startOAuth(company.id, connected.connectionId, {
redirectUri: "http://paperclip-dev:3100/api/tools/oauth/callback",
actor: { actorType: "user", actorId: "board" },
})).rejects.toMatchObject({
status: 422,
message: "This provider requires an HTTPS or loopback origin. Configure TLS before connecting.",
details: expect.objectContaining({
code: "oauth_redirect_origin_unsupported",
docsPath: "docs/deploy",
}),
});
expect(fetchMock).not.toHaveBeenCalled();
});
it("leases rotating OAuth refresh tokens across service instances before concurrent remote app calls", async () => {
vi.stubEnv("PAPERCLIP_TOOL_OAUTH_SLACK_CLIENT_ID", "slack-client-id");
vi.stubEnv("PAPERCLIP_TOOL_OAUTH_SLACK_CLIENT_SECRET", "slack-client-secret");
const company = await createCompany(db);
const service = toolAccessService(db);
const concurrentService = toolAccessService(db);
const connect = await service.connectGalleryApp(company.id, { galleryKey: "slack", name: "Slack refresh" });
const start = await service.startOAuth(company.id, connect.connectionId, {
@ -3252,6 +3455,7 @@ describeEmbeddedPostgres("tool access service", () => {
actor: { actorType: "user", actorId: "board" },
});
const state = new URL(start.authorizationUrl).searchParams.get("state")!;
let refreshCallCount = 0;
vi.spyOn(globalThis, "fetch").mockImplementation(async (url, init) => {
const href = String(url);
if (href === "https://slack.com/api/oauth.v2.access") {
@ -3270,6 +3474,8 @@ describeEmbeddedPostgres("tool access service", () => {
}
expect(body.get("grant_type")).toBe("refresh_token");
expect(body.get("refresh_token")).toBe("refresh-token");
refreshCallCount += 1;
await new Promise((resolve) => setTimeout(resolve, 10));
return {
ok: true,
json: async () => ({
@ -3311,9 +3517,14 @@ describeEmbeddedPostgres("tool access service", () => {
})
.where(eq(toolConnections.id, connect.connectionId));
const health = await service.checkHealth(connect.connectionId);
const [health, concurrentHealth] = await Promise.all([
service.checkHealth(connect.connectionId),
concurrentService.checkHealth(connect.connectionId),
]);
expect(health.connection.healthStatus).toBe("ok");
expect(concurrentHealth.connection.healthStatus).toBe("ok");
expect(refreshCallCount).toBe(1);
const fetchCalls = vi.mocked(globalThis.fetch).mock.calls;
const mcpCalls = fetchCalls.filter(([url]) => String(url) === "https://mcp.slack.com/mcp");
expect(mcpCalls.at(-1)?.[1]?.headers).toEqual(expect.objectContaining({ Authorization: "Bearer new-access-token" }));
@ -3336,6 +3547,231 @@ describeEmbeddedPostgres("tool access service", () => {
]));
});
it("treats invalid_grant as terminal without replaying a rotated refresh token", async () => {
vi.stubEnv("PAPERCLIP_TOOL_OAUTH_SLACK_CLIENT_ID", "slack-client-id");
vi.stubEnv("PAPERCLIP_TOOL_OAUTH_SLACK_CLIENT_SECRET", "slack-client-secret");
const company = await createCompany(db);
const service = toolAccessService(db);
const connect = await service.connectGalleryApp(company.id, { galleryKey: "slack", name: "Slack invalid grant" });
const start = await service.startOAuth(company.id, connect.connectionId, {
redirectUri: "http://paperclip.test/api/tools/oauth/callback",
actor: { actorType: "user", actorId: "board" },
});
const state = new URL(start.authorizationUrl).searchParams.get("state")!;
const fetchMock = vi.spyOn(globalThis, "fetch").mockImplementation(async (url) => {
const href = String(url);
if (href === "https://slack.com/api/oauth.v2.access") {
return mcpHttpResponse({
ok: true,
access_token: "expired-access-token",
refresh_token: "single-use-refresh-token",
expires_in: 3600,
token_type: "Bearer",
});
}
if (href === "https://mcp.slack.com/mcp") {
return mcpHttpResponse({ jsonrpc: "2.0", id: "paperclip-catalog-refresh", result: { tools: [] } });
}
throw new Error(`unexpected fetch ${href}`);
});
await service.completeOAuthCallback({
state,
code: "oauth-code",
redirectUri: "http://paperclip.test/api/tools/oauth/callback",
actor: { actorType: "user", actorId: "board" },
});
const [connected] = await db.select().from(toolConnections).where(eq(toolConnections.id, connect.connectionId));
await db.update(toolConnections).set({
config: {
...connected.config,
oauth: {
...(connected.config.oauth as Record<string, unknown>),
expiresAt: "2000-01-01T00:00:00.000Z",
},
},
}).where(eq(toolConnections.id, connect.connectionId));
let refreshCallCount = 0;
fetchMock.mockImplementation(async (url, init) => {
const href = String(url);
if (href === "https://slack.com/api/oauth.v2.access") {
const body = init?.body as URLSearchParams;
expect(body.get("grant_type")).toBe("refresh_token");
expect(body.get("refresh_token")).toBe("single-use-refresh-token");
refreshCallCount += 1;
await new Promise((resolve) => setTimeout(resolve, 10));
return {
ok: false,
status: 400,
headers: { get: () => null },
json: async () => ({ error: "invalid_grant", error_description: "Refresh token was already used" }),
} as unknown as Response;
}
throw new Error(`unexpected fetch ${href}`);
});
const results = await Promise.allSettled([
service.checkHealth(connect.connectionId),
service.checkHealth(connect.connectionId),
]);
expect(results).toEqual([
expect.objectContaining({ status: "rejected" }),
expect.objectContaining({ status: "rejected" }),
]);
for (const result of results) {
expect(result.status === "rejected" ? result.reason : null).toMatchObject({
details: expect.objectContaining({ code: "oauth_reauthorization_required" }),
});
}
expect(refreshCallCount).toBe(1);
const [reauthorizationRequired] = await db
.select()
.from(toolConnections)
.where(eq(toolConnections.id, connect.connectionId));
expect(reauthorizationRequired).toMatchObject({
status: "draft",
enabled: false,
healthStatus: "error",
});
expect(reauthorizationRequired.credentialSecretRefs.map((ref) => ref.configPath)).not.toContain("oauth.access_token");
expect(reauthorizationRequired.credentialSecretRefs.map((ref) => ref.configPath)).not.toContain("oauth.refresh_token");
expect(reauthorizationRequired.credentialRefs.map((ref) => ref.name)).not.toContain("oauth.access_token");
});
it("does not disable a connection when invalid_grant used a superseded refresh-token version", async () => {
vi.stubEnv("PAPERCLIP_TOOL_OAUTH_SLACK_CLIENT_ID", "slack-client-id");
vi.stubEnv("PAPERCLIP_TOOL_OAUTH_SLACK_CLIENT_SECRET", "slack-client-secret");
const company = await createCompany(db);
const service = toolAccessService(db);
const connect = await service.connectGalleryApp(company.id, {
galleryKey: "slack",
name: "Slack stale invalid grant",
});
const start = await service.startOAuth(company.id, connect.connectionId, {
redirectUri: "http://paperclip.test/api/tools/oauth/callback",
actor: { actorType: "user", actorId: "board" },
});
const state = new URL(start.authorizationUrl).searchParams.get("state")!;
const fetchMock = vi.spyOn(globalThis, "fetch").mockImplementation(async (url) => {
const href = String(url);
if (href === "https://slack.com/api/oauth.v2.access") {
return mcpHttpResponse({
ok: true,
access_token: "expired-access-token",
refresh_token: "submitted-refresh-token",
expires_in: 3600,
token_type: "Bearer",
});
}
if (href === "https://mcp.slack.com/mcp") {
return mcpHttpResponse({ jsonrpc: "2.0", id: "paperclip-catalog-refresh", result: { tools: [] } });
}
throw new Error(`unexpected fetch ${href}`);
});
await service.completeOAuthCallback({
state,
code: "oauth-code",
redirectUri: "http://paperclip.test/api/tools/oauth/callback",
actor: { actorType: "user", actorId: "board" },
});
const [connected] = await db
.select()
.from(toolConnections)
.where(eq(toolConnections.id, connect.connectionId));
const refreshRef = connected.credentialSecretRefs.find((ref) => ref.configPath === "oauth.refresh_token")!;
await db.update(toolConnections).set({
config: {
...connected.config,
oauth: {
...(connected.config.oauth as Record<string, unknown>),
expiresAt: "2000-01-01T00:00:00.000Z",
},
},
}).where(eq(toolConnections.id, connect.connectionId));
fetchMock.mockImplementation(async (url, init) => {
const href = String(url);
if (href === "https://slack.com/api/oauth.v2.access") {
const body = init?.body as URLSearchParams;
expect(body.get("refresh_token")).toBe("submitted-refresh-token");
await secretService(db).rotate(refreshRef.secretId, { value: "newer-refresh-token" });
return {
ok: false,
status: 400,
headers: { get: () => null },
json: async () => ({ error: "invalid_grant" }),
} as unknown as Response;
}
throw new Error(`unexpected fetch ${href}`);
});
await expect(service.checkHealth(connect.connectionId)).rejects.toMatchObject({
status: 502,
details: expect.objectContaining({ code: "oauth_refresh_superseded" }),
});
const [preserved] = await db
.select()
.from(toolConnections)
.where(eq(toolConnections.id, connect.connectionId));
expect(preserved).toMatchObject({ status: "active", enabled: false });
expect(preserved.credentialSecretRefs.map((ref) => ref.configPath)).toEqual(expect.arrayContaining([
"oauth.access_token",
"oauth.refresh_token",
]));
expect((preserved.config.oauth as Record<string, unknown>).refreshLease).toBeUndefined();
});
it("fails closed instead of replaying a refresh token after an abandoned lease", async () => {
vi.stubEnv("PAPERCLIP_TOOL_OAUTH_SLACK_CLIENT_ID", "slack-client-id");
vi.stubEnv("PAPERCLIP_TOOL_OAUTH_SLACK_CLIENT_SECRET", "slack-client-secret");
const company = await createCompany(db);
const service = toolAccessService(db);
const fixture = await createOAuthConnection(db, company.id);
const refreshSecret = await secretService(db).create(company.id, {
provider: "local_encrypted",
name: `OAuth refresh ${randomUUID()}`,
key: `oauth.refresh.${randomUUID()}`,
value: "possibly-consumed-refresh-token",
});
await db.insert(companySecretBindings).values({
companyId: company.id,
secretId: refreshSecret.id,
targetType: "tool_connection",
targetId: fixture.connection.id,
configPath: "oauth.refresh_token",
});
await db.update(toolConnections).set({
config: {
...fixture.connection.config,
oauth: {
...(fixture.connection.config.oauth as Record<string, unknown>),
expiresAt: "2000-01-01T00:00:00.000Z",
refreshLease: {
id: "abandoned-refresh",
expiresAt: "2000-01-01T00:00:00.000Z",
},
},
},
credentialSecretRefs: [
...fixture.connection.credentialSecretRefs,
{
secretId: refreshSecret.id,
versionSelector: "latest",
configPath: "oauth.refresh_token",
required: false,
label: "OAuth refresh token",
},
],
}).where(eq(toolConnections.id, fixture.connection.id));
const fetchMock = vi.spyOn(globalThis, "fetch");
await expect(service.checkHealth(fixture.connection.id)).rejects.toMatchObject({
status: 502,
details: expect.objectContaining({ code: "oauth_refresh_outcome_unknown" }),
});
expect(fetchMock).not.toHaveBeenCalled();
});
it("uses OAuth client credentials for shared machine-to-machine MCP connections", async () => {
vi.stubEnv("PAPERCLIP_TOOL_OAUTH_M2M_CLIENT_ID", "m2m-client-id");
vi.stubEnv("PAPERCLIP_TOOL_OAUTH_M2M_CLIENT_SECRET", "m2m-client-secret");
@ -4650,6 +5086,99 @@ describeEmbeddedPostgres("tool access service", () => {
});
});
it("resolves Notion reads as allowed, mutations as ask-first, and denies cross-company use", async () => {
const company = await createCompany(db);
const otherCompany = await createCompany(db);
const agent = await createAgent(db, company.id);
const service = toolAccessService(db);
const [application] = await db.insert(toolApplications).values({
companyId: company.id,
applicationKey: `app-gallery:notion:${randomUUID()}`,
name: "Notion workspace",
type: "mcp_http",
status: "draft",
metadata: { sourceTemplateKey: "notion", galleryKey: "notion" },
}).returning();
const [connection] = await db.insert(toolConnections).values({
companyId: company.id,
applicationId: application.id,
name: "Notion workspace",
uid: `notion/${randomUUID()}`,
transport: "mcp_remote",
authKind: "oauth",
status: "draft",
enabled: false,
config: {
url: "https://mcp.notion.com/mcp",
sourceTemplateKey: "notion",
quarantineNewEntries: true,
},
transportConfig: { url: "https://mcp.notion.com/mcp" },
healthStatus: "ok",
}).returning();
mockToolsList([
{ name: "notion-fetch", annotations: { readOnlyHint: true } },
// These two mutations do not contain a generic create/update verb.
{ name: "notion-move-pages" },
{ name: "notion-duplicate-page", annotations: { readOnlyHint: true } },
]);
const refresh = await service.refreshCatalog(connection.id);
const fetchEntry = refresh.catalog.find((entry) => entry.toolName === "notion-fetch")!;
const moveEntry = refresh.catalog.find((entry) => entry.toolName === "notion-move-pages")!;
const duplicateEntry = refresh.catalog.find((entry) => entry.toolName === "notion-duplicate-page")!;
expect(refresh.catalog).toEqual(expect.arrayContaining([
expect.objectContaining({ id: fetchEntry.id, riskLevel: "read", isReadOnly: true }),
expect.objectContaining({ id: moveEntry.id, riskLevel: "write", isWrite: true }),
expect.objectContaining({ id: duplicateEntry.id, riskLevel: "write", isWrite: true }),
]));
await expect(service.finishGalleryAppConnection(otherCompany.id, connection.id, {
enabledCatalogEntryIds: [fetchEntry.id],
askFirstCatalogEntryIds: [],
access: "all_agents",
})).rejects.toMatchObject({ status: 404 });
await service.finishGalleryAppConnection(company.id, connection.id, {
enabledCatalogEntryIds: [fetchEntry.id, moveEntry.id, duplicateEntry.id],
askFirstCatalogEntryIds: [moveEntry.id, duplicateEntry.id],
access: "all_agents",
});
const policyService = toolAccessPolicyService(db);
const decide = (catalogEntryId: string, toolName: string) => policyService.decide({
companyId: company.id,
actor: { actorType: "agent", actorId: agent.id },
request: { connectionId: connection.id, catalogEntryId, toolName, arguments: {} },
});
await expect(decide(fetchEntry.id, fetchEntry.toolName)).resolves.toMatchObject({
decision: "allow",
reasonCode: "allow_profile",
});
await expect(decide(moveEntry.id, moveEntry.toolName)).resolves.toMatchObject({
decision: "require_approval",
reasonCode: "requires_approval_policy",
});
await expect(decide(duplicateEntry.id, duplicateEntry.toolName)).resolves.toMatchObject({
decision: "require_approval",
reasonCode: "requires_approval_policy",
});
await expect(policyService.decide({
companyId: otherCompany.id,
actor: { actorType: "user", actorId: "other-board" },
request: {
connectionId: connection.id,
catalogEntryId: fetchEntry.id,
toolName: fetchEntry.toolName,
arguments: {},
},
})).resolves.toMatchObject({
decision: "deny",
reasonCode: "deny_missing_tool",
});
});
it("rolls back gallery app finish when a later write fails after clearing profile state", async () => {
const company = await createCompany(db);
const service = toolAccessService(db);
@ -6348,4 +6877,40 @@ describe("classifyRisk", () => {
expect(risk("list_items", { writeHint: true })).toBe("write");
expect(risk("list_items", { readOnlyHint: false })).toBe("write");
});
it("classifies the reviewed Notion MCP catalog with provider-scoped defaults", () => {
const notionRisk = (name: string, annotations?: Record<string, unknown>) =>
classifyRisk({ name, annotations }, "notion");
const readTools = [
"notion-search",
"notion-fetch",
"notion-query-data-sources",
"notion-query-database-view",
"notion-query-meeting-notes",
"notion-get-comments",
"notion-get-teams",
"notion-get-users",
"notion-get-async-task",
];
const writeTools = [
"notion-create-pages",
"notion-update-page",
"notion-convert-page-to-skill",
"notion-move-pages",
"notion-duplicate-page",
"notion-create-database",
"notion-create-folder",
"notion-update-data-source",
"notion-create-view",
"notion-update-view",
"notion-create-comment",
];
for (const toolName of readTools) expect(notionRisk(toolName)).toBe("read");
for (const toolName of writeTools) {
expect(notionRisk(toolName, { readOnlyHint: true })).toBe("write");
}
expect(notionRisk("notion-delete-page")).toBe("destructive");
expect(classifyRisk({ name: "move_pages" })).toBe("read");
});
});

File diff suppressed because it is too large Load Diff

View File

@ -60,6 +60,7 @@ import { ProfileDetailRoute } from "./pages/tools/profiles/ProfileDetailRoute";
import { Connections } from "./pages/apps/Connections";
import { Browse } from "./pages/apps/Browse";
import { AppsConnect } from "./pages/apps/AppsConnect";
import { canEnterAppsConnect } from "./pages/apps/app-connect-policy";
import { AppsReview } from "./pages/apps/AppsReview";
import { AppDetail } from "./pages/apps/AppDetail";
import { AppNotConnected } from "./pages/apps/AppNotConnected";
@ -308,7 +309,7 @@ function boardRoutes() {
function AppsConnectEntryRoute() {
const location = useLocation();
const searchParams = new URLSearchParams(location.search);
return searchParams.get("byo") === "1" ? <AppsConnect /> : <Navigate to="/apps/browse" replace />;
return canEnterAppsConnect(searchParams) ? <AppsConnect /> : <Navigate to="/apps/browse" replace />;
}
function InboxRootRedirect() {

View File

@ -23,6 +23,7 @@ const startOAuthMock = vi.hoisted(() => vi.fn());
const mockNavigate = vi.hoisted(() => vi.fn());
const mockParams = vi.hoisted(() => ({ connectionId: "conn-1", tab: "setup" as string | undefined }));
const navigateComponentMock = vi.hoisted(() => vi.fn());
const navigateTopLevelMock = vi.hoisted(() => vi.fn());
vi.mock("@/api/tools", () => ({
toolsApi: {
@ -57,6 +58,10 @@ vi.mock("@/api/agents", () => ({
},
}));
vi.mock("@/lib/browserNavigation", () => ({
navigateTopLevel: (target: string) => navigateTopLevelMock(target),
}));
vi.mock("@/lib/router", () => ({
useParams: () => mockParams,
useNavigate: () => mockNavigate,
@ -371,6 +376,40 @@ describe("AppDetail", () => {
).toBe(true);
});
it("matches connected Notion guidance to the reconnect action", async () => {
getConnectionMock.mockResolvedValue(connection({
name: "Notion",
config: {
sourceTemplateKey: "notion",
oauth: {
provider: "notion",
connectedAt: "2026-08-06T20:00:00.000Z",
},
},
}));
listGalleryMock.mockResolvedValue({
apps: [{
key: "notion",
name: "Notion",
logoUrl: "https://example.com/notion.png",
tagline: "Search and update your Notion workspace.",
description: "Give agents governed access to Notion.",
authKind: "oauth",
transportTemplate: { transport: "mcp_remote", url: "https://mcp.notion.com/mcp" },
credentialFields: [],
recommendedDefaults: {},
urlPatterns: [],
}],
});
await renderAppDetail();
expect(container.textContent).toContain(
"Your workspace authorization is active. Reconnect any time to replace it.",
);
expect(container.textContent).not.toContain("Sign in again any time");
});
it("lets Google Sheets connections add spreadsheet links from setup", async () => {
mockParams.tab = "setup";
getConnectionMock.mockResolvedValue(connection({
@ -681,4 +720,29 @@ describe("AppDetail", () => {
expect(container.textContent).toContain("Token expired.");
expect(container.textContent).toContain("Who can use it");
});
it("shows terminal OAuth failures as reconnect-required sign-in", async () => {
mockParams.tab = "permissions";
getConnectionMock.mockResolvedValue(connection({
authKind: "oauth",
healthStatus: "failed",
healthMessage: "Authorization expired (invalid_grant).",
}));
await renderAppDetail();
expect(container.textContent).toContain("Reconnect required");
expect(container.textContent).toContain("Authorization expired (invalid_grant).");
expect(container.querySelector('input[placeholder="Paste your new key"]')).toBeNull();
await act(async () => {
Array.from(container.querySelectorAll("button"))
.find((button) => button.textContent?.trim() === "Reconnect")
?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flushReact();
expect(startOAuthMock).toHaveBeenCalledWith("conn-1");
expect(navigateTopLevelMock).toHaveBeenCalledWith("http://example.test/oauth");
});
});

View File

@ -22,6 +22,7 @@ import { accessApi } from "@/api/access";
import { authApi } from "@/api/auth";
import { buildCompanyUserLabelMap } from "@/lib/company-members";
import { installPayload, installStateFrom, type InstallState } from "@/lib/tool-installs";
import { navigateTopLevel } from "@/lib/browserNavigation";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Skeleton } from "@/components/ui/skeleton";
@ -239,7 +240,7 @@ export function AppDetail() {
const startOAuth = useMutation({
mutationFn: () => toolsApi.startOAuth(connectionId),
onSuccess: ({ authorizationUrl }) => {
window.location.assign(authorizationUrl);
navigateTopLevel(authorizationUrl);
},
onError: (error) =>
pushToast({

View File

@ -5,24 +5,33 @@ import { createRoot } from "react-dom/client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { CONNECTABLE_APP_DEFINITIONS } from "@paperclipai/shared";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { queryKeys } from "@/lib/queryKeys";
import { AppsConnect } from "./AppsConnect";
const listGalleryMock = vi.hoisted(() => vi.fn());
const listApplicationsMock = vi.hoisted(() => vi.fn());
const listConnectionsMock = vi.hoisted(() => vi.fn());
const connectAppMock = vi.hoisted(() => vi.fn());
const startOAuthMock = vi.hoisted(() => vi.fn());
const finishAppMock = vi.hoisted(() => vi.fn());
const putConnectionInstallsMock = vi.hoisted(() => vi.fn());
const listAgentsMock = vi.hoisted(() => vi.fn());
const mockNavigate = vi.hoisted(() => vi.fn());
const navigateTopLevelMock = vi.hoisted(() => vi.fn());
const mockSearch = vi.hoisted(() => ({ value: "" }));
const mockParams = vi.hoisted(() => ({ appKey: undefined as string | undefined }));
const ZAPIER = CONNECTABLE_APP_DEFINITIONS.find((app) => app.slug === "zapier")!;
const NOTION = CONNECTABLE_APP_DEFINITIONS.find((app) => app.slug === "notion")!;
const GOOGLE_SHEETS = CONNECTABLE_APP_DEFINITIONS.find((app) => app.slug === "google-sheets")!;
vi.mock("@/api/tools", () => ({
toolsApi: {
listGallery: (companyId: string) => listGalleryMock(companyId),
listApplications: (companyId: string) => listApplicationsMock(companyId),
listConnections: (companyId: string) => listConnectionsMock(companyId),
connectApp: (companyId: string, input: unknown) => connectAppMock(companyId, input),
startOAuth: (connectionId: string) => startOAuthMock(connectionId),
finishApp: (companyId: string, connectionId: string, input: unknown) =>
finishAppMock(companyId, connectionId, input),
putConnectionInstalls: (connectionId: string, installs: unknown) =>
@ -34,6 +43,10 @@ vi.mock("@/api/agents", () => ({
agentsApi: { list: (companyId: string) => listAgentsMock(companyId) },
}));
vi.mock("@/lib/browserNavigation", () => ({
navigateTopLevel: (target: string) => navigateTopLevelMock(target),
}));
vi.mock("@/lib/router", () => ({
useNavigate: () => mockNavigate,
useParams: () => mockParams,
@ -129,6 +142,14 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
ZAPIER,
],
});
listApplicationsMock.mockResolvedValue({ applications: [] });
listConnectionsMock.mockResolvedValue({ connections: [] });
startOAuthMock.mockResolvedValue({
connectionId: "conn-notion",
provider: "notion",
authorizationUrl: "https://mcp.notion.com/authorize?state=resumed",
expiresAt: "2099-01-01T00:00:00.000Z",
});
finishAppMock.mockResolvedValue({});
putConnectionInstallsMock.mockResolvedValue({ connectionId: "conn-1", installs: [] });
connectAppMock.mockResolvedValue({
@ -150,12 +171,12 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
vi.clearAllMocks();
});
async function render() {
async function render(queryClient?: QueryClient) {
const root = createRoot(container);
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
const client = queryClient ?? new QueryClient({ defaultOptions: { queries: { retry: false } } });
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<QueryClientProvider client={client}>
<AppsConnect />
</QueryClientProvider>,
);
@ -190,6 +211,298 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
expect(container.textContent).not.toContain("Pick the app you want your agents to use.");
});
it("auto-starts the allowlisted Notion source deep link and opens provider sign-in", async () => {
mockSearch.value = "source=notion";
listGalleryMock.mockResolvedValueOnce({ apps: [NOTION] });
connectAppMock.mockResolvedValueOnce({
connectionId: "conn-notion",
application: { id: "app-notion", name: "Notion" },
connection: { id: "conn-notion" },
actions: { readOnly: [], canMakeChanges: [] },
catalog: [],
suggestedDefaults: {},
auth: { kind: "oauth", startUrl: "https://mcp.notion.com/authorize?state=opaque" },
});
await render();
expect(connectAppMock).toHaveBeenCalledTimes(1);
expect(connectAppMock).toHaveBeenCalledWith("company-1", {
galleryKey: "notion",
name: "Notion",
credentialValues: {},
configValues: undefined,
applicationId: undefined,
});
expect(navigateTopLevelMock).toHaveBeenCalledWith(
"https://mcp.notion.com/authorize?state=opaque",
);
});
it("shows an in-flight state while Paperclip prepares Notion sign-in", async () => {
mockSearch.value = "source=notion";
listGalleryMock.mockResolvedValueOnce({ apps: [NOTION] });
connectAppMock.mockReturnValueOnce(new Promise(() => {}));
await render();
expect(container.textContent).toContain("Connect Notion");
expect(container.textContent).toContain("Preparing secure sign-in");
expect(container.textContent).toContain("Preparing…");
expect(connectAppMock).toHaveBeenCalledTimes(1);
});
it("resumes an existing Notion OAuth connection instead of creating another draft", async () => {
mockSearch.value = "source=notion";
listGalleryMock.mockResolvedValueOnce({ apps: [NOTION] });
listApplicationsMock.mockResolvedValueOnce({
applications: [{
id: "app-notion",
status: "draft",
metadata: { sourceTemplateKey: "notion" },
}],
});
listConnectionsMock.mockResolvedValueOnce({
connections: [{
id: "conn-existing",
applicationId: "app-notion",
authKind: "oauth",
status: "draft",
config: { sourceTemplateKey: "notion" },
transportConfig: {},
}],
});
startOAuthMock.mockResolvedValueOnce({
connectionId: "conn-existing",
provider: "notion",
authorizationUrl: "https://mcp.notion.com/authorize?state=existing",
expiresAt: "2099-01-01T00:00:00.000Z",
});
await render();
expect(connectAppMock).not.toHaveBeenCalled();
expect(startOAuthMock).toHaveBeenCalledWith("conn-existing");
expect(navigateTopLevelMock).toHaveBeenCalledWith(
"https://mcp.notion.com/authorize?state=existing",
);
});
it("waits for fresh connection data before creating a Notion OAuth draft", async () => {
mockSearch.value = "source=notion";
listGalleryMock.mockResolvedValueOnce({ apps: [NOTION] });
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
queryClient.setQueryData(queryKeys.tools.applications("company-1"), { applications: [] });
queryClient.setQueryData(queryKeys.tools.connections("company-1"), { connections: [] });
let resolveApplications!: (value: unknown) => void;
let resolveConnections!: (value: unknown) => void;
listApplicationsMock.mockReturnValueOnce(new Promise((resolve) => {
resolveApplications = resolve;
}));
listConnectionsMock.mockReturnValueOnce(new Promise((resolve) => {
resolveConnections = resolve;
}));
await render(queryClient);
expect(connectAppMock).not.toHaveBeenCalled();
expect(startOAuthMock).not.toHaveBeenCalled();
resolveApplications({
applications: [{
id: "app-notion",
status: "draft",
metadata: { sourceTemplateKey: "notion" },
}],
});
resolveConnections({
connections: [{
id: "conn-refreshed",
applicationId: "app-notion",
authKind: "oauth",
status: "draft",
config: { sourceTemplateKey: "notion" },
transportConfig: {},
}],
});
await flushReact();
await flushReact();
expect(connectAppMock).not.toHaveBeenCalled();
expect(startOAuthMock).toHaveBeenCalledWith("conn-refreshed");
});
it("resumes Notion OAuth after a failed connection lookup is retried", async () => {
mockSearch.value = "source=notion";
listGalleryMock.mockResolvedValueOnce({ apps: [NOTION] });
listApplicationsMock
.mockRejectedValueOnce(new Error("Lookup unavailable"))
.mockResolvedValueOnce({
applications: [{
id: "app-notion",
status: "draft",
metadata: { sourceTemplateKey: "notion" },
}],
});
listConnectionsMock
.mockResolvedValueOnce({ connections: [] })
.mockResolvedValueOnce({
connections: [{
id: "conn-after-retry",
applicationId: "app-notion",
authKind: "oauth",
status: "draft",
config: { sourceTemplateKey: "notion" },
transportConfig: {},
}],
});
await render();
expect(container.textContent).toContain("couldnt check for an existing connection");
await act(async () => {
buttonByText("Try again")?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flushReact();
await flushReact();
expect(connectAppMock).not.toHaveBeenCalled();
expect(startOAuthMock).toHaveBeenCalledWith("conn-after-retry");
expect(navigateTopLevelMock).toHaveBeenCalledWith(
"https://mcp.notion.com/authorize?state=resumed",
);
});
it("restores the Notion lookup error when retrying still fails", async () => {
mockSearch.value = "source=notion";
listGalleryMock.mockResolvedValueOnce({ apps: [NOTION] });
listApplicationsMock.mockRejectedValue(new Error("Lookup unavailable"));
await render();
await act(async () => {
buttonByText("Try again")?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flushReact();
await flushReact();
expect(container.textContent).toContain("couldnt check for an existing connection");
expect(buttonByText("Try again")).toBeTruthy();
expect(connectAppMock).not.toHaveBeenCalled();
expect(startOAuthMock).not.toHaveBeenCalled();
});
it("retries OAuth on the prepared connection without creating another draft", async () => {
mockSearch.value = "source=notion";
listGalleryMock.mockResolvedValueOnce({ apps: [NOTION] });
connectAppMock.mockResolvedValueOnce({
connectionId: "conn-prepared",
application: { id: "app-notion", name: "Notion" },
connection: { id: "conn-prepared" },
actions: { readOnly: [], canMakeChanges: [] },
catalog: [],
suggestedDefaults: {},
auth: { kind: "oauth", startUrl: null },
});
startOAuthMock
.mockRejectedValueOnce(new Error("Provider unavailable"))
.mockResolvedValueOnce({
connectionId: "conn-prepared",
provider: "notion",
authorizationUrl: "https://mcp.notion.com/authorize?state=retry",
expiresAt: "2099-01-01T00:00:00.000Z",
});
await render();
expect(container.textContent).toContain("Provider unavailable");
await act(async () => {
buttonByText("Try again")?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flushReact();
expect(connectAppMock).toHaveBeenCalledTimes(1);
expect(startOAuthMock).toHaveBeenCalledTimes(2);
expect(startOAuthMock).toHaveBeenLastCalledWith("conn-prepared");
expect(navigateTopLevelMock).toHaveBeenCalledWith(
"https://mcp.notion.com/authorize?state=retry",
);
});
it("recovers a response-lost Notion draft before retrying creation", async () => {
mockSearch.value = "source=notion";
listGalleryMock.mockResolvedValueOnce({ apps: [NOTION] });
listApplicationsMock
.mockResolvedValueOnce({ applications: [] })
.mockResolvedValueOnce({
applications: [{
id: "app-response-lost",
status: "draft",
metadata: { sourceTemplateKey: "notion" },
}],
});
listConnectionsMock
.mockResolvedValueOnce({ connections: [] })
.mockResolvedValueOnce({
connections: [{
id: "conn-response-lost",
applicationId: "app-response-lost",
authKind: "oauth",
status: "draft",
config: { sourceTemplateKey: "notion" },
transportConfig: {},
}],
});
connectAppMock.mockRejectedValueOnce(new Error("Response lost"));
startOAuthMock.mockResolvedValueOnce({
connectionId: "conn-response-lost",
provider: "notion",
authorizationUrl: "https://mcp.notion.com/authorize?state=recovered",
expiresAt: "2099-01-01T00:00:00.000Z",
});
await render();
expect(container.textContent).toContain("Response lost");
await act(async () => {
buttonByText("Try again")?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flushReact();
await flushReact();
expect(connectAppMock).toHaveBeenCalledTimes(1);
expect(startOAuthMock).toHaveBeenCalledWith("conn-response-lost");
expect(navigateTopLevelMock).toHaveBeenCalledWith(
"https://mcp.notion.com/authorize?state=recovered",
);
});
it("keeps non-allowlisted OAuth apps blocked", async () => {
const slack = CONNECTABLE_APP_DEFINITIONS.find((app) => app.slug === "slack")!;
mockParams.appKey = "slack";
listGalleryMock.mockResolvedValueOnce({ apps: [slack] });
await render();
expect(connectAppMock).not.toHaveBeenCalled();
expect(mockNavigate).toHaveBeenCalledWith("/apps/connect", { replace: true });
});
it("routes the enabled Notion gallery tile through the generic source deep link", async () => {
listGalleryMock.mockResolvedValueOnce({ apps: [NOTION] });
await render();
const notionTile = buttonContaining("Notion");
expect(notionTile?.disabled).toBe(false);
expect(notionTile?.textContent).toContain("Connect");
await act(async () => {
notionTile?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
expect(mockNavigate).toHaveBeenCalledWith("/apps/connect?source=notion");
});
it("choosing No and clicking Check link connects with no credentials", async () => {
await render();
await gotoLinkFrame(container, "https://www.example.com/actions");

View File

@ -17,6 +17,8 @@ import type {
Agent,
AppDefinition,
ConnectToolAppResult,
ToolApplication,
ToolConnection,
ToolAppConnectionActionSummary,
} from "@paperclipai/shared";
import { credentialConfigPath, getAppDefinitionForUrl, getAvailableConnectionMethod } from "@paperclipai/shared";
@ -40,11 +42,14 @@ import { ToggleSwitch } from "@/components/ui/toggle-switch";
import { Skeleton } from "@/components/ui/skeleton";
import { cn } from "@/lib/utils";
import { copyTextToClipboard } from "@/lib/clipboard";
import { navigateTopLevel } from "@/lib/browserNavigation";
import { AppLogo } from "./AppLogo";
import { appSourceConnectHref, isMcpDirectOAuthConnectSlug } from "./app-connect-policy";
import { parseGoogleSheetIds } from "./google-sheets";
import { autoExtendNotice, INSTALL_ALL_WARNING, installInfoNotice, installPayload } from "@/lib/tool-installs";
type Step = "gallery" | "key" | "actions" | "who" | "install" | "success";
export type OAuthConnectPhase = "entry" | "starting" | "redirecting" | "error";
const ROUTE_STAGE_BY_STEP: Partial<Record<Step, string>> = {
key: "setup",
@ -88,6 +93,36 @@ function isGoogleSheetsEntry(entry: AppDefinition | null): boolean {
return entry?.slug === "google-sheets";
}
function appSourceSlug(application: ToolApplication): string | null {
const metadata = application.metadata;
if (!metadata) return null;
const source = metadata.sourceTemplateKey ?? metadata.galleryKey;
return typeof source === "string" ? source : null;
}
function connectionSourceSlug(connection: ToolConnection): string | null {
const source = connection.config?.sourceTemplateKey ?? connection.transportConfig.sourceTemplateKey;
return typeof source === "string" ? source : null;
}
function reusableOAuthConnection(
sourceSlug: string | null,
applications: ToolApplication[],
connections: ToolConnection[],
): ToolConnection | null {
if (!sourceSlug) return null;
const matchingApplicationIds = new Set(
applications
.filter((application) => application.status !== "archived" && appSourceSlug(application) === sourceSlug)
.map((application) => application.id),
);
return connections.find((connection) =>
connection.status !== "archived" &&
connection.authKind === "oauth" &&
(matchingApplicationIds.has(connection.applicationId) || connectionSourceSlug(connection) === sourceSlug)
) ?? null;
}
export function AppsConnect() {
const navigate = useNavigate();
const routeParams = useParams<{ appKey?: string }>();
@ -96,7 +131,10 @@ export function AppsConnect() {
const { pushToast } = useToast();
const [searchParams] = useSearchParams();
const appKey = routeParams.appKey ?? searchParams.get("appKey") ?? undefined;
const zapierSource = searchParams.get("source") === "zapier";
const sourceSlug = searchParams.get("source")?.trim() || null;
const directOAuthSource = isMcpDirectOAuthConnectSlug(sourceSlug) ? sourceSlug : null;
const requestedAppKey = appKey ?? directOAuthSource ?? undefined;
const zapierSource = sourceSlug === "zapier";
// Prefill arrives from the app page for reconnects; read once so later
// wizard navigation doesn't fight the URL.
@ -109,7 +147,7 @@ export function AppsConnect() {
};
});
const [step, setStep] = useState<Step>(appKey || prefill.link || zapierSource ? "key" : "gallery");
const [step, setStep] = useState<Step>(requestedAppKey || prefill.link || zapierSource ? "key" : "gallery");
const [entry, setEntry] = useState<AppDefinition | null>(null);
const [galleryName, setGalleryName] = useState("");
const [linkUrl, setLinkUrl] = useState(prefill.link);
@ -125,6 +163,10 @@ export function AppsConnect() {
const [agentIds, setAgentIds] = useState<Set<string>>(new Set());
const [installMode, setInstallMode] = useState<InstallMode>("none");
const [installAgentIds, setInstallAgentIds] = useState<Set<string>>(new Set());
const [oauthPhase, setOAuthPhase] = useState<OAuthConnectPhase>("entry");
const [oauthError, setOAuthError] = useState<string | null>(null);
const directOAuthStartedRef = useRef(false);
const directOAuthRetryingRef = useRef(false);
const openGallery = () => {
setEntry(null);
@ -157,12 +199,142 @@ export function AppsConnect() {
queryFn: () => toolsApi.listGallery(selectedCompanyId!),
enabled: !!selectedCompanyId,
});
const applicationsQuery = useQuery({
queryKey: queryKeys.tools.applications(selectedCompanyId ?? "__none__"),
queryFn: () => toolsApi.listApplications(selectedCompanyId!),
enabled: !!selectedCompanyId && !!directOAuthSource,
refetchOnMount: "always",
});
const connectionsQuery = useQuery({
queryKey: queryKeys.tools.connections(selectedCompanyId ?? "__none__"),
queryFn: () => toolsApi.listConnections(selectedCompanyId!),
enabled: !!selectedCompanyId && !!directOAuthSource,
refetchOnMount: "always",
});
const existingOAuthConnection = useMemo(
() => reusableOAuthConnection(
directOAuthSource,
applicationsQuery.data?.applications ?? [],
connectionsQuery.data?.connections ?? [],
),
[applicationsQuery.data, connectionsQuery.data, directOAuthSource],
);
const directOAuthEntry = entry &&
getAvailableConnectionMethod(entry)?.auth === "oauth" &&
isMcpDirectOAuthConnectSlug(entry.slug)
? entry
: null;
const setAppStep = (nextStep: Step) => {
setStep(nextStep);
if (entry) navigate(appConnectHref(entry.slug, nextStep));
};
const oauthStartMutation = useMutation({
mutationFn: (connectionId: string) => toolsApi.startOAuth(connectionId),
onSuccess: ({ authorizationUrl }) => {
setOAuthPhase("redirecting");
navigateTopLevel(authorizationUrl);
},
onError: (error) => {
const details = error instanceof ApiError && error.body && typeof error.body === "object"
? (error.body as { details?: { code?: unknown } }).details
: null;
setOAuthPhase("error");
setOAuthError(
details?.code === "invalid_grant"
? "Your authorization expired or was revoked. Reconnect to continue."
: error instanceof Error
? error.message
: "Paperclip couldnt start secure sign-in. Try again.",
);
},
});
const startOAuth = oauthStartMutation.mutate;
const connectMutation = useMutation({
mutationFn: (entryOverride?: AppDefinition) => {
const connectEntry = entryOverride ?? entry;
if (connectEntry) {
const sheetIds = isGoogleSheetsEntry(connectEntry) ? parseGoogleSheetIds(googleSheetsLinks).ids : [];
const trimmedGalleryName = galleryName.trim();
return toolsApi.connectApp(selectedCompanyId!, {
galleryKey: connectEntry.slug,
name: trimmedGalleryName || connectEntry.name,
credentialValues: credentials,
configValues: isGoogleSheetsEntry(connectEntry) ? { allowedSpreadsheetIds: sheetIds } : undefined,
applicationId: prefill.applicationId,
});
}
const trimmedKey = linkNeedsKey ? linkKey.trim() : "";
const trimmedName = linkName.trim();
return toolsApi.connectApp(selectedCompanyId!, {
link: linkUrl,
name: trimmedName || undefined,
credentialValues: trimmedKey ? { [LINK_CREDENTIAL_CONFIG_PATH]: trimmedKey } : undefined,
applicationId: prefill.applicationId,
});
},
onSuccess: (result) => {
if (result.auth?.kind === "oauth") {
setConnectResult(result);
const startUrl = result.auth.startUrl?.trim();
if (!startUrl) {
setOAuthPhase("starting");
startOAuth(result.connectionId);
return;
}
setOAuthPhase("redirecting");
navigateTopLevel(startUrl);
return;
}
setConnectResult(result);
const defaults: Record<string, boolean> = {};
for (const a of result.actions.readOnly) defaults[a.catalogEntryId] = true;
for (const a of result.actions.canMakeChanges) defaults[a.catalogEntryId] = false;
setEnabled(defaults);
setInstallMode("none");
setInstallAgentIds(new Set());
setAppStep("actions");
},
onError: (error) => {
const details = error instanceof ApiError && error.body && typeof error.body === "object"
? (error.body as { details?: { code?: unknown } }).details
: null;
if (isMcpDirectOAuthConnectSlug(requestedAppKey)) {
setOAuthPhase("error");
setOAuthError(
details?.code === "invalid_grant"
? "Your authorization expired or was revoked. Reconnect to continue."
: error instanceof Error
? error.message
: "Paperclip couldnt start secure sign-in. Try again.",
);
return;
}
const oauthRequired = details?.code === "oauth_challenge";
pushToast({
title: oauthRequired ? "Sign-in required" : "Couldnt connect",
body: oauthRequired
? "This app needs you to sign in - coming soon."
: error instanceof Error
? error.message
: "Please check your key and try again.",
tone: "error",
});
},
});
const connectApp = connectMutation.mutate;
useEffect(() => {
if (!appKey || galleryQuery.isLoading || !galleryQuery.data) return;
if (!requestedAppKey || galleryQuery.isLoading || !galleryQuery.data) return;
const requestedEntry = galleryQuery.data.apps.find((candidate) => candidate.slug === appKey);
if (!requestedEntry || getAvailableConnectionMethod(requestedEntry)?.auth === "oauth" || requestedEntry.availability?.available === false) {
const requestedEntry = galleryQuery.data.apps.find((candidate) => candidate.slug === requestedAppKey);
const method = requestedEntry ? getAvailableConnectionMethod(requestedEntry) : null;
const directOAuth = method?.auth === "oauth" && isMcpDirectOAuthConnectSlug(requestedEntry?.slug);
const unsupportedOAuth = method?.auth === "oauth" && !directOAuth;
if (!requestedEntry || unsupportedOAuth || requestedEntry.availability?.available === false) {
setEntry(null);
setStep("gallery");
navigate("/apps/connect", { replace: true });
@ -184,61 +356,42 @@ export function AppsConnect() {
setInstallMode("none");
setInstallAgentIds(new Set());
setStep("key");
}, [appKey, entry?.slug, galleryQuery.data, galleryQuery.isLoading, navigate]);
const setAppStep = (nextStep: Step) => {
setStep(nextStep);
if (entry) navigate(appConnectHref(entry.slug, nextStep));
};
if (directOAuth && (
!applicationsQuery.isFetchedAfterMount ||
!connectionsQuery.isFetchedAfterMount
)) return;
if (directOAuth && directOAuthRetryingRef.current) return;
if (directOAuth && (applicationsQuery.isError || connectionsQuery.isError)) {
setOAuthPhase("error");
setOAuthError("Paperclip couldnt check for an existing connection. Try again.");
return;
}
const connectMutation = useMutation({
mutationFn: () => {
if (entry) {
const sheetIds = isGoogleSheetsEntry(entry) ? parseGoogleSheetIds(googleSheetsLinks).ids : [];
const trimmedGalleryName = galleryName.trim();
return toolsApi.connectApp(selectedCompanyId!, {
galleryKey: entry.slug,
name: trimmedGalleryName || undefined,
credentialValues: credentials,
configValues: isGoogleSheetsEntry(entry) ? { allowedSpreadsheetIds: sheetIds } : undefined,
applicationId: prefill.applicationId,
});
if (directOAuth && !directOAuthStartedRef.current) {
directOAuthStartedRef.current = true;
setOAuthError(null);
setOAuthPhase("starting");
if (existingOAuthConnection) {
startOAuth(existingOAuthConnection.id);
} else {
connectApp(requestedEntry);
}
const trimmedKey = linkNeedsKey ? linkKey.trim() : "";
const trimmedName = linkName.trim();
return toolsApi.connectApp(selectedCompanyId!, {
link: linkUrl,
name: trimmedName || undefined,
credentialValues: trimmedKey ? { [LINK_CREDENTIAL_CONFIG_PATH]: trimmedKey } : undefined,
applicationId: prefill.applicationId,
});
},
onSuccess: (result) => {
setConnectResult(result);
const defaults: Record<string, boolean> = {};
for (const a of result.actions.readOnly) defaults[a.catalogEntryId] = true;
for (const a of result.actions.canMakeChanges) defaults[a.catalogEntryId] = false;
setEnabled(defaults);
setInstallMode("none");
setInstallAgentIds(new Set());
setAppStep("actions");
},
onError: (error) => {
const details = error instanceof ApiError && error.body && typeof error.body === "object"
? (error.body as { details?: { code?: unknown } }).details
: null;
const oauthRequired = details?.code === "oauth_challenge";
pushToast({
title: oauthRequired ? "Sign-in required" : "Couldnt connect",
body: oauthRequired
? "This app needs you to sign in - coming soon."
: error instanceof Error
? error.message
: "Please check your key and try again.",
tone: "error",
});
},
});
}
}, [
applicationsQuery.isError,
applicationsQuery.isFetchedAfterMount,
connectApp,
connectionsQuery.isError,
connectionsQuery.isFetchedAfterMount,
entry?.slug,
existingOAuthConnection,
galleryQuery.data,
galleryQuery.isLoading,
navigate,
requestedAppKey,
startOAuth,
]);
const finishMutation = useMutation({
mutationFn: async () => {
@ -280,6 +433,55 @@ export function AppsConnect() {
return <div className="p-6 text-sm text-muted-foreground">Select a company to connect apps.</div>;
}
if (directOAuthEntry && step === "key") {
return (
<OAuthConnectStateScreen
entry={directOAuthEntry}
phase={oauthPhase}
error={oauthError}
onRetry={async () => {
setOAuthError(null);
setOAuthPhase("starting");
const connectionId = connectResult?.connectionId ?? existingOAuthConnection?.id;
if (connectionId) {
startOAuth(connectionId);
return;
}
// The create request may have reached the server even when its
// response did not reach the browser. Re-read both resources before
// creating again so Retry resumes that durable draft instead of
// duplicating it.
directOAuthRetryingRef.current = true;
try {
const [applicationsResult, connectionsResult] = await Promise.all([
applicationsQuery.refetch(),
connectionsQuery.refetch(),
]);
if (applicationsResult.isError || connectionsResult.isError) {
setOAuthPhase("error");
setOAuthError("Paperclip couldnt check for an existing connection. Try again.");
return;
}
const refreshedConnection = reusableOAuthConnection(
directOAuthSource,
applicationsResult.data?.applications ?? [],
connectionsResult.data?.connections ?? [],
);
if (refreshedConnection) {
startOAuth(refreshedConnection.id);
} else {
connectMutation.mutate(directOAuthEntry);
}
} finally {
directOAuthRetryingRef.current = false;
}
}}
onCancel={() => navigate("/apps/browse")}
/>
);
}
const appName =
connectResult?.application.name ??
entry?.name ??
@ -326,6 +528,13 @@ export function AppsConnect() {
byo={searchParams.get("byo") === "1"}
source={searchParams.get("source")}
onPick={(picked) => {
if (
getAvailableConnectionMethod(picked)?.auth === "oauth" &&
isMcpDirectOAuthConnectSlug(picked.slug)
) {
navigate(appSourceConnectHref(picked.slug));
return;
}
setEntry(picked);
setGalleryName(picked.name);
setLinkUrl("");
@ -388,7 +597,7 @@ export function AppsConnect() {
return;
}
}
connectMutation.mutate();
connectMutation.mutate(undefined);
}}
/>
)}
@ -407,7 +616,7 @@ export function AppsConnect() {
onKeyChange={setLinkKey}
submitting={connectMutation.isPending}
onBack={() => setStep("gallery")}
onConnect={() => connectMutation.mutate()}
onConnect={() => connectMutation.mutate(undefined)}
/>
)}
@ -417,7 +626,7 @@ export function AppsConnect() {
onLinkChange={setLinkUrl}
submitting={connectMutation.isPending}
onBack={() => navigate("/apps/browse")}
onConnect={() => connectMutation.mutate()}
onConnect={() => connectMutation.mutate(undefined)}
/>
)}
@ -533,6 +742,85 @@ function StepHeader({
);
}
export function OAuthConnectStateScreen({
entry,
phase,
error,
onRetry,
onCancel,
}: {
entry: AppDefinition;
phase: OAuthConnectPhase;
error?: string | null;
onRetry: () => void;
onCancel: () => void;
}) {
const status = phase === "entry"
? {
title: `Connect ${entry.name} to Paperclip`,
body: `Paperclip will open ${entry.name} so you can choose a workspace and approve access.`,
}
: phase === "starting"
? {
title: "Preparing secure sign-in",
body: `Paperclip is creating a secure ${entry.name} connection.`,
}
: phase === "redirecting"
? {
title: `Opening ${entry.name}`,
body: `Continue in ${entry.name} to choose a workspace and approve access.`,
}
: {
title: `${entry.name} couldnt connect`,
body: error ?? "Paperclip couldnt start secure sign-in. Try again.",
};
return (
<div className="max-w-5xl">
<StepHeader
subtitle="Secure MCP sign-in"
step="key"
activeIndex={0}
labels={["Connect", "Review actions", "Choose access", "Install tools"]}
appIdentity={{ name: entry.name, logoUrl: entry.branding.logoUrl }}
onCancel={onCancel}
/>
<div className="mx-auto max-w-xl rounded-2xl border border-border bg-card p-8">
<div className="flex items-start gap-3">
<span className="mt-1 inline-flex h-10 w-10 shrink-0 items-center justify-center rounded-lg border border-border bg-background">
{phase === "error" ? (
<Link2 className="h-5 w-5 text-destructive" />
) : phase === "entry" ? (
<Lock className="h-5 w-5 text-muted-foreground" />
) : (
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
)}
</span>
<div className="min-w-0">
<h2 className="text-xl font-bold tracking-tight">{status.title}</h2>
<p className="mt-1 text-sm text-muted-foreground">{status.body}</p>
</div>
</div>
<div className="mt-6 flex items-center gap-2">
{phase === "error" ? (
<Button type="button" onClick={onRetry}>Try again</Button>
) : (
<Button type="button" disabled>
{phase === "redirecting" ? `Opening ${entry.name}` : "Preparing…"}
</Button>
)}
<Button type="button" variant="ghost" onClick={onCancel}>Back to apps</Button>
</div>
<p className="mt-5 flex items-center gap-1.5 text-xs text-muted-foreground">
<Lock className="h-3.5 w-3.5" />
Your authorization stays in Paperclips encrypted secret store.
</p>
</div>
</div>
);
}
function ZapierConnectStep({
link,
onLinkChange,
@ -675,12 +963,13 @@ function GalleryStep({
{filtered.map((app) => {
const copy = appCopyFor(app.slug, app.description);
const oauth = getAvailableConnectionMethod(app)?.auth === "oauth";
const oauthBlocked = oauth && !isMcpDirectOAuthConnectSlug(app.slug);
const unavailable = app.availability?.available === false;
return (
<button
key={app.slug}
type="button"
disabled={oauth || unavailable}
disabled={oauthBlocked || unavailable}
title={
unavailable
? `${app.name} isn't configured on this instance yet. Ask your Paperclip admin.`
@ -689,7 +978,7 @@ function GalleryStep({
onClick={() => onPick(app)}
className={cn(
"flex flex-col rounded-xl border border-border bg-card p-4 text-left transition-colors",
oauth || unavailable ? "cursor-not-allowed opacity-60" : "hover:border-foreground/30 hover:bg-accent/40",
oauthBlocked || unavailable ? "cursor-not-allowed opacity-60" : "hover:border-foreground/30 hover:bg-accent/40",
)}
>
<AppLogo name={app.name} logoUrl={app.branding.logoUrl} size={36} />
@ -698,7 +987,7 @@ function GalleryStep({
<div className="mt-3 text-xs font-semibold text-foreground">
{unavailable ? (
<span className="text-muted-foreground">Not available on this instance - ask your admin.</span>
) : oauth ? (
) : oauthBlocked ? (
<span className="text-muted-foreground">Sign-in coming soon</span>
) : (
<span>Connect </span>

View File

@ -76,6 +76,7 @@ describe("Browse store door (PAP-13254 door 1)", () => {
galleryEntry({ key: "zapier", name: "Zapier", tagline: "Connect automations." }),
galleryEntry({ key: "github", name: "GitHub", tagline: "Open PRs and issues." }),
galleryEntry({ key: "slack", name: "Slack", tagline: "Post messages to channels." }),
galleryEntry({ key: "notion", name: "Notion", tagline: "Read and update workspace content." }),
galleryEntry({ key: "acme", name: "Acme CRM", tagline: "Sync deals and contacts." }),
],
});
@ -107,7 +108,7 @@ describe("Browse store door (PAP-13254 door 1)", () => {
const text = container.textContent ?? "";
expect(text).toContain("Browse");
expect(text).toContain("Connect Zapier or your own MCP server.");
expect(text).toContain("Connect Notion, Zapier, or your own MCP server.");
expect(text).toContain("Popular");
expect(text).toContain("All apps");
expect(text).toContain("GitHub");
@ -117,7 +118,7 @@ describe("Browse store door (PAP-13254 door 1)", () => {
expect(text).toContain("Connect your own tool");
});
it("enables Zapier and custom URLs while fading unfinished integrations", async () => {
it("enables Notion, Zapier, and custom URLs while fading unfinished integrations", async () => {
await renderBrowse();
const zapierTiles = Array.from(container.querySelectorAll("button")).filter((button) =>
@ -126,6 +127,9 @@ describe("Browse store door (PAP-13254 door 1)", () => {
const githubTiles = Array.from(container.querySelectorAll("button")).filter((button) =>
button.textContent?.includes("GitHub"),
);
const notionTiles = Array.from(container.querySelectorAll("button")).filter((button) =>
button.textContent?.includes("Notion"),
);
const tile = Array.from(container.querySelectorAll("button")).find((button) =>
button.textContent?.includes("Acme CRM"),
);
@ -135,6 +139,8 @@ describe("Browse store door (PAP-13254 door 1)", () => {
expect(zapierTiles).toHaveLength(2);
expect(zapierTiles.every((button) => !button.disabled)).toBe(true);
expect(notionTiles).toHaveLength(2);
expect(notionTiles.every((button) => !button.disabled)).toBe(true);
expect(githubTiles.every((button) => button.disabled)).toBe(true);
expect(tile?.disabled).toBe(true);
expect(byoCard?.disabled).toBe(false);
@ -146,6 +152,11 @@ describe("Browse store door (PAP-13254 door 1)", () => {
});
expect(navigateMock).toHaveBeenCalledWith("/apps/connect?byo=1&source=zapier");
await act(async () => {
notionTiles[0]?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
expect(navigateMock).toHaveBeenCalledWith("/apps/connect?source=notion");
await act(async () => {
byoCard?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});

View File

@ -19,17 +19,25 @@ import {
AdvancedToolsLink,
BYO_CONNECT_HREF,
ByoConnectCard,
NOTION_CONNECT_HREF,
POPULAR_KEYS,
ZAPIER_CONNECT_HREF,
} from "./store-cards";
function connectHrefFor(entry: AppGalleryDisplayEntry): string | null {
const slug = appDefinitionSlug(entry);
if (slug === "notion") return NOTION_CONNECT_HREF;
if (slug === "zapier") return ZAPIER_CONNECT_HREF;
return null;
}
/**
* Door 1 Browse (the store) (PAP-13254 / U3 §4).
*
* A persistent, browsable storefront: search + a Popular grid + the full
* gallery + a first-class bring-your-own card + a labelled Developer link.
* Browse remains the single discoverability surface. Zapier and bring-your-own
* MCP servers use the URL flow; the remaining integrations stay unavailable.
* Browse remains the single discoverability surface. Notion uses MCP-direct
* OAuth, while Zapier and bring-your-own MCP servers use the URL flow.
*/
export function Browse() {
const navigate = useNavigate();
@ -82,7 +90,7 @@ export function Browse() {
<header>
<h1 className="text-2xl font-bold tracking-tight">Browse</h1>
<p className="mt-1 text-sm text-muted-foreground">
Connect Zapier or your own MCP server. More integrations are coming soon.
Connect Notion, Zapier, or your own MCP server. More integrations are coming soon.
</p>
</header>
@ -116,7 +124,7 @@ export function Browse() {
<AppTile
key={appDefinitionSlug(entry)}
entry={entry}
onConnect={appDefinitionSlug(entry) === "zapier" ? () => navigate(ZAPIER_CONNECT_HREF) : undefined}
onConnect={connectHrefFor(entry) ? () => navigate(connectHrefFor(entry)!) : undefined}
compact
/>
))}
@ -139,7 +147,7 @@ export function Browse() {
<AppTile
key={appDefinitionSlug(entry)}
entry={entry}
onConnect={appDefinitionSlug(entry) === "zapier" ? () => navigate(ZAPIER_CONNECT_HREF) : undefined}
onConnect={connectHrefFor(entry) ? () => navigate(connectHrefFor(entry)!) : undefined}
/>
))}
</div>
@ -150,7 +158,7 @@ export function Browse() {
<div className="flex flex-wrap items-center justify-between gap-2">
<p className="text-xs text-muted-foreground">
Zapier connects with the MCP URL it gives you. Other listed integrations are previews.
Notion connects with secure sign-in. Zapier connects with its MCP URL. Other integrations are previews.
</p>
<AdvancedToolsLink />
</div>

View File

@ -283,7 +283,9 @@ export function Connections() {
const attention = rowNeedsAttention(row);
const hint =
status.tone === "attention"
? "The key stopped working — reconnect to fix."
? primaryConnection?.authKind === "oauth"
? "Reconnect required — sign in again to restore access."
: "The key stopped working — reconnect to fix."
: status.tone === "paused"
? "Paused — agents cant use it right now."
: status.tone === "not_connected"

View File

@ -0,0 +1,28 @@
import { describe, expect, it } from "vitest";
import {
MCP_DIRECT_OAUTH_CONNECT_SLUGS,
appSourceConnectHref,
canEnterAppsConnect,
isMcpDirectOAuthConnectSlug,
} from "./app-connect-policy";
describe("app connect policy", () => {
it("allowlists exactly Notion for MCP-direct OAuth", () => {
expect(MCP_DIRECT_OAUTH_CONNECT_SLUGS).toEqual(["notion"]);
expect(isMcpDirectOAuthConnectSlug("notion")).toBe(true);
expect(isMcpDirectOAuthConnectSlug("github")).toBe(false);
expect(isMcpDirectOAuthConnectSlug("slack")).toBe(false);
expect(isMcpDirectOAuthConnectSlug(null)).toBe(false);
});
it("admits the Notion deep link without opening other source slugs", () => {
expect(canEnterAppsConnect(new URLSearchParams("source=notion"))).toBe(true);
expect(canEnterAppsConnect(new URLSearchParams("source=github"))).toBe(false);
expect(canEnterAppsConnect(new URLSearchParams("source=zapier"))).toBe(false);
expect(canEnterAppsConnect(new URLSearchParams("byo=1&source=zapier"))).toBe(true);
});
it("builds a generic source deep link", () => {
expect(appSourceConnectHref("notion")).toBe("/apps/connect?source=notion");
});
});

View File

@ -0,0 +1,14 @@
/** OAuth apps that are safe to connect directly through the MCP OAuth broker. */
export const MCP_DIRECT_OAUTH_CONNECT_SLUGS = ["notion"] as const;
export function isMcpDirectOAuthConnectSlug(slug: string | null | undefined): boolean {
return MCP_DIRECT_OAUTH_CONNECT_SLUGS.some((allowedSlug) => allowedSlug === slug);
}
export function appSourceConnectHref(slug: string): string {
return `/apps/connect?${new URLSearchParams({ source: slug }).toString()}`;
}
export function canEnterAppsConnect(searchParams: URLSearchParams): boolean {
return searchParams.get("byo") === "1" || isMcpDirectOAuthConnectSlug(searchParams.get("source"));
}

View File

@ -8,6 +8,7 @@ import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { useToast } from "@/context/ToastContext";
import { redactUrlSecrets } from "@/lib/redact-url-secrets";
import { navigateTopLevel } from "@/lib/browserNavigation";
import type { AppDetailSectionProps } from "./types";
export function AdvancedPanel({
@ -85,14 +86,43 @@ export function ReconnectCard({
galleryEntry: AppDefinition | null;
onReconnected: () => void;
}) {
const { pushToast } = useToast();
const reconnectOAuth = useMutation({
mutationFn: () => toolsApi.startOAuth(connection.id),
onSuccess: ({ authorizationUrl }) => navigateTopLevel(authorizationUrl),
onError: (error) =>
pushToast({
title: "Couldnt start sign-in",
body: error instanceof Error ? error.message : "Please try again.",
tone: "error",
}),
});
const oauth = connection.authKind === "oauth";
return (
<div className="rounded-xl border border-amber-500/50 bg-amber-500/10 p-5">
<h2 className="text-sm font-bold text-amber-900 dark:text-amber-100">This app needs reconnecting</h2>
<h2 className="text-sm font-bold text-amber-900 dark:text-amber-100">
{oauth ? "Reconnect required" : "This app needs reconnecting"}
</h2>
<p className="mt-1 text-sm text-amber-800 dark:text-amber-200">
{connection.healthMessage?.trim() || "The key stopped working. Paste a new one to get it back online."}
{connection.healthMessage?.trim() || (oauth
? "Authorization expired or was revoked. Sign in again to restore access."
: "The key stopped working. Paste a new one to get it back online.")}
</p>
<div className="mt-3">
<ReconnectForm connection={connection} galleryEntry={galleryEntry} onReconnected={onReconnected} />
{oauth ? (
<Button
type="button"
size="sm"
disabled={reconnectOAuth.isPending}
onClick={() => reconnectOAuth.mutate()}
>
{reconnectOAuth.isPending && <Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />}
{reconnectOAuth.isPending ? "Opening sign-in…" : "Reconnect"}
</Button>
) : (
<ReconnectForm connection={connection} galleryEntry={galleryEntry} onReconnected={onReconnected} />
)}
</div>
</div>
);

View File

@ -46,7 +46,7 @@ export function SetupPanel({
{hasOAuthSignIn && (
<OAuthConnectionSection
connected={Boolean((oauth as Record<string, unknown>).connectedAt)}
isSmokeLabFixture={isSmokeLabFixture}
providerName={appDefinitionSlug(galleryEntry) === "notion" ? "Notion" : isSmokeLabFixture ? "Smoke OAuth" : "OAuth"}
disabled={oauthStartDisabled}
onStart={onStartOAuth}
/>
@ -58,26 +58,25 @@ export function SetupPanel({
function OAuthConnectionSection({
connected,
isSmokeLabFixture,
providerName,
disabled,
onStart,
}: {
connected: boolean;
isSmokeLabFixture: boolean;
providerName: string;
disabled: boolean;
onStart: () => void;
}) {
const providerName = isSmokeLabFixture ? "Smoke OAuth" : "OAuth";
return (
<section className="rounded-xl border border-border bg-card px-5 py-4">
<div className="flex flex-wrap items-center justify-between gap-4">
<div>
<h2 className="text-sm font-bold text-foreground">
{connected ? `Connected with ${providerName}` : `Connect with ${providerName}`}
{connected ? `${providerName} connected` : `Connect with ${providerName}`}
</h2>
<p className="mt-0.5 text-sm text-muted-foreground">
{connected
? "Sign in again to replace this connection's OAuth session."
? "Your workspace authorization is active. Reconnect any time to replace it."
: "Open the provider's consent page to finish connecting this app."}
</p>
</div>

View File

@ -1,6 +1,7 @@
import { ServerCog, Wrench } from "lucide-react";
import { Link } from "@/lib/router";
import { advancedTabHref } from "@/pages/tools/tool-tabs";
import { appSourceConnectHref } from "./app-connect-policy";
/** Popular gallery keys surfaced first in the Browse store (PAP-13254, door 1). */
export const POPULAR_KEYS = ["zapier", "github", "slack", "notion", "linear"];
@ -11,6 +12,9 @@ export const BYO_CONNECT_HREF = "/apps/connect?byo=1";
/** Zapier connects with the complete MCP URL issued by Zapier. */
export const ZAPIER_CONNECT_HREF = "/apps/connect?byo=1&source=zapier";
/** MCP-direct OAuth apps enter through the generic source deep link. */
export const NOTION_CONNECT_HREF = appSourceConnectHref("notion");
/**
* First-class "Connect your own tool" card (PAP-12371, Finding C; PAP-13254).
* Lives in Browse as a persistent row and launches the guided URL flow.

View File

@ -0,0 +1,177 @@
import { useMemo } from "react";
import type { Meta, StoryObj } from "@storybook/react-vite";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import {
CONNECTABLE_APP_DEFINITIONS,
type AppDefinition,
type ToolConnection,
} from "@paperclipai/shared";
import { queryKeys } from "@/lib/queryKeys";
import { Browse } from "@/pages/apps/Browse";
import { AppLogo } from "@/pages/apps/AppLogo";
import {
OAuthConnectStateScreen,
type OAuthConnectPhase,
} from "@/pages/apps/AppsConnect";
import { SetupPanel } from "@/pages/apps/app-detail/SetupPanel";
import { ReconnectCard } from "@/pages/apps/app-detail/AdvancedPanel";
const COMPANY_ID = "company-storybook";
const NOTION = CONNECTABLE_APP_DEFINITIONS.find((app) => app.slug === "notion") as AppDefinition;
const ZAPIER = CONNECTABLE_APP_DEFINITIONS.find((app) => app.slug === "zapier") as AppDefinition;
const GITHUB = CONNECTABLE_APP_DEFINITIONS.find((app) => app.slug === "github") as AppDefinition;
function seededClient() {
const client = new QueryClient({
defaultOptions: {
queries: { staleTime: Infinity, gcTime: Infinity, retry: false, refetchOnMount: false },
},
});
client.setQueryData(queryKeys.apps.gallery(COMPANY_ID), { apps: [NOTION, ZAPIER, GITHUB] });
return client;
}
function BrowseHost() {
const client = useMemo(() => seededClient(), []);
return (
<QueryClientProvider client={client}>
<div className="mx-auto max-w-5xl p-6">
<Browse />
</div>
</QueryClientProvider>
);
}
function OAuthStateHost({ phase, error }: { phase: OAuthConnectPhase; error?: string }) {
return (
<div className="mx-auto max-w-5xl p-6">
<OAuthConnectStateScreen
entry={NOTION}
phase={phase}
error={error}
onRetry={() => undefined}
onCancel={() => undefined}
/>
</div>
);
}
function notionConnection(overrides: Partial<ToolConnection> = {}): ToolConnection {
return {
id: "connection-notion",
companyId: COMPANY_ID,
applicationId: "application-notion",
name: "Notion",
uid: "notion-storybook",
connectionKind: "managed",
ownership: "dcr",
transport: "mcp_remote",
authKind: "oauth",
status: "active",
transportConfig: { url: "https://mcp.notion.com/mcp" },
config: {
url: "https://mcp.notion.com/mcp",
sourceTemplateKey: "notion",
oauth: { provider: "notion", connectedAt: "2026-08-06T19:00:00.000Z" },
},
credentialSecretRefs: [],
credentialRefs: [],
healthStatus: "healthy",
healthMessage: null,
healthCheckedAt: new Date("2026-08-06T19:00:00.000Z"),
lastError: null,
enabled: true,
createdByAgentId: null,
createdByUserId: "board-user",
createdAt: new Date("2026-08-06T18:55:00.000Z"),
updatedAt: new Date("2026-08-06T19:00:00.000Z"),
...overrides,
};
}
function ConnectedHost() {
return (
<div className="mx-auto max-w-3xl p-6">
<header className="mb-6 flex items-center gap-3">
<AppLogo name={NOTION.name} logoUrl={NOTION.branding.logoUrl} size={44} />
<div>
<h1 className="text-2xl font-bold tracking-tight">Notion</h1>
<p className="mt-1 text-sm text-muted-foreground">Connected app setup</p>
</div>
</header>
<SetupPanel
connection={notionConnection()}
galleryEntry={NOTION}
onToggleApp={() => undefined}
appToggleDisabled={false}
onUpdateConfig={() => undefined}
configUpdateDisabled={false}
onStartOAuth={() => undefined}
oauthStartDisabled={false}
/>
</div>
);
}
function ReconnectRequiredHost() {
return (
<div className="mx-auto max-w-3xl p-6">
<header className="mb-6">
<h1 className="text-2xl font-bold tracking-tight">Notion</h1>
<p className="mt-1 text-sm text-muted-foreground">Connection needs attention</p>
</header>
<ReconnectCard
connection={notionConnection({
healthStatus: "failed",
healthMessage: "Notion authorization expired or was revoked (invalid_grant).",
lastError: "invalid_grant",
})}
galleryEntry={NOTION}
onReconnected={() => undefined}
/>
</div>
);
}
const meta: Meta = {
title: "Apps/Notion MCP connect flow (PAP-16650)",
parameters: { layout: "fullscreen" },
};
export default meta;
type Story = StoryObj;
export const BrowseEntry: Story = {
name: "1 — Browse entry",
render: () => <BrowseHost />,
};
export const ConnectEntry: Story = {
name: "2 — Connect entry",
render: () => <OAuthStateHost phase="entry" />,
};
export const InFlight: Story = {
name: "3 — In flight",
render: () => <OAuthStateHost phase="starting" />,
};
export const Connected: Story = {
name: "4 — Connected",
render: () => <ConnectedHost />,
};
export const ConnectError: Story = {
name: "5 — Connect error",
render: () => (
<OAuthStateHost
phase="error"
error="Paperclip couldnt reach Notions authorization service. Check the connection and try again."
/>
),
};
export const ReconnectRequired: Story = {
name: "6 — Reconnect required",
render: () => <ReconnectRequiredHost />,
};