Skill: maverick-software/zapier-mcp (version 1.0.0)

--- name: zapier-mcp description: Connect 8,000+ apps via Zapier MCP. Includes full UI integration for Clawdbot Gateway dashboard. Use when setting up Zapier integration, connecting apps, or using Zapier tools via mcporter. metadata: {"clawdbot":{"emoji":"⚡","requires":{"bins":["mcporter"],"clawdbot":">=2026.1.0"},"category":"integrations"}} --- # Zapier MCP Connect your AI agent to 8,000+ apps via Zapier's MCP (Model Context Protocol) integration. This skill provides: - **Full UI Dashboard** — Configure your MCP URL, test connections, browse available tools - **No OAuth Complexity** — Zapier handles all authentication flows - **MCP Integration** — Tools are accessible via mcporter ## Overview Zapier MCP exposes your configured Zapier actions as tools your agent can call. Unlike Pipedream (which requires OAuth token management), Zapier MCP uses a simple URL-based authentication — just paste your MCP URL and you're connected. ## Prerequisites 1. **Zapier Account** — Sign up at [zapier.com](https://zapier.com) 2. **mcporter** — MCP tool runner (`npm install -g mcporter`) 3. **Clawdbot Gateway** — v2026.1.0 or later with UI enabled ## Quick Start ### Step 1: Get Your Zapier MCP URL 1. Go to [zapier.com/mcp](https://zapier.com/mcp) and sign in 2. Configure which actions to expose (e.g., "Send Slack message", "Create Google Sheet row") 3. Copy your personalized MCP URL (looks like `https://actions.zapier.com/mcp/...`) ### Step 2: Configure in Clawdbot UI 1. Open Clawdbot Dashboard → **Tools** → **Zapier** 2. Click **Configure** 3. Paste your MCP URL 4. Click **Save** That's it! Zapier will validate the URL and show how many tools are available. ### Step 3: Use Your Tools Once connected, tools are available via mcporter: ```bash # List available tools mcporter list zapier-mcp --schema # Call a tool mcporter call zapier-mcp.<tool_name> --args '{"instructions": "your request"}' ``` ## Usage Patterns ### The `instructions` Parameter Every Zapier tool accepts an `instructions` parameter. Zapier's AI interprets this to fill in missing parameters: ```bash # ❌ Vague - may prompt for clarification mcporter call zapier-mcp.slack_send_message \ --args '{"instructions": "Send a message"}' # ✅ Specific - AI can resolve parameters mcporter call zapier-mcp.slack_send_message \ --args '{"instructions": "Send \"Hello team!\" to the #general channel"}' ``` ### The `output_hint` Parameter Control what data is returned: ```bash mcporter call zapier-mcp.google_sheets_find_row \ --args '{ "instructions": "Find row where email is [REDACTED_SECRET]", "output_hint": "name, email, phone number" }' ``` ### Common Tool Patterns ```bash # Slack mcporter call zapier-mcp.slack_send_message \ --args '{"instructions": "Send \"Build complete!\" to #deployments"}' # Gmail mcporter call zapier-mcp.gmail_send_email \ --args '{"instructions": "Send email to [REDACTED_SECRET] with subject \"Meeting\" and body \"See you at 3pm\""}' # Google Sheets mcporter call zapier-mcp.google_sheets_create_row \ --args '{"instructions": "Add row with Name=John, Email=[REDACTED_SECRET] to Sales Leads spreadsheet"}' # Notion mcporter call zapier-mcp.notion_create_page \ --args '{"instructions": "Create page titled \"Meeting Notes\" in the Team Wiki database"}' # Calendar mcporter call zapier-mcp.google_calendar_create_event \ --args '{"instructions": "Create meeting \"Team Standup\" tomorrow at 10am for 30 minutes"}' ``` ## Architecture ### How It Works 1. You configure actions in Zapier's MCP dashboard 2. Zapier generates a unique MCP URL for your account 3. Clawdbot stores the URL in mcporter config 4. When you call a tool, mcporter sends a JSON-RPC request to Zapier 5. Zapier executes the action and returns results ### Files Modified | Location | Purpose | |----------|---------| | `~/clawd/config/mcporter.json` | MCP server configuration with Zapier URL | ### Backend Endpoints The skill uses these gateway RPC methods: | Method | Purpose | |--------|---------| | `zapier.status` | Get connection status and tool count | | `zapier.save` | Validate and store MCP URL | | `zapier.test` | Test the connection | | `zapier.disconnect` | Remove Zapier from mcporter config | | `zapier.tools` | List all available tools | ### SSE Response Format Zapier MCP uses Server-Sent Events format: ``` event: message data: {"result":{"tools":[...]},"jsonrpc":"2.0","id":1} ``` The backend automatically parses this format. ## UI Features The Zapier page in Clawdbot dashboard provides: - **Connection Status** — Shows if configured and tool count - **MCP URL Configuration** — Paste and validate your URL - **Test Connection** — Verify the URL works - **Tool Browser** — Expandable groups showing all available tools by app ### Tool Grouping Tools are automatically grouped by app: - QuickBooks Online (47 tools) - Google Sheets (12 tools) - Slack (8 tools) - etc. ## Comparison: Zapier vs Pipedream | Feature | Zapier MCP | Pipedream Connect | |---------|------------|-------------------| | Setup | Paste URL | OAuth + credentials | | Token refresh | Not needed | Every 45 minutes | | Apps | 8,000+ | 2,000+ | | Cost | Zapier subscription | Pipedream subscription | | Complexity | Simple | More control | **Use Zapier when:** You want simple setup and already use Zapier. **Use Pipedream when:** You need fine-grained OAuth control or prefer Pipedream's pricing. ## Troubleshooting ### "Connection test failed" - Verify the URL is correct (should start with `https://actions.zapier.com/mcp/`) - Check that you've configured at least one action in Zapier's MCP dashboard - Try regenerating the MCP URL in Zapier ### "No tools available" - Go to [zapier.com/mcp](https://zapier.com/mcp) and add some actions - Click "Refresh" in the Clawdbot UI after adding actions ### "followUpQuestion" in response - Zapier needs more information. Be more specific in your `instructions` parameter. - Example: Instead of "find customer", use "find customer named Acme Corp" ### Tool not found - Run `mcporter list zapier-mcp` to see available tools - Tool names use underscores: `quickbooks_online_find_customer` - You may need to add the action in Zapier's MCP configuration ## Adding App-Specific Skills Once Zapier MCP is connected, you can create app-specific skills for commonly used integrations. See: - `zapier-quickbooks` — QuickBooks Online tools with detailed parameter documentation These skills provide deeper documentation for specific apps while using the same underlying Zapier MCP connection. ## Reference Files This skill includes reference implementations: - `reference/zapier-backend.ts` — Gateway RPC handlers - `reference/zapier-controller.ts` — UI controller logic - `reference/zapier-views.ts` — UI rendering (Lit) These are for reference when building custom integrations or debugging. ## Security Considerations | Behavior | Description | |----------|-------------| | **URL contains auth** | Your MCP URL includes authentication — treat it like a password | | **Stored in config** | URL saved to `~/clawd/config/mcporter.json` | | **External API calls** | Calls `actions.zapier.com` | **Best practices:** - Don't share your MCP URL publicly - Regenerate the URL if compromised (in Zapier dashboard) - Review which actions are exposed in Zapier's MCP settings ## Support - **ClawdHub**: [clawdhub.com/skills/zapier-mcp](https://clawdhub.com/skills/zapier-mcp) - **Zapier MCP**: [zapier.com/mcp](https://zapier.com/mcp) - **Zapier Help**: [help.zapier.com](https://help.zapier.com) - **Clawdbot Discord**: [discord.com/invite/clawd](https://discord.com/invite/clawd)

path: reference/zapier-controller.ts
content: import type { GatewayBrowserClient } from "../gateway";
import type { ZapierState } from "../views/zapier";

export function initialZapierState(): ZapierState {
  return {
    loading: false,
    configured: false,
    mcpUrl: "",
    showForm: false,
    toolCount: undefined,
    error: null,
    success: null,
    testing: false,
    tools: [],
  };
}

type SetState = (fn: (prev: ZapierState) => ZapierState) => void;

export async function loadZapierState(
  client: GatewayBrowserClient,
  setState: SetState
): Promise<void> {
  setState((prev) => ({ ...prev, loading: true, error: null }));

  try {
    const result = await client.request("zapier.status", {}) as {
      configured?: boolean;
      mcpUrl?: string;
      toolCount?: number;
      testError?: string;
      error?: string;
    };

    if (result.error) {
      setState((prev) => ({
        ...prev,
        loading: false,
        error: result.error ?? "Unknown error",
      }));
      return;
    }

    setState((prev) => ({
      ...prev,
      loading: false,
      configured: result.configured ?? false,
      mcpUrl: result.mcpUrl ?? "",
      toolCount: result.toolCount,
      error: result.testError ?? null,
    }));
  } catch (err) {
    const message = err instanceof Error ? err.message : String(err);
    setState((prev) => ({
      ...prev,
      loading: false,
      error: `Failed to load status: ${message}`,
    }));
  }
}

export async function saveZapierUrl(
  client: GatewayBrowserClient,
  state: ZapierState,
  setState: SetState
): Promise<void> {
  if (!state.mcpUrl.trim()) {
    setState((prev) => ({ ...prev, error: "MCP URL is required" }));
    return;
  }

  setState((prev) => ({ ...prev, loading: true, error: null, success: null }));

  try {
    const result = await client.request("zapier.save", { mcpUrl: state.mcpUrl }) as {
      success?: boolean;
      toolCount?: number;
      message?: string;
      error?: string;
    };

    if (!result.success) {
      setState((prev) => ({
        ...prev,
        loading: false,
        error: result.error ?? "Failed to save",
      }));
      return;
    }

    setState((prev) => ({
      ...prev,
      loading: false,
      configured: true,
      showForm: false,
      toolCount: result.toolCount,
      success: result.message ?? "Zapier connected!",
    }));

    setTimeout(() => {
      setState((prev) => ({ ...prev, success: null }));
    }, 3000);
  } catch (err) {
    const message = err instanceof Error ? err.message : String(err);
    setState((prev) => ({
      ...prev,
      loading: false,
      error: `Failed to save: ${message}`,
    }));
  }
}

export async function testZapier(
  client: GatewayBrowserClient,
  state: ZapierState,
  setState: SetState
): Promise<void> {
  setState((prev) => ({ ...prev, testing: true, error: null, success: null }));

  try {
    const result = await client.request("zapier.test", { mcpUrl: state.mcpUrl }) as {
      success?: boolean;
      toolCount?: number;
      error?: string;
    };

    if (!result.success) {
      setState((prev) => ({
        ...prev,
        testing: false,
        error: `Test failed: ${result.error ?? "Unknown error"}`,
      }));
      return;
    }

    setState((prev) => ({
      ...prev,
      testing: false,
      toolCount: result.toolCount,
      success: `Connection working! ${result.toolCount} tools available.`,
    }));

    setTimeout(() => {
      setState((prev) => ({ ...prev, success: null }));
    }, 3000);
  } catch (err) {
    const message = err instanceof Error ? err.message : String(err);
    setState((prev) => ({
      ...prev,
      testing: false,
      error: `Test failed: ${message}`,
    }));
  }
}

export async function disconnectZapier(
  client: GatewayBrowserClient,
  setState: SetState
): Promise<void> {
  setState((prev) => ({ ...prev, loading: true, error: null }));

  try {
    const result = await client.request("zapier.disconnect", {}) as {
      success?: boolean;
      message?: string;
      error?: string;
    };

    if (!result.success) {
      setState((prev) => ({
        ...prev,
        loading: false,
        error: result.error ?? "Failed to disconnect",
      }));
      return;
    }

    setState((prev) => ({
      ...prev,
      loading: false,
      configured: false,
      mcpUrl: "",
      toolCount: undefined,
      tools: [],
      success: result.message ?? "Disconnected",
    }));

    setTimeout(() => {
      setState((prev) => ({ ...prev, success: null }));
    }, 3000);
  } catch (err) {
    const message = err instanceof Error ? err.message : String(err);
    setState((prev) => ({
      ...prev,
      loading: false,
      error: `Failed to disconnect: ${message}`,
    }));
  }
}

export async function loadZapierTools(
  client: GatewayBrowserClient,
  setState: SetState
): Promise<void> {
  try {
    const result = await client.request("zapier.tools", {}) as {
      success?: boolean;
      tools?: Array<{ name: string; description?: string }>;
      error?: string;
    };

    if (!result.success) {
      return;
    }

    setState((prev) => ({
      ...prev,
      tools: result.tools ?? [],
    }));
  } catch {
    // Silently fail - tools list is optional
  }
}


path: reference/README.md
content: # Zapier MCP Reference Files

These files are the source implementations for the Zapier MCP integration in Clawdbot.

## Files

| File | Description |
|------|-------------|
| `zapier-backend.ts` | Gateway RPC handlers (`zapier.status`, `zapier.save`, etc.) |
| `zapier-controller.ts` | UI state management and API calls |
| `zapier-views.ts` | Lit templates for the dashboard UI |

## Installation

These files are included in Clawdbot v2026.1.0+. No manual installation required.

If you need to customize or debug:

1. Backend handlers are in `src/gateway/server-methods/zapier.ts`
2. UI controller is in `ui/src/ui/controllers/zapier.ts`
3. UI views are in `ui/src/ui/views/zapier.ts`

## API Reference

### Backend RPC Methods

```typescript
// Get connection status
zapier.status: {} → { configured, mcpUrl, toolCount, testError }

// Save MCP URL
zapier.save: { mcpUrl } → { success, toolCount, message, error }

// Test connection
zapier.test: { mcpUrl? } → { success, toolCount, error }

// Disconnect
zapier.disconnect: {} → { success, message, error }

// List tools
zapier.tools: {} → { success, tools[], error }
```

### SSE Response Parsing

Zapier returns Server-Sent Events format. The backend parses:

```
event: message
data: {"result":...,"jsonrpc":"2.0","id":1}
```

Extract JSON from the `data:` line.


path: reference/zapier-backend.ts
content: import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";

import type { GatewayRequestHandlers } from "./types.js";

const ZAPIER_SERVER_NAME = "zapier-mcp";

// Find mcporter config file (same logic as pipedream.ts)
function findMcporterConfig(): string | null {
  const candidates = [
    path.join(os.homedir(), "clawd", "config", "mcporter.json"),
    path.join(os.homedir(), "clawdbot", "config", "mcporter.json"),
    path.join(os.homedir(), ".config", "mcporter", "config.json"),
    path.join(os.homedir(), ".mcporter.json"),
  ];

  for (const configPath of candidates) {
    if (fs.existsSync(configPath)) {
      return configPath;
    }
  }
  return null;
}

type McpServerEntry = {
  baseUrl?: string;
  command?: string;
  headers?: Record<string, string>;
  env?: Record<string, string>;
};

type McporterConfig = {
  mcpServers?: Record<string, McpServerEntry>;
  imports?: unknown[];
};

function readMcporterConfig(): McporterConfig | null {
  const configPath = findMcporterConfig();
  if (!configPath) return null;

  try {
    const content = fs.readFileSync(configPath, "utf-8");
    return JSON.parse(content) as McporterConfig;
  } catch {
    return null;
  }
}

function writeMcporterConfig(config: McporterConfig): boolean {
  let configPath = findMcporterConfig();

  if (!configPath) {
    // Create default path
    configPath = path.join(os.homedir(), "clawd", "config", "mcporter.json");
    const dir = path.dirname(configPath);
    if (!fs.existsSync(dir)) {
      fs.mkdirSync(dir, { recursive: true });
    }
  }

  try {
    fs.writeFileSync(configPath, JSON.stringify(config, null, 2));
    return true;
  } catch {
    return false;
  }
}

function getZapierConfig(): { configured: boolean; mcpUrl: string | null; serverName: string } {
  const config = readMcporterConfig();
  if (!config?.mcpServers) {
    return { configured: false, mcpUrl: null, serverName: ZAPIER_SERVER_NAME };
  }

  const zapierEntry = config.mcpServers[ZAPIER_SERVER_NAME];
  if (!zapierEntry?.baseUrl) {
    return { configured: false, mcpUrl: null, serverName: ZAPIER_SERVER_NAME };
  }

  return {
    configured: true,
    mcpUrl: zapierEntry.baseUrl,
    serverName: ZAPIER_SERVER_NAME,
  };
}

/**
 * Parse SSE response from Zapier MCP.
 * Zapier returns responses in Server-Sent Events format:
 *   event: message
 *   data: {"result":...,"jsonrpc":"2.0","id":1}
 */
function parseSseResponse(text: string): unknown {
  const lines = text.split("\n");
  for (const line of lines) {
    if (line.startsWith("data: ")) {
      const jsonStr = line.slice(6).trim();
      if (jsonStr) {
        return JSON.parse(jsonStr);
      }
    }
  }
  // Fallback: try parsing the whole thing as JSON
  return JSON.parse(text);
}

async function testZapierUrl(
  mcpUrl: string,
): Promise<{ success: boolean; toolCount?: number; error?: string }> {
  try {
    const response = await fetch(mcpUrl, {
      method: "POST",
      headers: {
        "Content-Type": "application/json",
        Accept: "application/json, text/event-stream",
      },
      body: JSON.stringify({
        jsonrpc: "2.0",
        id: 1,
        method: "tools/list",
      }),
    });

    if (!response.ok) {
      return { success: false, error: `HTTP ${response.status}: ${response.statusText}` };
    }

    const text = await response.text();
    const data = parseSseResponse(text) as {
      error?: { message?: string };
      result?: { tools?: unknown[] };
    };

    if (data.error) {
      return { success: false, error: data.error.message || "API error" };
    }

    const toolCount = data.result?.tools?.length || 0;
    return { success: true, toolCount };
  } catch (err) {
    const message = err instanceof Error ? err.message : String(err);
    return { success: false, error: message };
  }
}

export const zapierHandlers: GatewayRequestHandlers = {
  "zapier.status": async ({ respond }) => {
    try {
      const status = getZapierConfig();

      let toolCount: number | undefined;
      let testError: string | undefined;

      // If configured, test the connection
      if (status.configured && status.mcpUrl) {
        const testResult = await testZapierUrl(status.mcpUrl);
        if (testResult.success) {
          toolCount = testResult.toolCount;
        } else {
          testError = testResult.error;
        }
      }

      respond(true, {
        configured: status.configured,
        mcpUrl: status.mcpUrl,
        serverName: status.serverName,
        toolCount,
        testError,
      });
    } catch (err) {
      const message = err instanceof Error ? err.message : String(err);
      respond(true, { configured: false, error: message });
    }
  },

  "zapier.save": async ({ respond, params }) => {
    try {
      const mcpUrl = typeof params?.mcpUrl === "string" ? params.mcpUrl.trim() : "";

      if (!mcpUrl) {
        respond(true, { success: false, error: "MCP URL is required" });
        return;
      }

      // Validate URL format
      if (!mcpUrl.startsWith("https://")) {
        respond(true, { success: false, error: "MCP URL must use HTTPS" });
        return;
      }

      // Test the URL first
      const testResult = await testZapierUrl(mcpUrl);
      if (!testResult.success) {
        respond(true, { success: false, error: `Connection test failed: ${testResult.error}` });
        return;
      }

      // Read existing config
      let config = readMcporterConfig();
      if (!config) {
        config = { mcpServers: {}, imports: [] };
      }
      if (!config.mcpServers) {
        config.mcpServers = {};
      }

      // Add/update Zapier server entry
      // Zapier MCP uses SSE transport, needs Accept header for text/event-stream
      config.mcpServers[ZAPIER_SERVER_NAME] = {
        baseUrl: mcpUrl,
        headers: {
          "Content-Type": "application/json",
          Accept: "application/json, text/event-stream",
        },
      };

      // Write config
      const written = writeMcporterConfig(config);
      if (!written) {
        respond(true, { success: false, error: "Failed to write config file" });
        return;
      }

      respond(true, {
        success: true,
        toolCount: testResult.toolCount,
        serverName: ZAPIER_SERVER_NAME,
        message: `Zapier MCP connected with ${testResult.toolCount} tools available`,
      });
    } catch (err) {
      const message = err instanceof Error ? err.message : String(err);
      respond(true, { success: false, error: message });
    }
  },

  "zapier.test": async ({ respond, params }) => {
    try {
      let mcpUrl = typeof params?.mcpUrl === "string" ? params.mcpUrl.trim() : "";

      if (!mcpUrl) {
        // Use saved URL if not provided
        const status = getZapierConfig();
        if (!status.mcpUrl) {
          respond(true, { success: false, error: "No URL configured" });
          return;
        }
        mcpUrl = status.mcpUrl;
      }

      const testResult = await testZapierUrl(mcpUrl);
      respond(true, testResult);
    } catch (err) {
      const message = err instanceof Error ? err.message : String(err);
      respond(true, { success: false, error: message });
    }
  },

  "zapier.disconnect": async ({ respond }) => {
    try {
      const config = readMcporterConfig();
      if (!config?.mcpServers?.[ZAPIER_SERVER_NAME]) {
        respond(true, { success: true, message: "Already disconnected" });
        return;
      }

      delete config.mcpServers[ZAPIER_SERVER_NAME];

      const written = writeMcporterConfig(config);
      if (!written) {
        respond(true, { success: false, error: "Failed to write config file" });
        return;
      }

      respond(true, { success: true, message: "Zapier MCP disconnected" });
    } catch (err) {
      const message = err instanceof Error ? err.message : String(err);
      respond(true, { success: false, error: message });
    }
  },

  "zapier.tools": async ({ respond }) => {
    try {
      const status = getZapierConfig();
      if (!status.configured || !status.mcpUrl) {
        respond(true, { success: false, error: "Not configured", tools: [] });
        return;
      }

      const response = await fetch(status.mcpUrl, {
        method: "POST",
        headers: {
          "Content-Type": "application/json",
          Accept: "application/json, text/event-stream",
        },
        body: JSON.stringify({
          jsonrpc: "2.0",
          id: 1,
          method: "tools/list",
        }),
      });

      if (!response.ok) {
        respond(true, { success: false, error: `HTTP ${response.status}`, tools: [] });
        return;
      }

      const text = await response.text();
      const data = parseSseResponse(text) as {
        error?: { message?: string };
        result?: { tools?: Array<{ name: string; description?: string }> };
      };

      if (data.error) {
        respond(true, { success: false, error: data.error.message, tools: [] });
        return;
      }

      const tools = data.result?.tools || [];
      respond(true, { success: true, tools });
    } catch (err) {
      const message = err instanceof Error ? err.message : String(err);
      respond(true, { success: false, error: message, tools: [] });
    }
  },
};


path: reference/zapier-views.ts
content: import { html, nothing } from "lit";

export type ZapierTool = { name: string; description?: string };

export type ZapierState = {
  loading: boolean;
  configured: boolean;
  mcpUrl: string;
  showForm: boolean;
  toolCount?: number;
  error: string | null;
  success: string | null;
  testing: boolean;
  tools?: Array<ZapierTool>;
  expandedGroups?: Set<string>;
};

export type ZapierProps = ZapierState & {
  onConfigure: () => void;
  onSave: () => void;
  onCancel: () => void;
  onUrlChange: (value: string) => void;
  onTest: () => void;
  onDisconnect: () => void;
  onRefresh: () => void;
  onLoadTools: () => void;
  onToggleGroup: (groupName: string) => void;
};

/**
 * Extract a friendly group name from a tool name.
 * e.g., "quickbooks_online_find_customer" -> "QuickBooks Online"
 * e.g., "get_configuration_url" -> "Configuration"
 */
function getToolGroup(toolName: string): string {
  // Common prefixes to group by
  const prefixMappings: Record<string, string> = {
    "quickbooks_online": "QuickBooks Online",
    "google_sheets": "Google Sheets",
    "google_calendar": "Google Calendar",
    "google_docs": "Google Docs",
    "google_drive": "Google Drive",
    "slack": "Slack",
    "gmail": "Gmail",
    "notion": "Notion",
    "airtable": "Airtable",
    "hubspot": "HubSpot",
    "salesforce": "Salesforce",
    "trello": "Trello",
    "asana": "Asana",
    "jira": "Jira",
    "github": "GitHub",
    "discord": "Discord",
    "twitter": "Twitter",
    "linkedin": "LinkedIn",
    "stripe": "Stripe",
    "shopify": "Shopify",
    "mailchimp": "Mailchimp",
    "zendesk": "Zendesk",
    "intercom": "Intercom",
    "twilio": "Twilio",
    "dropbox": "Dropbox",
    "zoom": "Zoom",
    "calendly": "Calendly",
  };

  // Check known prefixes
  for (const [prefix, friendlyName] of Object.entries(prefixMappings)) {
    if (toolName.startsWith(prefix + "_")) {
      return friendlyName;
    }
  }

  // Fall back to extracting prefix before first action word
  const actionWords = ["find", "create", "update", "delete", "send", "get", "list", "add", "remove", "void", "attach"];
  for (const action of actionWords) {
    const idx = toolName.indexOf("_" + action);
    if (idx > 0) {
      const prefix = toolName.substring(0, idx);
      // Convert snake_case to Title Case
      return prefix.split("_").map(w => w.charAt(0).toUpperCase() + w.slice(1)).join(" ");
    }
  }

  // If no pattern matches, use "Other"
  return "Other";
}

/**
 * Get a short action name from the full tool name.
 * e.g., "quickbooks_online_find_customer" -> "find_customer"
 */
function getToolShortName(toolName: string, groupPrefix: string): string {
  // Find the prefix in the tool name and strip it
  const lowerGroup = groupPrefix.toLowerCase().replace(/ /g, "_");
  if (toolName.startsWith(lowerGroup + "_")) {
    return toolName.substring(lowerGroup.length + 1);
  }
  return toolName;
}

type ToolGroup = {
  name: string;
  tools: ZapierTool[];
};

/**
 * Group tools by their app/service prefix and render as expandable sections.
 */
function renderGroupedTools(props: ZapierProps) {
  const tools = props.tools || [];
  const expandedGroups = props.expandedGroups || new Set<string>();

  // Group tools by their prefix
  const groupMap = new Map<string, ZapierTool[]>();
  for (const tool of tools) {
    const groupName = getToolGroup(tool.name);
    if (!groupMap.has(groupName)) {
      groupMap.set(groupName, []);
    }
    groupMap.get(groupName)!.push(tool);
  }

  // Convert to array and sort by group name
  const groups: ToolGroup[] = Array.from(groupMap.entries())
    .map(([name, tools]) => ({ name, tools }))
    .sort((a, b) => {
      // Put "Other" at the end
      if (a.name === "Other") return 1;
      if (b.name === "Other") return -1;
      return a.name.localeCompare(b.name);
    });

  return html`
    <div style="margin-top: 12px; max-height: 400px; overflow-y: auto;">
      ${groups.map((group) => {
        const isExpanded = expandedGroups.has(group.name);
        const groupPrefix = group.name.toLowerCase().replace(/ /g, "_");
        
        return html`
          <div style="border: 1px solid var(--border); border-radius: 8px; margin-bottom: 8px; overflow: hidden;">
            <div
              style="
                display: flex;
                justify-content: space-between;
                align-items: center;
                padding: 12px 16px;
                background: var(--bg-secondary);
                cursor: pointer;
                user-select: none;
              "
              @click=${() => props.onToggleGroup(group.name)}
            >
              <div style="display: flex; align-items: center; gap: 8px;">
                <span style="font-size: 12px; opacity: 0.6;">${isExpanded ? "▼" : "▶"}</span>
                <span style="font-weight: 600;">${group.name}</span>
                <span class="chip" style="font-size: 11px;">${group.tools.length} tools</span>
              </div>
            </div>
            ${isExpanded
              ? html`
                  <div style="border-top: 1px solid var(--border);">
                    ${group.tools.map((tool) => {
                      const shortName = getToolShortName(tool.name, group.name);
                      return html`
                        <div style="padding: 10px 16px; border-bottom: 1px solid var(--border);">
                          <div style="font-family: monospace; font-size: 13px; color: var(--text-primary);">
                            ${shortName}
                          </div>
                          ${tool.description
                            ? html`<div style="font-size: 12px; opacity: 0.7; margin-top: 4px;">${tool.description}</div>`
                            : nothing}
                        </div>
                      `;
                    })}
                  </div>
                `
              : nothing}
          </div>
        `;
      })}
    </div>
  `;
}

export function renderZapier(props: ZapierProps) {
  const statusLabel = props.configured ? "Active" : "Not Configured";
  const statusClass = props.configured ? "chip-ok" : "chip-warn";

  return html`
    <div class="page-header" style="display: flex; justify-content: space-between; align-items: flex-start;">
      <div>
        <h1>Zapier</h1>
        <p class="muted">Connect to 8,000+ apps via Zapier MCP.</p>
      </div>
      <button
        class="btn"
        ?disabled=${props.loading}
        @click=${props.onRefresh}
        title="Refresh status"
      >
        ${props.loading ? "Loading..." : "↻ Refresh"}
      </button>
    </div>

    ${props.error
      ? html`<div class="callout danger" style="margin-bottom: 16px;">${props.error}</div>`
      : nothing}
    
    ${props.success
      ? html`<div class="callout success" style="margin-bottom: 16px;">${props.success}</div>`
      : nothing}

    <section class="card">
      <div class="row" style="justify-content: space-between; align-items: flex-start;">
        <div>
          <div class="card-title">
            ⚡ Connection Status
            <span class="chip ${statusClass}" style="margin-left: 8px;">${statusLabel}</span>
          </div>
          <div class="card-sub">
            ${props.configured
              ? `Connected${props.toolCount ? ` with ${props.toolCount} tools available.` : "."}`
              : "Configure your Zapier MCP URL to get started."}
          </div>
        </div>
      </div>
    </section>

    <section class="card" style="margin-top: 16px;">
      <div class="row" style="justify-content: space-between; align-items: center;">
        <div class="card-title">🔗 MCP URL</div>
        ${!props.showForm
          ? html`
              <button class="btn" @click=${props.onConfigure}>
                ${props.configured ? "Edit" : "Configure"}
              </button>
            `
          : nothing}
      </div>

      ${props.showForm
        ? html`
            <div style="margin-top: 12px; padding: 16px; border: 1px solid var(--border); border-radius: 8px; background: var(--bg-secondary);">
              <div class="callout info" style="margin-bottom: 16px; font-size: 13px;">
                Get your MCP URL from
                <a href="https://zapier.com/mcp" target="_blank">zapier.com/mcp</a>
              </div>

              <label class="field">
                <span>Zapier MCP URL</span>
                <input
                  type="text"
                  .value=${props.mcpUrl}
                  @input=${(e: Event) => props.onUrlChange((e.target as HTMLInputElement).value)}
                  placeholder="https://actions.zapier.com/mcp/..."
                />
              </label>

              <div class="row" style="margin-top: 16px; gap: 8px;">
                <button class="btn primary" ?disabled=${props.loading} @click=${props.onSave}>
                  ${props.loading ? "Saving..." : "Save"}
                </button>
                <button class="btn" @click=${props.onCancel}>Cancel</button>
              </div>
            </div>
          `
        : html`
            <div class="card-sub" style="margin-top: 8px;">
              ${props.configured
                ? html`
                    <div style="margin-top: 12px;">
                      <code style="word-break: break-all;">${props.mcpUrl}</code>
                    </div>
                    <div class="row" style="margin-top: 16px; gap: 8px;">
                      <button
                        class="btn small"
                        ?disabled=${props.testing}
                        @click=${props.onTest}
                      >
                        ${props.testing ? "Testing..." : "Test Connection"}
                      </button>
                      <button class="btn small danger" @click=${props.onDisconnect}>
                        Disconnect
                      </button>
                    </div>
                  `
                : html`<p class="muted">No URL configured. Click "Configure" to get started.</p>`}
            </div>
          `}
    </section>

    ${props.configured
      ? html`
          <section class="card" style="margin-top: 16px;">
            <div class="row" style="justify-content: space-between; align-items: center;">
              <div class="card-title">🔧 Available Tools</div>
              <button class="btn small" @click=${props.onLoadTools}>
                ${props.tools && props.tools.length > 0 ? "Refresh" : "Load Tools"}
              </button>
            </div>
            ${props.tools && props.tools.length > 0
              ? renderGroupedTools(props)
              : html`
                  <div class="card-sub" style="margin-top: 8px;">
                    <p class="muted">Click "Load Tools" to see available Zapier actions.</p>
                  </div>
                `}
          </section>
        `
      : nothing}

    <section class="card" style="margin-top: 16px;">
      <div class="card-title">📚 Setup Guide</div>
      <div class="card-sub" style="margin-top: 8px;">
        <ol style="margin: 12px 0; padding-left: 20px; line-height: 1.8;">
          <li>
            <strong>Get your MCP URL</strong> — Go to
            <a href="https://zapier.com/mcp" target="_blank">zapier.com/mcp</a>
            and sign in
          </li>
          <li>
            <strong>Configure actions</strong> — Choose which apps and actions to expose to your agent
          </li>
          <li><strong>Copy URL</strong> — Copy your personalized MCP URL</li>
          <li><strong>Paste above</strong> — Enter the URL in the form above</li>
          <li>
            <strong>Use naturally</strong> — Just ask your agent to "Send a Slack message" or
            "Add a row to my spreadsheet"
          </li>
        </ol>
      </div>
    </section>

    <section class="card" style="margin-top: 16px;">
      <div class="card-title">🤖 Using with MCPorter</div>
      <div class="card-sub" style="margin-top: 8px;">
        <p>Once connected, your Zapier tools are available via MCPorter:</p>
        <pre style="margin-top: 12px; padding: 12px; background: var(--bg-secondary); border-radius: 6px; overflow-x: auto; font-size: 12px;"><code># List available Zapier tools
mcporter list zapier-mcp --schema

# Call a specific tool
mcporter call zapier-mcp.&lt;tool_name&gt; param=value</code></pre>
      </div>
    </section>
  `;
}


Security scan verdicts — ClawScan: suspicious | static analysis: clean | VirusTotal: suspicious | SkillSpector: suspicious (MEDIUM)