Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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) ?? "");
Expand Down
Original file line number Diff line number Diff line change
@@ -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 <tool>` 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 <tool> {json}`", () => {
expect(
getPostHogExecDisplay({
command: 'call --json experiment-update {"id":1}',
}),
).toEqual({
label: "experiment-update",
input: '{"id":1}',
});
});
});
Comment thread
skoob13 marked this conversation as resolved.

describe("info verb", () => {
it("formats `info <tool>` 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 <tool>` (no field path) as field summary", () => {
expect(getPostHogExecDisplay({ command: "schema query-trends" })).toEqual(
{
label: "Inspect query-trends fields",
input: undefined,
},
);
});

it("formats `schema <tool> <field_path>` 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 });
});
});
});
110 changes: 110 additions & 0 deletions apps/code/src/renderer/features/mcp-apps/utils/posthog-exec-display.ts
Original file line number Diff line number Diff line change
@@ -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 <regex> — search by name/title/description
* info <tool> — show description + input schema
* schema <tool> [field_path] — drill into a specific field
* call [--json] <tool> <json_input> — 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 <tool>` — 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 <tool> [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] <tool> [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;
}
}
Loading