mcp-connection-form.tsx6.6 KBView on GitHub
'use client';

import {
  Field,
  FieldRows,
  FormActions,
  FormError,
  TextField,
  TextareaField,
} from '@/components/ui/field';
import { AlertTriangle, Loader2, Search } from 'lucide-react';
import { Button } from '@/components/ui/button';

import { canSubmitConnectionForm, type McpProbeStatus, type McpToolRow } from './mcp-tool-policy';
import { McpToolChecklist } from './mcp-tool-checklist';

export interface McpConnectionFormValues {
  name: string;
  serverUrl: string;
  authorizationHeader: string;
  instructions: string;
}

interface McpConnectionFormProps {
  mode: 'add' | 'edit';
  values: McpConnectionFormValues;
  onChange: (patch: Partial<McpConnectionFormValues>) => void;
  /** Tool discovery is part of connecting, not of editing an existing connection. */
  probeStatus: McpProbeStatus;
  probeError: string | null;
  tools: McpToolRow[];
  onProbe: () => void;
  onToggleTool: (toolName: string, allowed: boolean) => void;
  onToolInstructionChange: (toolName: string, instruction: string) => void;
  onSubmit: () => void;
  onCancel: () => void;
  isSaving: boolean;
  /**
   * The "share with my organization" control, supplied by the caller.
   *
   * A slot rather than a prop pair, because the switch writes through the caller's
   * mutation and this file is presentational. It sits HERE, in the add-a-server form,
   * and not in the picker that lists servers: it describes the connection you are
   * creating, so a picker-level toggle was a setting floating next to a list it did
   * not apply to.
   */
  orgShare?: React.ReactNode;
}

/**
 * The add / edit form, presentational only.
 *
 * Tool permissions are captured here, at setup, rather than on a settings page the
 * user never revisits — so the checklist sits between the credential fields and the
 * Connect button, and the connection saves deny-by-default.
 */
export function McpConnectionForm({
  mode,
  values,
  onChange,
  probeStatus,
  probeError,
  tools,
  onProbe,
  onToggleTool,
  onToolInstructionChange,
  onSubmit,
  onCancel,
  isSaving,
  orgShare,
}: McpConnectionFormProps) {
  const isEditing = mode === 'edit';
  const canSubmit = canSubmitConnectionForm({
    name: values.name,
    serverUrl: values.serverUrl,
    probeStatus,
    isSaving,
  });

  return (
    // `bare`, and NO heading of its own. This form is always rendered INSIDE
    // something that already frames and titles it — the connections dialog, an
    // inspector panel. A bordered card inside a dialog is two frames around one
    // thing, and an "Add MCP Connection" heading under the dialog's own header is a
    // title inside a title (CLAUDE.md → UI affordances).
    <FieldRows bare>
      <TextField
        label="Name"
        placeholder="e.g. Outreach Tracker"
        value={values.name}
        onChange={(e) => onChange({ name: e.target.value })}
      />

      <TextField
        label="Server URL"
        placeholder="https://mcp.notion.com/mcp"
        value={values.serverUrl}
        onChange={(e) => onChange({ serverUrl: e.target.value })}
      />

      <TextField
        label="API key / token"
        optional
        type="password"
        placeholder="your_access_token_here"
        value={values.authorizationHeader}
        onChange={(e) => onChange({ authorizationHeader: e.target.value })}
        hint={
          isEditing
            ? 'Blank keeps the existing token.'
            : 'Stored encrypted, sent as a Bearer header.'
        }
      />

      <TextareaField
        label="Description"
        optional
        placeholder="e.g. Tracks outreach contacts. Use this to check status or mark someone as done after outreach."
        value={values.instructions}
        onChange={(e) => onChange({ instructions: e.target.value })}
        hint="Tell the agent when and how to use this connection."
      />

      {/* Sharing belongs to the ACT of adding a server — it is a property of the
          connection being created, not of the picker you got here from. */}
      {orgShare}

      {!isEditing && (
        <>
          {/* The button belongs on the label's row: "Tool permissions — [Check available tools]"
              is the whole instruction. The paragraph that used to sit here told the reader to do
              the thing the button next to it does. */}
          <Field label="Tool permissions">
            <Button
              type="button"
              variant="outline"
              size="sm"
              className="h-8 cursor-pointer gap-1.5 text-xs"
              onClick={onProbe}
              disabled={probeStatus === 'probing' || !values.serverUrl.trim()}
            >
              {probeStatus === 'probing' ? (
                <Loader2 className="h-3.5 w-3.5 animate-spin" />
              ) : (
                <Search className="h-3.5 w-3.5" />
              )}
              {probeStatus === 'idle' ? 'Check available tools' : 'Check again'}
            </Button>
          </Field>

          {probeStatus === 'failed' && (
            <div className="px-4 pb-4">
              <FormError tone="warning" className="space-y-1">
                <p className="flex items-center gap-1.5 font-medium">
                  <AlertTriangle className="h-3.5 w-3.5 flex-shrink-0" />
                  Could not reach this server
                </p>
                {probeError && <p className="break-words opacity-80">{probeError}</p>}
                <p className="opacity-80">
                  You can still save this connection — no tool is enabled until you enable it, so
                  nothing is granted by saving. Retry the check from Permissions once the server is
                  up.
                </p>
              </FormError>
            </div>
          )}

          {probeStatus === 'ready' && (
            <div className="px-4 pb-4">
              <McpToolChecklist
                tools={tools}
                onToggle={onToggleTool}
                onInstructionChange={onToolInstructionChange}
                idPrefix="mcp-add-tool"
                emptyMessage="This server reported no tools."
              />
            </div>
          )}
        </>
      )}

      <FormActions className="p-4 pt-2">
        <Button onClick={onSubmit} disabled={!canSubmit} size="sm" className="cursor-pointer">
          {isSaving
            ? isEditing
              ? 'Saving…'
              : 'Connecting…'
            : isEditing
              ? 'Save changes'
              : 'Connect'}
        </Button>
        <Button
          variant="ghost"
          size="sm"
          className="cursor-pointer"
          onClick={onCancel}
          disabled={isSaving}
        >
          Cancel
        </Button>
      </FormActions>
    </FieldRows>
  );
}