diff --git a/apps/code/src/renderer/features/mcp-apps/components/McpToolView.tsx b/apps/code/src/renderer/features/mcp-apps/components/McpToolView.tsx index e5a9a4a9b..ef692dddf 100644 --- a/apps/code/src/renderer/features/mcp-apps/components/McpToolView.tsx +++ b/apps/code/src/renderer/features/mcp-apps/components/McpToolView.tsx @@ -8,12 +8,19 @@ import { stripCodeFences, ToolTitle, type ToolViewProps, + truncateText, useToolCallStatus, } from "@features/sessions/components/session-update/toolCallUtils"; import { Plugs } from "@phosphor-icons/react"; import { Box, Flex } from "@radix-ui/themes"; import { useState } from "react"; import { parseMcpToolKey } from "../utils/mcp-app-host-utils"; +import { + getPostHogExecDisplay, + isPostHogExecTool, +} from "../utils/posthog-exec-display"; + +const POSTHOG_EXEC_INPUT_PREVIEW_MAX_LENGTH = 60; interface McpToolViewProps extends ToolViewProps { mcpToolName: string; @@ -34,8 +41,21 @@ export function McpToolView({ turnComplete, ); - const { serverName, toolName } = parseMcpToolKey(mcpToolName); - const inputPreview = compactInput(rawInput); + const { serverName: defaultServerName, toolName: defaultToolName } = + parseMcpToolKey(mcpToolName); + const posthogDisplay = isPostHogExecTool(mcpToolName) + ? getPostHogExecDisplay(rawInput) + : null; + const serverName = posthogDisplay ? "posthog" : defaultServerName; + const toolName = posthogDisplay?.label ?? defaultToolName; + const inputPreview = posthogDisplay + ? posthogDisplay.input + ? truncateText( + posthogDisplay.input, + POSTHOG_EXEC_INPUT_PREVIEW_MAX_LENGTH, + ) + : undefined + : compactInput(rawInput); const fullInput = formatInput(rawInput); const output = stripCodeFences(getContentText(content) ?? ""); diff --git a/apps/code/src/renderer/features/mcp-apps/utils/posthog-exec-display.test.ts b/apps/code/src/renderer/features/mcp-apps/utils/posthog-exec-display.test.ts new file mode 100644 index 000000000..8b19a03cd --- /dev/null +++ b/apps/code/src/renderer/features/mcp-apps/utils/posthog-exec-display.test.ts @@ -0,0 +1,201 @@ +import { describe, expect, it } from "vitest"; +import { + getPostHogExecDisplay, + isPostHogExecTool, +} from "./posthog-exec-display"; + +describe("isPostHogExecTool", () => { + it("matches the bare posthog exec tool", () => { + expect(isPostHogExecTool("mcp__posthog__exec")).toBe(true); + }); + + it("matches plugin-prefixed variants", () => { + expect(isPostHogExecTool("mcp__posthog_posthog__exec")).toBe(true); + expect(isPostHogExecTool("mcp__plugin_posthog_posthog__exec")).toBe(true); + expect(isPostHogExecTool("mcp__posthog_cloud__exec")).toBe(true); + }); + + it("rejects other MCP tools", () => { + expect(isPostHogExecTool("mcp__posthog__list")).toBe(false); + expect(isPostHogExecTool("mcp__other__exec")).toBe(false); + expect(isPostHogExecTool("Bash")).toBe(false); + }); +}); + +describe("getPostHogExecDisplay", () => { + describe("call verb", () => { + it("collapses `call ` to the bare sub-tool label", () => { + expect( + getPostHogExecDisplay({ command: "call experiment-list" }), + ).toEqual({ + label: "experiment-list", + input: undefined, + }); + }); + + it("uses the JSON args portion as input", () => { + expect( + getPostHogExecDisplay({ + command: 'call execute-sql {"query":"SELECT 1"}', + }), + ).toEqual({ + label: "execute-sql", + input: '{"query":"SELECT 1"}', + }); + }); + + it("handles `call --json {json}`", () => { + expect( + getPostHogExecDisplay({ + command: 'call --json experiment-update {"id":1}', + }), + ).toEqual({ + label: "experiment-update", + input: '{"id":1}', + }); + }); + }); + + describe("info verb", () => { + it("formats `info ` with no args", () => { + expect(getPostHogExecDisplay({ command: "info execute-sql" })).toEqual({ + label: "Read execute-sql", + input: undefined, + }); + }); + + it("falls back to a generic label when no tool given", () => { + expect(getPostHogExecDisplay({ command: "info" })).toEqual({ + label: "Read tool", + input: undefined, + }); + }); + }); + + describe("schema verb", () => { + it("formats `schema ` (no field path) as field summary", () => { + expect(getPostHogExecDisplay({ command: "schema query-trends" })).toEqual( + { + label: "Inspect query-trends fields", + input: undefined, + }, + ); + }); + + it("formats `schema ` as a dotted locator", () => { + expect( + getPostHogExecDisplay({ + command: "schema query-trends series", + }), + ).toEqual({ + label: "Inspect query-trends.series", + input: undefined, + }); + }); + + it("supports dotted field paths", () => { + expect( + getPostHogExecDisplay({ + command: "schema query-trends breakdownFilter.breakdowns", + }), + ).toEqual({ + label: "Inspect query-trends.breakdownFilter.breakdowns", + input: undefined, + }); + }); + }); + + describe("search verb", () => { + it("uses the regex pattern as input", () => { + expect(getPostHogExecDisplay({ command: "search query-" })).toEqual({ + label: "Search tools", + input: "query-", + }); + }); + + it("falls back to bare `Search tools` when no pattern given", () => { + expect(getPostHogExecDisplay({ command: "search" })).toEqual({ + label: "Search tools", + input: undefined, + }); + }); + }); + + describe("tools verb", () => { + it("formats bare `tools`", () => { + expect(getPostHogExecDisplay({ command: "tools" })).toEqual({ + label: "List tools", + input: undefined, + }); + }); + }); + + describe("explicit input field", () => { + it("prefers an explicit string `input` over command-embedded args (call)", () => { + expect( + getPostHogExecDisplay({ + command: 'call execute-sql {"query":"SELECT 1"}', + input: "SELECT 2", + }), + ).toEqual({ label: "execute-sql", input: "SELECT 2" }); + }); + + it("prefers an explicit object `input` (serialised) over command-embedded args (call)", () => { + expect( + getPostHogExecDisplay({ + command: "call execute-sql", + input: { query: "SELECT 1" }, + }), + ).toEqual({ label: "execute-sql", input: '{"query":"SELECT 1"}' }); + }); + + it("folds explicit `input` into the schema dotted locator", () => { + expect( + getPostHogExecDisplay({ + command: "schema query-trends", + input: "series.0", + }), + ).toEqual({ label: "Inspect query-trends.series.0", input: undefined }); + }); + + it("ignores empty-string explicit input and falls back to command args", () => { + expect( + getPostHogExecDisplay({ + command: 'call execute-sql {"query":"x"}', + input: " ", + }), + ).toEqual({ label: "execute-sql", input: '{"query":"x"}' }); + }); + }); + + describe("malformed / unsupported", () => { + it("returns null for unknown verbs", () => { + expect(getPostHogExecDisplay({ command: "unknown-verb foo" })).toBeNull(); + expect(getPostHogExecDisplay({ command: "list" })).toBeNull(); + expect(getPostHogExecDisplay({ command: "run something" })).toBeNull(); + }); + + it("returns null for missing or malformed input", () => { + expect(getPostHogExecDisplay(undefined)).toBeNull(); + expect(getPostHogExecDisplay(null)).toBeNull(); + expect(getPostHogExecDisplay({})).toBeNull(); + expect(getPostHogExecDisplay({ command: 42 })).toBeNull(); + expect(getPostHogExecDisplay({ command: "" })).toBeNull(); + }); + + it("returns null for `call` with no sub-tool", () => { + expect(getPostHogExecDisplay({ command: "call" })).toBeNull(); + expect(getPostHogExecDisplay({ command: "call " })).toBeNull(); + }); + + it("tolerates leading/trailing whitespace around the verb", () => { + expect(getPostHogExecDisplay({ command: " tools " })).toEqual({ + label: "List tools", + input: undefined, + }); + expect( + getPostHogExecDisplay({ command: " call execute-sql " }), + ).toEqual({ label: "execute-sql", input: undefined }); + }); + }); +}); diff --git a/apps/code/src/renderer/features/mcp-apps/utils/posthog-exec-display.ts b/apps/code/src/renderer/features/mcp-apps/utils/posthog-exec-display.ts new file mode 100644 index 000000000..f77f1e3c2 --- /dev/null +++ b/apps/code/src/renderer/features/mcp-apps/utils/posthog-exec-display.ts @@ -0,0 +1,110 @@ +/** + * The PostHog MCP exposes a single `exec` dispatcher that runs CLI-style + * subcommands. Generic MCP rendering would show this as + * `posthog - exec (MCP) {"command":"call execute-sql {…}"}` — pure plumbing + * with the dispatched action buried inside a JSON wrapper. + * + * These helpers pull the action out of the `command` string so the row can + * read `posthog - execute-sql {…}` (call), `posthog - Read execute-sql` + * (info), `posthog - Inspect query-trends.series` (schema), + * `posthog - Search tools query-` (search), or `posthog - List tools` + * (tools) instead. + * + * Supported verbs (per the `exec` tool description): + * tools — list every tool + * search — search by name/title/description + * info — show description + input schema + * schema [field_path] — drill into a specific field + * call [--json] — invoke a tool + */ + +const POSTHOG_EXEC_TOOL_RE = /^mcp__(?:plugin_)?posthog(?:_[^_]+)*__exec$/; + +const POSTHOG_VERB_RE = + /^\s*(tools|search|info|schema|call)(?:\s+([\s\S]*))?\s*$/; +const POSTHOG_CALL_BODY_RE = /^(?:--json\s+)?([a-zA-Z0-9_-]+)\s*([\s\S]*)$/; +const POSTHOG_TOOL_NAME_RE = /^([a-zA-Z0-9_-]+)\s*([\s\S]*)$/; + +export interface PostHogExecDisplay { + /** Replaces the tool name in the title — e.g. "execute-sql", "Read execute-sql". */ + label: string; + /** Args to show as the input preview, undefined when there is none to display. */ + input?: string; +} + +export function isPostHogExecTool(toolName: string): boolean { + return POSTHOG_EXEC_TOOL_RE.test(toolName); +} + +export function getPostHogExecDisplay( + toolInput: unknown, +): PostHogExecDisplay | null { + if (!toolInput || typeof toolInput !== "object") return null; + const obj = toolInput as { command?: unknown; input?: unknown }; + + if (typeof obj.command !== "string") return null; + const verbMatch = obj.command.match(POSTHOG_VERB_RE); + if (!verbMatch) return null; + + const verb = verbMatch[1] as "tools" | "search" | "info" | "schema" | "call"; + const rest = (verbMatch[2] ?? "").trim(); + const explicitInput = readExplicitInput(obj.input); + + switch (verb) { + case "tools": + // `tools` returns names only, not full schemas — "List", not "Read". + return { label: "List tools", input: undefined }; + + case "search": + return { + label: "Search tools", + input: explicitInput ?? (rest.length > 0 ? rest : undefined), + }; + + case "info": + // `info ` — fold the tool name into the label so the args slot stays clean. + return rest.length > 0 + ? { label: `Read ${rest}`, input: undefined } + : { label: "Read tool", input: undefined }; + + case "schema": { + // `schema [field_path]` is the drill-down verb. Fold the + // tool + path into a dotted locator so it reads as one path. + const m = rest.match(POSTHOG_TOOL_NAME_RE); + if (!m) return { label: "Inspect schema", input: undefined }; + const subTool = m[1]; + const fieldPath = (m[2] ?? "").trim(); + const path = + explicitInput ?? (fieldPath.length > 0 ? fieldPath : undefined); + return { + label: path + ? `Inspect ${subTool}.${path}` + : `Inspect ${subTool} fields`, + input: undefined, + }; + } + + case "call": { + // `call [--json] [json_input]` — collapse the verb, surface the + // sub-tool as the label and the JSON body as args. + const m = rest.match(POSTHOG_CALL_BODY_RE); + if (!m) return null; + const subTool = m[1]; + const args = (m[2] ?? "").trim(); + return { + label: subTool, + input: explicitInput ?? (args.length > 0 ? args : undefined), + }; + } + } +} + +function readExplicitInput(value: unknown): string | undefined { + if (value === undefined || value === null) return undefined; + if (typeof value === "string") return value.trim() || undefined; + try { + return JSON.stringify(value); + } catch { + return undefined; + } +}