{"slug":"connector-panel","category":"permissions","job":"steer","added":"2026-09-15T20:37Z","title":"Connector Panel","description":"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.","url":"https://ai-patterns.app/patterns/permissions/connector-panel","markdown":"https://ai-patterns.app/patterns/permissions/connector-panel.md","json":"https://ai-patterns.app/api/patterns/permissions/connector-panel","seenIn":[{"team":"Claude","source":"https://support.claude.com/en/articles/11176164"},{"team":"chatgpt","source":"https://developers.openai.com/api/docs/guides/developer-mode"},{"team":"Intercom","source":"https://www.intercom.com/help/en/articles/11461635-add-mcp-connectors-for-popular-apps-or-custom-mcps"},{"team":"Perplexity","source":"https://www.perplexity.ai/help-center/en/collections/18799295-connectors-integrations"}],"spec":"# Connector Panel\n\n## Summary\nA 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.\n\n## When to use\n- When onboarding a new MCP server or reviewing an existing integration's permissions before activating it.\n- 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.\n- In an agent settings area, marketplace, or connector catalog — not inline in an agent chat transcript.\n\n## When not to use\n- For in-transcript per-call approval (one tool, one moment) — use Tool Approval instead. This panel manages standing permissions, not live prompts.\n- As a generic settings form or feature-flag manager unrelated to agent tool permissions.\n- 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.\n\n## Anatomy\n- **Header**: app icon, name, verified badge (if publisher-verified), one-line description, \"Add connector\" CTA, close button.\n- **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.\n- **Filter bar**: Group filter dropdown (All + each group name) and a text search that matches on tool name or description.\n- **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.\n- **Tool row**: tool name, protocol badge (e.g. \"MCP\"), description, and a three-way permission toggle (Disable / Always ask / Allow).\n\n## Behavior\n- Search immediately filters tool rows by name or description substring across all groups.\n- The group dropdown constrains the visible groups; combining it with search narrows within that group.\n- The group-level \"Allow\" button sets every tool in the group to Allow in one action.\n- Per-tool permission is a mutually exclusive three-way toggle: only one of Disable, Always ask, Allow is active at a time.\n- Groups are individually collapsible; collapsing hides tool rows without changing their permissions.\n- 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.\n\n## Content guidelines\n- Overview bullets phrase capabilities from the user's perspective: \"Access deployment logs for debugging\", not \"Provides log access\".\n- Tool names match the tool's identifier exactly — no paraphrasing.\n- 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\").\n- The verified badge appears only when the publisher's identity has been confirmed — don't use it as a general quality signal.\n- Protocol badges (\"MCP\") are short, literal, and uppercased.\n\n## Accessibility\n- The permission toggle group for each tool uses `role=\"group\"` with `aria-label` naming the tool (\"Permission for Check domain availability\").\n- Each permission button exposes its active state via `aria-pressed`.\n- The verified badge icon carries `aria-label=\"Verified\"` so it's not invisible to screen readers.\n- Group collapse buttons set `aria-expanded` to reflect the open/closed state.\n- The group filter dropdown follows the listbox disclosure pattern: `aria-haspopup=\"listbox\"`, `aria-expanded`, closes on Escape or outside click.\n- The search input has `aria-label=\"Search tools\"`.\n\n## Related patterns\n- **Tool Approval** — Use it as well for in-context permission on each call, while this panel manages standing permissions set beforehand.\n- **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.\n- **Agent Triggers** — Use it as a layout reference, since it shares the sidebar overview plus main content shape.\n","code":"\"use client\";\n\nimport * as React from \"react\";\nimport { AnimatePresence, motion } from \"motion/react\";\nimport {\n  BadgeCheck,\n  ChevronDown,\n  ExternalLink,\n  Plus,\n  Search,\n  X,\n} from \"lucide-react\";\n\nimport { cn } from \"@/lib/utils\";\n\nexport type ToolPermission = \"allow\" | \"always-ask\" | \"disable\";\n\nexport interface ConnectorTool {\n  id: string;\n  name: string;\n  description: string;\n  badge?: string;\n  permission: ToolPermission;\n}\n\nexport interface ConnectorToolGroup {\n  id: string;\n  label: string;\n  tools: ConnectorTool[];\n}\n\nexport interface ConnectorLink {\n  label: string;\n  href: string;\n  icon?: React.ComponentType<{ className?: string }>;\n}\n\nexport interface ConnectorPanelProps {\n  appName: string;\n  appDescription: string;\n  appIcon?: React.ReactNode;\n  verified?: boolean;\n  overview?: string[];\n  links?: ConnectorLink[];\n  toolGroups: ConnectorToolGroup[];\n  onAddConnector?: () => void;\n  onClose?: () => void;\n  onPermissionChange?: (toolId: string, permission: ToolPermission) => void;\n  onGroupAllow?: (groupId: string) => void;\n  className?: string;\n}\n\n// ─── Permission toggle ────────────────────────────────────────────────────────\n\nfunction PermissionToggle({\n  value,\n  onChange,\n  toolName,\n}: {\n  value: ToolPermission;\n  onChange: (p: ToolPermission) => void;\n  toolName: string;\n}) {\n  const options: { value: ToolPermission; label: string }[] = [\n    { value: \"disable\", label: \"Disable\" },\n    { value: \"always-ask\", label: \"Always ask\" },\n    { value: \"allow\", label: \"Allow\" },\n  ];\n\n  return (\n    <div\n      role=\"group\"\n      aria-label={`Permission for ${toolName}`}\n      className=\"flex shrink-0 items-center gap-1\"\n    >\n      {options.map((opt) => (\n        <button\n          key={opt.value}\n          type=\"button\"\n          onClick={() => onChange(opt.value)}\n          aria-pressed={value === opt.value}\n          className={cn(\n            \"rounded-md px-2.5 py-1 text-xs font-medium transition-colors\",\n            value === opt.value\n              ? \"bg-foreground text-background\"\n              : \"text-muted-foreground hover:bg-accent hover:text-foreground\",\n          )}\n        >\n          {opt.label}\n        </button>\n      ))}\n    </div>\n  );\n}\n\n// ─── Tool row ─────────────────────────────────────────────────────────────────\n\nfunction ToolRow({\n  tool,\n  onPermissionChange,\n}: {\n  tool: ConnectorTool;\n  onPermissionChange: (p: ToolPermission) => void;\n}) {\n  return (\n    <div className=\"flex items-start gap-3 border-t px-4 py-3\">\n      <div className=\"min-w-0 flex-1\">\n        <div className=\"flex flex-wrap items-center gap-1.5\">\n          <span className=\"text-xs font-semibold\">{tool.name}</span>\n          {tool.badge && (\n            <span className=\"rounded-full border px-1.5 py-0.5 text-[10px] font-medium text-muted-foreground\">\n              {tool.badge}\n            </span>\n          )}\n        </div>\n        <p className=\"mt-0.5 text-xs leading-relaxed text-muted-foreground\">\n          {tool.description}\n        </p>\n      </div>\n      <PermissionToggle\n        value={tool.permission}\n        onChange={onPermissionChange}\n        toolName={tool.name}\n      />\n    </div>\n  );\n}\n\n// ─── Tool group section ───────────────────────────────────────────────────────\n\nfunction ToolGroupSection({\n  group,\n  onPermissionChange,\n  onGroupAllow,\n}: {\n  group: ConnectorToolGroup & { tools: ConnectorTool[] };\n  onPermissionChange: (toolId: string, p: ToolPermission) => void;\n  onGroupAllow: (groupId: string) => void;\n}) {\n  const [collapsed, setCollapsed] = React.useState(false);\n  const allAllowed = group.tools.every((t) => t.permission === \"allow\");\n\n  return (\n    <div>\n      <div className=\"flex items-center gap-2 px-4 py-2\">\n        <button\n          type=\"button\"\n          onClick={() => setCollapsed((c) => !c)}\n          aria-expanded={!collapsed}\n          className=\"flex flex-1 items-center gap-1 text-xs font-medium text-muted-foreground hover:text-foreground\"\n        >\n          <ChevronDown\n            className={cn(\n              \"size-3.5 shrink-0 transition-transform\",\n              collapsed && \"-rotate-90\",\n            )}\n            aria-hidden\n          />\n          {group.label}\n        </button>\n        {!allAllowed && (\n          <button\n            type=\"button\"\n            onClick={() => onGroupAllow(group.id)}\n            className=\"rounded-md bg-foreground px-2.5 py-1 text-xs font-medium text-background transition-opacity hover:opacity-80\"\n          >\n            Allow\n          </button>\n        )}\n      </div>\n\n      <AnimatePresence initial={false}>\n        {!collapsed && (\n          <motion.div\n            initial={{ height: 0, opacity: 0 }}\n            animate={{ height: \"auto\", opacity: 1 }}\n            exit={{ height: 0, opacity: 0 }}\n            transition={{ duration: 0.18 }}\n            className=\"overflow-hidden\"\n          >\n            {group.tools.map((tool) => (\n              <ToolRow\n                key={tool.id}\n                tool={tool}\n                onPermissionChange={(p) => onPermissionChange(tool.id, p)}\n              />\n            ))}\n          </motion.div>\n        )}\n      </AnimatePresence>\n    </div>\n  );\n}\n\n// ─── Filter bar ───────────────────────────────────────────────────────────────\n\nfunction FilterBar({\n  groups,\n  activeGroup,\n  onGroupChange,\n  query,\n  onQueryChange,\n}: {\n  groups: ConnectorToolGroup[];\n  activeGroup: string | null;\n  onGroupChange: (id: string | null) => void;\n  query: string;\n  onQueryChange: (q: string) => void;\n}) {\n  const [dropOpen, setDropOpen] = React.useState(false);\n  const dropRef = React.useRef<HTMLDivElement>(null);\n\n  React.useEffect(() => {\n    if (!dropOpen) return;\n    function handle(e: MouseEvent) {\n      if (!dropRef.current?.contains(e.target as Node)) setDropOpen(false);\n    }\n    document.addEventListener(\"mousedown\", handle);\n    return () => document.removeEventListener(\"mousedown\", handle);\n  }, [dropOpen]);\n\n  const currentLabel =\n    groups.find((g) => g.id === activeGroup)?.label ?? \"All\";\n\n  return (\n    <div className=\"flex items-center gap-2 border-b px-4 py-2.5\">\n      <div ref={dropRef} className=\"relative shrink-0\">\n        <button\n          type=\"button\"\n          onClick={() => setDropOpen((o) => !o)}\n          aria-haspopup=\"listbox\"\n          aria-expanded={dropOpen}\n          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\"\n        >\n          {currentLabel}\n          <ChevronDown\n            className={cn(\n              \"size-3 opacity-60 transition-transform\",\n              dropOpen && \"rotate-180\",\n            )}\n            aria-hidden\n          />\n        </button>\n\n        <AnimatePresence>\n          {dropOpen && (\n            <motion.ul\n              role=\"listbox\"\n              initial={{ opacity: 0, y: 4 }}\n              animate={{ opacity: 1, y: 0 }}\n              exit={{ opacity: 0, y: 4 }}\n              transition={{ duration: 0.1 }}\n              className=\"absolute left-0 top-full z-10 mt-1 min-w-[140px] overflow-hidden rounded-xl border bg-popover shadow-lg\"\n            >\n              <li>\n                <button\n                  type=\"button\"\n                  role=\"option\"\n                  aria-selected={activeGroup === null}\n                  onClick={() => {\n                    onGroupChange(null);\n                    setDropOpen(false);\n                  }}\n                  className={cn(\n                    \"w-full px-3 py-2 text-left text-xs transition-colors hover:bg-accent\",\n                    activeGroup === null && \"font-semibold\",\n                  )}\n                >\n                  All\n                </button>\n              </li>\n              {groups.map((g) => (\n                <li key={g.id}>\n                  <button\n                    type=\"button\"\n                    role=\"option\"\n                    aria-selected={activeGroup === g.id}\n                    onClick={() => {\n                      onGroupChange(g.id);\n                      setDropOpen(false);\n                    }}\n                    className={cn(\n                      \"w-full px-3 py-2 text-left text-xs transition-colors hover:bg-accent\",\n                      activeGroup === g.id && \"font-semibold\",\n                    )}\n                  >\n                    {g.label}\n                  </button>\n                </li>\n              ))}\n            </motion.ul>\n          )}\n        </AnimatePresence>\n      </div>\n\n      <div className=\"relative flex-1\">\n        <Search\n          className=\"absolute left-2.5 top-1/2 size-3.5 -translate-y-1/2 text-muted-foreground\"\n          aria-hidden\n        />\n        <input\n          type=\"search\"\n          value={query}\n          onChange={(e) => onQueryChange(e.target.value)}\n          placeholder=\"Search tools\"\n          aria-label=\"Search tools\"\n          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\"\n        />\n      </div>\n    </div>\n  );\n}\n\n// ─── Main export ──────────────────────────────────────────────────────────────\n\nexport function ConnectorPanel({\n  appName,\n  appDescription,\n  appIcon,\n  verified = false,\n  overview = [],\n  links = [],\n  toolGroups: initialGroups,\n  onAddConnector,\n  onClose,\n  onPermissionChange,\n  onGroupAllow,\n  className,\n}: ConnectorPanelProps) {\n  const [toolGroups, setToolGroups] = React.useState(initialGroups);\n  const [query, setQuery] = React.useState(\"\");\n  const [activeGroup, setActiveGroup] = React.useState<string | null>(null);\n\n  function handlePermissionChange(toolId: string, permission: ToolPermission) {\n    setToolGroups((prev) =>\n      prev.map((group) => ({\n        ...group,\n        tools: group.tools.map((t) =>\n          t.id === toolId ? { ...t, permission } : t,\n        ),\n      })),\n    );\n    onPermissionChange?.(toolId, permission);\n  }\n\n  function handleGroupAllow(groupId: string) {\n    setToolGroups((prev) =>\n      prev.map((group) =>\n        group.id === groupId\n          ? {\n              ...group,\n              tools: group.tools.map((t) => ({\n                ...t,\n                permission: \"allow\" as ToolPermission,\n              })),\n            }\n          : group,\n      ),\n    );\n    onGroupAllow?.(groupId);\n  }\n\n  const filteredGroups = toolGroups\n    .filter((g) => !activeGroup || g.id === activeGroup)\n    .map((g) => ({\n      ...g,\n      tools: g.tools.filter(\n        (t) =>\n          !query ||\n          t.name.toLowerCase().includes(query.toLowerCase()) ||\n          t.description.toLowerCase().includes(query.toLowerCase()),\n      ),\n    }))\n    .filter((g) => g.tools.length > 0);\n\n  return (\n    <div\n      className={cn(\n        \"flex w-full flex-col overflow-hidden rounded-2xl border bg-background shadow-xl\",\n        className,\n      )}\n    >\n      {/* Header */}\n      <div className=\"flex items-start gap-3 border-b px-5 py-4\">\n        {appIcon && (\n          <div className=\"flex size-10 shrink-0 items-center justify-center overflow-hidden rounded-xl border bg-muted\">\n            {appIcon}\n          </div>\n        )}\n        <div className=\"min-w-0 flex-1\">\n          <div className=\"flex items-center gap-1.5\">\n            <span className=\"text-sm font-semibold\">{appName}</span>\n            {verified && (\n              <BadgeCheck\n                className=\"size-4 shrink-0 text-blue-500\"\n                aria-label=\"Verified\"\n              />\n            )}\n          </div>\n          <p className=\"mt-0.5 text-xs leading-relaxed text-muted-foreground\">\n            {appDescription}\n          </p>\n        </div>\n        <div className=\"flex shrink-0 items-center gap-1.5\">\n          <button\n            type=\"button\"\n            onClick={onAddConnector}\n            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\"\n          >\n            <Plus className=\"size-3.5\" aria-hidden />\n            Add connector\n          </button>\n          {onClose && (\n            <button\n              type=\"button\"\n              onClick={onClose}\n              aria-label=\"Close\"\n              className=\"rounded-md p-1.5 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground\"\n            >\n              <X className=\"size-4\" aria-hidden />\n            </button>\n          )}\n        </div>\n      </div>\n\n      {/* Body */}\n      <div className=\"flex min-h-0 flex-1\">\n        {/* Sidebar */}\n        <aside\n          aria-label=\"Connector overview\"\n          className=\"w-44 shrink-0 border-r px-4 py-4\"\n        >\n          {overview.length > 0 && (\n            <div className=\"mb-5\">\n              <p className=\"mb-2 text-[10px] font-semibold uppercase tracking-wider text-muted-foreground/60\">\n                Overview\n              </p>\n              <ul className=\"space-y-2\">\n                {overview.map((item, i) => (\n                  <li key={i} className=\"flex items-start gap-1.5\">\n                    <span\n                      className=\"mt-1.5 size-1.5 shrink-0 rounded-full bg-muted-foreground/40\"\n                      aria-hidden\n                    />\n                    <span className=\"text-xs leading-relaxed text-muted-foreground\">\n                      {item}\n                    </span>\n                  </li>\n                ))}\n              </ul>\n            </div>\n          )}\n\n          {links.length > 0 && (\n            <div>\n              <p className=\"mb-2 text-[10px] font-semibold uppercase tracking-wider text-muted-foreground/60\">\n                Links\n              </p>\n              <ul className=\"space-y-1.5\">\n                {links.map((link, i) => {\n                  const Icon = link.icon ?? ExternalLink;\n                  return (\n                    <li key={i}>\n                      <a\n                        href={link.href}\n                        target=\"_blank\"\n                        rel=\"noopener noreferrer\"\n                        className=\"flex items-center gap-1.5 text-xs text-muted-foreground transition-colors hover:text-foreground\"\n                      >\n                        <Icon className=\"size-3.5 shrink-0\" aria-hidden />\n                        {link.label}\n                      </a>\n                    </li>\n                  );\n                })}\n              </ul>\n            </div>\n          )}\n        </aside>\n\n        {/* Tools column */}\n        <div className=\"flex min-w-0 flex-1 flex-col\">\n          <div className=\"px-4 py-2.5\">\n            <h2 className=\"text-xs font-semibold\">Tools</h2>\n          </div>\n\n          <FilterBar\n            groups={toolGroups}\n            activeGroup={activeGroup}\n            onGroupChange={setActiveGroup}\n            query={query}\n            onQueryChange={setQuery}\n          />\n\n          <div className=\"flex-1 overflow-y-auto\">\n            {filteredGroups.length === 0 ? (\n              <p className=\"px-4 py-10 text-center text-xs text-muted-foreground\">\n                No tools match your search.\n              </p>\n            ) : (\n              filteredGroups.map((group) => (\n                <ToolGroupSection\n                  key={group.id}\n                  group={group}\n                  onPermissionChange={handlePermissionChange}\n                  onGroupAllow={handleGroupAllow}\n                />\n              ))\n            )}\n          </div>\n        </div>\n      </div>\n    </div>\n  );\n}\n","dependencies":["lucide-react","motion"],"localImports":["@/lib/utils"]}