# Connector Panel

> A settings panel for connecting an app or MCP server — shows capabilities overview, groups tools by type, and lets users set per-tool permission levels.

- Category: permissions
- Job: Steer — Shape how the AI works: models, sources, triggers, procedures and refinements.
- Slug: `connector-panel`
- HTML: https://ai-patterns.app/patterns/permissions/connector-panel
- JSON: https://ai-patterns.app/api/patterns/permissions/connector-panel
- Seen in: [Claude](https://support.claude.com/en/articles/11176164), [chatgpt](https://developers.openai.com/api/docs/guides/developer-mode), [Intercom](https://www.intercom.com/help/en/articles/11461635-add-mcp-connectors-for-popular-apps-or-custom-mcps), [Perplexity](https://www.perplexity.ai/help-center/en/collections/18799295-connectors-integrations)

## Summary
A structured settings panel for connecting and configuring an external app or MCP server with an AI agent. It shows what the integration can do (overview bullets and links in a sidebar), lists every tool the integration exposes grouped by capability, and lets users set per-tool or per-group permission levels — Disable, Always ask, or Allow — without leaving the panel. The header carries the app identity (name, verified badge) and an Add connector CTA; a sidebar holds discovery content; the main column offers search and group filtering over the tool list.

## When to use
- When onboarding a new MCP server or reviewing an existing integration's permissions before activating it.
- When users need to browse what an integration can do and control exactly which tools it may run autonomously, run after confirmation, or never run.
- In an agent settings area, marketplace, or connector catalog — not inline in an agent chat transcript.

## When not to use
- For in-transcript per-call approval (one tool, one moment) — use Tool Approval instead. This panel manages standing permissions, not live prompts.
- As a generic settings form or feature-flag manager unrelated to agent tool permissions.
- When the integration exposes only one or two tools — a simple toggle row suffices; the full panel layout is only worth its overhead at five or more tools.

## Anatomy
- **Header**: app icon, name, verified badge (if publisher-verified), one-line description, "Add connector" CTA, close button.
- **Sidebar**: "Overview" section — a short bullet list of capabilities phrased from the user's perspective; "Links" section — anchor links to the app's website and documentation.
- **Filter bar**: Group filter dropdown (All + each group name) and a text search that matches on tool name or description.
- **Tool group section**: a collapsible label row showing the group name and a group-level "Allow" button that disappears once every tool in the group is allowed; below it, one tool row per tool.
- **Tool row**: tool name, protocol badge (e.g. "MCP"), description, and a three-way permission toggle (Disable / Always ask / Allow).

## Behavior
- Search immediately filters tool rows by name or description substring across all groups.
- The group dropdown constrains the visible groups; combining it with search narrows within that group.
- The group-level "Allow" button sets every tool in the group to Allow in one action.
- Per-tool permission is a mutually exclusive three-way toggle: only one of Disable, Always ask, Allow is active at a time.
- Groups are individually collapsible; collapsing hides tool rows without changing their permissions.
- The panel is layout-agnostic — it can be rendered as a modal (with a backdrop owned by the caller) or embedded directly in a settings page.

## Content guidelines
- Overview bullets phrase capabilities from the user's perspective: "Access deployment logs for debugging", not "Provides log access".
- Tool names match the tool's identifier exactly — no paraphrasing.
- Tool descriptions state what the tool does, not why the agent would use it ("Generate a temporary shareable link…", not "Useful for sharing protected pages").
- The verified badge appears only when the publisher's identity has been confirmed — don't use it as a general quality signal.
- Protocol badges ("MCP") are short, literal, and uppercased.

## Accessibility
- The permission toggle group for each tool uses `role="group"` with `aria-label` naming the tool ("Permission for Check domain availability").
- Each permission button exposes its active state via `aria-pressed`.
- The verified badge icon carries `aria-label="Verified"` so it's not invisible to screen readers.
- Group collapse buttons set `aria-expanded` to reflect the open/closed state.
- The group filter dropdown follows the listbox disclosure pattern: `aria-haspopup="listbox"`, `aria-expanded`, closes on Escape or outside click.
- The search input has `aria-label="Search tools"`.

## Related patterns
- **Tool Approval** — Use it as well for in-context permission on each call, while this panel manages standing permissions set beforehand.
- **Memory Panel** — Use it as well when people also need to manage what the AI has learned about them, not just what it can read.
- **Agent Triggers** — Use it as a layout reference, since it shares the sidebar overview plus main content shape.

## Implementation

Reference implementation (React, TypeScript, Tailwind v4, Motion, Radix). Keep every rule from the spec above; if the host project has its own design system, re-express the markup and motion in it rather than copying these classes.

npm dependencies: `lucide-react`, `motion`

Local imports to provide: `@/lib/utils` (`cn` is the usual `clsx` + `tailwind-merge` helper).

```tsx title="component.tsx"
"use client";

import * as React from "react";
import { AnimatePresence, motion } from "motion/react";
import {
  BadgeCheck,
  ChevronDown,
  ExternalLink,
  Plus,
  Search,
  X,
} from "lucide-react";

import { cn } from "@/lib/utils";

export type ToolPermission = "allow" | "always-ask" | "disable";

export interface ConnectorTool {
  id: string;
  name: string;
  description: string;
  badge?: string;
  permission: ToolPermission;
}

export interface ConnectorToolGroup {
  id: string;
  label: string;
  tools: ConnectorTool[];
}

export interface ConnectorLink {
  label: string;
  href: string;
  icon?: React.ComponentType<{ className?: string }>;
}

export interface ConnectorPanelProps {
  appName: string;
  appDescription: string;
  appIcon?: React.ReactNode;
  verified?: boolean;
  overview?: string[];
  links?: ConnectorLink[];
  toolGroups: ConnectorToolGroup[];
  onAddConnector?: () => void;
  onClose?: () => void;
  onPermissionChange?: (toolId: string, permission: ToolPermission) => void;
  onGroupAllow?: (groupId: string) => void;
  className?: string;
}

// ─── Permission toggle ────────────────────────────────────────────────────────

function PermissionToggle({
  value,
  onChange,
  toolName,
}: {
  value: ToolPermission;
  onChange: (p: ToolPermission) => void;
  toolName: string;
}) {
  const options: { value: ToolPermission; label: string }[] = [
    { value: "disable", label: "Disable" },
    { value: "always-ask", label: "Always ask" },
    { value: "allow", label: "Allow" },
  ];

  return (
    <div
      role="group"
      aria-label={`Permission for ${toolName}`}
      className="flex shrink-0 items-center gap-1"
    >
      {options.map((opt) => (
        <button
          key={opt.value}
          type="button"
          onClick={() => onChange(opt.value)}
          aria-pressed={value === opt.value}
          className={cn(
            "rounded-md px-2.5 py-1 text-xs font-medium transition-colors",
            value === opt.value
              ? "bg-foreground text-background"
              : "text-muted-foreground hover:bg-accent hover:text-foreground",
          )}
        >
          {opt.label}
        </button>
      ))}
    </div>
  );
}

// ─── Tool row ─────────────────────────────────────────────────────────────────

function ToolRow({
  tool,
  onPermissionChange,
}: {
  tool: ConnectorTool;
  onPermissionChange: (p: ToolPermission) => void;
}) {
  return (
    <div className="flex items-start gap-3 border-t px-4 py-3">
      <div className="min-w-0 flex-1">
        <div className="flex flex-wrap items-center gap-1.5">
          <span className="text-xs font-semibold">{tool.name}</span>
          {tool.badge && (
            <span className="rounded-full border px-1.5 py-0.5 text-[10px] font-medium text-muted-foreground">
              {tool.badge}
            </span>
          )}
        </div>
        <p className="mt-0.5 text-xs leading-relaxed text-muted-foreground">
          {tool.description}
        </p>
      </div>
      <PermissionToggle
        value={tool.permission}
        onChange={onPermissionChange}
        toolName={tool.name}
      />
    </div>
  );
}

// ─── Tool group section ───────────────────────────────────────────────────────

function ToolGroupSection({
  group,
  onPermissionChange,
  onGroupAllow,
}: {
  group: ConnectorToolGroup & { tools: ConnectorTool[] };
  onPermissionChange: (toolId: string, p: ToolPermission) => void;
  onGroupAllow: (groupId: string) => void;
}) {
  const [collapsed, setCollapsed] = React.useState(false);
  const allAllowed = group.tools.every((t) => t.permission === "allow");

  return (
    <div>
      <div className="flex items-center gap-2 px-4 py-2">
        <button
          type="button"
          onClick={() => setCollapsed((c) => !c)}
          aria-expanded={!collapsed}
          className="flex flex-1 items-center gap-1 text-xs font-medium text-muted-foreground hover:text-foreground"
        >
          <ChevronDown
            className={cn(
              "size-3.5 shrink-0 transition-transform",
              collapsed && "-rotate-90",
            )}
            aria-hidden
          />
          {group.label}
        </button>
        {!allAllowed && (
          <button
            type="button"
            onClick={() => onGroupAllow(group.id)}
            className="rounded-md bg-foreground px-2.5 py-1 text-xs font-medium text-background transition-opacity hover:opacity-80"
          >
            Allow
          </button>
        )}
      </div>

      <AnimatePresence initial={false}>
        {!collapsed && (
          <motion.div
            initial={{ height: 0, opacity: 0 }}
            animate={{ height: "auto", opacity: 1 }}
            exit={{ height: 0, opacity: 0 }}
            transition={{ duration: 0.18 }}
            className="overflow-hidden"
          >
            {group.tools.map((tool) => (
              <ToolRow
                key={tool.id}
                tool={tool}
                onPermissionChange={(p) => onPermissionChange(tool.id, p)}
              />
            ))}
          </motion.div>
        )}
      </AnimatePresence>
    </div>
  );
}

// ─── Filter bar ───────────────────────────────────────────────────────────────

function FilterBar({
  groups,
  activeGroup,
  onGroupChange,
  query,
  onQueryChange,
}: {
  groups: ConnectorToolGroup[];
  activeGroup: string | null;
  onGroupChange: (id: string | null) => void;
  query: string;
  onQueryChange: (q: string) => void;
}) {
  const [dropOpen, setDropOpen] = React.useState(false);
  const dropRef = React.useRef<HTMLDivElement>(null);

  React.useEffect(() => {
    if (!dropOpen) return;
    function handle(e: MouseEvent) {
      if (!dropRef.current?.contains(e.target as Node)) setDropOpen(false);
    }
    document.addEventListener("mousedown", handle);
    return () => document.removeEventListener("mousedown", handle);
  }, [dropOpen]);

  const currentLabel =
    groups.find((g) => g.id === activeGroup)?.label ?? "All";

  return (
    <div className="flex items-center gap-2 border-b px-4 py-2.5">
      <div ref={dropRef} className="relative shrink-0">
        <button
          type="button"
          onClick={() => setDropOpen((o) => !o)}
          aria-haspopup="listbox"
          aria-expanded={dropOpen}
          className="inline-flex items-center gap-1 rounded-md border px-2.5 py-1.5 text-xs font-medium transition-colors hover:bg-accent"
        >
          {currentLabel}
          <ChevronDown
            className={cn(
              "size-3 opacity-60 transition-transform",
              dropOpen && "rotate-180",
            )}
            aria-hidden
          />
        </button>

        <AnimatePresence>
          {dropOpen && (
            <motion.ul
              role="listbox"
              initial={{ opacity: 0, y: 4 }}
              animate={{ opacity: 1, y: 0 }}
              exit={{ opacity: 0, y: 4 }}
              transition={{ duration: 0.1 }}
              className="absolute left-0 top-full z-10 mt-1 min-w-[140px] overflow-hidden rounded-xl border bg-popover shadow-lg"
            >
              <li>
                <button
                  type="button"
                  role="option"
                  aria-selected={activeGroup === null}
                  onClick={() => {
                    onGroupChange(null);
                    setDropOpen(false);
                  }}
                  className={cn(
                    "w-full px-3 py-2 text-left text-xs transition-colors hover:bg-accent",
                    activeGroup === null && "font-semibold",
                  )}
                >
                  All
                </button>
              </li>
              {groups.map((g) => (
                <li key={g.id}>
                  <button
                    type="button"
                    role="option"
                    aria-selected={activeGroup === g.id}
                    onClick={() => {
                      onGroupChange(g.id);
                      setDropOpen(false);
                    }}
                    className={cn(
                      "w-full px-3 py-2 text-left text-xs transition-colors hover:bg-accent",
                      activeGroup === g.id && "font-semibold",
                    )}
                  >
                    {g.label}
                  </button>
                </li>
              ))}
            </motion.ul>
          )}
        </AnimatePresence>
      </div>

      <div className="relative flex-1">
        <Search
          className="absolute left-2.5 top-1/2 size-3.5 -translate-y-1/2 text-muted-foreground"
          aria-hidden
        />
        <input
          type="search"
          value={query}
          onChange={(e) => onQueryChange(e.target.value)}
          placeholder="Search tools"
          aria-label="Search tools"
          className="w-full rounded-md border bg-background py-1.5 pl-8 pr-3 text-xs placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring"
        />
      </div>
    </div>
  );
}

// ─── Main export ──────────────────────────────────────────────────────────────

export function ConnectorPanel({
  appName,
  appDescription,
  appIcon,
  verified = false,
  overview = [],
  links = [],
  toolGroups: initialGroups,
  onAddConnector,
  onClose,
  onPermissionChange,
  onGroupAllow,
  className,
}: ConnectorPanelProps) {
  const [toolGroups, setToolGroups] = React.useState(initialGroups);
  const [query, setQuery] = React.useState("");
  const [activeGroup, setActiveGroup] = React.useState<string | null>(null);

  function handlePermissionChange(toolId: string, permission: ToolPermission) {
    setToolGroups((prev) =>
      prev.map((group) => ({
        ...group,
        tools: group.tools.map((t) =>
          t.id === toolId ? { ...t, permission } : t,
        ),
      })),
    );
    onPermissionChange?.(toolId, permission);
  }

  function handleGroupAllow(groupId: string) {
    setToolGroups((prev) =>
      prev.map((group) =>
        group.id === groupId
          ? {
              ...group,
              tools: group.tools.map((t) => ({
                ...t,
                permission: "allow" as ToolPermission,
              })),
            }
          : group,
      ),
    );
    onGroupAllow?.(groupId);
  }

  const filteredGroups = toolGroups
    .filter((g) => !activeGroup || g.id === activeGroup)
    .map((g) => ({
      ...g,
      tools: g.tools.filter(
        (t) =>
          !query ||
          t.name.toLowerCase().includes(query.toLowerCase()) ||
          t.description.toLowerCase().includes(query.toLowerCase()),
      ),
    }))
    .filter((g) => g.tools.length > 0);

  return (
    <div
      className={cn(
        "flex w-full flex-col overflow-hidden rounded-2xl border bg-background shadow-xl",
        className,
      )}
    >
      {/* Header */}
      <div className="flex items-start gap-3 border-b px-5 py-4">
        {appIcon && (
          <div className="flex size-10 shrink-0 items-center justify-center overflow-hidden rounded-xl border bg-muted">
            {appIcon}
          </div>
        )}
        <div className="min-w-0 flex-1">
          <div className="flex items-center gap-1.5">
            <span className="text-sm font-semibold">{appName}</span>
            {verified && (
              <BadgeCheck
                className="size-4 shrink-0 text-blue-500"
                aria-label="Verified"
              />
            )}
          </div>
          <p className="mt-0.5 text-xs leading-relaxed text-muted-foreground">
            {appDescription}
          </p>
        </div>
        <div className="flex shrink-0 items-center gap-1.5">
          <button
            type="button"
            onClick={onAddConnector}
            className="inline-flex items-center gap-1.5 rounded-lg bg-foreground px-3 py-1.5 text-xs font-medium text-background transition-opacity hover:opacity-80"
          >
            <Plus className="size-3.5" aria-hidden />
            Add connector
          </button>
          {onClose && (
            <button
              type="button"
              onClick={onClose}
              aria-label="Close"
              className="rounded-md p-1.5 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
            >
              <X className="size-4" aria-hidden />
            </button>
          )}
        </div>
      </div>

      {/* Body */}
      <div className="flex min-h-0 flex-1">
        {/* Sidebar */}
        <aside
          aria-label="Connector overview"
          className="w-44 shrink-0 border-r px-4 py-4"
        >
          {overview.length > 0 && (
            <div className="mb-5">
              <p className="mb-2 text-[10px] font-semibold uppercase tracking-wider text-muted-foreground/60">
                Overview
              </p>
              <ul className="space-y-2">
                {overview.map((item, i) => (
                  <li key={i} className="flex items-start gap-1.5">
                    <span
                      className="mt-1.5 size-1.5 shrink-0 rounded-full bg-muted-foreground/40"
                      aria-hidden
                    />
                    <span className="text-xs leading-relaxed text-muted-foreground">
                      {item}
                    </span>
                  </li>
                ))}
              </ul>
            </div>
          )}

          {links.length > 0 && (
            <div>
              <p className="mb-2 text-[10px] font-semibold uppercase tracking-wider text-muted-foreground/60">
                Links
              </p>
              <ul className="space-y-1.5">
                {links.map((link, i) => {
                  const Icon = link.icon ?? ExternalLink;
                  return (
                    <li key={i}>
                      <a
                        href={link.href}
                        target="_blank"
                        rel="noopener noreferrer"
                        className="flex items-center gap-1.5 text-xs text-muted-foreground transition-colors hover:text-foreground"
                      >
                        <Icon className="size-3.5 shrink-0" aria-hidden />
                        {link.label}
                      </a>
                    </li>
                  );
                })}
              </ul>
            </div>
          )}
        </aside>

        {/* Tools column */}
        <div className="flex min-w-0 flex-1 flex-col">
          <div className="px-4 py-2.5">
            <h2 className="text-xs font-semibold">Tools</h2>
          </div>

          <FilterBar
            groups={toolGroups}
            activeGroup={activeGroup}
            onGroupChange={setActiveGroup}
            query={query}
            onQueryChange={setQuery}
          />

          <div className="flex-1 overflow-y-auto">
            {filteredGroups.length === 0 ? (
              <p className="px-4 py-10 text-center text-xs text-muted-foreground">
                No tools match your search.
              </p>
            ) : (
              filteredGroups.map((group) => (
                <ToolGroupSection
                  key={group.id}
                  group={group}
                  onPermissionChange={handlePermissionChange}
                  onGroupAllow={handleGroupAllow}
                />
              ))
            )}
          </div>
        </div>
      </div>
    </div>
  );
}
```
