Skip to content
Merged
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
@@ -1,7 +1,9 @@
import type Anthropic from "@anthropic-ai/sdk";
import type { Client } from "@modelcontextprotocol/sdk/client/index.js";
import type { McpContext } from "@superset/mcp/auth";
import { createInMemoryMcpClient } from "@superset/mcp/in-memory";
import { createInMemoryMcpClient as createV1Client } from "@superset/mcp/in-memory";
import { createInMemoryMcpClient as createV2Client } from "@superset/mcp-v2/in-memory";
import { env } from "@/env";
import { posthog } from "@/lib/analytics";

interface McpTool {
Expand All @@ -10,6 +12,8 @@ interface McpTool {
inputSchema: unknown;
}

const SLACK_CLIENT_LABEL = "slack-agent";

// Uses InMemoryTransport — no HTTP, no forgeable headers.
export async function createSupersetMcpClient({
organizationId,
Expand All @@ -18,7 +22,7 @@ export async function createSupersetMcpClient({
organizationId: string;
userId: string;
}): Promise<{ client: Client; cleanup: () => Promise<void> }> {
return createInMemoryMcpClient({
return createV1Client({
organizationId,
userId,
source: "slack",
Expand All @@ -36,6 +40,38 @@ export async function createSupersetMcpClient({
});
}

export async function createSupersetMcpV2Client({
organizationId,
userId,
}: {
organizationId: string;
userId: string;
}): Promise<{ client: Client; cleanup: () => Promise<void> }> {
return createV2Client({
organizationId,
userId,
clientLabel: SLACK_CLIENT_LABEL,
relayUrl: env.RELAY_URL,
onToolCall: (event) => {
posthog.capture({
distinctId: event.userId,
event: "mcp_tool_called",
properties: {
tool: event.toolName,
organization_id: event.organizationId,
auth_source: event.source,
client_label: event.clientLabel,
duration_ms: event.durationMs,
success: event.success,
error_message: event.errorMessage,
mcp_server: "superset-v2",
},
groups: { organization: event.organizationId },
});
},
});
}

export function mcpToolToAnthropicTool(
tool: McpTool,
prefix: string,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,12 +1,15 @@
import Anthropic from "@anthropic-ai/sdk";
import type { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { WebClient } from "@slack/web-api";
import { FEATURE_FLAGS } from "@superset/shared/constants";
import { env } from "@/env";
import { posthog } from "@/lib/analytics";
import { DEFAULT_SLACK_MODEL } from "../../../constants";
import type { AgentAction } from "../slack-blocks";
import type { SlackImageAsset } from "../slack-image-assets";
import {
createSupersetMcpClient,
createSupersetMcpV2Client,
mcpToolToAnthropicTool,
parseToolName,
} from "./mcp-clients";
Expand Down Expand Up @@ -159,6 +162,7 @@ function getActionFromToolResult(
const data = result.structuredContent ?? parseTextContent(result.content);
if (!data) return null;

// v1 tool names — kept while the feature flag rollout is incomplete.
if (toolName === "create_task" && data.created) {
return {
type: "task_created",
Expand Down Expand Up @@ -225,6 +229,38 @@ function getActionFromToolResult(
};
}

// v2 tool names. Wire format differs: tasks_create/update return
// `{ task, txid }`, tasks_delete returns `{ txid }` only (no task info
// — we skip surfacing that as an action), workspaces_create returns
// `{ workspace: { id, name, branch }, ... }`.
if (toolName === "tasks_create" && data.task) {
const t = data.task as { id: string; slug: string; title: string };
return {
type: "task_created",
tasks: [{ id: t.id, slug: t.slug, title: t.title, status: "Backlog" }],
};
}

if (toolName === "tasks_update" && data.task) {
const t = data.task as { id: string; slug: string; title: string };
return {
type: "task_updated",
tasks: [{ id: t.id, slug: t.slug, title: t.title }],
};
}

if (toolName === "workspaces_create" && data.workspace) {
const w = data.workspace as {
id: string;
name: string;
branch: string;
};
return {
type: "workspace_created",
workspaces: [{ id: w.id, name: w.name, branch: w.branch }],
};
}

return null;
}

Expand Down Expand Up @@ -262,6 +298,7 @@ function stripServerToolBlocks(
}

const TOOL_PROGRESS_STATUS: Record<string, string> = {
// v1
create_task: "Creating task...",
update_task: "Updating task...",
delete_task: "Deleting task...",
Expand All @@ -270,18 +307,43 @@ const TOOL_PROGRESS_STATUS: Record<string, string> = {
create_workspace: "Creating workspace...",
list_workspaces: "Fetching workspaces...",
list_projects: "Fetching projects...",
// v2
tasks_create: "Creating task...",
tasks_update: "Updating task...",
tasks_delete: "Deleting task...",
tasks_list: "Searching tasks...",
tasks_get: "Fetching task details...",
workspaces_create: "Creating workspace...",
workspaces_list: "Fetching workspaces...",
workspaces_delete: "Deleting workspace...",
projects_list: "Fetching projects...",
hosts_list: "Fetching hosts...",
organization_members_list: "Fetching members...",
tasks_statuses_list: "Fetching task statuses...",
automations_list: "Fetching automations...",
automations_run: "Running automation...",
agents_list: "Fetching agents...",
agents_run: "Launching agent...",
// Server-side
slack_get_channel_history: "Reading channel history...",
};

// Tools excluded from Slack agent context
const DENIED_SUPERSET_TOOLS = new Set([
// v1 tools excluded from the Slack agent's tool list (preloaded as context).
const DENIED_SUPERSET_TOOLS_V1 = new Set([
"switch_workspace",
"get_app_context",
"list_members",
"list_task_statuses",
"list_devices",
]);

// v2 tools excluded for the same reason.
const DENIED_SUPERSET_TOOLS_V2 = new Set([
"organization_members_list",
"tasks_statuses_list",
"hosts_list",
]);

const SLACK_GET_CHANNEL_HISTORY_TOOL: Anthropic.Tool = {
name: "slack_get_channel_history",
description:
Expand Down Expand Up @@ -361,17 +423,28 @@ Context gathering:
async function fetchAgentContext({
mcpClient,
userId,
useV2,
}: {
mcpClient: Client;
userId: string;
useV2: boolean;
}): Promise<string> {
const [membersResult, statusesResult, devicesResult] = await Promise.all([
mcpClient.callTool({ name: "list_members", arguments: {} }),
mcpClient.callTool({ name: "list_task_statuses", arguments: {} }),
mcpClient.callTool({
name: "list_devices",
arguments: {},
}),
const toolNames = useV2
? {
members: "organization_members_list",
statuses: "tasks_statuses_list",
hosts: "hosts_list",
}
: {
members: "list_members",
statuses: "list_task_statuses",
hosts: "list_devices",
};

const [membersResult, statusesResult, hostsResult] = await Promise.all([
mcpClient.callTool({ name: toolNames.members, arguments: {} }),
mcpClient.callTool({ name: toolNames.statuses, arguments: {} }),
mcpClient.callTool({ name: toolNames.hosts, arguments: {} }),
]);

const sections: string[] = [];
Expand Down Expand Up @@ -403,20 +476,33 @@ async function fetchAgentContext({
sections.push(`Task statuses:\n${lines.join("\n")}`);
}

const devicesData = devicesResult.structuredContent as {
devices: {
deviceId: string;
deviceName: string | null;
ownerName: string | null;
ownerEmail: string;
}[];
} | null;
if (devicesData?.devices?.length) {
const lines = devicesData.devices.map(
(d) =>
`- ${d.deviceName ?? "Unknown"} (id: ${d.deviceId}, owner: ${d.ownerName ?? d.ownerEmail})`,
);
sections.push(`Devices:\n${lines.join("\n")}`);
if (useV2) {
// v2 hosts_list returns a bare array, which the SDK wraps as `{ result }`.
const hostsData = hostsResult.structuredContent as {
result: { id: string; name: string; online: boolean }[];
} | null;
if (hostsData?.result?.length) {
const lines = hostsData.result.map(
(h) => `- ${h.name} (id: ${h.id}, online: ${h.online ? "yes" : "no"})`,
);
sections.push(`Hosts:\n${lines.join("\n")}`);
}
} else {
const devicesData = hostsResult.structuredContent as {
devices: {
deviceId: string;
deviceName: string | null;
ownerName: string | null;
ownerEmail: string;
}[];
} | null;
if (devicesData?.devices?.length) {
const lines = devicesData.devices.map(
(d) =>
`- ${d.deviceName ?? "Unknown"} (id: ${d.deviceId}, owner: ${d.ownerName ?? d.ownerEmail})`,
);
sections.push(`Devices:\n${lines.join("\n")}`);
}
}

return sections.join("\n\n");
Expand Down Expand Up @@ -464,6 +550,15 @@ export async function runSlackAgent(
const anthropic = new Anthropic({ apiKey: env.ANTHROPIC_API_KEY });
const actions: AgentAction[] = [];

let useV2 = false;
try {
useV2 = Boolean(
await posthog.getFeatureFlag(FEATURE_FLAGS.SLACK_MCP_V2, params.userId),
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: Evaluate slack-mcp-v2 using organization scope, not user scope, to preserve the intended per-org rollout behavior.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/api/src/app/api/integrations/slack/events/utils/run-agent/run-agent.ts, line 556:

<comment>Evaluate `slack-mcp-v2` using organization scope, not user scope, to preserve the intended per-org rollout behavior.</comment>

<file context>
@@ -553,11 +553,7 @@ export async function runSlackAgent(
-				params.organizationId,
-				{ groups: { organization: params.organizationId } },
-			),
+			await posthog.getFeatureFlag(FEATURE_FLAGS.SLACK_MCP_V2, params.userId),
 		);
 	} catch (error) {
</file context>
Suggested change
await posthog.getFeatureFlag(FEATURE_FLAGS.SLACK_MCP_V2, params.userId),
await posthog.getFeatureFlag(
FEATURE_FLAGS.SLACK_MCP_V2,
params.organizationId,
{ groups: { organization: params.organizationId } },
),

);
} catch (error) {
console.warn("[slack-agent] Failed to load mcp-v2 flag:", error);
}

let supersetMcp: Client | null = null;
let cleanupSuperset: (() => Promise<void>) | null = null;

Expand All @@ -474,10 +569,15 @@ export async function runSlackAgent(
channelId: params.channelId,
threadTs: params.threadTs,
}),
createSupersetMcpClient({
organizationId: params.organizationId,
userId: params.userId,
}),
useV2
? createSupersetMcpV2Client({
organizationId: params.organizationId,
userId: params.userId,
})
: createSupersetMcpClient({
organizationId: params.organizationId,
userId: params.userId,
}),
]);

supersetMcp = supersetMcpResult.client;
Expand All @@ -488,11 +588,15 @@ export async function runSlackAgent(
fetchAgentContext({
mcpClient: supersetMcp,
userId: params.userId,
useV2,
}),
]);

const deniedTools = useV2
? DENIED_SUPERSET_TOOLS_V2
: DENIED_SUPERSET_TOOLS_V1;
const supersetTools = supersetToolsResult.tools
.filter((t) => !DENIED_SUPERSET_TOOLS.has(t.name))
.filter((t) => !deniedTools.has(t.name))
.map((t) => mcpToolToAnthropicTool(t, "superset"));

const tools: Anthropic.Messages.ToolUnion[] = [
Expand Down
22 changes: 22 additions & 0 deletions packages/cli/src/commands/organization/members/list/command.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { number, string, table } from "@superset/cli-framework";
import { command } from "../../../../lib/command";

export default command({
description: "List members of the active organization",
options: {
search: string().alias("s").desc("Search by name or email"),
limit: number().default(50).desc("Max results"),
},
display: (data) =>
table(
data as Record<string, unknown>[],
["name", "email", "role", "id"],
["NAME", "EMAIL", "ROLE", "ID"],
),
run: async ({ ctx, options }) => {
return ctx.api.organization.members.list.query({
search: options.search ?? undefined,
limit: options.limit,
});
},
});
3 changes: 3 additions & 0 deletions packages/cli/src/commands/organization/members/meta.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export default {
description: "Manage organization members",
};
15 changes: 15 additions & 0 deletions packages/cli/src/commands/tasks/statuses/list/command.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { table } from "@superset/cli-framework";
import { command } from "../../../../lib/command";

export default command({
description: "List task statuses in the active organization",
display: (data) =>
table(
data as Record<string, unknown>[],
["name", "type", "position", "id"],
["NAME", "TYPE", "POS", "ID"],
),
run: async ({ ctx }) => {
return ctx.api.task.statuses.list.query();
},
});
3 changes: 3 additions & 0 deletions packages/cli/src/commands/tasks/statuses/meta.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export default {
description: "Manage task statuses",
};
Loading
Loading