mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-28 12:04:41 +01:00
feat(connections): one-screen connector setup with stated defaults (#14811)
## Thinking Path
> - Paperclip is the open source app people use to manage AI agents for
work
> - Agents use Connections (the Apps catalog) to act in services like
Notion, GitHub, Google Workspace and Railway
> - Each connector asked the user to answer setup questions before it
went to the provider. Most of the questions already had the correct
answer selected
> - ROADMAP.md lists "simpler setup" for Apps and Connections as ongoing
work. This change continues that work
> - This pull request removes the questions that Paperclip can answer
itself. It states the defaults in one line and moves the choices behind
"Change" and onto the Permissions tab
> - The benefit is that most connectors take one click in Paperclip and
then the provider's own consent screen
## Linked Issues or Issue Description
No public issue exists. This is the description, from the enhancement
template.
**What existing behavior does this improve?**
The setup flow for tool connectors in the Apps catalog.
**Subsystem affected**
Apps and Connections: `ui/src/features/connections`,
`ui/src/pages/apps`, the `packages/shared` app definitions, and the
OAuth routes in `server/src/routes/tool-access.ts`.
**Current behavior**
Every connector opened with an Access step. The step asked who can use
the connection and which agents get it, and both answers were already
selected. 18 connectors also asked "How do you want to connect?" when
Paperclip could rank the methods. The Google apps and Postman also asked
"What should Paperclip be able to do?" before sign-in. The four gateway
connectors (Zapier, Arcade, Composio, Executor) used a separate two-step
wizard. Asana was pinned to a customer-owned OAuth app, so the user had
to register an app in Asana's developer console. The "Set all" control
on the Permissions tab changed only one action. After the user approved
access, Railway's consent page showed "you can close this window" and
did not return to Paperclip.
**Proposed behavior**
One screen per connector, with one primary button. The screen states the
defaults in one sentence, for example "Connects for everyone in your
organization, available to all agents". A "Change" link opens one
Advanced panel. When the provider's metadata allows dynamic client
registration, Paperclip registers a client itself. Connecting lands on
the Permissions tab. On that tab, "Set all" changes every action in the
group.
**Reason and benefit**
The user makes fewer decisions before the connection exists. Most
choices are easier to make after the connection, on the Permissions tab,
where a change has an immediate effect.
**Breaking changes**
None. No schema or API change. Existing connections keep their settings.
## What Changed
- **No Access step.** `ConnectionSetupFlow` no longer has the Access
step. The flow shows the resolved default above the primary button and
on the completion screen. The access controls moved into one Advanced
panel. The panel opens automatically only when a setting in it is
required.
- **A default method for every app.** The flow always picks the ranked
default method. Alternate methods are in the Advanced panel. The Google
and Postman capability choice is not asked before sign-in. The
write-capable method is the default.
- **Gateway connectors.** `RemoteMcpProductionSetup` (Zapier, Arcade,
Composio, Executor) no longer has its own Access step. Its commit path
and the main commit path use one helper, `askFirstCatalogEntryIdsFor`,
for server-suggested defaults.
- **Dynamic registration from live metadata.**
`canRegisterOAuthClientDynamically` now allows registration when the
provider advertises a registration endpoint, even if the catalog entry
lists only customer-owned clients. The Asana and Linear definitions and
catalog text match live probes. Asana issues clients for loopback
callbacks only, so a hosted deployment still needs an Asana app.
- **Connection setup states.** New
`packages/shared/src/connection-setup-state.ts` sorts each method into
`instant`, `authorize`, `paste` or `register`. The gallery card verb
("Connect" or "Add key") comes from this resolver and the instance's
ownership availability.
- **Generic MCP.** The generic path no longer asks "Does it need a key?"
first. A credential challenge from the server shows the key field.
- **Permissions tab.** Each action row shows its risk level. Each group
has a "Set all" control. The control sends one change for the whole
group. Before, each row's save started from the same render, so the
saves overwrote each other. The Zapier/Arcade/Composio/Executor setup
screen had the same defect.
- **OAuth callback interstitial.** A cross-site browser navigation to
`/api/tools/oauth/callback` gets a small same-origin "Finishing your
connection…" page. That page repeats the request, and the repeat does
the code exchange. Railway's consent page replaces itself after about
two seconds, and the code exchange plus tool discovery takes longer than
that. The interstitial uses only a meta refresh, because the OAuth code
is single-use. Requests without `Sec-Fetch-Site: cross-site` take the
old path.
- **Linear registers through its MCP server.** Linear pins the console
endpoints at `linear.app`. Pinned endpoints now replace discovery only
when the method cannot register, or when the connection has an
operator-entered client. So a Linear connection now finds the
registration endpoint at `mcp.linear.app`.
- **Own-OAuth-app recovery stays on the one-click screen.** When the
method also accepts a customer-owned client, the client fields are in
the Advanced panel. The panel opens after a failed sign-in. "Try again"
resumes the draft with the operator's client.
- **E2E specs** follow the one-screen flow. The Access-step clicks are
removed, the specs open **Change** before they pick agents, and they
expect GitHub's **Add key** verb.
- **Default permissions do not change.** New connections still allow
every action. The user can set actions to Ask first or Off on the
Permissions tab.
## Verification
- `cd ui && npx vitest run src/pages/apps src/features/connections
--no-file-parallelism`
- `cd packages/shared && npx vitest run src/app-definitions.test.ts
src/connection-setup-state.test.ts`
- `cd server && npx vitest run src/__tests__/tool-access-service.test.ts
src/__tests__/remote-mcp-connectors.test.ts`
- `pnpm check:token-gates`
- New tests:
- `PermissionsPanel.group.test.tsx` checks that "Set all" sends one
change for the whole group. It fails on the old code.
- `action-permissions.test.ts` checks the group update.
- `connection-setup-state.test.ts` checks the four setup states.
- A server test checks that a cross-site callback gets the interstitial
and does not use the OAuth state, and that the same-origin repeat
completes the connection.
- Manual check on a hosted staging deployment. GitHub, Google Drive,
Composio, Notion, PostHog and Railway each connected from one screen and
returned to the Permissions tab. On Railway, "Set all" changed all 65
write actions, and the change remained after a reload.
- Visual changes: snapshot baselines are intentionally not updated. See
the `doc/design/DECISION-SHEET.md` entry "Per-change snapshot
verification demoted to dormant (Jul 13 2026)".
## Risks
- **Fewer confirmation clicks.** Organization-wide access is the
default, and the user does not confirm it on a separate step. This was
already the preselected answer. The flow shows the default before the
user clicks and again after the connection.
- **Google write scope.** Google apps now request the write-capable
scope by default. A narrower scope needs a new sign-in.
- **Dynamic registration from live metadata.** A provider can advertise
registration and then reject a redirect URI. Asana rejects hosted
callbacks, for example. In that case registration fails, and the
customer-owned client path remains available for recovery.
- **Callback interstitial.** The OAuth callback adds one same-origin
step for cross-site browser navigations. Browsers without `Sec-Fetch-*`
headers use the old direct path.
- Chat and bot connectors (Discord, Telegram, Microsoft Teams, iMessage)
do not change.
> 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
- Claude Opus 5.5 (Anthropic), model ID `claude-opus-5-5`, used through
Claude Code with tool use (shell, file editing, browser automation) and
extended thinking. It wrote the code, the tests and this description. A
human product owner directed the work and tested it by hand.
## 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
🤖 Generated with [Claude Code](https://claude.com/claude-code)
---------
Co-authored-by: scotttong <squadbot000@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
39 files changed
+1550
-543
No files matched your search
@@ -662,8 +662,16 @@ internal discussion; a task comment or agent progress update must not imply
|
||||
that an external action occurred. Reuse existing task-feed components and
|
||||
preserve one visible record per external event.
|
||||
|
||||
**Keep setup to one screen.** The connect screen collects only what proves who
|
||||
the user is: a provider sign-in, a key, or an endpoint. It states the default
|
||||
access in one line, with **Change** for other choices, and does not add an
|
||||
access step. Pick the ranked default method instead of asking. Put scope,
|
||||
capability, and per-action choices on the Permissions tab after the connection.
|
||||
`connectionSetupStateForMethod` in `packages/shared` classifies each method as
|
||||
`instant`, `authorize`, `paste`, or `register`; the gallery verb comes from it.
|
||||
|
||||
**Make interactive Storybooks for setup and actual use.** Include the catalog
|
||||
card, access and credential steps, any agent-resource wizard, and the task
|
||||
card, the connect screen and its credential states, any agent-resource wizard, and the task
|
||||
journeys after setup. Provide a clickable walkthrough plus focused stories for
|
||||
important steps, loading, errors, and recovery. Use realistic fixtures and
|
||||
clearly label simulated actions. Reuse production components as implementation
|
||||
|
||||
@@ -19,12 +19,12 @@ repository and MCP URLs still resolve to the ordinary GitHub tool connection.
|
||||
|
||||
## Self-hosted setup
|
||||
|
||||
The Access step uses **Continue** to open the local setup screen.
|
||||
**Continue to GitHub** on that screen starts the provider handoff. The first
|
||||
button does not imply that the browser is leaving Paperclip yet.
|
||||
The setup screen states the default access in one line, with **Change** for
|
||||
other choices. **Continue to GitHub** on that screen starts the provider
|
||||
handoff.
|
||||
|
||||
A self-hosted instance needs one Paperclip Cloud approval before its first
|
||||
managed connection. After approval, setup returns to step 2 and continues to
|
||||
managed connection. After approval, setup returns to the connect screen and continues to
|
||||
GitHub without another instance approval or a service restart.
|
||||
|
||||
If an unapproved enrollment link expires, return to setup and select
|
||||
|
||||
@@ -262,7 +262,7 @@ describe("AppDefinition catalog", () => {
|
||||
"google-workspace-search",
|
||||
]),
|
||||
);
|
||||
expect(SELF_SERVE_MCP_CANDIDATES).toHaveLength(48);
|
||||
expect(SELF_SERVE_MCP_CANDIDATES).toHaveLength(49);
|
||||
expect(BLOCKED_MCP_PROVIDERS.map((entry) => entry.slug)).toEqual([
|
||||
"g2",
|
||||
"vercel",
|
||||
@@ -426,15 +426,15 @@ describe("AppDefinition catalog", () => {
|
||||
expect(channel("slack")?.guidanceMd).toContain("reactions");
|
||||
expect(channel("slack")?.guidanceMd).toContain("direct messages");
|
||||
});
|
||||
it("keeps a complete, unique, dated evidence ledger for all 51 researched MCP providers", () => {
|
||||
it("keeps a complete, unique, dated evidence ledger for all 52 researched MCP providers", () => {
|
||||
// Ledger-wide date reflects the last full re-verification (2026-08-26);
|
||||
// later provider additions carry their own research evidence, but
|
||||
// bumping the shared date would overstate freshness for the other providers.
|
||||
expect(SELF_SERVE_MCP_RESEARCH.verifiedAt).toBe("2026-08-26");
|
||||
expect(SELF_SERVE_MCP_RESEARCH.entries).toHaveLength(51);
|
||||
expect(SELF_SERVE_MCP_RESEARCH.entries).toHaveLength(52);
|
||||
expect(
|
||||
new Set(SELF_SERVE_MCP_RESEARCH.entries.map((entry) => entry.slug)),
|
||||
).toHaveProperty("size", 51);
|
||||
).toHaveProperty("size", 52);
|
||||
for (const entry of SELF_SERVE_MCP_RESEARCH.entries) {
|
||||
expect(new URL(entry.docsUrl).protocol).toBe("https:");
|
||||
expect(new URL(entry.serverUrl).protocol).toBe("https:");
|
||||
@@ -572,7 +572,11 @@ describe("AppDefinition catalog", () => {
|
||||
(field) => field.key === "readOnly",
|
||||
)?.defaultValue,
|
||||
).toBe(false);
|
||||
expect(method("asana")?.ownershipModes).toEqual(["customer"]);
|
||||
// Asana and Linear both advertise dynamic client registration and issue
|
||||
// clients on request (verified live 2026-09-28), so neither needs an
|
||||
// operator-registered OAuth app. "customer" stays as the manual fallback.
|
||||
expect(method("asana")?.ownershipModes).toEqual(["dcr", "customer"]);
|
||||
expect(method("linear")?.ownershipModes).toEqual(["dcr", "customer"]);
|
||||
expect(method("zapier")).toMatchObject({
|
||||
key: "generated-url",
|
||||
auth: "none",
|
||||
@@ -617,7 +621,7 @@ describe("AppDefinition catalog", () => {
|
||||
APP_DEFINITIONS.find((app) => app.slug === "hugging-face")?.methods[0]
|
||||
?.defaults?.scopesHint,
|
||||
).toEqual(["read-mcp", "read-repos", "contribute-repos", "jobs"]));
|
||||
it("defaults every new connection action to allowed", () => {
|
||||
it("defaults every action, reads and writes, to allowed", () => {
|
||||
for (const app of APP_DEFINITIONS)
|
||||
for (const method of app.methods)
|
||||
expect(recommendedDefaultsForApp(app, method.key)).toEqual({
|
||||
|
||||
@@ -255,10 +255,12 @@ export function resolveConnectionMethodServerUrl(
|
||||
|
||||
export function recommendedDefaultsForApp(app: AppDefinition, methodKey?: string | null): Record<string, unknown> {
|
||||
// Keep the parameters in the public contract: callers resolve defaults for a
|
||||
// concrete app/method even though the initial policy is now uniform. This is
|
||||
// concrete app/method even though the initial policy is uniform. This is
|
||||
// an open default, not an approval bypass: connection finalization remains a
|
||||
// configure-authorized, audited operation, and Ask first stays available as
|
||||
// an operator-selected policy for any action after the connection is made.
|
||||
// The connect flow lands on the Permissions tab so that choice is the very
|
||||
// next screen (PAP-659: agents get full permissions unless someone narrows them).
|
||||
void app;
|
||||
void methodKey;
|
||||
return {
|
||||
|
||||
@@ -21,25 +21,23 @@
|
||||
"transport": "mcp_remote",
|
||||
"auth": "oauth",
|
||||
"ownershipModes": [
|
||||
"dcr",
|
||||
"customer"
|
||||
],
|
||||
"whenToUse": "Register an OAuth app with Asana, then enter its client ID and secret.",
|
||||
"whenToUse": "Sign in with Asana. Paperclip registers its own OAuth client automatically.",
|
||||
"defaults": {
|
||||
"serverUrl": "https://mcp.asana.com/v2/mcp",
|
||||
"scopesHint": [
|
||||
"default"
|
||||
]
|
||||
},
|
||||
"guidanceMd": "Connect Asana in the browser. Create an Asana MCP OAuth app and register Paperclip's callback URI; DCR is not supported.",
|
||||
"guidanceMd": "Connect Asana in the browser. Paperclip registers its own OAuth client with Asana's MCP server, so a local Paperclip needs no developer-console setup. Asana only accepts localhost callbacks, so a hosted deployment falls back to entering your own OAuth app.",
|
||||
"riskTier": "S3",
|
||||
"label": "Use your own OAuth app",
|
||||
"label": "Sign in with Asana",
|
||||
"consoleLinks": {
|
||||
"register": "https://developers.asana.com/docs/integrating-with-asanas-mcp-server",
|
||||
"docs": "https://developers.asana.com/docs/integrating-with-asanas-mcp-server"
|
||||
},
|
||||
"warnings": [
|
||||
"Create an Asana MCP OAuth app and register Paperclip's callback URI; DCR is not supported."
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -20,6 +20,7 @@
|
||||
"transport": "mcp_remote",
|
||||
"auth": "oauth",
|
||||
"ownershipModes": [
|
||||
"dcr",
|
||||
"customer"
|
||||
],
|
||||
"whenToUse": "Use the provider-hosted connection for the quickest setup.",
|
||||
@@ -32,7 +33,7 @@
|
||||
"write"
|
||||
]
|
||||
},
|
||||
"guidanceMd": "Register a Linear OAuth app and add Paperclip's redirect URI before connecting.",
|
||||
"guidanceMd": "Connect Linear for issues and projects. Paperclip registers its own OAuth client with Linear's MCP server, so no developer-console setup is needed.",
|
||||
"riskTier": "S2",
|
||||
"requiredResourceFilters": [
|
||||
"workspace",
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
CONNECTABLE_APP_DEFINITIONS,
|
||||
getAvailableConnectionMethods,
|
||||
getConnectableAppDefinition,
|
||||
} from "./app-definitions.js";
|
||||
import {
|
||||
connectionSetupStateForApp,
|
||||
connectionSetupVerbForApp,
|
||||
type ConnectionSetupState,
|
||||
} from "./connection-setup-state.js";
|
||||
|
||||
/**
|
||||
* These assert against the shipped catalog rather than fixtures, because the
|
||||
* claim being protected is about real connectors: the gallery card's verb has
|
||||
* to match what the connect screen then asks for.
|
||||
*/
|
||||
describe("connectionSetupStateForApp", () => {
|
||||
it("puts one-click OAuth connectors in the authorize state", () => {
|
||||
for (const slug of ["notion", "linear", "sentry", "stripe", "jira", "asana"]) {
|
||||
expect(connectionSetupStateForApp(getConnectableAppDefinition(slug)), slug).toBe("authorize");
|
||||
}
|
||||
});
|
||||
|
||||
it("follows the instance's ownership availability rather than the catalog order", () => {
|
||||
// Google and GitHub publish a Paperclip-managed method, but it is
|
||||
// `platform_shared` and therefore unavailable until an operator configures
|
||||
// the cloud connector. The state has to reflect what this instance can
|
||||
// actually do, or the card promises one click and the screen shows a form.
|
||||
const gmail = getConnectableAppDefinition("gmail")!;
|
||||
expect(connectionSetupStateForApp(gmail)).toBe("register");
|
||||
expect(
|
||||
connectionSetupStateForApp({
|
||||
...gmail,
|
||||
ownershipAvailability: { ...gmail.ownershipAvailability, platform_shared: true },
|
||||
}),
|
||||
).toBe("authorize");
|
||||
});
|
||||
|
||||
it("reserves register for providers that genuinely cannot issue a client", () => {
|
||||
for (const slug of ["box", "xero"]) {
|
||||
expect(connectionSetupStateForApp(getConnectableAppDefinition(slug)), slug).toBe("register");
|
||||
}
|
||||
});
|
||||
|
||||
it("treats an API key as a paste, and says so on the card", () => {
|
||||
for (const slug of ["honcho", "mem0", "openrouter"]) {
|
||||
expect(connectionSetupStateForApp(getConnectableAppDefinition(slug)), slug).toBe("paste");
|
||||
expect(connectionSetupVerbForApp(getConnectableAppDefinition(slug)), slug).toBe("Add key");
|
||||
}
|
||||
});
|
||||
|
||||
it("reaches instant only when the catalog already knows the endpoint", () => {
|
||||
// Composio and Context7 ship a fixed server URL and ask for nothing.
|
||||
expect(connectionSetupStateForApp(getConnectableAppDefinition("composio"))).toBe("instant");
|
||||
expect(connectionSetupStateForApp(getConnectableAppDefinition("context7"))).toBe("instant");
|
||||
});
|
||||
|
||||
it("does not promise instant for a provider-generated URL", () => {
|
||||
// Zapier, Arcade and Executor declare no server URL: the operator brings
|
||||
// one. Calling these instant is the mistake this helper exists to prevent.
|
||||
for (const slug of ["zapier", "arcade", "executor"]) {
|
||||
expect(connectionSetupStateForApp(getConnectableAppDefinition(slug)), slug).toBe("paste");
|
||||
}
|
||||
});
|
||||
|
||||
it("does not promise instant for a templated endpoint", () => {
|
||||
// Shopify's URL has a {storeDomain} placeholder the operator fills in.
|
||||
expect(connectionSetupStateForApp(getConnectableAppDefinition("shopify"))).toBe("paste");
|
||||
});
|
||||
|
||||
it("defaults the AI providers to their subscription sign-in", () => {
|
||||
for (const slug of ["anthropic", "openai", "xai"]) {
|
||||
expect(connectionSetupStateForApp(getConnectableAppDefinition(slug)), slug).toBe("authorize");
|
||||
expect(connectionSetupVerbForApp(getConnectableAppDefinition(slug)), slug).toBe("Connect");
|
||||
}
|
||||
});
|
||||
|
||||
it("never says Add key for something that does not want a secret", () => {
|
||||
for (const app of CONNECTABLE_APP_DEFINITIONS) {
|
||||
if (connectionSetupVerbForApp(app) !== "Add key") continue;
|
||||
const wantsSecret = getAvailableConnectionMethods(app)
|
||||
.filter((method) => (method.purpose ?? "tool") !== "channel")
|
||||
.some((method) => (method.credentialFields?.length ?? 0) > 0);
|
||||
expect(wantsSecret, app.slug).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("resolves a state for every connectable tool connector", () => {
|
||||
const states = new Map<string, ConnectionSetupState | null>();
|
||||
for (const app of CONNECTABLE_APP_DEFINITIONS) {
|
||||
const hasToolMethod = app.methods.some((method) => (method.purpose ?? "tool") !== "channel");
|
||||
if (!hasToolMethod) continue;
|
||||
states.set(app.slug, connectionSetupStateForApp(app));
|
||||
}
|
||||
expect([...states].filter(([, state]) => state === null)).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns null rather than guessing for an unknown app", () => {
|
||||
expect(connectionSetupStateForApp(null)).toBeNull();
|
||||
});
|
||||
|
||||
it("survives a gallery row that carries no method list", () => {
|
||||
// Rows are assembled from the gallery, live connections and custom
|
||||
// endpoints, so a row can reach a card without methods. Throwing here would
|
||||
// take the whole connectors page down with it.
|
||||
const partial = { slug: "mystery", name: "Mystery" } as never;
|
||||
expect(connectionSetupStateForApp(partial)).toBeNull();
|
||||
expect(connectionSetupVerbForApp(partial)).toBe("Connect");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,112 @@
|
||||
import {
|
||||
connectionMethodAcceptsCustomerOAuthClient,
|
||||
connectionMethodRequiresConfiguration,
|
||||
connectionMethodSupportsAutomaticOAuth,
|
||||
getAvailableConnectionMethods,
|
||||
getRecommendedConnectionMethod,
|
||||
} from "./app-definitions.js";
|
||||
import type { AppDefinition, ConnectionMethodDef } from "./types/app-definition.js";
|
||||
|
||||
/**
|
||||
* The four states every connect screen lands in (PAP-659, "one shell, four
|
||||
* states").
|
||||
*
|
||||
* The point of naming them here rather than in the wizard is that two surfaces
|
||||
* have to agree: the gallery card decides its verb from the state, and the
|
||||
* connect screen decides its body from the same state. When each re-derived it
|
||||
* from raw method flags they drifted — a card offering "Connect" for something
|
||||
* that then demanded a pasted URL is exactly the inconsistency the ticket is
|
||||
* about.
|
||||
*
|
||||
* - `instant` nothing to supply: connect resolves entirely from the catalog.
|
||||
* - `authorize` one click, then the provider's own consent screen.
|
||||
* - `paste` one thing the operator has to bring: a key, a URL, a tenant.
|
||||
* - `register` the provider cannot issue a client, so one must be registered
|
||||
* in its console first. A recovery state, never a normal step.
|
||||
*/
|
||||
export type ConnectionSetupState = "instant" | "authorize" | "paste" | "register";
|
||||
|
||||
/**
|
||||
* True when the catalog already knows the endpoint. A `serverUrlTemplate` does
|
||||
* not count: its placeholders are the operator's to fill, which is a paste.
|
||||
*/
|
||||
function hasResolvedEndpoint(method: ConnectionMethodDef): boolean {
|
||||
if (method.transport !== "mcp_remote") return true;
|
||||
if (method.defaults?.serverUrlTemplate) return false;
|
||||
return Boolean(method.defaults?.serverUrl);
|
||||
}
|
||||
|
||||
export function connectionSetupStateForMethod(
|
||||
method: ConnectionMethodDef | null | undefined,
|
||||
): ConnectionSetupState | null {
|
||||
if (!method) return null;
|
||||
if (method.auth === "oauth") {
|
||||
// An AI subscription signs in through the adapter's own device/OAuth flow.
|
||||
// No client is ever registered, so `register` would be a false detour.
|
||||
if (method.transport === "runtime_auth") return "authorize";
|
||||
if (connectionMethodSupportsAutomaticOAuth(method)) return "authorize";
|
||||
// A customer-owned client is only a *register* state when the provider
|
||||
// offers nothing better. Live discovery can still overrule the catalog at
|
||||
// connect time, which is why the flow re-resolves rather than trusting this.
|
||||
if (connectionMethodAcceptsCustomerOAuthClient(method)) return "register";
|
||||
return "authorize";
|
||||
}
|
||||
if (method.auth === "none") {
|
||||
return connectionMethodRequiresConfiguration(method) || !hasResolvedEndpoint(method)
|
||||
? "paste"
|
||||
: "instant";
|
||||
}
|
||||
return "paste";
|
||||
}
|
||||
|
||||
/**
|
||||
* The default tool method for an app, or null.
|
||||
*
|
||||
* Gallery rows are not guaranteed to carry a method list: the display entry is
|
||||
* assembled from several sources, and a row can stand for a connection whose
|
||||
* catalog entry has since been withdrawn. Resolving that to null — rather than
|
||||
* throwing inside a card render — is the difference between a missing verb and
|
||||
* a blank connectors page.
|
||||
*/
|
||||
function defaultToolMethod(
|
||||
app: AppDefinition | null | undefined,
|
||||
methodKey?: string | null,
|
||||
): ConnectionMethodDef | null {
|
||||
if (!app || !Array.isArray(app.methods)) return null;
|
||||
const methods = getAvailableConnectionMethods(app).filter(
|
||||
(method) => (method.purpose ?? "tool") !== "channel",
|
||||
);
|
||||
return methodKey
|
||||
? methods.find((candidate) => candidate.key === methodKey) ?? null
|
||||
: getRecommendedConnectionMethod(methods);
|
||||
}
|
||||
|
||||
export function connectionSetupStateForApp(
|
||||
app: AppDefinition | null | undefined,
|
||||
methodKey?: string | null,
|
||||
): ConnectionSetupState | null {
|
||||
return connectionSetupStateForMethod(defaultToolMethod(app, methodKey));
|
||||
}
|
||||
|
||||
/**
|
||||
* The gallery card's verb (PAP-659 C4).
|
||||
*
|
||||
* Only a method that genuinely wants a secret says so. "Add key" on a connector
|
||||
* that actually wants a pasted URL would be worse than the generic verb, so a
|
||||
* paste state without credential fields keeps "Connect" — the operator still
|
||||
* lands on one screen with one field, which the verb does not need to restate.
|
||||
*/
|
||||
export function connectionSetupVerbForMethod(
|
||||
method: ConnectionMethodDef | null | undefined,
|
||||
): "Connect" | "Add key" {
|
||||
if (!method) return "Connect";
|
||||
const wantsSecret = (method.credentialFields?.length ?? 0) > 0;
|
||||
return connectionSetupStateForMethod(method) === "paste" && wantsSecret ? "Add key" : "Connect";
|
||||
}
|
||||
|
||||
export function connectionSetupVerbForApp(
|
||||
app: AppDefinition | null | undefined,
|
||||
methodKey?: string | null,
|
||||
): "Connect" | "Add key" {
|
||||
return connectionSetupVerbForMethod(defaultToolMethod(app, methodKey));
|
||||
}
|
||||
@@ -333,6 +333,13 @@ export {
|
||||
recommendedDefaultsForApp,
|
||||
resolveConnectionMethodServerUrl,
|
||||
} from "./app-definitions.js";
|
||||
export {
|
||||
connectionSetupStateForApp,
|
||||
connectionSetupStateForMethod,
|
||||
connectionSetupVerbForApp,
|
||||
connectionSetupVerbForMethod,
|
||||
type ConnectionSetupState,
|
||||
} from "./connection-setup-state.js";
|
||||
export { APP_DEFINITIONS } from "./app-definitions.generated.js";
|
||||
export * from "./google-workspace-connectors.js";
|
||||
export * from "./github-connectors.js";
|
||||
|
||||
@@ -40,7 +40,8 @@
|
||||
{ "slug": "supabase", "name": "Supabase", "wave": 2, "status": "self_serve", "docsUrl": "https://supabase.com/docs/guides/ai-tools/mcp", "serverUrl": "https://mcp.supabase.com/mcp", "authMode": "dcr_or_api_key", "prerequisite": "A Supabase account; use a development project and review write actions before connecting production data.", "riskTier": "S4" },
|
||||
{ "slug": "ticket-tailor", "name": "Ticket Tailor", "wave": 2, "status": "self_serve", "docsUrl": "https://developers.tickettailor.com/docs/mcp/authentication/", "serverUrl": "https://mcp.tickettailor.ai/mcp", "authMode": "dcr", "prerequisite": "A Ticket Tailor account; the provider may request an API key during its hosted authorization prompt.", "riskTier": "S3" },
|
||||
|
||||
{ "slug": "asana", "name": "Asana", "wave": 3, "status": "self_serve", "docsUrl": "https://developers.asana.com/docs/integrating-with-asanas-mcp-server", "serverUrl": "https://mcp.asana.com/v2/mcp", "authMode": "customer_oauth", "prerequisite": "Create an Asana MCP OAuth app and register Paperclip's callback URI; DCR is not supported.", "riskTier": "S3" },
|
||||
{ "slug": "asana", "name": "Asana", "wave": 3, "status": "self_serve", "docsUrl": "https://developers.asana.com/docs/integrating-with-asanas-mcp-server", "serverUrl": "https://mcp.asana.com/v2/mcp", "authMode": "dcr", "prerequisite": "An active Asana account. Re-verified 2026-09-28: mcp.asana.com advertises a registration endpoint and issues clients for http://localhost callbacks, so a local Paperclip registers its own OAuth client. Asana refuses non-localhost redirect URIs, so a hosted deployment still registers an app in Asana's developer console.", "riskTier": "S3" },
|
||||
{ "slug": "linear", "name": "Linear", "wave": 3, "status": "self_serve", "docsUrl": "https://linear.app/docs/mcp", "serverUrl": "https://mcp.linear.app/mcp", "authMode": "dcr_cimd", "prerequisite": "An active Linear workspace. Verified 2026-09-28: mcp.linear.app advertises a registration endpoint and client-ID metadata documents, so Paperclip registers its own OAuth client.", "riskTier": "S2" },
|
||||
{ "slug": "box", "name": "Box", "wave": 3, "status": "self_serve", "docsUrl": "https://support.box.com/hc/en-us/articles/43847256139923-Managing-Box-MCP-Servers", "serverUrl": "https://mcp.box.com", "authMode": "customer_oauth", "prerequisite": "A Box administrator creates the OAuth integration and enables AI access.", "riskTier": "S3" },
|
||||
{ "slug": "mem0", "name": "Mem0", "wave": 3, "status": "self_serve", "docsUrl": "https://docs.mem0.ai/platform/mem0-mcp", "serverUrl": "https://mcp.mem0.ai/mcp/", "authMode": "api_key", "prerequisite": "A Mem0 API key; the live server currently requires the slash-normalized endpoint.", "riskTier": "S3" },
|
||||
{ "slug": "pagerduty", "name": "PagerDuty", "wave": 3, "status": "self_serve", "docsUrl": "https://support.pagerduty.com/main/docs/pagerduty-mcp-server", "serverUrl": "https://mcp.pagerduty.com/mcp", "authMode": "api_key", "prerequisite": "A PagerDuty API token; choose the regional endpoint that hosts the account.", "riskTier": "S4" },
|
||||
|
||||
@@ -10031,6 +10031,64 @@ describeEmbeddedPostgres("tool access service", () => {
|
||||
).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("registers Linear against its MCP authorization server instead of the pinned console endpoints", async () => {
|
||||
vi.stubEnv("PAPERCLIP_PUBLIC_URL", "https://paperclip.example");
|
||||
vi.stubEnv("PAPERCLIP_TOOL_OAUTH_LINEAR_CLIENT_ID", "");
|
||||
vi.stubEnv("PAPERCLIP_TOOL_OAUTH_LINEAR_CLIENT_SECRET", "");
|
||||
vi.stubEnv("PAPERCLIP_TOOL_OAUTH_CLIENT_ID", "");
|
||||
vi.stubEnv("PAPERCLIP_TOOL_OAUTH_CLIENT_SECRET", "");
|
||||
const company = await createCompany(db);
|
||||
const userId = `linear-owner-${randomUUID()}`;
|
||||
await grantBoardUser(db, company.id, userId, [], "owner");
|
||||
const app = createRouteApp(
|
||||
db,
|
||||
boardSessionActor(company.id, "owner", userId),
|
||||
);
|
||||
const fetched: string[] = [];
|
||||
vi.spyOn(globalThis, "fetch").mockImplementation(async (url) => {
|
||||
const href = String(url);
|
||||
fetched.push(href);
|
||||
if (href === "https://mcp.linear.app/.well-known/oauth-protected-resource/mcp") {
|
||||
return mcpHttpResponse({
|
||||
resource: "https://mcp.linear.app/mcp",
|
||||
authorization_servers: ["https://mcp.linear.app"],
|
||||
scopes_supported: ["read", "write"],
|
||||
});
|
||||
}
|
||||
if (href === "https://mcp.linear.app/.well-known/oauth-authorization-server") {
|
||||
return mcpHttpResponse({
|
||||
issuer: "https://mcp.linear.app",
|
||||
authorization_endpoint: "https://mcp.linear.app/authorize",
|
||||
token_endpoint: "https://mcp.linear.app/token",
|
||||
registration_endpoint: "https://mcp.linear.app/register",
|
||||
code_challenge_methods_supported: ["S256"],
|
||||
token_endpoint_auth_methods_supported: ["none"],
|
||||
});
|
||||
}
|
||||
if (href === "https://mcp.linear.app/register") {
|
||||
return mcpHttpResponse({
|
||||
client_id: "linear-registered-client",
|
||||
redirect_uris: ["https://paperclip.example/api/tools/oauth/callback"],
|
||||
grant_types: ["authorization_code", "refresh_token"],
|
||||
response_types: ["code"],
|
||||
token_endpoint_auth_method: "none",
|
||||
});
|
||||
}
|
||||
throw new Error(`unexpected fetch ${href}`);
|
||||
});
|
||||
|
||||
const connectRes = await request(app)
|
||||
.post(`/api/companies/${company.id}/tools/apps/connect`)
|
||||
.send({ galleryKey: "linear", name: "Linear", grantKind: "user" })
|
||||
.expect(201);
|
||||
|
||||
const startUrl = new URL(connectRes.body.auth.startUrl);
|
||||
expect(startUrl.origin + startUrl.pathname).toBe("https://mcp.linear.app/authorize");
|
||||
expect(startUrl.searchParams.get("client_id")).toBe("linear-registered-client");
|
||||
expect(fetched).toContain("https://mcp.linear.app/register");
|
||||
expect(fetched.some((href) => href.startsWith("https://linear.app/"))).toBe(false);
|
||||
});
|
||||
|
||||
it("returns a pre-scoped personal Notion callback directly to Permissions", async () => {
|
||||
vi.stubEnv("PAPERCLIP_PUBLIC_URL", "https://paperclip.example");
|
||||
vi.stubEnv("PAPERCLIP_TOOL_OAUTH_NOTION_CLIENT_ID", "");
|
||||
@@ -10116,9 +10174,31 @@ describeEmbeddedPostgres("tool access service", () => {
|
||||
);
|
||||
expect(state).toBeTruthy();
|
||||
|
||||
// The provider's redirect is a cross-site navigation: Paperclip commits a
|
||||
// page at once (Railway's consent page otherwise replaces itself after ~2s)
|
||||
// and leaves the state unconsumed for the same-origin repeat.
|
||||
const interstitialRes = await request(app)
|
||||
.get("/api/tools/oauth/callback")
|
||||
.set("Accept", "text/html")
|
||||
.set("Sec-Fetch-Site", "cross-site")
|
||||
.set("Sec-Fetch-Mode", "navigate")
|
||||
.query({ state, code: "notion-choice-code" });
|
||||
expect(interstitialRes.status).toBe(200);
|
||||
expect(interstitialRes.headers["cache-control"]).toBe("no-store");
|
||||
expect(interstitialRes.text).toContain(
|
||||
`<meta http-equiv="refresh" content="0;url=/api/tools/oauth/callback?state=${state}&code=notion-choice-code">`,
|
||||
);
|
||||
const [pendingConnection] = await db
|
||||
.select()
|
||||
.from(toolConnections)
|
||||
.where(eq(toolConnections.id, connectRes.body.connectionId));
|
||||
expect(pendingConnection?.status).not.toBe("active");
|
||||
|
||||
const callbackRes = await request(app)
|
||||
.get("/api/tools/oauth/callback")
|
||||
.set("Accept", "text/html")
|
||||
.set("Sec-Fetch-Site", "same-origin")
|
||||
.set("Sec-Fetch-Mode", "navigate")
|
||||
.query({ state, code: "notion-choice-code" });
|
||||
|
||||
expect(callbackRes.status).toBe(303);
|
||||
|
||||
@@ -191,6 +191,29 @@ export function connectionIntentOAuthOutcomeHtml(input: {
|
||||
return `<!doctype html><html><head><meta charset="utf-8"><title>Connection authorization</title></head><body><p>Returning to Paperclip…</p><script>const message=${message};const targetOrigin=${targetOrigin}||window.location.origin;if(window.opener&&window.opener!==window){window.opener.postMessage(message,targetOrigin);window.close();}else{window.location.replace(${fallback});}</script></body></html>`;
|
||||
}
|
||||
|
||||
// Some providers' consent pages navigate to this callback and, if their own
|
||||
// page is still on screen ~2s later, replace it with a "you can close this
|
||||
// window" screen (Railway does exactly this). Exchanging the code and
|
||||
// discovering the tool catalog routinely takes longer than that, so the
|
||||
// provider's timer wins and the browser never lands back in Paperclip even
|
||||
// though the connection completed. For a cross-site browser navigation, commit
|
||||
// a Paperclip document immediately and repeat the same request from it; the
|
||||
// repeat is same-origin and does the slow work.
|
||||
export function isCrossSiteOAuthCallbackNavigation(req: Request): boolean {
|
||||
return req.get("sec-fetch-site") === "cross-site"
|
||||
&& req.get("sec-fetch-mode") === "navigate";
|
||||
}
|
||||
|
||||
export function oauthCallbackInterstitialHtml(continuePath: string): string {
|
||||
const attribute = continuePath
|
||||
.replaceAll("&", "&")
|
||||
.replaceAll("\"", """)
|
||||
.replaceAll("<", "<");
|
||||
// A meta refresh alone, not a script as well: the OAuth code is single-use, so
|
||||
// two racing follow-ups would let the loser render an expired-state error.
|
||||
return `<!doctype html><html><head><meta charset="utf-8"><meta name="referrer" content="no-referrer"><meta http-equiv="refresh" content="0;url=${attribute}"><title>Finishing connection</title></head><body><p>Finishing your connection…</p></body></html>`;
|
||||
}
|
||||
|
||||
function normalizeCloudConnectorEnrollmentReturnTo(returnTo?: string | null): string | null {
|
||||
if (!returnTo || returnTo.length > 2_048) return null;
|
||||
try {
|
||||
@@ -1331,6 +1354,13 @@ function connectorEnrollmentPrincipal(req: Request): string {
|
||||
await assertToolConnectionConfigureAccess(req, pendingConnection);
|
||||
}
|
||||
const acceptsHtml = req.get("accept")?.includes("text/html") === true;
|
||||
if (acceptsHtml && isCrossSiteOAuthCallbackNavigation(req)) {
|
||||
// State is only peeked above, so the same-origin repeat still owns it.
|
||||
res.set("Cache-Control", "no-store");
|
||||
res.set("Referrer-Policy", "no-referrer");
|
||||
res.type("html").send(oauthCallbackInterstitialHtml(req.originalUrl));
|
||||
return;
|
||||
}
|
||||
let result: Awaited<ReturnType<typeof svc.completeOAuthCallback>>;
|
||||
try {
|
||||
result = await svc.completeOAuthCallback({
|
||||
|
||||
@@ -9018,9 +9018,22 @@ export function toolAccessService(
|
||||
const galleryMethod = galleryEntry
|
||||
? connectionMethodForConnection(galleryEntry, connection)
|
||||
: null;
|
||||
// Pinned endpoints describe the provider's console-registered OAuth app.
|
||||
// A method that also offers dynamic registration registers against the MCP
|
||||
// server's own authorization server (Linear: mcp.linear.app, not
|
||||
// linear.app), which only discovery finds. So the pins stand in for
|
||||
// discovery only when no registration is possible or the connection
|
||||
// already carries an operator-entered client.
|
||||
const storedOAuth = oauthConfig(connection);
|
||||
const usesOperatorClient =
|
||||
storedOAuth.clientRegistrationSource === "manual" &&
|
||||
connection.ownership !== "dcr" &&
|
||||
typeof storedOAuth.clientId === "string" &&
|
||||
storedOAuth.clientId.trim().length > 0;
|
||||
const hasCompleteGalleryEndpointHints = Boolean(
|
||||
galleryMethod?.defaults?.authorizationEndpoint &&
|
||||
galleryMethod.defaults.tokenEndpoint,
|
||||
galleryMethod.defaults.tokenEndpoint &&
|
||||
(!galleryMethod.ownershipModes.includes("dcr") || usesOperatorClient),
|
||||
);
|
||||
// The smoke-lab fixture's endpoints are first-party and complete, so
|
||||
// discovery is not just unnecessary there, it must not run: an unreachable
|
||||
@@ -9744,6 +9757,13 @@ export function toolAccessService(
|
||||
* connection may register once — and only once — protected-resource and
|
||||
* authorization-server discovery actually produced a metadata document; an
|
||||
* endpoint that merely returned a 401 does not earn a registration.
|
||||
*
|
||||
* A curated method pinned to customer-owned clients still earns a registration
|
||||
* when the provider's own metadata advertises one. `ownershipModes` is a
|
||||
* point-in-time research snapshot, and providers add dynamic registration
|
||||
* without telling us; live discovery is the better evidence of the two, so a
|
||||
* stale catalog entry costs the operator a console detour rather than silently
|
||||
* outranking what the server just said about itself.
|
||||
*/
|
||||
function canRegisterOAuthClientDynamically(
|
||||
connection: typeof toolConnections.$inferSelect,
|
||||
@@ -9751,10 +9771,9 @@ export function toolAccessService(
|
||||
galleryEntry: AppDefinition | null,
|
||||
): boolean {
|
||||
if (galleryEntry) {
|
||||
return connectionMethodForConnection(
|
||||
galleryEntry,
|
||||
connection,
|
||||
).ownershipModes.includes("dcr");
|
||||
const method = connectionMethodForConnection(galleryEntry, connection);
|
||||
if (method.ownershipModes.includes("dcr")) return true;
|
||||
return method.auth === "oauth" && Boolean(endpoints.registrationUrl);
|
||||
}
|
||||
return (
|
||||
connection.transport === "mcp_remote" && Boolean(endpoints.metadataUrl)
|
||||
|
||||
@@ -119,7 +119,6 @@ test.describe.serial("not-connected app page", () => {
|
||||
await page.getByRole("button", { name: "Reconnect", exact: true }).click();
|
||||
await expect(page).toHaveURL(/\/apps\/connect\?/, { timeout: 20_000 });
|
||||
await expect(page.getByText("Connect your own MCP server")).toBeVisible({ timeout: 20_000 });
|
||||
await page.getByRole("button", { name: "Save and continue" }).click();
|
||||
await expect(page.getByText(mock.url)).toBeVisible();
|
||||
await page.screenshot({ path: `${SCREENSHOT_DIR}/apps-nav-w6-02-reconnect-prefilled.png`, fullPage: true });
|
||||
|
||||
|
||||
@@ -161,22 +161,17 @@ test.describe.serial("prosumer MCP flow prosumer MCP flow", () => {
|
||||
await linkInput.fill(mock.url);
|
||||
await page.getByRole("button", { name: "Continue" }).click();
|
||||
|
||||
// Access is chosen before credentials so the user knows who and which
|
||||
// agents will receive the connection before Paperclip contacts it.
|
||||
await expect(page.getByText("Which humans can use this credential?")).toBeVisible();
|
||||
await page.getByRole("button", { name: "Save and continue" }).click();
|
||||
|
||||
// LinkKey step keeps the BYO connection heading. Mock doesn't
|
||||
// require a key — leave the default "No" answer.
|
||||
// There is no separate Access step: the link opens the key screen, which
|
||||
// states the default access in one line. The mock needs no key, and a
|
||||
// credential challenge from the server is what would ask for one.
|
||||
await expect(page.getByRole("heading", { name: "Connect your own MCP server" })).toBeVisible({ timeout: 15_000 });
|
||||
await page.screenshot({ path: `${SCREENSHOT_DIR}/prosumer-mcp-02-key-step.png`, fullPage: true });
|
||||
|
||||
// Submit (button label is "Check link").
|
||||
await page.getByRole("button", { name: /Check link/i }).click();
|
||||
|
||||
// The Access choice was captured before credentials. A successful generic
|
||||
// probe now commits discovered actions and risk defaults transactionally,
|
||||
// so the key check lands directly on success.
|
||||
// A successful generic probe commits discovered actions and the stated
|
||||
// access defaults transactionally, so the key check lands on success.
|
||||
await expect(page.getByRole("heading", { name: /is ready\.$/i })).toBeVisible({ timeout: 30_000 });
|
||||
await page.screenshot({ path: `${SCREENSHOT_DIR}/prosumer-mcp-05-success.png`, fullPage: true });
|
||||
|
||||
|
||||
@@ -140,10 +140,14 @@ test.describe.serial("native chat adapter UI", () => {
|
||||
'[role="listitem"][data-app-slug="github"]',
|
||||
);
|
||||
await expect(connector).toBeVisible();
|
||||
await connector.getByRole("button", { name: "Connect GitHub" }).click();
|
||||
// Without the cloud connector GitHub's default method is a token, so the
|
||||
// card's verb is "Add key" rather than "Connect".
|
||||
await connector.getByRole("button", { name: "Add key GitHub" }).click();
|
||||
|
||||
await expect(page).toHaveURL(/\/apps\/connect\?/);
|
||||
expect(new URL(page.url()).searchParams.get("source")).toBe("github");
|
||||
// Identity is a stated default; its choices sit behind "Change".
|
||||
await page.getByRole("button", { name: "Change", exact: true }).click();
|
||||
await expect(
|
||||
page.getByRole("heading", { name: "Connect GitHub as" }),
|
||||
).toBeVisible();
|
||||
@@ -218,7 +222,8 @@ test.describe.serial("native chat adapter UI", () => {
|
||||
await expect(connector).toBeVisible({ timeout: 30_000 });
|
||||
if (provider.provider === "github") {
|
||||
const tools = page.locator('[role="listitem"][data-app-slug="github"]');
|
||||
await tools.getByRole("button", { name: "Connect GitHub", exact: true }).click();
|
||||
await tools.getByRole("button", { name: "Add key GitHub", exact: true }).click();
|
||||
await page.getByRole("button", { name: "Change", exact: true }).click();
|
||||
await expect(page.getByRole("heading", { name: "Connect GitHub as" })).toBeVisible();
|
||||
await expect(page.getByRole("heading", { name: "Choose how to connect" })).toHaveCount(0);
|
||||
await page.goto(`/${seed.prefix}/apps`);
|
||||
|
||||
@@ -234,7 +234,6 @@ test("store setup and task connection intent share one fake provider through con
|
||||
.getByPlaceholder("https://example.com/actions")
|
||||
.fill(provider.url);
|
||||
await page.getByRole("button", { name: "Continue" }).click();
|
||||
await page.getByRole("button", { name: "Save and continue" }).click();
|
||||
await page.getByRole("button", { name: /Check link/i }).click();
|
||||
// A no-auth read-only provider can complete the access/install defaults in
|
||||
// one commit. Other methods exercise the same intermediate steps in the
|
||||
|
||||
@@ -149,7 +149,6 @@ for (const journey of [
|
||||
.getByPlaceholder("https://example.com/actions")
|
||||
.fill(provider.url);
|
||||
await page.getByRole("button", { name: "Continue", exact: true }).click();
|
||||
await page.getByRole("button", { name: "Save and continue" }).click();
|
||||
await page.getByRole("button", { name: /Check link/i }).click();
|
||||
await expect(
|
||||
page.getByRole("heading", { name: /is ready/i }),
|
||||
|
||||
@@ -324,11 +324,11 @@ for (const journey of [
|
||||
await page
|
||||
.getByRole("button", { name: "Continue", exact: true })
|
||||
.click();
|
||||
await page.getByRole("button", { name: "Change", exact: true }).click();
|
||||
await page.getByRole("radio", { name: "Just agents I pick" }).click();
|
||||
await page.getByRole("button", { name: /Select agents/ }).click();
|
||||
await page.getByRole("checkbox", { name: /Archive holder/ }).check();
|
||||
await page.keyboard.press("Escape");
|
||||
await page.getByRole("button", { name: "Save and continue" }).click();
|
||||
await page.getByRole("button", { name: /Check link/i }).click();
|
||||
await expect(
|
||||
page.getByRole("heading", { name: /is ready/i }),
|
||||
|
||||
@@ -71,11 +71,11 @@ for (const journey of ['connect', 'decline', 'restart'] as const) test(`fresh na
|
||||
await custom.getByRole('button', { name: 'Connect your own MCP server' }).click();
|
||||
await page.getByPlaceholder('https://example.com/actions').fill(`http://127.0.0.1:${port}/`);
|
||||
await page.getByRole('button', { name: 'Continue', exact: true }).click();
|
||||
await page.getByRole('button', { name: 'Change', exact: true }).click();
|
||||
await page.getByRole('radio', { name: 'Just agents I pick' }).click();
|
||||
await page.getByRole('button', { name: /Select agents/ }).click();
|
||||
await page.getByRole('checkbox', { name: /Archive holder/ }).check();
|
||||
await page.keyboard.press('Escape');
|
||||
await page.getByRole('button', { name: 'Save and continue' }).click();
|
||||
await page.getByRole('button', { name: /Check link/i }).click();
|
||||
await expect(page.getByRole('heading', { name: /is ready/i })).toBeVisible({ timeout: 30_000 });
|
||||
const [connection] = (await api(`/companies/${company.id}/tools/connections`)).connections;
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,49 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { askFirstCatalogEntryIdsFor } from "./connection-defaults";
|
||||
|
||||
const action = (catalogEntryId: string, riskLevel: string) =>
|
||||
({ catalogEntryId, riskLevel }) as never;
|
||||
|
||||
describe("askFirstCatalogEntryIdsFor", () => {
|
||||
const result = {
|
||||
actions: {
|
||||
readOnly: [action("read-1", "read")],
|
||||
canMakeChanges: [
|
||||
action("write-1", "write"),
|
||||
action("destroy-1", "destructive"),
|
||||
action("odd-1", "medium"),
|
||||
],
|
||||
},
|
||||
suggestedDefaults: { askFirstRiskLevels: ["write", "destructive", "high", "critical"] },
|
||||
};
|
||||
|
||||
it("gates the write and destructive actions the server named", () => {
|
||||
expect(askFirstCatalogEntryIdsFor(result, () => true)).toEqual(["write-1", "destroy-1"]);
|
||||
});
|
||||
|
||||
it("leaves a risk level the policy did not name alone", () => {
|
||||
// `medium` is not in the default set; gating it would be the classifier
|
||||
// making policy instead of the policy function.
|
||||
expect(askFirstCatalogEntryIdsFor(result, () => true)).not.toContain("odd-1");
|
||||
});
|
||||
|
||||
it("never gates an action that is switched off", () => {
|
||||
expect(askFirstCatalogEntryIdsFor(result, (id) => id !== "write-1")).toEqual(["destroy-1"]);
|
||||
});
|
||||
|
||||
it("gates nothing when the server sends an open policy", () => {
|
||||
expect(
|
||||
askFirstCatalogEntryIdsFor({ ...result, suggestedDefaults: { askFirstRiskLevels: [] } }, () => true),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("gates nothing rather than throwing when the field is missing or malformed", () => {
|
||||
expect(askFirstCatalogEntryIdsFor({ ...result, suggestedDefaults: {} }, () => true)).toEqual([]);
|
||||
expect(
|
||||
askFirstCatalogEntryIdsFor({ ...result, suggestedDefaults: undefined as never }, () => true),
|
||||
).toEqual([]);
|
||||
expect(
|
||||
askFirstCatalogEntryIdsFor({ ...result, suggestedDefaults: { askFirstRiskLevels: "write" } }, () => true),
|
||||
).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,30 @@
|
||||
import type { ConnectToolAppResult } from "@paperclipai/shared";
|
||||
|
||||
/**
|
||||
* Which of a fresh connection's actions start behind a human approval (PAP-659
|
||||
* C6a/C7).
|
||||
*
|
||||
* The runtime gate and its approval card already existed; what was missing was
|
||||
* a default with anything behind it. This lives outside the wizard because two
|
||||
* different setup paths commit connections — the catalog flow and the
|
||||
* remote-MCP gateway flow — and a gate that is armed on only one of them is a
|
||||
* gate you cannot reason about. The risk levels come from the server's
|
||||
* `suggestedDefaults` rather than a constant here, so retuning the policy stays
|
||||
* a one-line change in `recommendedDefaultsForApp`.
|
||||
*/
|
||||
export function askFirstCatalogEntryIdsFor(
|
||||
result: Pick<ConnectToolAppResult, "actions" | "suggestedDefaults">,
|
||||
isEnabled: (catalogEntryId: string) => boolean,
|
||||
): string[] {
|
||||
const riskLevels = new Set(
|
||||
Array.isArray(result.suggestedDefaults?.askFirstRiskLevels)
|
||||
? result.suggestedDefaults.askFirstRiskLevels.filter(
|
||||
(riskLevel): riskLevel is string => typeof riskLevel === "string",
|
||||
)
|
||||
: [],
|
||||
);
|
||||
if (riskLevels.size === 0) return [];
|
||||
return result.actions.canMakeChanges
|
||||
.filter((action) => isEnabled(action.catalogEntryId) && riskLevels.has(action.riskLevel))
|
||||
.map((action) => action.catalogEntryId);
|
||||
}
|
||||
@@ -8,11 +8,22 @@ import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { RemoteMcpManagement } from "./RemoteMcpManagement";
|
||||
import { AccessStepContent, StepHeader } from "../ConnectionSetupFlow";
|
||||
import {
|
||||
AccessStepContent,
|
||||
ConnectionAccessDefaults,
|
||||
connectionDefaultSummarySentence,
|
||||
StepHeader,
|
||||
} from "../ConnectionSetupFlow";
|
||||
import type { RemoteMcpProvider } from "./providers";
|
||||
import type { RemoteMcpSetupActions, RemoteMcpSetupState } from "./types";
|
||||
|
||||
const steps = ["access", "connect"] as const;
|
||||
/**
|
||||
* PAP-659 C0: the connect path is one screen. `access` is still a real screen,
|
||||
* but only as management after the connection exists — the way in states the
|
||||
* resolved default and puts its controls in the Advanced disclosure, the same
|
||||
* as every catalog connector.
|
||||
*/
|
||||
const steps = ["connect"] as const;
|
||||
const selectClass = "h-9 w-full rounded-md border border-input bg-background px-3 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-50";
|
||||
|
||||
function FieldHelp({ label, children }: { label: string; children: ReactNode }) {
|
||||
@@ -25,7 +36,8 @@ function FieldHelp({ label, children }: { label: string; children: ReactNode })
|
||||
|
||||
/** Controlled presentation shared by provider setup, configuration imports and review stories.
|
||||
* Authentication, persistence and calls belong to the controller, never these views. */
|
||||
export function RemoteMcpConnectionSetup({ provider, state: s, actions: a, agents, connectionId, fixedGrantKind, lockedAgentId, host = "page", authorizationUrl, upstreamServiceName, onCancel }: {
|
||||
export function RemoteMcpConnectionSetup({ provider, state: s, actions: a, agents, companyId, connectionId, fixedGrantKind, lockedAgentId, host = "page", authorizationUrl, upstreamServiceName, onCancel }: {
|
||||
companyId: string;
|
||||
onCancel?: () => void;
|
||||
upstreamServiceName?: string;
|
||||
host?: "page" | "dialog";
|
||||
@@ -54,6 +66,48 @@ export function RemoteMcpConnectionSetup({ provider, state: s, actions: a, agent
|
||||
</InlineBanner>;
|
||||
const footer = (children: ReactNode) => <SetupWizardFooter onSaveExit={a.saveExit} disabled={busy}>{children}</SetupWizardFooter>;
|
||||
|
||||
/**
|
||||
* The stated default and the one Advanced disclosure (PAP-659 C0).
|
||||
*
|
||||
* Step 1 of this work learned the lesson the hard way on Gmail: adding an
|
||||
* access disclosure beside a connector's existing "Advanced authentication"
|
||||
* panel leaves two of them on one screen, which is worse than the step it
|
||||
* replaced. So the provider's authentication settings are passed in here as
|
||||
* `extra` and share a single panel with the access controls.
|
||||
*/
|
||||
// Deliberately not derived from `s.auth`. A gateway URL can carry a personal
|
||||
// token even when the method declares `auth: "none"`, so the shared-vs-mine
|
||||
// credential choice has to stay offered; deriving "none" here would silently
|
||||
// remove it and pin every Zapier connection to the organization.
|
||||
const authKind = "oauth";
|
||||
const defaults = (extra: ReactNode) => <ConnectionAccessDefaults
|
||||
companyId={companyId}
|
||||
{...(companyId ? {} : { agents })}
|
||||
sentence={connectionDefaultSummarySentence({
|
||||
grantKind: s.grantKind,
|
||||
authKind,
|
||||
installChoice: s.allAgents ? "all" : "specific",
|
||||
installCount: s.agentIds.length,
|
||||
lockedAgentId,
|
||||
preserveAgentAccess: s.setupComplete,
|
||||
})}
|
||||
extra={extra}
|
||||
// Something inside is load-bearing: the operator has already chosen a
|
||||
// non-default sign-in method, or the provider just rejected a credential.
|
||||
forceOpen={s.auth !== (provider.supportsBrowserAuth ? "auto" : "none") || s.connectStatus === "rejected"}
|
||||
disabled={busy}
|
||||
authKind={authKind}
|
||||
grantKinds={fixedGrantKind ? [fixedGrantKind] : undefined}
|
||||
grantKind={s.grantKind}
|
||||
setGrantKind={(grantKind) => { if (grantKind !== "agent") change({ grantKind }); }}
|
||||
installChoice={s.allAgents ? "all" : "specific"}
|
||||
setInstallChoice={(choice) => change({ allAgents: choice === "all" })}
|
||||
installAgentIds={new Set(s.agentIds)}
|
||||
setInstallAgentIds={(ids) => change({ agentIds: [...ids] })}
|
||||
lockedAgentId={lockedAgentId}
|
||||
preserveAgentAccess={s.setupComplete}
|
||||
/>;
|
||||
|
||||
const error = s.connectStatus === "invalid_url" ? { title: "Enter a valid MCP URL", body: "Paste the complete server URL, including https:// or http://. A dashboard page is not an MCP endpoint." }
|
||||
: s.connectStatus === "oauth_failed" ? { title: `${provider.name} couldn’t connect`, body: "Authorization did not complete. Your saved connection is still here, so you can try again." }
|
||||
: s.connectStatus === "rejected" ? { title: "Credentials were rejected", body: `Check or replace the credentials from ${provider.name}, then reconnect. Your agent access and tool choices are preserved.` }
|
||||
@@ -63,8 +117,8 @@ export function RemoteMcpConnectionSetup({ provider, state: s, actions: a, agent
|
||||
return <div className={host === "dialog" ? "min-w-0 text-foreground" : "mx-auto max-w-6xl p-4 text-foreground sm:p-8"} data-remote-mcp-provider={provider.id}>
|
||||
<StepHeader headingRef={heading} appIdentity={{ name: provider.name, logoUrl: null }}
|
||||
title={upstreamServiceName ? `Connect ${upstreamServiceName} through ${provider.name}` : s.step === "draft" ? "Continue your setup" : s.setupComplete ? s.step === "access" ? "Who can use this connection" : s.step === "connect" ? `Reconnect ${provider.name}` : provider.name : undefined}
|
||||
subtitle={currentStep >= 0 && !s.setupComplete ? `Step ${currentStep + 1} of 2` : s.step === "draft" ? `Your ${provider.name} setup is ready to resume.` : s.step === "permissions" ? `Connected${s.identity ? ` as ${s.identity}` : ""} · ${s.tools.length} actions available` : `Manage this ${provider.name} connection.`}
|
||||
step={currentStep >= 0 && !s.setupComplete ? "access" : "gallery"} activeIndex={currentStep} labels={["Access", "Connect"]} onCancel={busy || s.step === "management" || s.step === "permissions" || s.step === "draft" ? undefined : onCancel ?? a.saveExit} />
|
||||
subtitle={currentStep >= 0 && !s.setupComplete ? `Paperclip will use ${provider.name} on your behalf.` : s.step === "draft" ? `Your ${provider.name} setup is ready to resume.` : s.step === "permissions" ? `Connected${s.identity ? ` as ${s.identity}` : ""} · ${s.tools.length} actions available` : `Manage this ${provider.name} connection.`}
|
||||
step={currentStep >= 0 && !s.setupComplete ? "key" : "gallery"} activeIndex={currentStep} labels={steps.map(() => "Connect")} onCancel={busy || s.step === "management" || s.step === "permissions" || s.step === "draft" ? undefined : onCancel ?? a.saveExit} />
|
||||
<main className="space-y-6">
|
||||
{upstreamServiceName && <InlineBanner compact>{provider.name} is an external service that handles the connection and requests to {upstreamServiceName}. After connecting, the agent will verify the app and guide you through any additional authorization.</InlineBanner>}
|
||||
{s.notice && <p role="status" className="text-sm text-muted-foreground">{s.notice}</p>}
|
||||
@@ -81,7 +135,7 @@ export function RemoteMcpConnectionSetup({ provider, state: s, actions: a, agent
|
||||
enabledIds={new Set(s.tools.filter((entry) => s.permissions[entry.id] !== "off").map((entry) => entry.id))}
|
||||
askFirstIds={new Set(s.tools.filter((entry) => s.permissions[entry.id] === "ask_first").map((entry) => entry.id))}
|
||||
disabled={!s.connected} refreshPending={s.refreshing} canConfigure
|
||||
onSetPermission={(id, next) => change({ permissions: { ...s.permissions, [id]: next === "ask" ? "ask_first" : next } })}
|
||||
onSetPermission={(ids, next) => change({ permissions: { ...s.permissions, ...Object.fromEntries(ids.map((id) => [id, next === "ask" ? "ask_first" : next])) } })}
|
||||
onReviewQuarantined={() => {}} onRefreshActions={a.refresh} />
|
||||
</>}
|
||||
<div className="mx-auto max-w-2xl space-y-6">
|
||||
@@ -105,11 +159,10 @@ export function RemoteMcpConnectionSetup({ provider, state: s, actions: a, agent
|
||||
<Input id={`${uid}-url`} type="password" autoComplete="off" spellCheck={false} placeholder={provider.placeholder} value={s.url} aria-invalid={s.connectStatus === "invalid_url"} aria-describedby={`${uid}-url-help`} onChange={(event) => change({ url: event.target.value })} />
|
||||
<p id={`${uid}-url-help`} className="text-xs text-muted-foreground">{provider.urlHelp}</p>
|
||||
</div>
|
||||
<details open={s.advanced} onToggle={(event) => { if (event.currentTarget.open !== s.advanced) change({ advanced: event.currentTarget.open }); }}>
|
||||
<summary className="cursor-pointer text-sm font-medium">Advanced authentication</summary>
|
||||
<div className="space-y-4 pt-4">
|
||||
{defaults(<div className="space-y-4">
|
||||
<p className="text-sm font-medium text-foreground">Authentication</p>
|
||||
<p className="text-sm text-muted-foreground">{provider.authHelp}</p>
|
||||
<div className="space-y-2"><Label htmlFor={`${uid}-auth`}>Authentication</Label><select id={`${uid}-auth`} className={selectClass} value={s.auth} onChange={(event) => change({ auth: event.target.value as RemoteMcpSetupState["auth"] })}>
|
||||
<div className="space-y-2"><Label htmlFor={`${uid}-auth`}>Sign-in method</Label><select id={`${uid}-auth`} className={selectClass} value={s.auth} onChange={(event) => change({ auth: event.target.value as RemoteMcpSetupState["auth"] })}>
|
||||
{provider.supportsBrowserAuth && <option value="auto">Automatic (sign in if required)</option>}<option value="bearer">Bearer token</option><option value="headers">Custom headers</option><option value="none">No additional authentication</option>
|
||||
</select></div>
|
||||
{s.auth === "bearer" && <div className="space-y-2"><div className="flex items-center gap-2"><Label htmlFor={`${uid}-token`}>Bearer token</Label><FieldHelp label="bearer token">Paste the token only, without the word Bearer. It is kept in the connection’s credentials.</FieldHelp></div><Input id={`${uid}-token`} type="password" autoComplete="off" value={s.token} onChange={(event) => change({ token: event.target.value })} /></div>}
|
||||
@@ -122,11 +175,10 @@ export function RemoteMcpConnectionSetup({ provider, state: s, actions: a, agent
|
||||
</div>)}
|
||||
<Button type="button" variant="outline" size="sm" onClick={() => change({ headers: [...s.headers, { id: crypto.randomUUID(), name: "", value: "" }] })}><Plus className="size-4" />Add header</Button>
|
||||
</div>}
|
||||
</div>
|
||||
</details>
|
||||
</div>)}
|
||||
</fieldset>
|
||||
{busy && <p role="status" className="flex items-center gap-2 text-sm"><Loader2 className="size-4 animate-spin motion-reduce:animate-none" />Connecting and discovering tools…</p>}
|
||||
{footer(<><Button type="button" variant="outline" disabled={busy} onClick={() => s.setupComplete ? a.finish() : a.navigate("access")}>Back</Button><Button type="submit" disabled={busy || !s.url.trim()}>{busy ? "Connecting…" : error || s.connectStatus === "cancelled" ? "Try again" : "Connect"}</Button></>)}
|
||||
{footer(<>{s.setupComplete ? <Button type="button" variant="outline" disabled={busy} onClick={a.finish}>Back</Button> : <span />}<Button type="submit" disabled={busy || !s.url.trim()}>{busy ? "Connecting…" : error || s.connectStatus === "cancelled" ? "Try again" : `Connect ${provider.name}`}</Button></>)}
|
||||
</form>}
|
||||
</>}
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ function ConnectExample({ providerId }: { providerId: RemoteMcpProviderId }) {
|
||||
const provider = remoteMcpProviders[providerId];
|
||||
const [state, setState] = useState<RemoteMcpSetupState>({
|
||||
step: "access", grantKind: "organization", setupComplete: false, url: provider.defaultUrl,
|
||||
auth: provider.supportsBrowserAuth ? "auto" : "none", token: "", headers: [], advanced: false, connectStatus: "idle",
|
||||
auth: provider.supportsBrowserAuth ? "auto" : "none", token: "", headers: [], connectStatus: "idle",
|
||||
connected: false, identity: null, allAgents: true, agentIds: [], permissions: {},
|
||||
tools: [], notice: null, refreshing: false,
|
||||
});
|
||||
@@ -17,7 +17,7 @@ function ConnectExample({ providerId }: { providerId: RemoteMcpProviderId }) {
|
||||
const actions: RemoteMcpSetupActions = { edit, navigate: (step) => edit({ step }), connect: explain, cancelConnect: explain,
|
||||
openProvider: explain, saveExit: explain, resumeDraft: explain, finish: explain, refresh: explain, reconnect: explain, disconnect: explain,
|
||||
};
|
||||
return <RemoteMcpConnectionSetup connectionId="design-example" provider={provider} state={state} actions={actions} agents={[]} />;
|
||||
return <RemoteMcpConnectionSetup companyId="" connectionId="design-example" provider={provider} state={state} actions={actions} agents={[]} />;
|
||||
}
|
||||
|
||||
/** Design-guide specimen; full state matrix is maintained in each provider's Storybook group. */
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { REMOTE_MCP_CONNECTOR_METHODS, type ToolConnection } from "@paperclipai/shared";
|
||||
import { askFirstCatalogEntryIdsFor } from "../connection-defaults";
|
||||
import { RemoteMcpAccountChoice } from "./RemoteMcpAccountChoice";
|
||||
import { readConnectionIntentOAuthOutcome, type ConnectionSetupFlowProps } from "../ConnectionSetupFlow";
|
||||
import { agentsApi } from "@/api/agents";
|
||||
@@ -45,11 +46,14 @@ export function RemoteMcpProductionSetup({ providerId, connection, host = "page"
|
||||
const busy = useRef(false);
|
||||
const authorizationUrl = useRef<string | undefined>(undefined);
|
||||
const [state, setState] = useState<RemoteMcpSetupState>(() => ({
|
||||
step: connection ? "connect" : "access", grantKind: connection ? connection.credentialPolicy === "per_user" ? "user" : "organization" : requestedAgentId ? "user" : "organization",
|
||||
// PAP-659 C0: there is no Access step on the way in. The resolved default
|
||||
// is stated on the connect screen and changed in its Advanced disclosure;
|
||||
// `access` survives only as a management screen reached after connecting.
|
||||
step: "connect", grantKind: connection ? connection.credentialPolicy === "per_user" ? "user" : "organization" : requestedAgentId ? "user" : "organization",
|
||||
setupComplete: Boolean(connection && connection.status !== "draft"),
|
||||
url: typeof connection?.config?.url === "string" ? connection.config?.url : provider.defaultUrl,
|
||||
auth: connection?.config?.mcpAuthMode === "bearer" ? "bearer" : connection?.authKind === "api_key" ? "headers" : provider.supportsBrowserAuth ? "auto" : "none",
|
||||
token: "", headers: [], advanced: false, connectStatus: oauthOutcome === "denied" ? "cancelled" : oauthOutcome === "failed" ? "oauth_failed" : "idle", connected: false,
|
||||
token: "", headers: [], connectStatus: oauthOutcome === "denied" ? "cancelled" : oauthOutcome === "failed" ? "oauth_failed" : "idle", connected: false,
|
||||
identity: null, allAgents: true, agentIds: [], permissions: {}, tools: [], notice: connection?.authKind === "api_key" ? "Saved credentials are retained when these fields are left blank. Enter a replacement only to change them." : null, refreshing: false,
|
||||
...(!connection ? readAccessDraft(accessDraftKey) : {}),
|
||||
...(requestedAgentId ? { allAgents: false, agentIds: [requestedAgentId] } : {}),
|
||||
@@ -149,9 +153,14 @@ export function RemoteMcpProductionSetup({ providerId, connection, host = "page"
|
||||
popup.current = null;
|
||||
// The server retains existing permissions when reconnecting. Only a fresh setup enables everything.
|
||||
{
|
||||
const enabled = new Set(result.catalog.filter((tool) => tool.status === "active").map((tool) => tool.id));
|
||||
await toolsApi.finishApp(selectedCompanyId, result.connectionId, {
|
||||
enabledCatalogEntryIds: result.catalog.filter((tool) => tool.status === "active").map((tool) => tool.id),
|
||||
askFirstCatalogEntryIds: [], access: requestedAgentId ? { agentIds: [requestedAgentId] } : state.allAgents ? "all_agents" : { agentIds: state.agentIds },
|
||||
enabledCatalogEntryIds: [...enabled],
|
||||
// PAP-659 C6a/C7: this path used to send an empty list, so the four
|
||||
// gateway connectors were the one place the armed write gate did not
|
||||
// apply. The policy now comes from the same helper as the catalog flow.
|
||||
askFirstCatalogEntryIds: askFirstCatalogEntryIdsFor(result, (id) => enabled.has(id)),
|
||||
access: requestedAgentId ? { agentIds: [requestedAgentId] } : state.allAgents ? "all_agents" : { agentIds: state.agentIds },
|
||||
...(requestedAgentId ? { preserveExistingAccess: true } : {}),
|
||||
});
|
||||
}
|
||||
@@ -189,5 +198,5 @@ export function RemoteMcpProductionSetup({ providerId, connection, host = "page"
|
||||
if (connection && !installs.data) return <div className="space-y-3 p-8"><p>{installs.isError ? "Could not load saved access. Retry before changing this connection." : "Loading saved access…"}</p>{installs.isError && <button type="button" className="text-primary underline" onClick={() => void installs.refetch()}>Try again</button>}</div>;
|
||||
// Header Cancel abandons unsaved input, including invalid URLs. The separate
|
||||
// Save & exit action persists a resumable draft through actions.saveExit.
|
||||
return <RemoteMcpConnectionSetup onCancel={onCancel ?? (() => navigate("/apps"))} upstreamServiceName={upstreamServiceName} host={host} lockedAgentId={requestedAgentId} authorizationUrl={host === "dialog" ? authorizationUrl.current : undefined} provider={provider} connectionId={savedConnection.current?.id ?? ""} fixedGrantKind={savedConnection.current ? savedConnection.current.credentialPolicy === "per_user" ? "user" : "organization" : undefined} state={state} actions={actions} agents={agents.data ?? []} />;
|
||||
return <RemoteMcpConnectionSetup companyId={selectedCompanyId!} onCancel={onCancel ?? (() => navigate("/apps"))} upstreamServiceName={upstreamServiceName} host={host} lockedAgentId={requestedAgentId} authorizationUrl={host === "dialog" ? authorizationUrl.current : undefined} provider={provider} connectionId={savedConnection.current?.id ?? ""} fixedGrantKind={savedConnection.current ? savedConnection.current.credentialPolicy === "per_user" ? "user" : "organization" : undefined} state={state} actions={actions} agents={agents.data ?? []} />;
|
||||
}
|
||||
@@ -15,7 +15,6 @@ export interface RemoteMcpSetupState {
|
||||
auth: "auto" | "bearer" | "headers" | "none";
|
||||
token: string;
|
||||
headers: { id: string; name: string; value: string }[];
|
||||
advanced: boolean;
|
||||
connectStatus: ConnectStatus;
|
||||
connected: boolean;
|
||||
identity: string | null;
|
||||
|
||||
@@ -52,6 +52,7 @@ import { appTabHref, appTabLabel, isAppTabKey, type AppTabKey } from "./app-tabs
|
||||
import { ConnectionProvenanceChip } from "./ConnectionProvenanceChip";
|
||||
import { IdentitiesSection } from "./app-detail/IdentitiesSection";
|
||||
import { PermissionsPanel } from "./app-detail/PermissionsPanel";
|
||||
import { actionPermissionMutation } from "./app-detail/action-permissions";
|
||||
import { RailwayAccessPanel } from "./app-detail/RailwayAccessPanel";
|
||||
import { ReviewPanel } from "./app-detail/ReviewPanel";
|
||||
import {
|
||||
@@ -691,7 +692,7 @@ export function AppDetail({ renderActions, onReconnect }: {
|
||||
}
|
||||
onSaveAccess={(next) => apply({ access: connection.connectionPurpose === "ai" || managesRemoteMcpAccess ? next : accessIncludingInstalls(next, install) })}
|
||||
onRefreshActions={() => refreshTools.mutate()}
|
||||
onSetActionPermission={(id, next) => apply(actionPermissionMutation(id, next, enabledIds, askFirstIds))}
|
||||
onSetActionPermission={(ids, next) => apply(actionPermissionMutation(ids, next, enabledIds, askFirstIds))}
|
||||
onReviewQuarantined={reviewQuarantined}
|
||||
/>
|
||||
{managesRemoteMcpAccess && isRemoteMcpConnectorId(connection.config?.sourceTemplateKey) && <RemoteMcpManagement
|
||||
@@ -957,21 +958,3 @@ function galleryEntryFor(
|
||||
null;
|
||||
}
|
||||
|
||||
function actionPermissionMutation(
|
||||
id: string,
|
||||
next: "off" | "allowed" | "ask",
|
||||
enabledIds: Set<string>,
|
||||
askFirstIds: Set<string>,
|
||||
) {
|
||||
const enabled = new Set(enabledIds);
|
||||
const askFirst = new Set(askFirstIds);
|
||||
if (next === "off") {
|
||||
enabled.delete(id);
|
||||
askFirst.delete(id);
|
||||
} else {
|
||||
enabled.add(id);
|
||||
if (next === "ask") askFirst.add(id);
|
||||
else askFirst.delete(id);
|
||||
}
|
||||
return { enabled, askFirst };
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -361,10 +361,28 @@ describe("Connectors landing page", () => {
|
||||
}
|
||||
expect(container.textContent).not.toContain("Private bot");
|
||||
expect(container.textContent).not.toContain("Chat with agents");
|
||||
await act(() => void container.querySelector<HTMLButtonElement>('button[aria-label="Connect GitHub"]')!.click());
|
||||
await act(() => void container.querySelector<HTMLButtonElement>('button[aria-label="Add key GitHub"]')!.click());
|
||||
expect(navigateMock).toHaveBeenLastCalledWith("/apps/connect?source=github");
|
||||
});
|
||||
|
||||
it("names what the card will actually ask for", async () => {
|
||||
// PAP-659 C4. The verb is derived from the connector's resolved default
|
||||
// method, so it changes with what this instance can do rather than being a
|
||||
// fixed string: Notion signs in, GitHub-without-the-cloud-connector wants a
|
||||
// token, and GitHub with it signs in too.
|
||||
const github = getAppStoreDefinition("github")!;
|
||||
listGalleryMock.mockResolvedValue({
|
||||
apps: [
|
||||
getAppStoreDefinition("notion"),
|
||||
{ ...github, ownershipAvailability: { ...github.ownershipAvailability, platform_shared: true } },
|
||||
],
|
||||
});
|
||||
await renderBrowse();
|
||||
expect(container.querySelector('button[aria-label="Connect Notion"]')).not.toBeNull();
|
||||
expect(container.querySelector('button[aria-label="Connect GitHub"]')).not.toBeNull();
|
||||
expect(container.querySelector('button[aria-label="Add key GitHub"]')).toBeNull();
|
||||
});
|
||||
|
||||
it("separates tools and saved bots and hides only bots when chat connectors are disabled", async () => {
|
||||
listGalleryMock.mockResolvedValue({ apps: ["github", "github-code-review-bot"].map(getAppStoreDefinition) });
|
||||
listApplicationsMock.mockResolvedValue({ applications: [
|
||||
@@ -407,7 +425,7 @@ describe("Connectors landing page", () => {
|
||||
await renderBrowse();
|
||||
expect(container.querySelector('[data-app-slug="github"]')).not.toBeNull();
|
||||
expect(container.querySelector('[data-app-slug="github-code-review-bot"]')).not.toBeNull();
|
||||
await act(() => void container.querySelector<HTMLButtonElement>('button[aria-label="Connect GitHub"]')!.click());
|
||||
await act(() => void container.querySelector<HTMLButtonElement>('button[aria-label="Add key GitHub"]')!.click());
|
||||
expect(navigateMock).toHaveBeenLastCalledWith("/apps/connect?source=github");
|
||||
await act(() => void container.querySelector<HTMLButtonElement>('button[aria-label="Connect GitHub Code Review Bot"]')!.click());
|
||||
expect(navigateMock).toHaveBeenLastCalledWith("/apps/chat/connect?provider=github&purpose=chat");
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import { isRetiredComposioConnection, RETIRED_COMPOSIO_MESSAGE } from "@paperclipai/shared";
|
||||
import {
|
||||
connectionSetupVerbForApp,
|
||||
isRetiredComposioConnection,
|
||||
RETIRED_COMPOSIO_MESSAGE,
|
||||
} from "@paperclipai/shared";
|
||||
import { ManagedAiConnectionRow } from "@/components/ai-connections/ManagedAiConnectionDetails";
|
||||
import { useEffect, useMemo, useState, type ReactNode } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
@@ -259,7 +263,14 @@ function connectorAction(
|
||||
};
|
||||
}
|
||||
if (chatHref) return { label: "Connect", href: chatHref };
|
||||
if (row.entry) return { label: "Connect", href: connectHrefFor(row.entry) };
|
||||
// PAP-659 C4: the card's verb comes from the same four-state resolver the
|
||||
// connect screen uses, so "Connect" never turns out to mean "paste a key".
|
||||
if (row.entry) {
|
||||
return {
|
||||
label: connectionSetupVerbForApp(row.entry),
|
||||
href: connectHrefFor(row.entry),
|
||||
};
|
||||
}
|
||||
return {
|
||||
label: "Connect",
|
||||
href: applicationId ? `/apps/app/${applicationId}/permissions` : null,
|
||||
|
||||
@@ -15,7 +15,13 @@ describe("app connect policy", () => {
|
||||
expect(MCP_DIRECT_OAUTH_CONNECT_SLUGS).toEqual(expect.arrayContaining(["jira", "notion", "sentry"]));
|
||||
expect(isMcpDirectOAuthConnectSlug("notion")).toBe(true);
|
||||
expect(isMcpDirectOAuthConnectSlug("jira")).toBe(true);
|
||||
expect(isMcpDirectOAuthConnectSlug("asana")).toBe(false);
|
||||
// PAP-659 step 4: Asana's own OAuth metadata advertises registration, and
|
||||
// live discovery now outranks its pinned customer-only ownership mode, so
|
||||
// it reaches the provider directly instead of the client-ID form.
|
||||
expect(isMcpDirectOAuthConnectSlug("asana")).toBe(true);
|
||||
// Still false, and for two different reasons worth keeping apart: GitHub's
|
||||
// one-click path is Paperclip-managed rather than direct, and Slack really
|
||||
// does require a customer-registered OAuth client.
|
||||
expect(isMcpDirectOAuthConnectSlug("github")).toBe(false);
|
||||
expect(isMcpDirectOAuthConnectSlug("slack")).toBe(false);
|
||||
expect(isMcpDirectOAuthConnectSlug(null)).toBe(false);
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { flushSync } from "react-dom";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { ToolCatalogEntry } from "@paperclipai/shared";
|
||||
import { ActionsSection } from "./PermissionsPanel";
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
vi.mock("@/context/CompanyContext", () => ({
|
||||
useCompany: () => ({ selectedCompanyId: "company-1", selectedCompany: { id: "company-1", issuePrefix: "PAP" } }),
|
||||
}));
|
||||
|
||||
let container: HTMLDivElement | null = null;
|
||||
|
||||
afterEach(() => {
|
||||
container?.remove();
|
||||
container = null;
|
||||
});
|
||||
|
||||
const entry = (id: string, riskLevel: string) =>
|
||||
({ id, toolName: id, title: id, riskLevel, status: "active" }) as unknown as ToolCatalogEntry;
|
||||
|
||||
describe("Permissions group control", () => {
|
||||
it("sets every action in the group in one change", () => {
|
||||
const onSetPermission = vi.fn();
|
||||
const writes = [entry("send", "write"), entry("delete", "destructive"), entry("label", "write")];
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
const root = createRoot(container);
|
||||
flushSync(() =>
|
||||
root.render(
|
||||
<QueryClientProvider client={new QueryClient()}>
|
||||
<MemoryRouter>
|
||||
<ActionsSection
|
||||
connectionId="conn-1"
|
||||
appName="Gmail"
|
||||
readOnly={[entry("search", "read")]}
|
||||
canChange={writes}
|
||||
quarantined={[]}
|
||||
enabledIds={new Set(["search", "send", "delete", "label"])}
|
||||
askFirstIds={new Set(["delete"])}
|
||||
disabled={false}
|
||||
refreshPending={false}
|
||||
canConfigure
|
||||
onSetPermission={onSetPermission}
|
||||
onReviewQuarantined={vi.fn()}
|
||||
onRefreshActions={vi.fn()}
|
||||
/>
|
||||
</MemoryRouter>
|
||||
</QueryClientProvider>,
|
||||
),
|
||||
);
|
||||
|
||||
const select = container.querySelector<HTMLSelectElement>('select[aria-label="Set every action in Write (3)"]');
|
||||
expect(select).toBeTruthy();
|
||||
expect(select!.value).toBe("");
|
||||
flushSync(() => {
|
||||
select!.value = "ask";
|
||||
select!.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
});
|
||||
|
||||
expect(onSetPermission).toHaveBeenCalledTimes(1);
|
||||
const [ids, next] = onSetPermission.mock.calls[0]!;
|
||||
expect([...ids].sort()).toEqual(["delete", "label", "send"]);
|
||||
expect(next).toBe("ask");
|
||||
});
|
||||
});
|
||||
@@ -53,7 +53,7 @@ export function PermissionsPanel({
|
||||
connectionId: string;
|
||||
install: InstallState;
|
||||
onSaveAccess: (next: AccessDraft) => void;
|
||||
onSetActionPermission: (id: string, next: ActionPermission) => void;
|
||||
onSetActionPermission: (ids: string[], next: ActionPermission) => void;
|
||||
onReviewQuarantined: (enabledIds: string[]) => void;
|
||||
onRefreshActions: () => void;
|
||||
refreshPending: boolean;
|
||||
@@ -222,7 +222,7 @@ export function ActionsSection({
|
||||
focusId?: string | null;
|
||||
canConfigure: boolean;
|
||||
permissionChangeWarning?: string;
|
||||
onSetPermission: (id: string, next: ActionPermission) => void;
|
||||
onSetPermission: (ids: string[], next: ActionPermission) => void;
|
||||
onReviewQuarantined: (enabledIds: string[]) => void;
|
||||
onRefreshActions: () => void;
|
||||
}) {
|
||||
@@ -316,9 +316,9 @@ export function ActionsSection({
|
||||
disabled={disabled}
|
||||
focusId={focusId}
|
||||
canConfigure={canConfigure}
|
||||
onSetPermission={(id, next) => {
|
||||
onSetPermission={(ids, next) => {
|
||||
setShowPermissionChangeWarning(true);
|
||||
onSetPermission(id, next);
|
||||
onSetPermission(ids, next);
|
||||
}}
|
||||
/>
|
||||
<ActionGroup
|
||||
@@ -331,9 +331,9 @@ export function ActionsSection({
|
||||
disabled={disabled}
|
||||
focusId={focusId}
|
||||
canConfigure={canConfigure}
|
||||
onSetPermission={(id, next) => {
|
||||
onSetPermission={(ids, next) => {
|
||||
setShowPermissionChangeWarning(true);
|
||||
onSetPermission(id, next);
|
||||
onSetPermission(ids, next);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
@@ -381,12 +381,45 @@ function ActionGroup({
|
||||
disabled: boolean;
|
||||
focusId?: string | null;
|
||||
canConfigure: boolean;
|
||||
onSetPermission: (id: string, next: ActionPermission) => void;
|
||||
onSetPermission: (ids: string[], next: ActionPermission) => void;
|
||||
}) {
|
||||
if (actions.length === 0) return null;
|
||||
const groupValue = (() => {
|
||||
const first = actionPermission(actions[0]!.id, enabledIds, askFirstIds);
|
||||
return actions.every((action) => actionPermission(action.id, enabledIds, askFirstIds) === first)
|
||||
? first
|
||||
: "";
|
||||
})();
|
||||
return (
|
||||
<div>
|
||||
<h3 className="mb-1.5 text-xs font-semibold uppercase tracking-wide text-muted-foreground">{title}</h3>
|
||||
<div className="mb-1.5 flex flex-wrap items-center justify-between gap-2">
|
||||
<h3 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">{title}</h3>
|
||||
{canConfigure ? (
|
||||
// PAP-659 C6b: set the whole group at once, so narrowing a fresh
|
||||
// connection's writes is one choice rather than one per action;
|
||||
// per-row overrides stay underneath.
|
||||
<label className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<span className="sr-only">{`Set every action in ${title}`}</span>
|
||||
<select
|
||||
aria-label={`Set every action in ${title}`}
|
||||
value={groupValue}
|
||||
disabled={disabled}
|
||||
onChange={(event) => {
|
||||
// One change for the whole group: each row's setter rebuilds the
|
||||
// full permission set from the same render, so looping it lets
|
||||
// the last call overwrite the others.
|
||||
onSetPermission(actions.map((action) => action.id), event.target.value as ActionPermission);
|
||||
}}
|
||||
className="h-7 rounded-md border border-input bg-background px-2 text-xs text-foreground outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/30"
|
||||
>
|
||||
{groupValue === "" ? <option value="">Mixed</option> : null}
|
||||
{PERMISSION_OPTIONS.map((option) => (
|
||||
<option key={option.value} value={option.value}>{`Set all: ${option.label}`}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="divide-y divide-border">
|
||||
{actions.map((action) => (
|
||||
<ActionRow
|
||||
@@ -434,7 +467,7 @@ function ActionRow({
|
||||
disabled: boolean;
|
||||
focused: boolean;
|
||||
canConfigure: boolean;
|
||||
onSetPermission: (id: string, next: ActionPermission) => void;
|
||||
onSetPermission: (ids: string[], next: ActionPermission) => void;
|
||||
}) {
|
||||
const rowRef = useRef<HTMLDivElement | null>(null);
|
||||
const [testOpen, setTestOpen] = useState(false);
|
||||
@@ -455,7 +488,18 @@ function ActionRow({
|
||||
data-action-id={action.id}
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-sm font-medium text-foreground">{title}</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-sm font-medium text-foreground">{title}</span>
|
||||
{/* PAP-659 C7: the gate is only as good as the classifier, so say
|
||||
what each action was classified as. A misfiled tool is then one
|
||||
glance to spot and one click to move. */}
|
||||
<span
|
||||
className="rounded-full border border-border px-1.5 py-px text-xs font-medium uppercase tracking-wide text-muted-foreground"
|
||||
title={`Paperclip classified this action as ${action.riskLevel}`}
|
||||
>
|
||||
{action.riskLevel}
|
||||
</span>
|
||||
</div>
|
||||
{action.description ? (
|
||||
<div className="truncate text-xs text-muted-foreground">{action.description}</div>
|
||||
) : null}
|
||||
@@ -480,7 +524,7 @@ function ActionRow({
|
||||
aria-checked={selected}
|
||||
aria-label={`${title}: ${option.label}`}
|
||||
disabled={disabled}
|
||||
onClick={() => onSetPermission(action.id, option.value)}
|
||||
onClick={() => onSetPermission([action.id], option.value)}
|
||||
className={cn(
|
||||
"flex h-8 w-8 items-center justify-center rounded-sm text-muted-foreground outline-none transition-colors",
|
||||
"hover:bg-background hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring",
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { actionPermissionMutation } from "./action-permissions";
|
||||
|
||||
describe("actionPermissionMutation", () => {
|
||||
const enabled = new Set(["a", "b", "c"]);
|
||||
const askFirst = new Set(["b"]);
|
||||
|
||||
it("sets every action in a group at once", () => {
|
||||
expect(actionPermissionMutation(["a", "b", "c"], "ask", enabled, askFirst)).toEqual({
|
||||
enabled: new Set(["a", "b", "c"]),
|
||||
askFirst: new Set(["a", "b", "c"]),
|
||||
});
|
||||
expect(actionPermissionMutation(["a", "b", "c"], "off", enabled, askFirst)).toEqual({
|
||||
enabled: new Set(),
|
||||
askFirst: new Set(),
|
||||
});
|
||||
expect(actionPermissionMutation(["a", "b", "c"], "allowed", new Set(), askFirst)).toEqual({
|
||||
enabled: new Set(["a", "b", "c"]),
|
||||
askFirst: new Set(),
|
||||
});
|
||||
});
|
||||
|
||||
it("leaves actions outside the group alone", () => {
|
||||
expect(actionPermissionMutation(["a"], "off", enabled, askFirst)).toEqual({
|
||||
enabled: new Set(["b", "c"]),
|
||||
askFirst: new Set(["b"]),
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,27 @@
|
||||
/**
|
||||
* Apply one permission to a set of actions in a single change.
|
||||
*
|
||||
* Every save rewrites the connection's whole enabled/ask-first set, so a
|
||||
* group control must not issue one save per action: each would start from the
|
||||
* same render and the last would overwrite the rest.
|
||||
*/
|
||||
export function actionPermissionMutation(
|
||||
ids: string[],
|
||||
next: "off" | "allowed" | "ask",
|
||||
enabledIds: Set<string>,
|
||||
askFirstIds: Set<string>,
|
||||
) {
|
||||
const enabled = new Set(enabledIds);
|
||||
const askFirst = new Set(askFirstIds);
|
||||
for (const id of ids) {
|
||||
if (next === "off") {
|
||||
enabled.delete(id);
|
||||
askFirst.delete(id);
|
||||
} else {
|
||||
enabled.add(id);
|
||||
if (next === "ask") askFirst.add(id);
|
||||
else askFirst.delete(id);
|
||||
}
|
||||
}
|
||||
return { enabled, askFirst };
|
||||
}
|
||||
@@ -86,7 +86,7 @@ export function initialReviewState(provider: RemoteMcpProviderId, scenario: Revi
|
||||
url: ["initial", "journey", "connect", "selected_agents"].includes(scenario) ? config.defaultUrl : scenario === "invalid_url" ? "not-a-server-url" : exampleUrl(provider),
|
||||
auth: scenario === "advanced" ? provider === "composio" ? "headers" : "bearer" : config.supportsBrowserAuth ? "auto" : "none",
|
||||
token: "", headers: scenario === "advanced" ? [{ id: "header-1", name: provider === "arcade" ? "Arcade-User-ID" : "X-Session-Key", value: "" }] : [],
|
||||
advanced: scenario === "advanced", connectStatus: connectCases[scenario] ?? "idle",
|
||||
connectStatus: connectCases[scenario] ?? "idle",
|
||||
connected: !access && !isConnect, identity: provider === "zapier" ? null : "reviewer@example.invalid",
|
||||
allAgents: scenario !== "selected_agents", agentIds: access && scenario !== "selected_agents" ? [] : ["researcher", "operator"],
|
||||
permissions: Object.fromEntries(tools.map((tool, index) => [tool.id, scenario !== "new_tools" || tool.id === newFixtureTool.id ? "allowed" : index === 1 ? "ask_first" : index === 2 ? "off" : "allowed"])),
|
||||
|
||||
@@ -123,8 +123,8 @@ export function RemoteMcpConnectionReview({ provider, scenario = "journey", inli
|
||||
<div className="border-b border-border bg-muted px-4 py-3 text-sm" role="note"><strong>Design review · {remoteMcpProviders[provider].name}</strong><span className="text-muted-foreground"> — Example accounts and tools. No real sign-in, calls, or credential storage. Use fake values only.</span></div>
|
||||
{inline ? <Dialog defaultOpen><DialogContent className="max-h-(--sz-85vh) overflow-y-auto sm:max-w-3xl" showCloseButton={false} aria-describedby={undefined}>
|
||||
<DialogTitle className="sr-only">Connect {remoteMcpProviders[provider].name}</DialogTitle>
|
||||
<RemoteMcpConnectionSetup upstreamServiceName={upstreamServiceName} host="dialog" lockedAgentId="researcher" connectionId={connectionId} provider={remoteMcpProviders[provider]} state={{ ...s, allAgents: false, agentIds: ["researcher"] }} actions={actions} agents={reviewAgents} />
|
||||
</DialogContent></Dialog> : <RemoteMcpConnectionSetup upstreamServiceName={upstreamServiceName} connectionId={connectionId} provider={remoteMcpProviders[provider]} state={s} actions={actions} agents={reviewAgents} />}
|
||||
<RemoteMcpConnectionSetup companyId="" upstreamServiceName={upstreamServiceName} host="dialog" lockedAgentId="researcher" connectionId={connectionId} provider={remoteMcpProviders[provider]} state={{ ...s, allAgents: false, agentIds: ["researcher"] }} actions={actions} agents={reviewAgents} />
|
||||
</DialogContent></Dialog> : <RemoteMcpConnectionSetup companyId="" upstreamServiceName={upstreamServiceName} connectionId={connectionId} provider={remoteMcpProviders[provider]} state={s} actions={actions} agents={reviewAgents} />}
|
||||
<aside aria-label="Storybook simulation" className="mx-auto max-w-6xl space-y-4 border-t border-border p-4 sm:p-8">
|
||||
<p className="text-xs font-semibold text-muted-foreground">STORYBOOK SIMULATION</p>
|
||||
<div className="flex flex-wrap items-end gap-4 text-sm">
|
||||
|
||||
@@ -70,7 +70,7 @@ function Permissions() {
|
||||
const [enabled, setEnabled] = useState(new Set(["transcripts", "summary"]));
|
||||
const [ask, setAsk] = useState(new Set(["share"]));
|
||||
const entries = [["transcripts", "List meeting transcripts", false], ["summary", "Get meeting summary and action items", false], ["transcript", "Get full transcript", false], ["share", "Share a meeting", true], ["revoke", "Revoke meeting access", true]].map(([id, title, write]) => ({ id, toolName: id, title, description: title, connectionId: "storybook-fireflies", entryKind: "tool", status: "active", isReadOnly: !write, isWrite: write, isDestructive: id === "revoke", riskLevel: write ? "medium" : "low" } as ToolCatalogEntry));
|
||||
return <Frame><ActionsSection connectionId="storybook-fireflies" appName="Fireflies" readOnly={entries.filter((e) => e.isReadOnly)} canChange={entries.filter((e) => e.isWrite)} quarantined={[]} enabledIds={enabled} askFirstIds={ask} disabled={false} refreshPending={false} canConfigure onSetPermission={(id, next) => { setEnabled((s) => { const n = new Set(s); if (next === "allowed") n.add(id); else n.delete(id); return n; }); setAsk((s) => { const n = new Set(s); if (next === "ask") n.add(id); else n.delete(id); return n; }); }} onReviewQuarantined={noop} onRefreshActions={noop} /></Frame>;
|
||||
return <Frame><ActionsSection connectionId="storybook-fireflies" appName="Fireflies" readOnly={entries.filter((e) => e.isReadOnly)} canChange={entries.filter((e) => e.isWrite)} quarantined={[]} enabledIds={enabled} askFirstIds={ask} disabled={false} refreshPending={false} canConfigure onSetPermission={(ids, next) => { setEnabled((s) => { const n = new Set(s); for (const id of ids) { if (next === "allowed") n.add(id); else n.delete(id); } return n; }); setAsk((s) => { const n = new Set(s); for (const id of ids) { if (next === "ask") n.add(id); else n.delete(id); } return n; }); }} onReviewQuarantined={noop} onRefreshActions={noop} /></Frame>;
|
||||
}
|
||||
export const RetainedPermissions: Story = { name: "08 · Permissions — retained Allowed, Ask first, Off", render: () => <Permissions /> };
|
||||
|
||||
|
||||
Reference in new issue
Block a user