mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-25 02:26:12 +02:00
feat(apps): add experimental MCP aggregator connectors (#13755)
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Its tool gateway applies company access rules and approval controls to connected apps. > - MCP aggregators expose many apps through one provider endpoint. > - Each aggregator needs its own credential, catalog, grants, and lifecycle in Paperclip. > - This pull request adds independent Zapier, Arcade, Composio Connect, and Executor setup with a common Access → Connect layout. > - A default-off MCP aggregators flag lets operators opt in while we complete provider acceptance tests. > - Agents use the normal Paperclip permissions, Test screen, and gateway after setup. ## Linked Issues or Issue Description **Subsystem affected** Apps, connection setup, shared contracts, and the remote MCP gateway. **Problem or motivation** Aggregator endpoints need clear provider setup and correct MCP sessions. Generic setup does not explain each provider's authentication or broad execution tools. Provider approval must preserve the original execution instead of replaying a write. **Proposed solution** Add four separate connectors behind Settings → Experimental → MCP aggregators. Start with human and agent access, then connect the endpoint and read its tools. Enable tools by default. Use the existing Permissions and Test screens after setup. Keep legacy Composio API-key and child connections intact. **Alternatives considered** A shared connection for all providers would mix credentials and access rules. Separate provider-specific permission and test screens would duplicate existing controls. Vercel Connect is outside this change. **Roadmap alignment** Extends the existing MCP Tool Gateway & Apps capability and the Connected Apps roadmap area. This work was requested and reviewed by the maintainer. Related work: #11894, #12630, #12632, #12634, and #12906 concern the legacy Composio broker. #13102 also covers remote MCP pagination. This change preserves the broker path and adds initialized sessions, response matching, and provider resume handling alongside pagination. ## What Changed - Add branded setup and interactive Storybooks for Zapier, Arcade, Composio Connect, and Executor. Use the existing access controls and normal action tests. Do not request a connection name or action choices during setup. - Add the default-off `enableMcpAggregators` flag to settings, managed feature metadata, the catalog, and setup guards. Hidden connections keep running. Legacy Composio connections remain unchanged. - Reuse the vault, grants, policy, and catalog models. Support OAuth discovery, bearer tokens, custom headers, and credential-bearing URLs. Add no database tables or migrations. - Initialize and retain Streamable HTTP sessions by connection and effective credentials. Read paginated catalogs and match streaming responses to request IDs. - Classify unfamiliar aggregator tools as writes despite upstream read-only hints; only exact reviewed read capabilities enter the read-only allowlist. Legacy Composio child behavior is preserved. - Preserve provider authorization links and execution IDs. Support Executor approve/resume, decline, and cancel without automatic replay of uncertain writes. - Preserve Off and Ask first choices during refresh and reconnect. Allow new tools and retire removed tools. Keep agent access updates atomic and preserve an empty agent selection. - Document connector UX rules, provider branding sources, and live acceptance results. - Stabilize the existing Sentry release fixture after its repeated CI failure by reusing one module mock; production Sentry behavior is unchanged. ## Verification - Final head `d11781970`: [CI run](https://github.com/paperclipai/paperclip/actions/runs/35633534900) passed, including broad typecheck, test shards, build, and E2E. All 54 checks pass; 2 optional checks are skipped. Greptile is 5/5, Security Scan passes, and all review threads are resolved. - Passed 27 focused connector Vitest checks and 18 connector-only Storybook browser checks before the flag change. All 85 stories rendered at desktop and narrow widths. - Passed 5 connector lifecycle/server checks and 7 selected flag checks after adding the flag. The latter cover settings, managed defaults, cached catalog visibility, and all four setup routes. - Review fixes passed 13 risk/handoff/lifecycle checks, dedicated session-expiration and transport regressions, 13 selected connector/gateway CI cases, and 10 selected setup/reconnect UI cases. A real Composio connection-list call also succeeded through the refreshed UI on `9ab115f71`. - UI and server TypeScript checks passed. UI build, Storybook build, token gates, and diff whitespace checks passed during implementation. - Real browser and real Paperclip agent tests passed for Arcade, Composio, and Executor. Tested action permissions, denied agent access, reconnect, disconnect, and isolation. Tested Arcade catalog additions/removal and Executor provider approve/resume, decline, and cancel. - Zapier live acceptance is incomplete. Its dedicated provider server is configured, but its credential-copy dialog returned an empty clipboard through browser automation. No live Zapier action is claimed. - The three isolated Sentry release cases pass after the CI fixture fix. - Local verification is deliberately narrow at the maintainer's request. The full local suite, recursive typecheck, and repository-wide build were not run. CI provides the broader checks. ## Risks - Shared MCP transport changes affect other remote MCP servers. Protocol fixtures cover initialized sessions, streaming response matching, pagination, and isolation. - Broad execution tools remain broad permissions. The provider governs actions inside those tools. - Provider handoff links are retained briefly in memory. After a server restart, a one-time link may require reopening the provider dashboard. Paperclip does not replay the original call. - Zapier remains unproven live. Custom-header imports and self-hosted endpoints have fixture coverage rather than a separate live account for every variant. - Turning the experimental flag off hides setup; it does not revoke existing credentials or stop existing connections. ## Model Used OpenAI GPT-6 through Codex, with reasoning, repository tools, shell execution, and browser automation. The exact runtime model ID and context-window size are not exposed in this session. A separate Anthropic-backed Paperclip agent performed live gateway acceptance tasks. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
3790ca2f13
commit
e8c8ba3c19
74 files changed
+2988
-240
No files matched your search
@@ -0,0 +1,42 @@
|
||||
// @vitest-environment jsdom
|
||||
import { act, useState } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { JsonSchemaForm, validateJsonSchemaForm, type JsonSchemaNode } from "./JsonSchemaForm";
|
||||
vi.mock("./SecretBindingPicker", () => ({ SecretBindingPicker: () => null }));
|
||||
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
|
||||
const schema: JsonSchemaNode = { type: "object", properties: { tools: { type: "array", items: { type: "object", properties: { arguments: { type: "object", additionalProperties: true } } } } } };
|
||||
let dispose: (() => void) | undefined;
|
||||
afterEach(async () => { await act(async () => dispose?.()); document.body.innerHTML = ""; });
|
||||
describe("remote MCP open-ended argument inputs", () => {
|
||||
it("edits nested tool arguments and rejects malformed JSON instead of submitting the previous object", async () => {
|
||||
const container = document.createElement("div"); document.body.append(container);
|
||||
const root = createRoot(container); dispose = () => root.unmount();
|
||||
let actual: Record<string, unknown> = {};
|
||||
function Form() {
|
||||
const [values, setValues] = useState({ tools: [{ arguments: {} }] } as Record<string, unknown>);
|
||||
actual = values;
|
||||
return <JsonSchemaForm schema={schema} values={values} onChange={setValues} />;
|
||||
}
|
||||
await act(async () => root.render(<Form />));
|
||||
const input = container.querySelector<HTMLTextAreaElement>('textarea[aria-label="Arguments JSON"]')!;
|
||||
expect(input).not.toBeNull();
|
||||
const fill = async (value: string) => act(async () => {
|
||||
Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, "value")!.set!.call(input, value);
|
||||
input.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
});
|
||||
await fill('{"repoName":"paperclipai/paperclip"}');
|
||||
expect(actual).toEqual({ tools: [{ arguments: { repoName: "paperclipai/paperclip" } }] });
|
||||
expect(validateJsonSchemaForm(schema, actual)).toEqual({});
|
||||
await fill('{"repoName":');
|
||||
expect(validateJsonSchemaForm(schema, actual)).toEqual({ "/tools/0/arguments": "Enter a valid JSON object" });
|
||||
await fill("[]");
|
||||
expect(validateJsonSchemaForm(schema, actual)["/tools/0/arguments"]).toBeTruthy();
|
||||
await fill("{}");
|
||||
expect(validateJsonSchemaForm(schema, actual)).toEqual({});
|
||||
});
|
||||
it("does not permit JSON null or array values for a required object", () => {
|
||||
const args: JsonSchemaNode = { type: "object", required: ["arguments"], properties: { arguments: { type: "object" } } };
|
||||
for (const value of [null, [], "broken"]) expect(Object.keys(validateJsonSchemaForm(args, { arguments: value }))).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
@@ -197,6 +197,9 @@ export function validateField(
|
||||
if (type === "secret-ref" && typeof value === "object") {
|
||||
return "Invalid secret reference";
|
||||
}
|
||||
if (type === "object" && (typeof value !== "object" || Array.isArray(value))) {
|
||||
return "Enter a valid JSON object";
|
||||
}
|
||||
|
||||
if (type === "string" || type === "secret-ref") {
|
||||
const str = String(value);
|
||||
@@ -524,7 +527,7 @@ const EnumField = React.memo(({
|
||||
onValueChange={handleChange}
|
||||
disabled={disabled}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectTrigger className="w-full" aria-label={label} aria-required={isRequired}>
|
||||
<SelectValue placeholder="Select an option" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
@@ -620,6 +623,8 @@ const SecretField = React.memo(({
|
||||
<div className="relative">
|
||||
{isVisible ? (
|
||||
<Textarea
|
||||
aria-label={label}
|
||||
aria-required={isRequired}
|
||||
value={stringValue}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={String(defaultValue ?? "")}
|
||||
@@ -629,6 +634,8 @@ const SecretField = React.memo(({
|
||||
/>
|
||||
) : (
|
||||
<Textarea
|
||||
aria-label={label}
|
||||
aria-required={isRequired}
|
||||
// Render a placeholder summary instead of the secret content while
|
||||
// hidden. This avoids exposing multi-line secrets (e.g. SSH
|
||||
// private keys) on screen-shares; clicking the eye toggle reveals
|
||||
@@ -666,6 +673,8 @@ const SecretField = React.memo(({
|
||||
) : (
|
||||
<div className="relative">
|
||||
<Input
|
||||
aria-label={label}
|
||||
aria-required={isRequired}
|
||||
type={isVisible ? "text" : "password"}
|
||||
value={stringValue}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
@@ -795,6 +804,8 @@ const NumberField = React.memo(({
|
||||
disabled={disabled}
|
||||
>
|
||||
<Input
|
||||
aria-label={label}
|
||||
aria-required={isRequired}
|
||||
type="number"
|
||||
step={type === "integer" ? "1" : "any"}
|
||||
min={minimum}
|
||||
@@ -860,6 +871,8 @@ const StringField = React.memo(({
|
||||
>
|
||||
{isTextArea ? (
|
||||
<Textarea
|
||||
aria-label={label}
|
||||
aria-required={isRequired}
|
||||
value={String(value ?? "")}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={String(defaultValue ?? "")}
|
||||
@@ -869,6 +882,8 @@ const StringField = React.memo(({
|
||||
/>
|
||||
) : (
|
||||
<Input
|
||||
aria-label={label}
|
||||
aria-required={isRequired}
|
||||
type="text"
|
||||
value={String(value ?? "")}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
@@ -999,6 +1014,34 @@ const ArrayField = React.memo(({
|
||||
|
||||
ArrayField.displayName = "ArrayField";
|
||||
|
||||
/** MCP execution tools often accept an arbitrary object rather than fixed fields. */
|
||||
function JsonObjectField({ value, onChange, disabled, label, error }: {
|
||||
value: unknown; onChange: (value: unknown) => void; disabled: boolean; label: string; error?: string;
|
||||
}) {
|
||||
const format = (next: unknown) => typeof next === "string" ? next : JSON.stringify(next ?? {}, null, 2);
|
||||
const [text, setText] = useState(() => format(value));
|
||||
const emitted = useRef(value);
|
||||
useEffect(() => {
|
||||
if (value !== emitted.current) setText(format(value));
|
||||
emitted.current = value;
|
||||
}, [value]);
|
||||
const change = (text: string) => {
|
||||
setText(text);
|
||||
let next: unknown = text.trim() ? text : undefined;
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(text);
|
||||
if (parsed !== null && typeof parsed === "object" && !Array.isArray(parsed)) next = parsed;
|
||||
} catch { /* Preserve invalid input so validation prevents submitting stale arguments. */ }
|
||||
emitted.current = next;
|
||||
onChange(next);
|
||||
};
|
||||
return <div className="space-y-2">
|
||||
<Textarea aria-label={`${label} JSON`} aria-invalid={!!error} value={text} onChange={(event) => change(event.target.value)} disabled={disabled} rows={5} className="font-mono text-sm" />
|
||||
<p className="text-xs text-muted-foreground">Enter a JSON object using the action's argument names.</p>
|
||||
{error && <p role="alert" className="text-xs text-destructive">{error}</p>}
|
||||
</div>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Specialized field for object values, handling recursive rendering of nested properties.
|
||||
*/
|
||||
@@ -1050,7 +1093,9 @@ const ObjectField = React.memo(({
|
||||
|
||||
{!isCollapsed && (
|
||||
<div className="pt-2">
|
||||
<JsonSchemaForm
|
||||
{Object.keys(propSchema.properties ?? {}).length === 0 && propSchema.additionalProperties !== false
|
||||
? <JsonObjectField value={value} onChange={onChange} disabled={disabled} label={label} error={errors[path]} />
|
||||
: <JsonSchemaForm
|
||||
schema={propSchema}
|
||||
values={(value as Record<string, unknown>) ?? {}}
|
||||
onChange={handleObjectChange}
|
||||
@@ -1060,7 +1105,7 @@ const ObjectField = React.memo(({
|
||||
.filter(([errPath]) => errPath.startsWith(`${path}/`))
|
||||
.map(([errPath, err]) => [errPath.replace(path, ""), err]),
|
||||
)}
|
||||
/>
|
||||
/>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -89,9 +89,9 @@ export function SetupWizardSidebarOutlet({ children }: { children: ReactNode })
|
||||
}
|
||||
|
||||
/** Each step owns one footer row; secondary actions stay with the primary action. */
|
||||
export function SetupWizardFooter({ onSaveExit, children }: { onSaveExit: () => void; children: ReactNode }) {
|
||||
export function SetupWizardFooter({ onSaveExit, children, disabled = false }: { onSaveExit: () => void; children: ReactNode; disabled?: boolean }) {
|
||||
return <div className="flex flex-wrap items-center justify-between gap-3 pt-2">
|
||||
<Button variant="ghost" className="text-muted-foreground" onClick={onSaveExit}>Save & exit</Button>
|
||||
<Button type="button" variant="ghost" className="text-muted-foreground" onClick={onSaveExit} disabled={disabled}>Save & exit</Button>
|
||||
<div className="ml-auto flex flex-wrap items-center gap-2">{children}</div>
|
||||
</div>;
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { AiConnectionCredentialStep } from "@/components/ai-connections/AiConnectionCredentialStep";
|
||||
import { ConnectionChoiceList } from "./ConnectionChoiceList";
|
||||
import { useCallback, useEffect, useId, useMemo, useRef, useState, type ReactNode } from "react";
|
||||
import { useCallback, useEffect, useId, useMemo, useRef, useState, type ReactNode, type Ref } from "react";
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
ArrowUpRight,
|
||||
@@ -19,7 +19,6 @@ import {
|
||||
UsersRound,
|
||||
} from "lucide-react";
|
||||
import type {
|
||||
Agent,
|
||||
AppDefinition,
|
||||
ConnectionGrantKind,
|
||||
ConnectionIntentSetupConnection,
|
||||
@@ -35,6 +34,7 @@ import type {
|
||||
} from "@paperclipai/shared";
|
||||
import {
|
||||
aiConnectionMetadataSchema,
|
||||
isRemoteMcpConnectorId,
|
||||
connectionMethodAcceptsCustomerOAuthClient,
|
||||
connectionMethodRequiresConfiguration,
|
||||
connectionMethodSupportsAutomaticOAuth,
|
||||
@@ -56,7 +56,7 @@ import { ApiError } from "@/api/client";
|
||||
import { toolsApi } from "@/api/tools";
|
||||
import { agentsApi } from "@/api/agents";
|
||||
import { appCopyFor, credentialFieldLabel } from "@/lib/app-gallery-copy";
|
||||
import { AgentMultiSelect } from "@/components/AgentMultiSelect";
|
||||
import { AgentMultiSelect, type AgentMultiSelectOption } from "@/components/AgentMultiSelect";
|
||||
import { InlineBanner } from "@/components/InlineBanner";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
|
||||
@@ -850,6 +850,10 @@ export function ConnectionSetupFlow({
|
||||
* definition — the generic path stays available either way.
|
||||
*/
|
||||
const useMatchedGalleryEntry = (picked: AppDefinition) => {
|
||||
if (host === "page" && !connectionIntentId && credentialSource === "paperclip_vault" && isRemoteMcpConnectorId(picked.slug)) {
|
||||
navigate(`/apps/connect?source=${picked.slug}`);
|
||||
return;
|
||||
}
|
||||
if (picked.slug === "zapier") {
|
||||
setEntry(null);
|
||||
setGalleryName("");
|
||||
@@ -2429,7 +2433,9 @@ export function ConnectionSetupFlow({
|
||||
);
|
||||
}
|
||||
|
||||
function StepHeader({
|
||||
export function StepHeader({
|
||||
title,
|
||||
headingRef,
|
||||
subtitle,
|
||||
step,
|
||||
activeIndex,
|
||||
@@ -2438,6 +2444,8 @@ function StepHeader({
|
||||
unverifiedHost,
|
||||
onCancel,
|
||||
}: {
|
||||
title?: string;
|
||||
headingRef?: Ref<HTMLHeadingElement>;
|
||||
subtitle: string;
|
||||
step: Step;
|
||||
activeIndex: number;
|
||||
@@ -2449,7 +2457,7 @@ function StepHeader({
|
||||
* just on the screen where they pasted the address.
|
||||
*/
|
||||
unverifiedHost?: string | null;
|
||||
onCancel: () => void;
|
||||
onCancel?: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="mb-6">
|
||||
@@ -2459,16 +2467,16 @@ function StepHeader({
|
||||
<AppLogo name={appIdentity.name} logoUrl={appIdentity.logoUrl} darkLogoUrl={appIdentity.darkLogoUrl} size={44} />
|
||||
) : null}
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">
|
||||
{appIdentity ? `Connect ${appIdentity.name}` : "Connect your own MCP server"}
|
||||
<h1 ref={headingRef} tabIndex={headingRef ? -1 : undefined} className="text-2xl font-bold tracking-tight outline-none">
|
||||
{title ?? (appIdentity ? `Connect ${appIdentity.name}` : "Connect your own MCP server")}
|
||||
</h1>
|
||||
<p className="mt-1 text-sm text-muted-foreground">{subtitle}</p>
|
||||
{unverifiedHost ? <UnverifiedServerBadge host={unverifiedHost} className="mt-2" /> : null}
|
||||
</div>
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" onClick={onCancel}>
|
||||
{onCancel && <Button variant="ghost" size="sm" onClick={onCancel}>
|
||||
Cancel
|
||||
</Button>
|
||||
</Button>}
|
||||
</div>
|
||||
{step !== "gallery" && (
|
||||
// A landmark with stable hooks, so the step model can be read without
|
||||
@@ -3886,8 +3894,18 @@ function MethodConfigField({
|
||||
* so the reader understands the identity and the reach the secret is about to
|
||||
* get. Hick's Law: two choices, not a matrix. Both use full-row radio targets.
|
||||
*/
|
||||
export function AccessStep({
|
||||
companyId,
|
||||
export function AccessStep({ companyId, ...props }: Omit<Parameters<typeof AccessStepContent>[0], "agents" | "agentsLoading"> & { companyId: string }) {
|
||||
const agentsQuery = useQuery({
|
||||
queryKey: queryKeys.agents.list(companyId),
|
||||
queryFn: () => agentsApi.list(companyId),
|
||||
});
|
||||
return <AccessStepContent {...props} agents={(agentsQuery.data ?? []).filter((agent) => agent.status !== "terminated")} agentsLoading={agentsQuery.isLoading} />;
|
||||
}
|
||||
|
||||
/** Shared Gmail access presentation; callers supply agents so review stories stay offline. */
|
||||
export function AccessStepContent({
|
||||
agents: allAgents,
|
||||
agentsLoading = false,
|
||||
authKind,
|
||||
grantKinds,
|
||||
grantKind,
|
||||
@@ -3907,7 +3925,8 @@ export function AccessStep({
|
||||
onBack,
|
||||
onContinue,
|
||||
}: {
|
||||
companyId: string;
|
||||
agents: AgentMultiSelectOption[];
|
||||
agentsLoading?: boolean;
|
||||
authKind: ToolConnectionAuthKind;
|
||||
grantKinds?: ConnectionGrantKind[];
|
||||
grantKind: ConnectionGrantKind;
|
||||
@@ -3935,11 +3954,6 @@ export function AccessStep({
|
||||
onBack: () => void;
|
||||
onContinue: () => void;
|
||||
}) {
|
||||
const agentsQuery = useQuery({
|
||||
queryKey: queryKeys.agents.list(companyId),
|
||||
queryFn: () => agentsApi.list(companyId),
|
||||
});
|
||||
const allAgents: Agent[] = (agentsQuery.data ?? []).filter((a) => a.status !== "terminated");
|
||||
// "Only agents I choose" / "Just agents I pick" means agents this person may actually edit. When the server
|
||||
// has not told us, fall back to every live agent rather than an empty list —
|
||||
// an empty picker would read as "you have no agents".
|
||||
@@ -4144,7 +4158,7 @@ export function AccessStep({
|
||||
onChange={(next) => setInstallAgentIds(
|
||||
grantKind === "agent" && next.size > 1 ? new Set([[...next].at(-1)!]) : next,
|
||||
)}
|
||||
loading={agentsQuery.isLoading}
|
||||
loading={agentsLoading}
|
||||
emptyMessage="You cannot edit any agents yet."
|
||||
showSelectionPreview={false}
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
import { useEffect, useId, useRef, useState, type ReactNode } from "react";
|
||||
import { CheckCircle2, ExternalLink, HelpCircle, Loader2, Plus, Trash2 } from "lucide-react";
|
||||
import { InlineBanner } from "@/components/InlineBanner";
|
||||
import { ActionsSection } from "@/pages/apps/app-detail/PermissionsPanel";
|
||||
import { SetupWizardFooter } from "@/components/SetupWizard";
|
||||
import { Button } from "@/components/ui/button";
|
||||
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 type { RemoteMcpProvider } from "./providers";
|
||||
import type { RemoteMcpSetupActions, RemoteMcpSetupState } from "./types";
|
||||
|
||||
const steps = ["access", "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 }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
return <Tooltip open={open} onOpenChange={setOpen}>
|
||||
<TooltipTrigger asChild><button type="button" aria-label={`Help with ${label}`} onClick={() => setOpen(!open)} className="rounded-sm text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"><HelpCircle className="size-4" /></button></TooltipTrigger>
|
||||
<TooltipContent className="max-w-xs">{children}</TooltipContent>
|
||||
</Tooltip>;
|
||||
}
|
||||
|
||||
/** 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 }: {
|
||||
provider: RemoteMcpProvider;
|
||||
connectionId: string;
|
||||
state: RemoteMcpSetupState;
|
||||
actions: RemoteMcpSetupActions;
|
||||
agents: { id: string; name: string }[];
|
||||
}) {
|
||||
const uid = useId();
|
||||
const heading = useRef<HTMLHeadingElement>(null);
|
||||
const previousStep = useRef(s.step);
|
||||
useEffect(() => {
|
||||
if (previousStep.current !== s.step) heading.current?.focus();
|
||||
previousStep.current = s.step;
|
||||
}, [s.step]);
|
||||
const currentStep = steps.indexOf(s.step as typeof steps[number]);
|
||||
const busy = s.connectStatus === "connecting";
|
||||
const change = (patch: Partial<RemoteMcpSetupState>) => a.edit(patch);
|
||||
const external = (purpose: Parameters<typeof a.openProvider>[0], text: string) => <Button type="button" variant="link" className="h-auto p-0 text-sm text-current underline" onClick={() => a.openProvider(purpose)}>{text}<ExternalLink className="size-3.5" aria-hidden="true" /></Button>;
|
||||
const boundary = <InlineBanner compact>
|
||||
Paperclip controls access to the tools listed here. App and action permissions inside these tools are managed in {external("manage", provider.name)}.
|
||||
</InlineBanner>;
|
||||
const footer = (children: ReactNode) => <SetupWizardFooter onSaveExit={a.saveExit} disabled={busy}>{children}</SetupWizardFooter>;
|
||||
|
||||
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 === "rejected" ? { title: "Credentials were rejected", body: `Check or replace the credentials from ${provider.name}, then reconnect. Your agent access and tool choices are preserved.` }
|
||||
: s.connectStatus === "unreachable" ? { title: "Paperclip could not reach this server", body: "Check that the endpoint is running and reachable from Paperclip, then try again. Your draft is still here." }
|
||||
: null;
|
||||
|
||||
return <div className="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={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 : a.saveExit} />
|
||||
<main className="space-y-6">
|
||||
{s.notice && <p role="status" className="text-sm text-muted-foreground">{s.notice}</p>}
|
||||
|
||||
{s.step === "access" && <AccessStepContent agents={agents} authKind="oauth" 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] })}
|
||||
submitLabel={s.setupComplete ? "Done" : "Continue"} onBack={s.setupComplete ? a.finish : a.saveExit} onContinue={s.setupComplete ? a.finish : () => a.navigate("connect")} />}
|
||||
{s.step === "permissions" && <>
|
||||
<div className="flex items-center justify-between gap-3"><h2 className="text-sm font-semibold">Permissions</h2><Button variant="outline" onClick={a.finish}>Connection settings</Button></div>
|
||||
{s.tools.some((entry) => entry.broad) && boundary}
|
||||
<ActionsSection connectionId={connectionId} appName={provider.name}
|
||||
readOnly={s.tools.filter((entry) => entry.isReadOnly)} canChange={s.tools.filter((entry) => !entry.isReadOnly)} quarantined={[]}
|
||||
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 } })}
|
||||
onReviewQuarantined={() => {}} onRefreshActions={a.refresh} />
|
||||
</>}
|
||||
<div className="mx-auto max-w-2xl space-y-6">
|
||||
{s.step === "connect" && <>
|
||||
<div className="space-y-3">
|
||||
<ol className="list-decimal space-y-2 pl-5 text-sm">{provider.instructions.map((instruction) => <li key={instruction}>{instruction}</li>)}</ol>
|
||||
{external("setup", `Open ${provider.name} setup guide`)}
|
||||
</div>
|
||||
{s.connectStatus === "sign_in" && provider.supportsBrowserAuth ? <>
|
||||
<div role="status"><InlineBanner title={`Finish signing in to ${provider.name}`}>
|
||||
Complete sign-in in the provider window, then return here. Paperclip is waiting for confirmation.
|
||||
</InlineBanner></div>
|
||||
<p className="text-sm text-muted-foreground">If a window did not open, {external("sign_in", "open sign-in again")}.</p>
|
||||
{footer(<><Button variant="outline" onClick={a.cancelConnect}>Cancel sign-in</Button><Button disabled>Waiting for sign-in</Button></>)}
|
||||
</> : <form className="space-y-6" onSubmit={(event) => { event.preventDefault(); a.connect(); }}>
|
||||
{error && <div role="alert"><InlineBanner tone="danger" title={error.title}>{error.body}</InlineBanner></div>}
|
||||
{s.connectStatus === "cancelled" && <p role="status" className="text-sm text-muted-foreground">Connection cancelled. Your setup details are preserved; try again when you are ready.</p>}
|
||||
<fieldset disabled={busy} className="min-w-0 space-y-5">
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2"><Label htmlFor={`${uid}-url`}>MCP server URL</Label><FieldHelp label="MCP server URL">{provider.urlHelp}</FieldHelp></div>
|
||||
<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">
|
||||
<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"] })}>
|
||||
{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>}
|
||||
{(s.auth === "bearer" || s.auth === "headers") && <div className="space-y-3">
|
||||
<p className="text-sm font-medium">{s.auth === "bearer" ? "Additional headers" : "Headers"}</p>
|
||||
{s.headers.map((header, index) => <div key={header.id} className="flex flex-wrap items-end gap-2">
|
||||
<div className="min-w-0 flex-1 space-y-2"><Label htmlFor={`${uid}-${header.id}-name`}>Header {index + 1} name</Label><Input id={`${uid}-${header.id}-name`} value={header.name} placeholder={provider.id === "arcade" ? "Arcade-User-ID" : "Header name"} onChange={(event) => change({ headers: s.headers.map((h) => h.id === header.id ? { ...h, name: event.target.value } : h) })} /></div>
|
||||
<div className="min-w-0 flex-1 space-y-2"><Label htmlFor={`${uid}-${header.id}-value`}>Header {index + 1} value</Label><Input id={`${uid}-${header.id}-value`} type="password" autoComplete="off" value={header.value} onChange={(event) => change({ headers: s.headers.map((h) => h.id === header.id ? { ...h, value: event.target.value } : h) })} /></div>
|
||||
<Button type="button" variant="ghost" size="icon" aria-label={`Remove header ${index + 1}`} onClick={() => change({ headers: s.headers.filter((h) => h.id !== header.id) })}><Trash2 className="size-4" /></Button>
|
||||
</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>
|
||||
</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></>)}
|
||||
</form>}
|
||||
</>}
|
||||
|
||||
{s.step === "management" && <>
|
||||
{!s.connected ? <InlineBanner tone="warning" title="Disconnected">Agents cannot use this connection. Reconnect to restore access under the saved permission choices.</InlineBanner> : <div className="space-y-2"><p className="flex items-center gap-2 text-sm"><CheckCircle2 className="size-4" />Connected{s.identity ? ` as ${s.identity}` : ""}</p><p className="text-sm text-muted-foreground">{s.grantKind === "user" ? "Just me" : "Any human in the organization"} · {s.tools.length} tools · {s.allAgents ? "Any agent" : `${s.agentIds.length} agents with access`}</p></div>}
|
||||
<div className="flex flex-wrap gap-2"><Button onClick={() => a.navigate("access")}>Who can use this connection</Button><Button variant="outline" disabled={!s.connected} onClick={() => a.navigate("permissions")}>Permissions</Button></div>
|
||||
<p className="text-sm text-muted-foreground">Refresh the catalog after changing tools in {provider.name}. Existing Off and Ask first choices are preserved.</p>
|
||||
<Button variant="outline" disabled={!s.connected || s.refreshing} onClick={a.refresh}>{s.refreshing ? "Refreshing…" : "Refresh tools"}</Button>
|
||||
<RemoteMcpManagement providerName={provider.name} connected={s.connected} onReconnect={a.reconnect} onManage={() => a.openProvider("manage")} onDisconnect={a.disconnect} />
|
||||
</>}
|
||||
{s.step === "draft" && <><p className="text-sm">Your access choices and setup progress are kept with this connection.</p><div className="flex justify-end"><Button onClick={a.resumeDraft}>Resume setup</Button></div></>}
|
||||
</div>
|
||||
</main>
|
||||
</div>;
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { remoteMcpProviders, type RemoteMcpProviderId } from "./providers";
|
||||
import { RemoteMcpConnectionSetup } from "./RemoteMcpConnectionSetup";
|
||||
import type { RemoteMcpSetupActions, RemoteMcpSetupState } from "./types";
|
||||
|
||||
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",
|
||||
connected: false, identity: null, allAgents: true, agentIds: [], permissions: {},
|
||||
tools: [], notice: null, refreshing: false,
|
||||
});
|
||||
const edit: RemoteMcpSetupActions["edit"] = (patch) => setState((value) => ({ ...value, ...patch }));
|
||||
const explain = () => edit({ notice: "Design example only. Review the interactive states in Storybook → Apps / Connections. No credentials are saved or sent." });
|
||||
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={[]} />;
|
||||
}
|
||||
|
||||
/** Design-guide specimen; full state matrix is maintained in each provider's Storybook group. */
|
||||
export function RemoteMcpDesignExample() {
|
||||
const [providerId, setProviderId] = useState<RemoteMcpProviderId>("zapier");
|
||||
return <div className="space-y-4">
|
||||
<div className="flex flex-wrap gap-2">{Object.values(remoteMcpProviders).map((provider) => <Button key={provider.id} size="sm" variant={providerId === provider.id ? "default" : "outline"} onClick={() => setProviderId(provider.id)}>{provider.name}</Button>)}</div>
|
||||
<ConnectExample key={providerId} providerId={providerId} />
|
||||
</div>;
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger } from "@/components/ui/alert-dialog";
|
||||
|
||||
/** Shared by the saved connection and its interactive review stories. */
|
||||
export function RemoteMcpManagement({ providerName, connected = true, canReconnect = true, canDisconnect = true, busy = false, onReconnect, onManage, onDisconnect }: {
|
||||
providerName: string;
|
||||
connected?: boolean;
|
||||
canReconnect?: boolean;
|
||||
canDisconnect?: boolean;
|
||||
busy?: boolean;
|
||||
onReconnect: () => void;
|
||||
onManage: () => void;
|
||||
onDisconnect: () => void | Promise<unknown>;
|
||||
}) {
|
||||
const [confirming, setConfirming] = useState(false);
|
||||
return <section className="space-y-4">
|
||||
<h2 className="text-sm font-semibold">Connection settings</h2>
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<Button variant="outline" disabled={!canReconnect || busy} onClick={onReconnect}>Reconnect</Button>
|
||||
<Button variant="outline" onClick={onManage}>Manage in {providerName}</Button>
|
||||
{connected && canDisconnect && <AlertDialog open={confirming} onOpenChange={(open) => { if (!busy) setConfirming(open); }}>
|
||||
<AlertDialogTrigger asChild><Button variant="ghost" className="text-destructive">Disconnect</Button></AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Disconnect {providerName}?</AlertDialogTitle>
|
||||
<AlertDialogDescription>Delete this connection’s saved credentials and stop further calls through Paperclip. Other connections are unaffected. Actions already sent to the provider may still complete. Connecting again requires a new sign-in or key.</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={busy}>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction disabled={busy} onClick={async (event) => {
|
||||
event.preventDefault();
|
||||
try { await onDisconnect(); setConfirming(false); } catch { /* The controller displays the failure. */ }
|
||||
}}>{busy ? "Disconnecting…" : "Disconnect connection"}</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>}
|
||||
</div>
|
||||
</section>;
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { REMOTE_MCP_CONNECTOR_METHODS, type ToolConnection } from "@paperclipai/shared";
|
||||
import { agentsApi } from "@/api/agents";
|
||||
import { toolsApi } from "@/api/tools";
|
||||
import { useCompany } from "@/context/CompanyContext";
|
||||
import { useNavigate } from "@/lib/router";
|
||||
import { resolveAuthorizationTarget } from "@/lib/authorizationUrl";
|
||||
import { navigateTopLevel } from "@/lib/browserNavigation";
|
||||
import { queryKeys } from "@/lib/queryKeys";
|
||||
import { RemoteMcpConnectionSetup } from "./RemoteMcpConnectionSetup";
|
||||
import { remoteMcpProviders, type RemoteMcpProviderId } from "./providers";
|
||||
import type { RemoteMcpSetupActions, RemoteMcpSetupState } from "./types";
|
||||
|
||||
function readAccessDraft(key: string): Partial<RemoteMcpSetupState> {
|
||||
try {
|
||||
const value = JSON.parse(sessionStorage.getItem(key) ?? "null");
|
||||
if (!value || !["organization", "user"].includes(value.grantKind) || typeof value.allAgents !== "boolean" || !Array.isArray(value.agentIds)) return {};
|
||||
return { grantKind: value.grantKind, allAgents: value.allAgents, agentIds: value.agentIds.filter((id: unknown) => typeof id === "string") };
|
||||
} catch { return {}; }
|
||||
}
|
||||
|
||||
export function RemoteMcpProductionSetup({ providerId, connection }: {
|
||||
providerId: RemoteMcpProviderId;
|
||||
connection?: ToolConnection;
|
||||
}) {
|
||||
const provider = remoteMcpProviders[providerId];
|
||||
const { selectedCompanyId } = useCompany();
|
||||
const navigate = useNavigate();
|
||||
const queries = useQueryClient();
|
||||
const accessDraftKey = `paperclip:mcp-access-draft:${selectedCompanyId}:${providerId}`;
|
||||
const savedConnection = useRef(connection);
|
||||
const busy = useRef(false);
|
||||
const authorizationUrl = useRef<string | undefined>(undefined);
|
||||
const [state, setState] = useState<RemoteMcpSetupState>(() => ({
|
||||
step: connection ? "connect" : "access", grantKind: connection?.credentialPolicy === "per_user" ? "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: "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) : {}),
|
||||
}));
|
||||
const installs = useQuery({ queryKey: queryKeys.tools.connectionInstalls(connection?.id ?? "__new__"), queryFn: () => toolsApi.getConnectionInstalls(connection!.id), enabled: !!connection });
|
||||
const agents = useQuery({
|
||||
queryKey: queryKeys.agents.list(selectedCompanyId!), queryFn: () => agentsApi.list(selectedCompanyId!), enabled: !!selectedCompanyId,
|
||||
});
|
||||
useEffect(() => {
|
||||
if (installs.data) setState((s) => ({ ...s, allAgents: installs.data.installs.some((i) => i.targetType === "company"), agentIds: installs.data.installs.filter((i) => i.targetType === "agent").map((i) => i.targetId) }));
|
||||
}, [installs.data]);
|
||||
useEffect(() => {
|
||||
if (connection) return;
|
||||
try { sessionStorage.setItem(accessDraftKey, JSON.stringify({ grantKind: state.grantKind, allAgents: state.allAgents, agentIds: state.agentIds })); } catch { /* Storage can be disabled. */ }
|
||||
}, [connection, accessDraftKey, state.grantKind, state.allAgents, state.agentIds]);
|
||||
const edit = (patch: Partial<RemoteMcpSetupState>) => setState((s) => ({ ...s, ...patch }));
|
||||
const finish = async (id: string) => {
|
||||
try { sessionStorage.removeItem(accessDraftKey); } catch { /* Storage can be disabled. */ }
|
||||
await queries.invalidateQueries({ queryKey: ["tools"] });
|
||||
navigate(`/apps/${id}/permissions`);
|
||||
};
|
||||
const submit = async (saveDraft = false) => {
|
||||
if (busy.current || !selectedCompanyId || (connection && !installs.data)) return;
|
||||
try {
|
||||
const url = new URL(state.url.trim());
|
||||
if (!["http:", "https:"].includes(url.protocol)) throw new Error();
|
||||
} catch { edit({ connectStatus: "invalid_url" }); return; }
|
||||
busy.current = true;
|
||||
edit({ connectStatus: "connecting", notice: null });
|
||||
try {
|
||||
const credentials: Record<string, string> = {};
|
||||
if (state.auth === "bearer" && state.token.trim()) credentials["credentials.authorization"] = state.token.trim();
|
||||
if (state.auth === "headers" || state.auth === "bearer") {
|
||||
for (const header of state.headers) {
|
||||
if (!header.name.trim() && !header.value) continue;
|
||||
if (!header.name.trim() || !header.value) throw new Error("Enter both a name and value for each header.");
|
||||
credentials[`headers.${header.name.trim()}`] = header.value;
|
||||
}
|
||||
}
|
||||
const prior = savedConnection.current;
|
||||
const result = await toolsApi.connectApp(selectedCompanyId, {
|
||||
galleryKey: providerId, connectionMethodKey: REMOTE_MCP_CONNECTOR_METHODS[providerId],
|
||||
name: provider.name, link: state.url.trim(), grantKind: state.grantKind,
|
||||
authMode: state.auth === "headers" ? "custom_headers" : state.auth,
|
||||
credentialValues: credentials, saveDraft,
|
||||
...(prior ? prior.status === "draft" ? { resumeConnectionId: prior.id } : { reconnectConnectionId: prior.id } : {}),
|
||||
});
|
||||
savedConnection.current = result.connection;
|
||||
// Persist the Access step before leaving for OAuth. Reconnect retains its existing installs.
|
||||
if (!prior || prior.status === "draft") {
|
||||
await toolsApi.putConnectionInstalls(result.connectionId, state.allAgents
|
||||
? [{ targetType: "company", targetId: selectedCompanyId }]
|
||||
: state.agentIds.map((targetId) => ({ targetType: "agent", targetId })));
|
||||
}
|
||||
if (saveDraft) { navigate("/apps"); return; }
|
||||
if (result.auth?.kind === "oauth") {
|
||||
const oauth = await toolsApi.startOAuth(result.connectionId, { asCurrentUser: state.grantKind === "user" });
|
||||
const target = resolveAuthorizationTarget(oauth.authorizationUrl);
|
||||
if (!target.ok) throw new Error(target.message);
|
||||
authorizationUrl.current = target.url;
|
||||
edit({ connectStatus: "sign_in", token: "", headers: [] });
|
||||
navigateTopLevel(target.url);
|
||||
return;
|
||||
}
|
||||
// The server retains existing permissions when reconnecting. Only a fresh setup enables everything.
|
||||
{
|
||||
await toolsApi.finishApp(selectedCompanyId, result.connectionId, {
|
||||
enabledCatalogEntryIds: result.catalog.filter((tool) => tool.status === "active").map((tool) => tool.id),
|
||||
askFirstCatalogEntryIds: [], access: state.allAgents ? "all_agents" : { agentIds: state.agentIds },
|
||||
});
|
||||
}
|
||||
await finish(result.connectionId);
|
||||
} catch (error) {
|
||||
edit({ connectStatus: "idle", notice: error instanceof Error ? error.message : "Could not connect. Please try again." });
|
||||
} finally { busy.current = false; }
|
||||
};
|
||||
const actions: RemoteMcpSetupActions = {
|
||||
edit, navigate: (step) => edit({ step }), connect: () => { void submit(); },
|
||||
cancelConnect: () => { if (!busy.current) edit({ connectStatus: "cancelled" }); },
|
||||
openProvider: (purpose) => {
|
||||
if (purpose === "sign_in" && authorizationUrl.current) navigateTopLevel(authorizationUrl.current);
|
||||
else window.open(provider.setupUrl, "_blank", "noopener,noreferrer");
|
||||
},
|
||||
saveExit: () => {
|
||||
if (busy.current) return;
|
||||
if (state.url.trim()) void submit(true);
|
||||
else navigate("/apps");
|
||||
},
|
||||
resumeDraft: () => edit({ step: "connect" }), finish: () => { if (savedConnection.current) void finish(savedConnection.current.id); },
|
||||
refresh: () => {}, reconnect: () => edit({ step: "connect" }), disconnect: () => {},
|
||||
};
|
||||
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>;
|
||||
return <RemoteMcpConnectionSetup provider={provider} connectionId={savedConnection.current?.id ?? ""} state={state} actions={actions} agents={agents.data ?? []} />;
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
/** Presentation metadata only. Each provider will have its own catalog entry and connection. */
|
||||
export type RemoteMcpProviderId = "zapier" | "arcade" | "composio" | "executor";
|
||||
|
||||
export interface RemoteMcpProvider {
|
||||
id: RemoteMcpProviderId;
|
||||
name: string;
|
||||
description: string;
|
||||
instructions: string[];
|
||||
setupUrl: string;
|
||||
dashboardUrl: string;
|
||||
defaultUrl: string;
|
||||
placeholder: string;
|
||||
urlHelp: string;
|
||||
authHelp: string;
|
||||
supportsBrowserAuth: boolean;
|
||||
}
|
||||
|
||||
export const remoteMcpProviders: Record<RemoteMcpProviderId, RemoteMcpProvider> = {
|
||||
zapier: {
|
||||
id: "zapier", name: "Zapier", supportsBrowserAuth: false,
|
||||
description: "Use the apps and actions on your Zapier MCP server.",
|
||||
instructions: ["Create an MCP server in Zapier and choose Other as the client.", "Connect your apps and select the actions to expose.", "Open Connect, generate a token, and copy the full server URL."],
|
||||
setupUrl: "https://docs.zapier.com/mcp/get-started/connect/other",
|
||||
dashboardUrl: "https://mcp.zapier.com",
|
||||
defaultUrl: "", placeholder: "Paste the full URL from Zapier",
|
||||
urlHelp: "The full server URL can contain a secret token. Paste it exactly as Zapier provides it.",
|
||||
authHelp: "For a separate token, use https://mcp.zapier.com/api/v1/connect and choose Bearer token.",
|
||||
},
|
||||
arcade: {
|
||||
id: "arcade", name: "Arcade", supportsBrowserAuth: true,
|
||||
description: "Use the tools exposed by your Arcade gateway.",
|
||||
instructions: ["Create a gateway in Arcade and select its tools.", "Choose its User Source and copy the gateway URL.", "Paste the URL here, then sign in when prompted."],
|
||||
setupUrl: "https://docs.arcade.dev/en/operate/governance/mcp-gateways",
|
||||
dashboardUrl: "https://app.arcade.dev",
|
||||
defaultUrl: "", placeholder: "https://api.arcade.dev/mcp/your-gateway",
|
||||
urlHelp: "Copy the MCP gateway URL from Arcade. App authorization may be required the first time you use a tool.",
|
||||
authHelp: "For Arcade Headers, use an API key as the bearer token and add the Arcade-User-ID header for the acting user.",
|
||||
},
|
||||
composio: {
|
||||
id: "composio", name: "Composio", supportsBrowserAuth: true,
|
||||
description: "Discover and use your apps through Composio Connect.",
|
||||
instructions: ["Connect to Composio and sign in with your account.", "Connect underlying apps in Composio when prompted."],
|
||||
setupUrl: "https://docs.composio.dev/docs/composio-connect",
|
||||
dashboardUrl: "https://dashboard.composio.dev",
|
||||
defaultUrl: "https://connect.composio.dev/mcp", placeholder: "https://connect.composio.dev/mcp",
|
||||
urlHelp: "Composio Connect is prefilled. For an externally configured session, replace this with its MCP URL and add its supplied headers under Advanced authentication.",
|
||||
authHelp: "Session URLs and headers come from your external Composio setup. Direct-tools sessions expose individual actions. Existing project API-key connections are managed separately.",
|
||||
},
|
||||
executor: {
|
||||
id: "executor", name: "Executor", supportsBrowserAuth: true,
|
||||
description: "Run tools through your Executor workspace.",
|
||||
instructions: ["Connect your apps and configure action policies in Executor.", "Copy the MCP URL from your workspace’s connection instructions.", "Paste it here and sign in when prompted."],
|
||||
setupUrl: "https://executor.sh/docs/mcp-proxy",
|
||||
dashboardUrl: "https://executor.sh",
|
||||
defaultUrl: "", placeholder: "Paste your Executor workspace MCP URL",
|
||||
urlHelp: "Use the hosted workspace URL or a self-hosted HTTP endpoint reachable from Paperclip. The server’s endpoint policy also applies.",
|
||||
authHelp: "Keep any options in the copied URL. If using an API key, use a user key; workspace and organization keys cannot open an MCP session.",
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,42 @@
|
||||
import type { ToolCatalogEntry } from "@paperclipai/shared";
|
||||
|
||||
export type ToolPermission = "allowed" | "ask_first" | "off";
|
||||
export type SetupStep = "access" | "connect" | "permissions" | "management" | "draft";
|
||||
export type ConnectStatus = "idle" | "connecting" | "sign_in" | "returned" | "cancelled" | "invalid_url" | "rejected" | "unreachable";
|
||||
export interface RemoteMcpTool extends ToolCatalogEntry {
|
||||
broad?: boolean;
|
||||
}
|
||||
|
||||
export interface RemoteMcpSetupState {
|
||||
step: SetupStep;
|
||||
grantKind: "user" | "organization";
|
||||
setupComplete: boolean;
|
||||
url: string;
|
||||
auth: "auto" | "bearer" | "headers" | "none";
|
||||
token: string;
|
||||
headers: { id: string; name: string; value: string }[];
|
||||
advanced: boolean;
|
||||
connectStatus: ConnectStatus;
|
||||
connected: boolean;
|
||||
identity: string | null;
|
||||
allAgents: boolean;
|
||||
agentIds: string[];
|
||||
permissions: Record<string, ToolPermission>;
|
||||
tools: RemoteMcpTool[];
|
||||
notice: string | null;
|
||||
refreshing: boolean;
|
||||
}
|
||||
|
||||
export interface RemoteMcpSetupActions {
|
||||
edit: (patch: Partial<RemoteMcpSetupState>) => void;
|
||||
navigate: (step: "access" | "connect" | "permissions") => void;
|
||||
connect: () => void;
|
||||
cancelConnect: () => void;
|
||||
openProvider: (purpose: "setup" | "manage" | "sign_in") => void;
|
||||
saveExit: () => void;
|
||||
resumeDraft: () => void;
|
||||
finish: () => void;
|
||||
refresh: () => void;
|
||||
reconnect: () => void;
|
||||
disconnect: () => void;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { instanceSettingsApi } from "@/api/instanceSettings";
|
||||
import { queryKeys } from "@/lib/queryKeys";
|
||||
|
||||
/** Default-off setup gate. Existing connections and their grants remain usable. */
|
||||
export function useMcpAggregatorsEnabled() {
|
||||
const query = useQuery({
|
||||
queryKey: queryKeys.instance.experimentalSettings,
|
||||
queryFn: () => instanceSettingsApi.getExperimental(),
|
||||
});
|
||||
return { enabled: !query.isError && query.data?.enableMcpAggregators === true, loaded: query.isFetched };
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { WebhookUrlWarning } from "@/components/routine-triggers/WebhookUrlWarning";
|
||||
import { SetupWizardNavigation, SetupWizardFooter } from "../components/SetupWizard";
|
||||
import { RemoteMcpDesignExample } from "@/features/connections/remote-mcp/RemoteMcpDesignExample";
|
||||
import { AgentChatPicker } from "@/components/AgentChatPicker";
|
||||
import { TaskChatProjectCreatedCard } from "@/components/task-chat/TaskChatProjectCreatedCard";
|
||||
import { AnnouncementCard } from "@/components/AnnouncementCard";
|
||||
@@ -1693,6 +1694,10 @@ export function DesignGuide() {
|
||||
{/* NAVIGATION PATTERNS */}
|
||||
{/* ============================================================ */}
|
||||
<Section title="Navigation Patterns">
|
||||
<SubSection title="Independent MCP connections">
|
||||
<p className="text-sm text-muted-foreground">Zapier, Arcade, Composio and Executor each own a connection. Their controlled setup views share Access → Connect. Tool discovery completes setup. Saved connections reuse the standard Permissions action list and per-action Test dialog. Storybook’s Apps / Connections groups use in-memory provider fixtures.</p>
|
||||
<RemoteMcpDesignExample />
|
||||
</SubSection>
|
||||
<SubSection title="Setup wizard">
|
||||
<p className="text-sm text-muted-foreground">Shared by connection setup and trigger previews. Setup navigation takes over the section sidebar; each step owns a single footer.</p>
|
||||
<div className="max-w-sm space-y-6">
|
||||
|
||||
@@ -78,6 +78,7 @@ function defaultExperimentalSettings(): InstanceExperimentalSettingsPayload {
|
||||
enableStreamlinedUi: true,
|
||||
enableApps: true,
|
||||
enableChatConnectors: false,
|
||||
enableMcpAggregators: false,
|
||||
enablePipelines: false,
|
||||
enableCases: false,
|
||||
enableAgentChat: false,
|
||||
@@ -209,6 +210,19 @@ describe("InstanceExperimentalSettings — Conference Room Chat card (PAP-11233)
|
||||
expect(container.textContent).not.toContain("Show the Apps navigation");
|
||||
});
|
||||
|
||||
it("defaults MCP aggregators off and persists an explicit toggle in both directions", async () => {
|
||||
await renderPage();
|
||||
const selector = 'button[aria-label="Toggle MCP aggregators experimental setting"]';
|
||||
expect(container.querySelector(selector)?.getAttribute("aria-checked")).toBe("false");
|
||||
expect(container.textContent).toContain("legacy Composio connections are unchanged");
|
||||
for (const enabled of [true, false]) {
|
||||
await act(() => container.querySelector<HTMLButtonElement>(selector)!.click());
|
||||
await flushReact();
|
||||
expect(mockInstanceSettingsApi.updateExperimental).toHaveBeenLastCalledWith({ enableMcpAggregators: enabled });
|
||||
expect(container.querySelector(selector)?.getAttribute("aria-checked")).toBe(String(enabled));
|
||||
}
|
||||
});
|
||||
|
||||
it("defaults chat connectors off and persists an explicit toggle in both directions", async () => {
|
||||
await renderPage();
|
||||
const selector = 'button[aria-label="Toggle chat connectors experimental setting"]';
|
||||
|
||||
@@ -164,6 +164,7 @@ export function InstanceExperimentalSettings() {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.adapters.all }),
|
||||
queryClient.invalidateQueries({ queryKey: ["built-in-agents"] }),
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.health }),
|
||||
queryClient.invalidateQueries({ queryKey: ["apps"] }),
|
||||
]);
|
||||
},
|
||||
onError: (error, _patch, context) => {
|
||||
@@ -337,6 +338,18 @@ export function InstanceExperimentalSettings() {
|
||||
ariaLabel="Toggle chat connectors experimental setting"
|
||||
/>
|
||||
|
||||
<ExperimentalToggleCard
|
||||
title="MCP aggregators"
|
||||
description="Connect Zapier, Arcade, Composio Connect, and Executor through their MCP servers."
|
||||
footnote="Turning this off hides setup for these connectors. Existing connections keep running, and legacy Composio connections are unchanged."
|
||||
checked={experimentalQuery.data?.enableMcpAggregators === true}
|
||||
onCheckedChange={(checked) => toggleMutation.mutate({ enableMcpAggregators: checked })}
|
||||
disabled={toggleMutation.isPending}
|
||||
settingKey="enableMcpAggregators"
|
||||
managed={managedKeys.enableMcpAggregators}
|
||||
ariaLabel="Toggle MCP aggregators experimental setting"
|
||||
/>
|
||||
|
||||
{SHOW_CONFERENCE_ROOM_EXPERIMENTAL_SETTING ? (
|
||||
<ExperimentalToggleCard
|
||||
title="Conference Room Chat"
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
import { isRemoteMcpConnectorId, isRemoteMcpConnectorMethod } from "@paperclipai/shared";
|
||||
import { RemoteMcpManagement } from "@/features/connections/remote-mcp/RemoteMcpManagement";
|
||||
import { remoteMcpProviders } from "@/features/connections/remote-mcp/providers";
|
||||
import { ManagedAiConnectionDetails } from "@/components/ai-connections/ManagedAiConnectionDetails";
|
||||
import { useEffect, useMemo, useRef, useState, type ReactNode } from "react";
|
||||
import { EmailConnectionAccess } from "@/components/EmailConnectionAccess";
|
||||
@@ -242,7 +245,19 @@ export function AppDetail({ renderActions, onReconnect }: {
|
||||
[connection?.installs, installsQuery.data?.installs],
|
||||
);
|
||||
const access = useMemo(() => accessFrom(connection?.connectionPurpose === "ai" ? undefined : profile, install), [connection?.connectionPurpose, profile, install]);
|
||||
const managesRemoteMcpAccess = isRemoteMcpConnectorMethod(connection?.config?.sourceTemplateKey, connection?.config?.connectionMethodKey);
|
||||
const agents = agentsQuery.data ?? [];
|
||||
const disconnectRemote = useMutation({
|
||||
mutationFn: () => toolsApi.archiveConnection(connectionId),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.tools.connections(selectedCompanyId!) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.tools.connection(connectionId) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.tools.applications(selectedCompanyId!) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.apps.attention(selectedCompanyId!) });
|
||||
navigate("/apps");
|
||||
},
|
||||
onError: (error) => pushToast({ title: "Couldn't disconnect", body: error instanceof Error ? error.message : "Please try again.", tone: "error" }),
|
||||
});
|
||||
const [pending, setPending] = useState(false);
|
||||
const persist = useMutation({
|
||||
mutationFn: async (next: {
|
||||
@@ -250,14 +265,16 @@ export function AppDetail({ renderActions, onReconnect }: {
|
||||
askFirst: Set<string>;
|
||||
access: AccessDraft;
|
||||
reviewed?: Set<string>;
|
||||
}) => connection?.connectionPurpose === "ai"
|
||||
}) => {
|
||||
return connection?.connectionPurpose === "ai"
|
||||
? toolsApi.putConnectionInstalls(connectionId, next.access.mode === "all" ? [{ targetType: "company", targetId: selectedCompanyId! }] : [...next.access.agentIds].map(targetId => ({ targetType: "agent" as const, targetId })))
|
||||
: toolsApi.finishApp(selectedCompanyId!, connectionId, {
|
||||
enabledCatalogEntryIds: [...next.enabled],
|
||||
askFirstCatalogEntryIds: [...next.askFirst].filter((id) => next.enabled.has(id)),
|
||||
...(next.reviewed ? { reviewedCatalogEntryIds: [...next.reviewed] } : {}),
|
||||
access: next.access.mode === "all" ? "all_agents" : { agentIds: [...next.access.agentIds] },
|
||||
}),
|
||||
});
|
||||
},
|
||||
onMutate: () => setPending(true),
|
||||
onSuccess: () => {
|
||||
void installsQuery.refetch();
|
||||
@@ -389,6 +406,9 @@ export function AppDetail({ renderActions, onReconnect }: {
|
||||
const refreshTools = useMutation({
|
||||
mutationFn: () => toolsApi.refreshCatalog(connectionId),
|
||||
onSuccess: (result) => {
|
||||
// Discovery extends the app profile for new actions as well as the catalog.
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.tools.profiles(selectedCompanyId!) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.tools.policies(selectedCompanyId!) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.tools.testAgentAccessesForConnection(connectionId) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.tools.connection(connectionId) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.tools.catalog(connectionId) });
|
||||
@@ -540,7 +560,7 @@ export function AppDetail({ renderActions, onReconnect }: {
|
||||
galleryEntry={logoEntry}
|
||||
canReconnect={canReconnect}
|
||||
reconnectUnavailableMessage={reconnectUnavailableMessage}
|
||||
onReconnect={onReconnect ? () => onReconnect(connection) : connection.connectionPurpose === "ai" ? () => navigate(`/apps/connect?source=${connection.config?.sourceTemplateKey}&reconnect=${connection.id}`) : undefined}
|
||||
onReconnect={onReconnect ? () => onReconnect(connection) : (connection.connectionPurpose === "ai" || isRemoteMcpConnectorMethod(connection.config?.sourceTemplateKey, connection.config?.connectionMethodKey)) ? () => navigate(`/apps/connect?source=${connection.config?.sourceTemplateKey}&reconnect=${connection.id}`) : undefined}
|
||||
onReconnected={() => {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.tools.connection(connectionId) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.tools.connections(selectedCompanyId) });
|
||||
@@ -614,6 +634,7 @@ export function AppDetail({ renderActions, onReconnect }: {
|
||||
onReplaceAudience={(grant, memberUserIds) =>
|
||||
replaceAudience.mutate({ grantId: grant.id, memberUserIds })}
|
||||
/>
|
||||
{isRemoteMcpConnectorMethod(connection.config?.sourceTemplateKey, connection.config?.connectionMethodKey) && <p className="text-sm text-muted-foreground">Paperclip controls access to the tools listed here. App and action permissions inside these tools are managed in {baseAppName}.</p>}
|
||||
<PermissionsPanel
|
||||
actions={actionsContent}
|
||||
connectionId={connectionId}
|
||||
@@ -621,7 +642,7 @@ export function AppDetail({ renderActions, onReconnect }: {
|
||||
appName={appName}
|
||||
agents={agents}
|
||||
access={access}
|
||||
install={connection.connectionPurpose === "ai" ? installStateFrom([]) : install}
|
||||
install={connection.connectionPurpose === "ai" || managesRemoteMcpAccess ? installStateFrom([]) : install}
|
||||
readOnly={readOnly}
|
||||
canChange={canChange}
|
||||
quarantined={quarantined}
|
||||
@@ -634,11 +655,18 @@ export function AppDetail({ renderActions, onReconnect }: {
|
||||
? "Shell Git and gh use this account for the run and are not constrained by per-tool Ask-first controls."
|
||||
: undefined
|
||||
}
|
||||
onSaveAccess={(next) => apply({ access: connection.connectionPurpose === "ai" ? next : accessIncludingInstalls(next, install) })}
|
||||
onSaveAccess={(next) => apply({ access: connection.connectionPurpose === "ai" || managesRemoteMcpAccess ? next : accessIncludingInstalls(next, install) })}
|
||||
onRefreshActions={() => refreshTools.mutate()}
|
||||
onSetActionPermission={(id, next) => apply(actionPermissionMutation(id, next, enabledIds, askFirstIds))}
|
||||
onReviewQuarantined={reviewQuarantined}
|
||||
/>
|
||||
{managesRemoteMcpAccess && isRemoteMcpConnectorId(connection.config?.sourceTemplateKey) && <RemoteMcpManagement
|
||||
providerName={baseAppName} canReconnect={canReconnect} canDisconnect={grantsQuery.data?.capabilities.canConfigure === true}
|
||||
busy={disconnectRemote.isPending}
|
||||
onReconnect={() => navigate(`/apps/connect?source=${connection.config?.sourceTemplateKey}&reconnect=${connection.id}`)}
|
||||
onManage={() => window.open(remoteMcpProviders[connection.config?.sourceTemplateKey as keyof typeof remoteMcpProviders].dashboardUrl, "_blank", "noopener,noreferrer")}
|
||||
onDisconnect={() => disconnectRemote.mutateAsync()}
|
||||
/>}
|
||||
</>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -10,6 +10,7 @@ import { aiConnectionsApi } from "@/api/ai-connections";
|
||||
import { queryKeys } from "@/lib/queryKeys";
|
||||
import { ConnectionSetupFlow } from "@/features/connections/ConnectionSetupFlow";
|
||||
import { AppsConnect } from "./AppsConnect";
|
||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||
|
||||
const listGalleryMock = vi.hoisted(() => vi.fn());
|
||||
const experimentalMock = vi.hoisted(() => vi.fn());
|
||||
@@ -310,7 +311,7 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<QueryClientProvider client={client}>
|
||||
{content ?? <AppsConnect byoOnly={byoOnly} />}
|
||||
<TooltipProvider>{content ?? <AppsConnect byoOnly={byoOnly} />}</TooltipProvider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
});
|
||||
@@ -319,6 +320,14 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
|
||||
return root;
|
||||
}
|
||||
|
||||
it.each(["zapier", "arcade", "composio", "executor"])("blocks direct %s setup while MCP aggregators are off", async (provider) => {
|
||||
mockSearch.value = `source=${provider}`;
|
||||
await render();
|
||||
expect(container.textContent).toContain("Enable MCP aggregators");
|
||||
expect(connectAppMock).not.toHaveBeenCalled();
|
||||
expect(startOAuthMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("shows only MCP URL setup on the BYO page", async () => {
|
||||
await render(undefined, true);
|
||||
|
||||
@@ -627,7 +636,8 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
|
||||
* methods must still let the operator deliberately choose a personal identity.
|
||||
*/
|
||||
it("defaults flexible methods to company identity and keeps personal credentials submittable", async () => {
|
||||
listGalleryMock.mockResolvedValue({ apps: [COMPOSIO, POSTHOG] });
|
||||
mockSearch.value = "source=composio&method=api-key";
|
||||
listGalleryMock.mockResolvedValue({ apps: [{ ...COMPOSIO, methods: COMPOSIO.methods.filter((method) => method.key === "api-key") }, POSTHOG] });
|
||||
|
||||
const identityChoices = () => {
|
||||
const radios = Array.from(
|
||||
@@ -693,6 +703,7 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
mockParams.appKey = "posthog";
|
||||
mockSearch.value = "";
|
||||
root = await render();
|
||||
|
||||
const posthog = identityChoices();
|
||||
@@ -1227,7 +1238,7 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
|
||||
await act(async () => {
|
||||
mountedRoot?.render(
|
||||
<QueryClientProvider client={coldLoadClient}>
|
||||
<AppsConnect />
|
||||
<TooltipProvider><AppsConnect /></TooltipProvider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
});
|
||||
@@ -1976,6 +1987,7 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
|
||||
await vi.waitFor(() => {
|
||||
expect(container.textContent).toContain("Finish connecting Notion");
|
||||
});
|
||||
expect(getConnectionMock).not.toHaveBeenCalled();
|
||||
expect(container.textContent).toContain("identity and agent access will stay the same");
|
||||
expect(container.textContent).not.toContain("Choose access before sign-in");
|
||||
expect(buttonByText("Continue to sign in")).toBeUndefined();
|
||||
@@ -2645,6 +2657,7 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
|
||||
});
|
||||
|
||||
it("gives Zapier the shared credential and agent access opener", async () => {
|
||||
experimentalMock.mockResolvedValue({ enableMcpAggregators: true });
|
||||
mockSearch.value = "source=zapier";
|
||||
listGalleryMock.mockResolvedValueOnce({
|
||||
apps: [
|
||||
@@ -2674,7 +2687,7 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
|
||||
},
|
||||
],
|
||||
},
|
||||
catalog: [],
|
||||
catalog: [{ id: "action-1", status: "active" }, { id: "action-2", status: "active" }],
|
||||
suggestedDefaults: { askFirstRiskLevels: [] },
|
||||
});
|
||||
await render();
|
||||
@@ -2683,9 +2696,9 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
|
||||
expect(container.textContent).toContain("Connect Zapier");
|
||||
expect(container.textContent).toContain("Which humans can use this credential?");
|
||||
expect(container.textContent).toContain("Which agents can use this connection?");
|
||||
expect(container.querySelector('img[src="https://example.com/zapier.png"]')).toBeTruthy();
|
||||
expect(container.querySelector('[data-remote-mcp-provider="zapier"]')).toBeTruthy();
|
||||
expect(container.textContent).not.toContain("Pick the app you want your agents to use.");
|
||||
expect(container.querySelector('input[placeholder^="https://mcp.zapier.com"]')).toBeNull();
|
||||
expect(container.querySelector('input[placeholder="Paste the full URL from Zapier"]')).toBeNull();
|
||||
|
||||
await act(async () => {
|
||||
radioContaining("Just me")?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
@@ -2694,10 +2707,10 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
|
||||
await passAccessStep();
|
||||
|
||||
expect(container.textContent).toContain("Step 2 of 2");
|
||||
expect(container.textContent).toContain("Add MCP URL");
|
||||
expect(container.textContent).toContain("MCP server URL");
|
||||
|
||||
const linkInput = container.querySelector<HTMLInputElement>(
|
||||
'input[placeholder^="https://mcp.zapier.com"]',
|
||||
'input[placeholder="Paste the full URL from Zapier"]',
|
||||
);
|
||||
expect(linkInput?.type).toBe("password");
|
||||
expect(linkInput?.autocomplete).toBe("off");
|
||||
@@ -2706,7 +2719,7 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
|
||||
await flushReact();
|
||||
|
||||
await act(async () => {
|
||||
buttonByText("Check link")?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
buttonByText("Connect")?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
await flushReact();
|
||||
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
import { ConnectionSetupFlow } from "@/features/connections/ConnectionSetupFlow";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { toolsApi } from "@/api/tools";
|
||||
import { RemoteMcpProductionSetup } from "@/features/connections/remote-mcp/RemoteMcpProductionSetup";
|
||||
import { useMcpAggregatorsEnabled } from "@/hooks/useMcpAggregatorsEnabled";
|
||||
import { isRemoteMcpConnectorId, isRemoteMcpConnectorMethod } from "@paperclipai/shared";
|
||||
import { useParams } from "@/lib/router";
|
||||
import { useSearchParams } from "@/lib/router";
|
||||
import type { ToolConnectionCredentialSource } from "@paperclipai/shared";
|
||||
|
||||
@@ -17,7 +23,27 @@ export function AppsConnect({
|
||||
credentialSource?: ToolConnectionCredentialSource;
|
||||
} = {}) {
|
||||
const [searchParams] = useSearchParams();
|
||||
const aggregators = useMcpAggregatorsEnabled();
|
||||
const interactionId = searchParams.get("intent")?.trim() || undefined;
|
||||
const params = useParams<{ appKey?: string }>();
|
||||
const existingId = searchParams.get("resume") || searchParams.get("reconnect");
|
||||
const explicitSource = searchParams.get("source") || params.appKey || searchParams.get("appKey");
|
||||
// Ordinary connectors own their existing recovery/loading path. Only load
|
||||
// here when selecting an aggregator controller, or resolving an unknown source.
|
||||
const lookupExisting = !!existingId && (!explicitSource || isRemoteMcpConnectorId(explicitSource));
|
||||
const existing = useQuery({ queryKey: ["tools", "connection", existingId], queryFn: () => toolsApi.getConnection(existingId!), enabled: lookupExisting });
|
||||
const source = explicitSource || existing.data?.config?.sourceTemplateKey;
|
||||
const method = searchParams.get("method") || existing.data?.config?.connectionMethodKey;
|
||||
if (lookupExisting && existing.isPending) return <p className="p-8 text-sm text-muted-foreground">Loading connection…</p>;
|
||||
if (lookupExisting && existing.isError) return <div role="alert" className="space-y-3 p-8"><p>Could not load this connection. Your saved access and credentials have not changed.</p><button type="button" className="text-primary underline" onClick={() => void existing.refetch()}>Try again</button></div>;
|
||||
if (isRemoteMcpConnectorId(source) && (!method || isRemoteMcpConnectorMethod(source, method))) {
|
||||
if (!aggregators.loaded) return <p className="p-8 text-sm text-muted-foreground">Loading connection settings…</p>;
|
||||
if (!aggregators.enabled) return <p role="status" className="p-8 text-sm text-muted-foreground">Enable MCP aggregators in Settings → Experimental to set up this connection.</p>;
|
||||
}
|
||||
if (!byoOnly && !interactionId && credentialSource === "paperclip_vault" && isRemoteMcpConnectorId(source)
|
||||
&& (!method || isRemoteMcpConnectorMethod(source, method))) {
|
||||
return <RemoteMcpProductionSetup key={existingId || source} providerId={source} connection={existing.data} />;
|
||||
}
|
||||
return (
|
||||
<ConnectionSetupFlow
|
||||
byoOnly={byoOnly}
|
||||
|
||||
@@ -190,6 +190,25 @@ describe("Connectors landing page", () => {
|
||||
return client;
|
||||
}
|
||||
|
||||
it("hides cached MCP aggregators until enabled and preserves existing legacy connections", async () => {
|
||||
const providers = ["zapier", "arcade", "composio", "executor"];
|
||||
listGalleryMock.mockResolvedValue({ apps: [...providers, "notion"].map(getAppStoreDefinition) });
|
||||
const client = await renderBrowse();
|
||||
for (const slug of providers) expect(container.querySelector(`[data-app-slug="${slug}"]`)).toBeNull();
|
||||
expect(container.querySelector('[data-app-slug="notion"]')).not.toBeNull();
|
||||
await act(() => { client.setQueryData(queryKeys.instance.experimentalSettings, { enableMcpAggregators: true }); });
|
||||
await flushReact();
|
||||
for (const slug of providers) expect(container.querySelector(`[data-app-slug="${slug}"]`)).not.toBeNull();
|
||||
await act(() => {
|
||||
client.setQueryData(queryKeys.tools.connections("company-1"), { connections: [connection({ id: "legacy", applicationId: "legacy-app", config: { sourceTemplateKey: "composio", connectionMethodKey: "api-key" }, transport: "rest_api" })] });
|
||||
client.setQueryData(queryKeys.tools.applications("company-1"), { applications: [application({ id: "legacy-app", name: "Composio", metadata: { sourceTemplateKey: "composio" } })] });
|
||||
client.setQueryData(queryKeys.instance.experimentalSettings, { enableMcpAggregators: false });
|
||||
});
|
||||
await flushReact();
|
||||
expect(container.textContent).toContain("Composio");
|
||||
for (const slug of ["zapier", "arcade", "executor"]) expect(container.querySelector(`[data-app-slug="${slug}"]`)).toBeNull();
|
||||
});
|
||||
|
||||
it("defaults to tools-only GitHub and hides chat-only catalog and existing chat accounts", async () => {
|
||||
experimentalMock.mockResolvedValue({});
|
||||
listGalleryMock.mockResolvedValue({ apps: ["github", "discord", "telegram", "microsoft-teams"].map(getAppStoreDefinition) });
|
||||
|
||||
@@ -18,12 +18,14 @@ import {
|
||||
import type { ToolApplication, ToolConnection } from "@paperclipai/shared";
|
||||
import {
|
||||
getAppDefinitionForUrl,
|
||||
isRemoteMcpConnectorId,
|
||||
getAppStoreDefinition,
|
||||
isToolConnectionAttentionHealth,
|
||||
aiSubscriptionNeedsIsolatedLogin,
|
||||
} from "@paperclipai/shared";
|
||||
import { useNavigate } from "@/lib/router";
|
||||
import { useChatConnectorsEnabled } from "@/hooks/useChatConnectorsEnabled";
|
||||
import { useMcpAggregatorsEnabled } from "@/hooks/useMcpAggregatorsEnabled";
|
||||
import { appCopyFor } from "@/lib/app-gallery-copy";
|
||||
import { useCompany } from "@/context/CompanyContext";
|
||||
import { useBreadcrumbs } from "@/context/BreadcrumbContext";
|
||||
@@ -276,6 +278,7 @@ export function Browse({ renderAccountDetails = (connection) => connection.conne
|
||||
const { pushToast } = useToast();
|
||||
const { selectedCompanyId } = useCompany();
|
||||
const { enabled: chatConnectorsEnabled } = useChatConnectorsEnabled();
|
||||
const { enabled: mcpAggregatorsEnabled } = useMcpAggregatorsEnabled();
|
||||
const { setBreadcrumbs } = useBreadcrumbs();
|
||||
const [query, setQuery] = useState("");
|
||||
const [connectionToRemove, setConnectionToRemove] =
|
||||
@@ -349,6 +352,7 @@ export function Browse({ renderAccountDetails = (connection) => connection.conne
|
||||
const gallery = (
|
||||
(galleryQuery.data?.apps ?? []) as AppGalleryDisplayEntry[]
|
||||
).filter((entry) => {
|
||||
if (!mcpAggregatorsEnabled && isRemoteMcpConnectorId(appDefinitionSlug(entry))) return false;
|
||||
const definition = getAppStoreDefinition(appDefinitionSlug(entry));
|
||||
return (
|
||||
chatConnectorsEnabled ||
|
||||
|
||||
@@ -193,7 +193,7 @@ function AgentAccessSection({
|
||||
);
|
||||
}
|
||||
|
||||
function ActionsSection({
|
||||
export function ActionsSection({
|
||||
connectionId,
|
||||
appName,
|
||||
readOnly,
|
||||
|
||||
@@ -19,7 +19,9 @@ import type {
|
||||
ToolConnectionTestCallResult,
|
||||
ToolConnectionTestCallStatus,
|
||||
ToolConnectionTestDecision,
|
||||
ToolUpstreamPending,
|
||||
} from "@paperclipai/shared";
|
||||
import { checkOAuthEndpointUrl } from "@paperclipai/shared";
|
||||
import { Link } from "@/lib/router";
|
||||
import { toolsApi } from "@/api/tools";
|
||||
import { queryKeys } from "@/lib/queryKeys";
|
||||
@@ -828,7 +830,8 @@ function splitRequiredOptional(schema: JsonSchemaNode): JsonSchemaNode {
|
||||
const props = schema.properties ?? {};
|
||||
const next: Record<string, JsonSchemaNode> = {};
|
||||
for (const [key, prop] of Object.entries(props)) {
|
||||
next[key] = required.has(key) ? prop : { ...prop, "x-paperclip-advanced": true };
|
||||
const presented = key === "code" && prop.type === "string" && !prop.format ? { ...prop, format: "textarea" } : prop;
|
||||
next[key] = required.has(key) ? presented : { ...presented, "x-paperclip-advanced": true };
|
||||
}
|
||||
return { ...schema, properties: next };
|
||||
}
|
||||
@@ -969,7 +972,7 @@ function ActionTester({
|
||||
<p className="text-xs text-muted-foreground">{GUT_CHECK[decision](appName, agent.name)}</p>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Button onClick={onRun} disabled={running} size="sm">
|
||||
<Button onClick={onRun} disabled={running || !!outcome?.result.upstreamPending?.resumeTool || outcome?.result.decision === "ask_first"} size="sm">
|
||||
{running ? (
|
||||
<>
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" /> Running…
|
||||
@@ -985,6 +988,8 @@ function ActionTester({
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{(outcome?.result.decision === "ask_first" || outcome?.result.upstreamPending?.resumeTool) && <p className="text-xs text-muted-foreground">Finish the existing request below. Use Reset only when you intend to start a new call.</p>}
|
||||
|
||||
{running && (
|
||||
<RunningCard entry={entry} appName={appName} agentName={agent.name} elapsedMs={elapsedMs} onCancel={onCancelRunning} />
|
||||
)}
|
||||
@@ -996,7 +1001,7 @@ function ActionTester({
|
||||
)}
|
||||
|
||||
{outcome && !running && (
|
||||
<ResultPanel outcome={outcome} entry={entry} appName={appName} connectionId={connectionId} />
|
||||
<ResultPanel outcome={outcome} entry={entry} appName={appName} connectionId={connectionId} agent={agent} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
@@ -1048,15 +1053,18 @@ function ResultPanel({
|
||||
entry,
|
||||
appName,
|
||||
connectionId,
|
||||
agent,
|
||||
}: {
|
||||
outcome: RunOutcome;
|
||||
entry: ToolCatalogEntry;
|
||||
appName: string;
|
||||
connectionId: string;
|
||||
agent?: TestAgentWithAccess;
|
||||
}) {
|
||||
const { result } = outcome;
|
||||
if (result.upstreamPending) return <ProviderPendingResult pending={result.upstreamPending} appName={appName} connectionId={connectionId} agent={agent} />;
|
||||
if (result.decision === "ask_first") {
|
||||
return <AskFirstResult outcome={outcome} entry={entry} appName={appName} connectionId={connectionId} />;
|
||||
return <AskFirstResult outcome={outcome} entry={entry} appName={appName} connectionId={connectionId} agent={agent} />;
|
||||
}
|
||||
if (result.decision === "off") {
|
||||
return (
|
||||
@@ -1075,6 +1083,80 @@ function ResultPanel({
|
||||
return <AllowedResult outcome={outcome} entry={entry} appName={appName} connectionId={connectionId} />;
|
||||
}
|
||||
|
||||
function ProviderPendingResult({ pending, appName, connectionId, agent }: { pending: ToolUpstreamPending; appName: string; connectionId: string; agent?: TestAgentWithAccess }) {
|
||||
const [resumed, setResumed] = useState<{ outcome: RunOutcome; entry: ToolCatalogEntry; action: "accept" | "decline" | "cancel" } | null>(null);
|
||||
const resumeError = resumed && (resumed.outcome.result.error ?? mcpToolError(resumed.outcome.result.result));
|
||||
const stoppedByUser = resumed && resumeError?.reasonCode === "tool_error" &&
|
||||
((resumed.action === "decline" && /request was declined by the user/i.test(resumeError.message)) ||
|
||||
(resumed.action === "cancel" && /request was cancelled by the user/i.test(resumeError.message)));
|
||||
if (stoppedByUser) return <div role="status" className="space-y-2 rounded-md border border-border bg-muted/40 p-4 text-sm">
|
||||
<p className="font-medium">{resumed.action === "decline" ? "Request declined" : "Request cancelled"}</p>
|
||||
<p>{resumeError.message}</p>
|
||||
<p className="text-muted-foreground">The original call was not repeated.</p>
|
||||
{pending.executionId && <p>Execution: <code className="break-all">{pending.executionId}</code></p>}
|
||||
</div>;
|
||||
if (resumed) return <ResultPanel outcome={resumed.outcome} entry={resumed.entry} appName={appName} connectionId={connectionId} agent={agent} />;
|
||||
return (
|
||||
<div role="status" className="space-y-3 rounded-md border border-border bg-muted/40 p-4 text-sm">
|
||||
<p className="font-medium">{pending.kind === "approval" ? "Approval needed" : "Authorization needed"} in {appName}</p>
|
||||
<p className="text-muted-foreground">Paperclip allowed this call. The provider needs your input before it can continue.</p>
|
||||
{pending.links.map((link) => {
|
||||
const checked = checkOAuthEndpointUrl(link.url);
|
||||
return checked.ok ? <Button key={checked.url} variant="outline" asChild><a href={checked.url} target="_blank" rel="noopener noreferrer">Continue at {checked.host}</a></Button> : null;
|
||||
})}
|
||||
{pending.message && <p className="whitespace-pre-wrap break-words">{pending.message}</p>}
|
||||
{pending.links.length === 0 && !pending.resumeTool && <p>Open the provider dashboard to complete this request.</p>}
|
||||
{pending.executionId && <p>Execution: <code className="break-all">{pending.executionId}</code></p>}
|
||||
{pending.elicitationId && <p>Request: <code className="break-all">{pending.elicitationId}</code></p>}
|
||||
{pending.expiresAt && <p>Approval expires {new Date(pending.expiresAt).toLocaleTimeString()}.</p>}
|
||||
{pending.resumeTool && agent ? <ProviderResumeControls pending={pending} connectionId={connectionId} agent={agent} onResult={setResumed} /> :
|
||||
<p className="text-muted-foreground">{pending.resumeTool
|
||||
? `After approval, test the ${pending.resumeTool} action with this execution ID. Do not start the original action again.`
|
||||
: "After authorizing, check the provider's result before using Run again. Paperclip will not repeat the call automatically."}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ProviderResumeControls({ pending, connectionId, agent, onResult }: {
|
||||
pending: ToolUpstreamPending; connectionId: string; agent: TestAgentWithAccess;
|
||||
onResult: (result: { outcome: RunOutcome; entry: ToolCatalogEntry; action: "accept" | "decline" | "cancel" }) => void;
|
||||
}) {
|
||||
const catalog = useQuery({ queryKey: queryKeys.tools.catalog(connectionId), queryFn: () => toolsApi.listCatalog(connectionId) });
|
||||
const entry = catalog.data?.catalog.find((item) => item.toolName === pending.resumeTool && item.status === "active");
|
||||
const schema = (pending.requestedSchema ?? { type: "object", properties: {} }) as JsonSchemaNode;
|
||||
const [content, setContent] = useState<Record<string, unknown>>(() => getDefaultValues(schema));
|
||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||
const resume = useMutation({
|
||||
mutationFn: async (action: "accept" | "decline" | "cancel") => {
|
||||
const started = Date.now();
|
||||
const result = await toolsApi.runTestCall(connectionId, { agentId: agent.id, toolName: entry!.toolName,
|
||||
parameters: { executionId: pending.executionId, action, ...(action === "accept" ? { content: JSON.stringify(content) } : {}) } });
|
||||
return { result, durationMs: Date.now() - started, agentName: agent.name, ranAt: new Date() };
|
||||
}, onSuccess: (outcome, action) => { if (entry) onResult({ outcome, entry, action }); },
|
||||
});
|
||||
const expired = !!pending.expiresAt && Date.parse(pending.expiresAt) <= Date.now();
|
||||
const permission = agent.effectiveAccess.tools.find((tool) => tool.toolName === entry?.toolName)?.decision ?? "off";
|
||||
const submit = (action: "accept" | "decline" | "cancel") => {
|
||||
const validation = action === "accept" ? validateJsonSchemaForm(schema, content) : {};
|
||||
setErrors(validation);
|
||||
if (!Object.keys(validation).length) resume.mutate(action);
|
||||
};
|
||||
return <div className="space-y-3">
|
||||
<p className="text-muted-foreground">Review the provider's request, then resume this execution as {agent.name}. The original action will not be started again.</p>
|
||||
<div className="flex items-center gap-2"><span>Resume permission</span><DecisionBadge decision={permission} /></div>
|
||||
{Object.keys(schema.properties ?? {}).length > 0 && <JsonSchemaForm schema={schema} values={content} onChange={setContent} errors={errors} disabled={resume.isPending} />}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button disabled={!entry || expired || permission === "off" || resume.isPending} onClick={() => submit("accept")}>{resume.isPending ? "Resuming…" : "Approve and resume"}</Button>
|
||||
<Button variant="outline" disabled={!entry || expired || permission === "off" || resume.isPending} onClick={() => submit("decline")}>Decline</Button>
|
||||
<Button variant="ghost" disabled={!entry || expired || permission === "off" || resume.isPending} onClick={() => submit("cancel")}>Cancel request</Button>
|
||||
</div>
|
||||
{expired && <p>This provider approval expired. Check the provider before starting a new action.</p>}
|
||||
{permission === "off" && <p>Allow the resume action in Permissions before continuing.</p>}
|
||||
{catalog.isError && <p role="alert">Could not load the resume action. Close this test and try again.</p>}
|
||||
{resume.isError && <p role="alert">{resume.error instanceof Error ? resume.error.message : "Could not resume. Check the provider before trying again."}</p>}
|
||||
</div>;
|
||||
}
|
||||
|
||||
/**
|
||||
* A tool can return `decision:"allowed"` and still fail at the MCP layer — the
|
||||
* gateway normalizes that into `{ data: { isError: true }, error: "…" }` inside
|
||||
@@ -1341,11 +1423,13 @@ function AskFirstResult({
|
||||
entry,
|
||||
appName,
|
||||
connectionId,
|
||||
agent,
|
||||
}: {
|
||||
outcome: RunOutcome;
|
||||
entry: ToolCatalogEntry;
|
||||
appName: string;
|
||||
connectionId: string;
|
||||
agent?: TestAgentWithAccess;
|
||||
}) {
|
||||
const { selectedCompanyId } = useCompany();
|
||||
const queryClient = useQueryClient();
|
||||
@@ -1378,6 +1462,7 @@ function AskFirstResult({
|
||||
// Once the call has been approved and run, mutate into the real result shape
|
||||
// so the tester sees the response (or failure) without re-running.
|
||||
if (phase === "done" && status) {
|
||||
if (status.upstreamPending) return <ProviderPendingResult pending={status.upstreamPending} appName={appName} connectionId={connectionId} agent={agent} />;
|
||||
// Same as the allowed path: an approved call can still fail at the MCP tool
|
||||
// layer (isError:true in the envelope) without a top-level error.
|
||||
const toolError = status.error ?? mcpToolError(status.result);
|
||||
|
||||
@@ -196,7 +196,7 @@ export function isComposioBrokerConnection(
|
||||
): boolean {
|
||||
if (!connection) return false;
|
||||
const config = configOf(connection);
|
||||
return config.sourceTemplateKey === "composio" && config.provider !== "composio";
|
||||
return config.sourceTemplateKey === "composio" && config.connectionMethodKey !== "mcp" && config.provider !== "composio";
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in new issue
Block a user