From cba60b2949bd474fa60d78a0d2df435fd1bde8e5 Mon Sep 17 00:00:00 2001 From: Satya Patel Date: Fri, 31 Jul 2026 14:09:32 -0700 Subject: [PATCH] refactor(api): remove v1 MCP server Removes packages/mcp (18 tools), the /api/agent route and its parallel 274-line auth flow, the workspace dep, and the v1 OAuth audience. v1 URLs now return 410 Gone pointing at /api/v2/agent/mcp. Remaining v1 traffic is desktop builds predating the chat runtime change, which degrade gracefully (chat loses Superset tools, no crash); the 410 also ends the 800-second SSE timeout invocations v1 clients were causing. Claude-Session: https://claude.ai/code/session_01Kub4p3Eim9pc1qaRRmGea2 (cherry picked from commit 3c570f67e35de0e670262190e5d5028560472bd5) --- apps/api/package.json | 1 - .../app/api/agent/[transport]/auth-flow.ts | 274 ----------------- .../src/app/api/agent/[transport]/route.ts | 30 +- apps/api/src/app/openapi.json/route.ts | 2 +- apps/docs/content/docs/mcp-server.mdx | 2 +- bun.lock | 20 -- packages/auth/src/server.ts | 1 - packages/mcp/package.json | 37 --- packages/mcp/src/auth.ts | 5 - packages/mcp/src/in-memory.ts | 48 --- packages/mcp/src/index.ts | 3 - packages/mcp/src/server.ts | 49 --- .../create-workspace/create-workspace.ts | 77 ----- .../tools/devices/create-workspace/index.ts | 1 - .../delete-workspace/delete-workspace.ts | 39 --- .../tools/devices/delete-workspace/index.ts | 1 - .../get-app-context/get-app-context.ts | 34 --- .../tools/devices/get-app-context/index.ts | 1 - .../get-workspace-details.ts | 41 --- .../devices/get-workspace-details/index.ts | 1 - .../src/tools/devices/list-devices/index.ts | 1 - .../devices/list-devices/list-devices.test.ts | 168 ---------- .../devices/list-devices/list-devices.ts | 62 ---- .../src/tools/devices/list-projects/index.ts | 1 - .../devices/list-projects/list-projects.ts | 33 -- .../tools/devices/list-workspaces/index.ts | 1 - .../list-workspaces/list-workspaces.ts | 33 -- .../devices/start-agent-session/index.ts | 8 - .../devices/start-agent-session/shared.ts | 286 ------------------ .../start-agent-session-with-prompt.ts | 47 --- .../start-agent-session.test.ts | 208 ------------- .../start-agent-session.ts | 55 ---- .../tools/devices/switch-workspace/index.ts | 1 - .../switch-workspace/switch-workspace.ts | 57 ---- .../tools/devices/update-workspace/index.ts | 1 - .../update-workspace/update-workspace.ts | 45 --- packages/mcp/src/tools/index.ts | 44 --- .../tools/organizations/list-members/index.ts | 1 - .../list-members/list-members.ts | 84 ----- .../tools/tasks/create-task/create-task.ts | 161 ---------- .../mcp/src/tools/tasks/create-task/index.ts | 1 - .../tools/tasks/delete-task/delete-task.ts | 72 ----- .../mcp/src/tools/tasks/delete-task/index.ts | 1 - .../mcp/src/tools/tasks/get-task/get-task.ts | 123 -------- .../mcp/src/tools/tasks/get-task/index.ts | 1 - .../tools/tasks/list-task-statuses/index.ts | 1 - .../list-task-statuses/list-task-statuses.ts | 49 --- .../mcp/src/tools/tasks/list-tasks/index.ts | 1 - .../src/tools/tasks/list-tasks/list-tasks.ts | 236 --------------- .../mcp/src/tools/tasks/update-task/index.ts | 1 - .../tools/tasks/update-task/update-task.ts | 151 --------- packages/mcp/src/tools/utils/index.ts | 1 - packages/mcp/src/tools/utils/utils.ts | 145 --------- packages/mcp/tsconfig.json | 11 - 54 files changed, 14 insertions(+), 2744 deletions(-) delete mode 100644 apps/api/src/app/api/agent/[transport]/auth-flow.ts delete mode 100644 packages/mcp/package.json delete mode 100644 packages/mcp/src/auth.ts delete mode 100644 packages/mcp/src/in-memory.ts delete mode 100644 packages/mcp/src/index.ts delete mode 100644 packages/mcp/src/server.ts delete mode 100644 packages/mcp/src/tools/devices/create-workspace/create-workspace.ts delete mode 100644 packages/mcp/src/tools/devices/create-workspace/index.ts delete mode 100644 packages/mcp/src/tools/devices/delete-workspace/delete-workspace.ts delete mode 100644 packages/mcp/src/tools/devices/delete-workspace/index.ts delete mode 100644 packages/mcp/src/tools/devices/get-app-context/get-app-context.ts delete mode 100644 packages/mcp/src/tools/devices/get-app-context/index.ts delete mode 100644 packages/mcp/src/tools/devices/get-workspace-details/get-workspace-details.ts delete mode 100644 packages/mcp/src/tools/devices/get-workspace-details/index.ts delete mode 100644 packages/mcp/src/tools/devices/list-devices/index.ts delete mode 100644 packages/mcp/src/tools/devices/list-devices/list-devices.test.ts delete mode 100644 packages/mcp/src/tools/devices/list-devices/list-devices.ts delete mode 100644 packages/mcp/src/tools/devices/list-projects/index.ts delete mode 100644 packages/mcp/src/tools/devices/list-projects/list-projects.ts delete mode 100644 packages/mcp/src/tools/devices/list-workspaces/index.ts delete mode 100644 packages/mcp/src/tools/devices/list-workspaces/list-workspaces.ts delete mode 100644 packages/mcp/src/tools/devices/start-agent-session/index.ts delete mode 100644 packages/mcp/src/tools/devices/start-agent-session/shared.ts delete mode 100644 packages/mcp/src/tools/devices/start-agent-session/start-agent-session-with-prompt.ts delete mode 100644 packages/mcp/src/tools/devices/start-agent-session/start-agent-session.test.ts delete mode 100644 packages/mcp/src/tools/devices/start-agent-session/start-agent-session.ts delete mode 100644 packages/mcp/src/tools/devices/switch-workspace/index.ts delete mode 100644 packages/mcp/src/tools/devices/switch-workspace/switch-workspace.ts delete mode 100644 packages/mcp/src/tools/devices/update-workspace/index.ts delete mode 100644 packages/mcp/src/tools/devices/update-workspace/update-workspace.ts delete mode 100644 packages/mcp/src/tools/index.ts delete mode 100644 packages/mcp/src/tools/organizations/list-members/index.ts delete mode 100644 packages/mcp/src/tools/organizations/list-members/list-members.ts delete mode 100644 packages/mcp/src/tools/tasks/create-task/create-task.ts delete mode 100644 packages/mcp/src/tools/tasks/create-task/index.ts delete mode 100644 packages/mcp/src/tools/tasks/delete-task/delete-task.ts delete mode 100644 packages/mcp/src/tools/tasks/delete-task/index.ts delete mode 100644 packages/mcp/src/tools/tasks/get-task/get-task.ts delete mode 100644 packages/mcp/src/tools/tasks/get-task/index.ts delete mode 100644 packages/mcp/src/tools/tasks/list-task-statuses/index.ts delete mode 100644 packages/mcp/src/tools/tasks/list-task-statuses/list-task-statuses.ts delete mode 100644 packages/mcp/src/tools/tasks/list-tasks/index.ts delete mode 100644 packages/mcp/src/tools/tasks/list-tasks/list-tasks.ts delete mode 100644 packages/mcp/src/tools/tasks/update-task/index.ts delete mode 100644 packages/mcp/src/tools/tasks/update-task/update-task.ts delete mode 100644 packages/mcp/src/tools/utils/index.ts delete mode 100644 packages/mcp/src/tools/utils/utils.ts delete mode 100644 packages/mcp/tsconfig.json diff --git a/apps/api/package.json b/apps/api/package.json index 32b90efe9de..45a5678ca68 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -24,7 +24,6 @@ "@slack/web-api": "7.15.0", "@superset/auth": "workspace:*", "@superset/db": "workspace:*", - "@superset/mcp": "workspace:*", "@superset/mcp-v2": "workspace:*", "@superset/shared": "workspace:*", "@superset/trpc": "workspace:*", diff --git a/apps/api/src/app/api/agent/[transport]/auth-flow.ts b/apps/api/src/app/api/agent/[transport]/auth-flow.ts deleted file mode 100644 index 6dcda29e11c..00000000000 --- a/apps/api/src/app/api/agent/[transport]/auth-flow.ts +++ /dev/null @@ -1,274 +0,0 @@ -import type { AuthInfo } from "@modelcontextprotocol/sdk/server/auth/types.js"; -import type { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js"; -import type { createMcpServer } from "@superset/mcp"; -import type { McpContext } from "@superset/mcp/auth"; -import type { verifyAccessToken as verifyOAuthAccessToken } from "better-auth/oauth2"; -import { getOAuthProtectedResourceMetadataUrl } from "@/lib/oauth-metadata"; - -interface SessionUser { - id: string; -} - -interface SessionRecord { - activeOrganizationId?: string | null; -} - -interface SessionResponse { - session?: SessionRecord | null; - user: SessionUser; -} - -interface VerifiedApiKey { - referenceId?: string | null; - metadata?: unknown; -} - -interface VerifyApiKeyResponse { - valid: boolean; - key: VerifiedApiKey | null; -} - -export interface McpRequestDeps { - apiUrl: string; - authApi: { - getSession(args: { - headers: Headers; - }): Promise; - verifyApiKey(args: { - body: { key: string }; - }): Promise; - }; - createServer: typeof createMcpServer; - createTransport: () => WebStandardStreamableHTTPServerTransport; - verifyAccessToken: typeof verifyOAuthAccessToken; -} - -function getBearerToken(req: Request): string | undefined { - const authorization = req.headers.get("authorization"); - const match = authorization?.match(/^Bearer\s+(.+)$/i); - return match?.[1]; -} - -export function isApiKeyBearerToken(token: string): boolean { - return token.startsWith("sk_live_"); -} - -function normalizeApiUrl(apiUrl: string): string { - return apiUrl.replace(/\/+$/, ""); -} - -function getSafeAuthErrorName(error: unknown): string { - if (error && typeof error === "object") { - const errorName = "name" in error ? error.name : undefined; - if (typeof errorName === "string" && errorName.length > 0) { - return errorName; - } - - const errorCode = "code" in error ? error.code : undefined; - if (typeof errorCode === "string" && errorCode.length > 0) { - return errorCode; - } - } - - return "AuthVerificationError"; -} - -function looksLikeJwt(token: string): boolean { - const parts = token.split("."); - return parts.length === 3 && parts.every(Boolean); -} - -function buildSessionAuthInfo(session: SessionResponse): AuthInfo | undefined { - const organizationId = session.session?.activeOrganizationId; - - if (!organizationId) { - console.error("[mcp/auth] Session missing activeOrganizationId"); - return undefined; - } - - return { - token: "session", - clientId: "session", - scopes: ["mcp:full"], - extra: { - mcpContext: { - userId: session.user.id, - organizationId, - } satisfies McpContext, - }, - }; -} - -function parseApiKeyMetadata( - metadata: unknown, -): Record | null { - if (!metadata) { - return null; - } - - if (typeof metadata === "string") { - try { - const parsed = JSON.parse(metadata); - return parsed && typeof parsed === "object" - ? (parsed as Record) - : null; - } catch (error) { - console.error("[mcp/auth] Failed to parse API key metadata:", error); - return null; - } - } - - return typeof metadata === "object" - ? (metadata as Record) - : null; -} - -function buildApiKeyAuthInfo(apiKey: VerifiedApiKey): AuthInfo | undefined { - const userId = apiKey.referenceId; - - if (!userId) { - console.error("[mcp/auth] API key missing referenceId"); - return undefined; - } - - const metadata = parseApiKeyMetadata(apiKey.metadata); - const organizationId = - typeof metadata?.organizationId === "string" - ? metadata.organizationId - : undefined; - - if (!organizationId) { - console.error("[mcp/auth] API key missing organizationId in metadata"); - return undefined; - } - - return { - token: "api-key", - clientId: "api-key", - scopes: ["mcp:full"], - extra: { - mcpContext: { - userId, - organizationId, - } satisfies McpContext, - }, - }; -} - -function buildOAuthAuthInfo( - bearerToken: string, - payload: Record, -): AuthInfo | undefined { - if ( - typeof payload.sub !== "string" || - typeof payload.organizationId !== "string" - ) { - console.error( - "[mcp/auth] Access token missing sub or organizationId claim", - ); - return undefined; - } - - const scopes = Array.isArray(payload.scope) - ? (payload.scope as string[]) - : typeof payload.scope === "string" - ? payload.scope.split(" ") - : []; - - return { - token: bearerToken, - clientId: typeof payload.azp === "string" ? payload.azp : "mcp-client", - scopes, - extra: { - mcpContext: { - userId: payload.sub, - organizationId: payload.organizationId, - } satisfies McpContext, - }, - }; -} - -export async function verifyToken( - req: Request, - deps: McpRequestDeps, -): Promise { - const bearerToken = getBearerToken(req); - const apiUrl = normalizeApiUrl(deps.apiUrl); - let oauthVerificationError: unknown; - - if (bearerToken) { - if (isApiKeyBearerToken(bearerToken)) { - try { - const result = await deps.authApi.verifyApiKey({ - body: { key: bearerToken }, - }); - - if (result.valid && result.key) { - return buildApiKeyAuthInfo(result.key); - } - } catch (error) { - console.error("[mcp/auth] API key verification failed", { - errorName: getSafeAuthErrorName(error), - }); - } - - return undefined; - } - - if (looksLikeJwt(bearerToken)) { - try { - const payload = (await deps.verifyAccessToken(bearerToken, { - jwksUrl: `${apiUrl}/api/auth/jwks`, - verifyOptions: { - issuer: apiUrl, - audience: [apiUrl, `${apiUrl}/`, `${apiUrl}/api/agent/mcp`], - }, - })) as Record; - - return buildOAuthAuthInfo(bearerToken, payload); - } catch (error) { - oauthVerificationError = error; - } - } - } - - const session = await deps.authApi.getSession({ headers: req.headers }); - if (session?.session) { - return buildSessionAuthInfo(session); - } - - if (oauthVerificationError) { - console.error("[mcp/auth] Access token verification failed", { - errorName: getSafeAuthErrorName(oauthVerificationError), - }); - } - - return undefined; -} - -export function unauthorizedResponse(req: Request): Response { - return new Response("Unauthorized", { - status: 401, - headers: { - "WWW-Authenticate": `Bearer resource_metadata="${getOAuthProtectedResourceMetadataUrl( - req, - )}"`, - }, - }); -} - -export async function handleMcpRequest( - req: Request, - deps: McpRequestDeps, -): Promise { - const authInfo = await verifyToken(req, deps); - if (!authInfo) { - return unauthorizedResponse(req); - } - - const transport = deps.createTransport(); - const server = deps.createServer(); - await server.connect(transport); - - return transport.handleRequest(req, { authInfo }); -} diff --git a/apps/api/src/app/api/agent/[transport]/route.ts b/apps/api/src/app/api/agent/[transport]/route.ts index c8e72cac681..12e3bb464db 100644 --- a/apps/api/src/app/api/agent/[transport]/route.ts +++ b/apps/api/src/app/api/agent/[transport]/route.ts @@ -1,22 +1,16 @@ -import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js"; -import { auth } from "@superset/auth/server"; -import { createMcpServer } from "@superset/mcp"; -import { verifyAccessToken } from "better-auth/oauth2"; import { env } from "@/env"; -import { handleMcpRequest, type McpRequestDeps } from "./auth-flow"; -const deps: McpRequestDeps = { - apiUrl: env.NEXT_PUBLIC_API_URL, - authApi: auth.api, - createServer: createMcpServer, - createTransport: () => new WebStandardStreamableHTTPServerTransport(), - verifyAccessToken, -}; - -async function handleRequest(req: Request): Promise { - return handleMcpRequest(req, deps); +function gone(): Response { + return Response.json( + { + error: { + code: "GONE", + message: "The v1 MCP server has been removed.", + hint: `Connect to ${env.NEXT_PUBLIC_API_URL}/api/v2/agent/mcp instead.`, + }, + }, + { status: 410 }, + ); } -export const maxDuration = 800; - -export { handleRequest as GET, handleRequest as POST, handleRequest as DELETE }; +export { gone as GET, gone as POST, gone as DELETE }; diff --git a/apps/api/src/app/openapi.json/route.ts b/apps/api/src/app/openapi.json/route.ts index 2147795a03d..98ed5ce9977 100644 --- a/apps/api/src/app/openapi.json/route.ts +++ b/apps/api/src/app/openapi.json/route.ts @@ -102,7 +102,7 @@ const SPEC = { "", `Authentication is OAuth 2.1 authorization code + PKCE with RFC 7591 dynamic client registration, or a user-issued Superset API key sent as a Bearer token. Agent walkthrough: ${MARKETING_URL}/auth.md`, "", - `Versioning and deprecation: the current surface is v2, versioned in the URL path (/api/v2/...). Deprecated surfaces stay available during a migration window — the v1 MCP server at /api/agent/mcp remains served for existing integrations — and deprecations are announced in the changelog (${MARKETING_URL}/changelog).`, + `Versioning and deprecation: the current surface is v2, versioned in the URL path (/api/v2/...). Deprecations are announced in the changelog (${MARKETING_URL}/changelog). The legacy v1 MCP server at /api/agent/mcp has been removed and returns 410 Gone.`, ].join("\n"), contact: { name: "Superset support", diff --git a/apps/docs/content/docs/mcp-server.mdx b/apps/docs/content/docs/mcp-server.mdx index 2f49adf0578..d9d35945f4b 100644 --- a/apps/docs/content/docs/mcp-server.mdx +++ b/apps/docs/content/docs/mcp-server.mdx @@ -4,7 +4,7 @@ description: Connect AI agents directly to Superset via MCP --- -This document covers **MCP v2**. The v1 server is deprecated but still available at `https://api.superset.sh/api/agent/mcp` for existing integrations. +This document covers **MCP v2**. The legacy v1 server (`/api/agent/mcp`) has been removed — v1 URLs return `410 Gone`. Point your client at the v2 URL below. ## Overview diff --git a/bun.lock b/bun.lock index fe6676c2ac5..d5c2886455a 100644 --- a/bun.lock +++ b/bun.lock @@ -76,7 +76,6 @@ "@slack/web-api": "7.15.0", "@superset/auth": "workspace:*", "@superset/db": "workspace:*", - "@superset/mcp": "workspace:*", "@superset/mcp-v2": "workspace:*", "@superset/shared": "workspace:*", "@superset/trpc": "workspace:*", @@ -915,23 +914,6 @@ "node-addon-api": "7.1.1", }, }, - "packages/mcp": { - "name": "@superset/mcp", - "version": "0.1.0", - "dependencies": { - "@modelcontextprotocol/sdk": "1.28.0", - "@superset/db": "workspace:*", - "@superset/shared": "workspace:*", - "drizzle-orm": "0.45.2", - "zod": "4.3.6", - }, - "devDependencies": { - "@superset/typescript": "workspace:*", - "@types/node": "24.12.0", - "bun-types": "1.3.14", - "typescript": "6.0.3", - }, - }, "packages/mcp-v2": { "name": "@superset/mcp-v2", "version": "0.1.0", @@ -2870,8 +2852,6 @@ "@superset/marketing": ["@superset/marketing@workspace:apps/marketing"], - "@superset/mcp": ["@superset/mcp@workspace:packages/mcp"], - "@superset/mcp-v2": ["@superset/mcp-v2@workspace:packages/mcp-v2"], "@superset/mobile": ["@superset/mobile@workspace:apps/mobile"], diff --git a/packages/auth/src/server.ts b/packages/auth/src/server.ts index 8b17e6f4ccb..11ddb8aded8 100644 --- a/packages/auth/src/server.ts +++ b/packages/auth/src/server.ts @@ -244,7 +244,6 @@ export const auth = betterAuth({ validAudiences: [ env.NEXT_PUBLIC_API_URL, `${env.NEXT_PUBLIC_API_URL}/`, - `${env.NEXT_PUBLIC_API_URL}/api/agent/mcp`, `${env.NEXT_PUBLIC_API_URL}/api/v2/agent/mcp`, `${env.NEXT_PUBLIC_API_URL}/mcp`, ], diff --git a/packages/mcp/package.json b/packages/mcp/package.json deleted file mode 100644 index d2637ad6614..00000000000 --- a/packages/mcp/package.json +++ /dev/null @@ -1,37 +0,0 @@ -{ - "name": "@superset/mcp", - "version": "0.1.0", - "private": true, - "type": "module", - "exports": { - ".": { - "types": "./src/index.ts", - "default": "./src/index.ts" - }, - "./auth": { - "types": "./src/auth.ts", - "default": "./src/auth.ts" - }, - "./in-memory": { - "types": "./src/in-memory.ts", - "default": "./src/in-memory.ts" - } - }, - "scripts": { - "test": "bun test", - "typecheck": "tsc --noEmit --emitDeclarationOnly false" - }, - "dependencies": { - "@modelcontextprotocol/sdk": "1.28.0", - "@superset/db": "workspace:*", - "@superset/shared": "workspace:*", - "drizzle-orm": "0.45.2", - "zod": "4.3.6" - }, - "devDependencies": { - "@superset/typescript": "workspace:*", - "@types/node": "24.12.0", - "bun-types": "1.3.14", - "typescript": "6.0.3" - } -} diff --git a/packages/mcp/src/auth.ts b/packages/mcp/src/auth.ts deleted file mode 100644 index 6a9eaf82b5e..00000000000 --- a/packages/mcp/src/auth.ts +++ /dev/null @@ -1,5 +0,0 @@ -export interface McpContext { - userId: string; - organizationId: string; - source?: "slack" | "desktop" | "api" | "external"; -} diff --git a/packages/mcp/src/in-memory.ts b/packages/mcp/src/in-memory.ts deleted file mode 100644 index 80bcefcb994..00000000000 --- a/packages/mcp/src/in-memory.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { Client } from "@modelcontextprotocol/sdk/client/index.js"; -import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; -import type { McpContext } from "./auth"; -import { createMcpServer, type McpServerOptions } from "./server"; - -export async function createInMemoryMcpClient({ - organizationId, - userId, - source, - onToolCall, -}: { - organizationId: string; - userId: string; - source?: McpContext["source"]; - onToolCall?: McpServerOptions["onToolCall"]; -}): Promise<{ client: Client; cleanup: () => Promise }> { - const server = createMcpServer({ onToolCall }); - const [serverTransport, clientTransport] = - InMemoryTransport.createLinkedPair(); - - // Inject auth context into every message from client → server - const originalSend = clientTransport.send.bind(clientTransport); - clientTransport.send = (message, options) => - originalSend(message, { - ...options, - authInfo: { - token: "internal", - clientId: "slack-agent", - scopes: ["mcp:full"], - extra: { - mcpContext: { userId, organizationId, source } satisfies McpContext, - }, - }, - }); - - await server.connect(serverTransport); - - const client = new Client({ name: "superset-internal", version: "1.0.0" }); - await client.connect(clientTransport); - - return { - client, - cleanup: async () => { - await client.close(); - await server.close(); - }, - }; -} diff --git a/packages/mcp/src/index.ts b/packages/mcp/src/index.ts deleted file mode 100644 index 2ad1bfab449..00000000000 --- a/packages/mcp/src/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export type { McpContext } from "./auth"; -export { createMcpServer } from "./server"; -export { registerTools } from "./tools"; diff --git a/packages/mcp/src/server.ts b/packages/mcp/src/server.ts deleted file mode 100644 index cd6e82be81b..00000000000 --- a/packages/mcp/src/server.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; -import type { McpContext } from "./auth"; -import { registerTools } from "./tools"; -import { getMcpContext } from "./tools/utils"; - -export interface McpServerOptions { - onToolCall?: (toolName: string, ctx: McpContext) => void; -} - -export function createMcpServer(options?: McpServerOptions): McpServer { - const server = new McpServer( - { name: "superset", version: "1.0.0" }, - { capabilities: { tools: {} } }, - ); - - registerTools(server); - - if (options?.onToolCall) { - // The MCP SDK has no middleware API, so we wrap registered tool handlers - // directly. _registeredTools is private but stable across SDK versions. - const tools = ( - server as unknown as { - _registeredTools: Record< - string, - { handler: { callback: (...args: unknown[]) => unknown } } - >; - } - )._registeredTools; - - for (const [name, tool] of Object.entries(tools)) { - const original = tool.handler.callback; - const onToolCall = options.onToolCall; - tool.handler.callback = (...args: unknown[]) => { - try { - const extra = args[1]; - const ctx = getMcpContext( - extra as Parameters[0], - ); - onToolCall(name, ctx); - } catch { - // Don't fail the tool call if tracking fails - } - return original(...args); - }; - } - } - - return server; -} diff --git a/packages/mcp/src/tools/devices/create-workspace/create-workspace.ts b/packages/mcp/src/tools/devices/create-workspace/create-workspace.ts deleted file mode 100644 index 75f20177284..00000000000 --- a/packages/mcp/src/tools/devices/create-workspace/create-workspace.ts +++ /dev/null @@ -1,77 +0,0 @@ -import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; -import { z } from "zod"; -import { executeOnDevice, getMcpContext } from "../../utils"; - -const workspaceInputSchema = z - .object({ - name: z - .string() - .optional() - .describe("Workspace name (auto-generated if not provided)"), - branchName: z - .string() - .optional() - .describe("Branch name (auto-generated if not provided)"), - baseBranch: z - .string() - .optional() - .describe( - "Branch to create from (defaults to main). Cannot be used with sourceWorkspaceId.", - ), - sourceWorkspaceId: z - .string() - .optional() - .describe( - "ID of an existing workspace to branch from. The new workspace will be based on that workspace's current branch. Cannot be used with baseBranch.", - ), - }) - .refine((data) => !(data.baseBranch && data.sourceWorkspaceId), { - message: - "Cannot specify both baseBranch and sourceWorkspaceId. Use one or the other.", - }); - -export function register(server: McpServer) { - server.registerTool( - "create_workspace", - { - description: - "Create one or more workspaces (git worktrees) on a device. Use this when the user asks to create worktrees or workspaces.", - inputSchema: { - deviceId: z.string().describe("Target device ID"), - projectId: z.string().describe("Project ID to create workspaces in"), - workspaces: z - .array(workspaceInputSchema) - .min(1) - .max(5) - .describe("Array of workspaces to create (1-5)"), - }, - }, - async (args, extra) => { - const ctx = getMcpContext(extra); - const deviceId = args.deviceId as string; - const projectId = args.projectId as string; - const workspaces = args.workspaces as z.infer< - typeof workspaceInputSchema - >[]; - - if (!deviceId || !projectId) { - return { - content: [ - { - type: "text", - text: "Error: deviceId and projectId are required", - }, - ], - isError: true, - }; - } - - return executeOnDevice({ - ctx, - deviceId, - tool: "create_workspace", - params: { projectId, workspaces }, - }); - }, - ); -} diff --git a/packages/mcp/src/tools/devices/create-workspace/index.ts b/packages/mcp/src/tools/devices/create-workspace/index.ts deleted file mode 100644 index 2ce9cc6b818..00000000000 --- a/packages/mcp/src/tools/devices/create-workspace/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { register } from "./create-workspace"; diff --git a/packages/mcp/src/tools/devices/delete-workspace/delete-workspace.ts b/packages/mcp/src/tools/devices/delete-workspace/delete-workspace.ts deleted file mode 100644 index 34289513dcb..00000000000 --- a/packages/mcp/src/tools/devices/delete-workspace/delete-workspace.ts +++ /dev/null @@ -1,39 +0,0 @@ -import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; -import { z } from "zod"; -import { executeOnDevice, getMcpContext } from "../../utils"; - -export function register(server: McpServer) { - server.registerTool( - "delete_workspace", - { - description: "Delete one or more workspaces (git worktrees) on a device", - inputSchema: { - deviceId: z.string().describe("Target device ID"), - workspaceIds: z - .array(z.string().uuid()) - .min(1) - .max(5) - .describe("Array of workspace IDs to delete (1-5)"), - }, - }, - async (args, extra) => { - const ctx = getMcpContext(extra); - const deviceId = args.deviceId as string; - const workspaceIds = args.workspaceIds as string[]; - - if (!deviceId) { - return { - content: [{ type: "text", text: "Error: deviceId is required" }], - isError: true, - }; - } - - return executeOnDevice({ - ctx, - deviceId, - tool: "delete_workspace", - params: { workspaceIds }, - }); - }, - ); -} diff --git a/packages/mcp/src/tools/devices/delete-workspace/index.ts b/packages/mcp/src/tools/devices/delete-workspace/index.ts deleted file mode 100644 index 10bd1d26194..00000000000 --- a/packages/mcp/src/tools/devices/delete-workspace/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { register } from "./delete-workspace"; diff --git a/packages/mcp/src/tools/devices/get-app-context/get-app-context.ts b/packages/mcp/src/tools/devices/get-app-context/get-app-context.ts deleted file mode 100644 index 80cf61a86b4..00000000000 --- a/packages/mcp/src/tools/devices/get-app-context/get-app-context.ts +++ /dev/null @@ -1,34 +0,0 @@ -import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; -import { z } from "zod"; -import { executeOnDevice, getMcpContext } from "../../utils"; - -export function register(server: McpServer) { - server.registerTool( - "get_app_context", - { - description: - "Get the current app context on a device, including pathname and active workspace. The target device must belong to the current user.", - inputSchema: { - deviceId: z.string().describe("Target device ID"), - }, - }, - async (args, extra) => { - const ctx = getMcpContext(extra); - const deviceId = args.deviceId as string; - - if (!deviceId) { - return { - content: [{ type: "text", text: "Error: deviceId is required" }], - isError: true, - }; - } - - return executeOnDevice({ - ctx, - deviceId, - tool: "get_app_context", - params: {}, - }); - }, - ); -} diff --git a/packages/mcp/src/tools/devices/get-app-context/index.ts b/packages/mcp/src/tools/devices/get-app-context/index.ts deleted file mode 100644 index 00f89158e9f..00000000000 --- a/packages/mcp/src/tools/devices/get-app-context/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { register } from "./get-app-context"; diff --git a/packages/mcp/src/tools/devices/get-workspace-details/get-workspace-details.ts b/packages/mcp/src/tools/devices/get-workspace-details/get-workspace-details.ts deleted file mode 100644 index 8eb8c85d592..00000000000 --- a/packages/mcp/src/tools/devices/get-workspace-details/get-workspace-details.ts +++ /dev/null @@ -1,41 +0,0 @@ -import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; -import { z } from "zod"; -import { executeOnDevice, getMcpContext } from "../../utils"; - -export function register(server: McpServer) { - server.registerTool( - "get_workspace_details", - { - description: - "Get detailed information about a workspace on a device, including its tabs and panes. Use this to discover pane IDs needed for the start_agent_session or start_agent_session_with_prompt paneId parameter. The target device must belong to the current user.", - inputSchema: { - deviceId: z.string().describe("Target device ID"), - workspaceId: z.string().describe("Workspace ID to get details for"), - }, - }, - async (args, extra) => { - const ctx = getMcpContext(extra); - const deviceId = args.deviceId as string; - const workspaceId = args.workspaceId as string; - - if (!deviceId || !workspaceId) { - return { - content: [ - { - type: "text", - text: "Error: deviceId and workspaceId are required", - }, - ], - isError: true, - }; - } - - return executeOnDevice({ - ctx, - deviceId, - tool: "get_workspace_details", - params: { workspaceId }, - }); - }, - ); -} diff --git a/packages/mcp/src/tools/devices/get-workspace-details/index.ts b/packages/mcp/src/tools/devices/get-workspace-details/index.ts deleted file mode 100644 index a167007068c..00000000000 --- a/packages/mcp/src/tools/devices/get-workspace-details/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { register } from "./get-workspace-details"; diff --git a/packages/mcp/src/tools/devices/list-devices/index.ts b/packages/mcp/src/tools/devices/list-devices/index.ts deleted file mode 100644 index 8d26a8b2e30..00000000000 --- a/packages/mcp/src/tools/devices/list-devices/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { register } from "./list-devices"; diff --git a/packages/mcp/src/tools/devices/list-devices/list-devices.test.ts b/packages/mcp/src/tools/devices/list-devices/list-devices.test.ts deleted file mode 100644 index 6ca08ce5d27..00000000000 --- a/packages/mcp/src/tools/devices/list-devices/list-devices.test.ts +++ /dev/null @@ -1,168 +0,0 @@ -import { beforeEach, describe, expect, it, mock } from "bun:test"; -import { z } from "zod"; - -const getMcpContextMock = mock(() => ({ organizationId: "org-1" })); - -let fetchedDevices = [ - { - deviceId: "device-a", - deviceName: "Ada's MacBook", - deviceType: "desktop", - lastSeenAt: new Date(Date.now() - 30_000), - ownerId: "user-1", - ownerName: "Ada", - ownerEmail: "ada@example.com", - }, - { - deviceId: "device-b", - deviceName: "Grace's iPhone", - deviceType: "mobile", - lastSeenAt: new Date(Date.now() - 120_000), - ownerId: "user-2", - ownerName: "Grace", - ownerEmail: "grace@example.com", - }, -]; - -const selectMock = mock(() => ({ - from: () => ({ - innerJoin: () => ({ - where: () => ({ - orderBy: async () => fetchedDevices, - }), - }), - }), -})); - -mock.module("@superset/db/client", () => ({ - db: { - select: selectMock, - }, -})); - -// mock.module is process-global in bun test. Preserve every real export -// (notably executeOnDevice, which start-agent-session.test.ts imports after -// this file mounts its mocks) so sibling test files don't break. -const realUtils = await import("../../utils"); -mock.module("../../utils", () => ({ - ...realUtils, - getMcpContext: getMcpContextMock, -})); - -const { register } = await import("./index"); - -type RegisteredToolHandler = ( - args: Record, - extra: unknown, -) => Promise<{ - content?: Array<{ text?: string }>; - isError?: boolean; - structuredContent?: { - devices: Array<{ - deviceId: string; - deviceName: string | null; - deviceType: string; - lastSeenAt: string; - ownerId: string; - ownerName: string | null; - ownerEmail: string; - }>; - }; -}>; - -type RegisteredToolConfig = { - inputSchema: Record; - outputSchema: Record; -}; - -function createTool() { - let config: RegisteredToolConfig | null = null; - let handler: RegisteredToolHandler | null = null; - - register({ - registerTool: ( - name: string, - nextConfig: RegisteredToolConfig, - nextHandler: RegisteredToolHandler, - ) => { - if (name === "list_devices") { - config = nextConfig; - handler = nextHandler; - } - }, - } as never); - - if (!config || !handler) { - throw new Error("list_devices was not registered"); - } - - return { - config: config as RegisteredToolConfig, - handler: handler as RegisteredToolHandler, - }; -} - -describe("list_devices MCP tool", () => { - beforeEach(() => { - fetchedDevices = [ - { - deviceId: "device-a", - deviceName: "Ada's MacBook", - deviceType: "desktop", - lastSeenAt: new Date(Date.now() - 30_000), - ownerId: "user-1", - ownerName: "Ada", - ownerEmail: "ada@example.com", - }, - { - deviceId: "device-b", - deviceName: "Grace's iPhone", - deviceType: "mobile", - lastSeenAt: new Date(Date.now() - 120_000), - ownerId: "user-2", - ownerName: "Grace", - ownerEmail: "grace@example.com", - }, - ]; - getMcpContextMock.mockClear(); - selectMock.mockClear(); - }); - - it("registers an output schema that validates the device list", async () => { - const { config, handler } = createTool(); - const outputSchema = z.object(config.outputSchema); - - const result = await handler({}, {}); - - expect(() => outputSchema.parse(result.structuredContent)).not.toThrow(); - }); - - it("returns every registered device regardless of lastSeenAt", async () => { - const { handler } = createTool(); - - const result = await handler({}, {}); - - expect(getMcpContextMock).toHaveBeenCalledTimes(1); - expect(selectMock).toHaveBeenCalledTimes(1); - expect(result.structuredContent?.devices).toEqual([ - { - deviceId: "device-a", - deviceName: "Ada's MacBook", - deviceType: "desktop", - lastSeenAt: expect.any(String), - ownerId: "user-1", - ownerName: "Ada", - ownerEmail: "ada@example.com", - }, - { - deviceId: "device-b", - deviceName: "Grace's iPhone", - deviceType: "mobile", - lastSeenAt: expect.any(String), - ownerId: "user-2", - ownerName: "Grace", - ownerEmail: "grace@example.com", - }, - ]); - }); -}); diff --git a/packages/mcp/src/tools/devices/list-devices/list-devices.ts b/packages/mcp/src/tools/devices/list-devices/list-devices.ts deleted file mode 100644 index 14feb8ad76f..00000000000 --- a/packages/mcp/src/tools/devices/list-devices/list-devices.ts +++ /dev/null @@ -1,62 +0,0 @@ -import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; -import { db } from "@superset/db/client"; -import { devicePresence, deviceTypeValues, users } from "@superset/db/schema"; -import { desc, eq } from "drizzle-orm"; -import { z } from "zod"; -import { getMcpContext } from "../../utils"; - -export function register(server: McpServer) { - server.registerTool( - "list_devices", - { - description: "List registered devices in the organization.", - inputSchema: {}, - outputSchema: { - devices: z.array( - z.object({ - deviceId: z.string(), - deviceName: z.string().nullable(), - deviceType: z.enum(deviceTypeValues), - lastSeenAt: z.string().datetime(), - ownerId: z.string(), - ownerName: z.string().nullable(), - ownerEmail: z.string(), - }), - ), - }, - }, - async (_args, extra) => { - const ctx = getMcpContext(extra); - - const devices = await db - .select({ - deviceId: devicePresence.deviceId, - deviceName: devicePresence.deviceName, - deviceType: devicePresence.deviceType, - lastSeenAt: devicePresence.lastSeenAt, - ownerId: devicePresence.userId, - ownerName: users.name, - ownerEmail: users.email, - }) - .from(devicePresence) - .innerJoin(users, eq(devicePresence.userId, users.id)) - .where(eq(devicePresence.organizationId, ctx.organizationId)) - .orderBy(desc(devicePresence.lastSeenAt)); - - const result = devices.map((d) => ({ - ...d, - lastSeenAt: d.lastSeenAt.toISOString(), - })); - - return { - structuredContent: { devices: result }, - content: [ - { - type: "text", - text: JSON.stringify({ devices: result }, null, 2), - }, - ], - }; - }, - ); -} diff --git a/packages/mcp/src/tools/devices/list-projects/index.ts b/packages/mcp/src/tools/devices/list-projects/index.ts deleted file mode 100644 index 18410496f5d..00000000000 --- a/packages/mcp/src/tools/devices/list-projects/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { register } from "./list-projects"; diff --git a/packages/mcp/src/tools/devices/list-projects/list-projects.ts b/packages/mcp/src/tools/devices/list-projects/list-projects.ts deleted file mode 100644 index fb85bdda652..00000000000 --- a/packages/mcp/src/tools/devices/list-projects/list-projects.ts +++ /dev/null @@ -1,33 +0,0 @@ -import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; -import { z } from "zod"; -import { executeOnDevice, getMcpContext } from "../../utils"; - -export function register(server: McpServer) { - server.registerTool( - "list_projects", - { - description: "List all projects on a device", - inputSchema: { - deviceId: z.string().describe("Target device ID"), - }, - }, - async (args, extra) => { - const ctx = getMcpContext(extra); - const deviceId = args.deviceId as string; - - if (!deviceId) { - return { - content: [{ type: "text", text: "Error: deviceId is required" }], - isError: true, - }; - } - - return executeOnDevice({ - ctx, - deviceId, - tool: "list_projects", - params: {}, - }); - }, - ); -} diff --git a/packages/mcp/src/tools/devices/list-workspaces/index.ts b/packages/mcp/src/tools/devices/list-workspaces/index.ts deleted file mode 100644 index f305c23d806..00000000000 --- a/packages/mcp/src/tools/devices/list-workspaces/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { register } from "./list-workspaces"; diff --git a/packages/mcp/src/tools/devices/list-workspaces/list-workspaces.ts b/packages/mcp/src/tools/devices/list-workspaces/list-workspaces.ts deleted file mode 100644 index 4cd47f85711..00000000000 --- a/packages/mcp/src/tools/devices/list-workspaces/list-workspaces.ts +++ /dev/null @@ -1,33 +0,0 @@ -import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; -import { z } from "zod"; -import { executeOnDevice, getMcpContext } from "../../utils"; - -export function register(server: McpServer) { - server.registerTool( - "list_workspaces", - { - description: "List all workspaces/worktrees on a device", - inputSchema: { - deviceId: z.string().describe("Target device ID"), - }, - }, - async (args, extra) => { - const ctx = getMcpContext(extra); - const deviceId = args.deviceId as string; - - if (!deviceId) { - return { - content: [{ type: "text", text: "Error: deviceId is required" }], - isError: true, - }; - } - - return executeOnDevice({ - ctx, - deviceId, - tool: "list_workspaces", - params: {}, - }); - }, - ); -} diff --git a/packages/mcp/src/tools/devices/start-agent-session/index.ts b/packages/mcp/src/tools/devices/start-agent-session/index.ts deleted file mode 100644 index 551b89fbcdd..00000000000 --- a/packages/mcp/src/tools/devices/start-agent-session/index.ts +++ /dev/null @@ -1,8 +0,0 @@ -import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; -import { registerTaskLaunchTool } from "./start-agent-session"; -import { registerPromptLaunchTool } from "./start-agent-session-with-prompt"; - -export function register(server: McpServer) { - registerTaskLaunchTool(server); - registerPromptLaunchTool(server); -} diff --git a/packages/mcp/src/tools/devices/start-agent-session/shared.ts b/packages/mcp/src/tools/devices/start-agent-session/shared.ts deleted file mode 100644 index 783b572d26a..00000000000 --- a/packages/mcp/src/tools/devices/start-agent-session/shared.ts +++ /dev/null @@ -1,286 +0,0 @@ -import { db } from "@superset/db/client"; -import { taskStatuses, tasks } from "@superset/db/schema"; -import { - type AGENT_TYPES, - buildAgentCommand, - buildAgentPromptCommand, - buildAgentTaskPrompt, -} from "@superset/shared/agent-command"; -import { - type AgentLaunchRequest, - STARTABLE_AGENT_LABELS, - STARTABLE_AGENT_TYPES, -} from "@superset/shared/agent-launch"; -import { and, eq, isNull } from "drizzle-orm"; -import { alias } from "drizzle-orm/pg-core"; -import { type ZodError, z } from "zod"; -import type { McpContext } from "../../../auth"; -import { executeOnDevice } from "../../utils"; - -export const START_AGENT_SESSION_TOOL_NAME = "start_agent_session"; -export const START_AGENT_SESSION_WITH_PROMPT_TOOL_NAME = - "start_agent_session_with_prompt"; -export const START_AGENT_SESSION_TOOL_NAMES = [ - START_AGENT_SESSION_TOOL_NAME, - START_AGENT_SESSION_WITH_PROMPT_TOOL_NAME, -] as const; - -export type StartAgentSessionToolName = - (typeof START_AGENT_SESSION_TOOL_NAMES)[number]; - -export const nonEmptyString = z.string().trim().min(1); - -function describeSupportedAgents(): string { - const quotedAgents = STARTABLE_AGENT_TYPES.map((agent) => `"${agent}"`); - const lastAgent = quotedAgents.at(-1); - if (!lastAgent) { - return 'AI agent to use. Defaults to "claude".'; - } - - if (quotedAgents.length === 1) { - return `AI agent to use: ${lastAgent}. Defaults to "claude".`; - } - - return `AI agent to use: ${quotedAgents.slice(0, -1).join(", ")}, or ${lastAgent}. Defaults to "claude".`; -} - -export const commonInputSchemaShape = { - deviceId: nonEmptyString.describe("Target device ID"), - workspaceId: nonEmptyString.describe( - "Workspace ID to run the session in (from create_workspace)", - ), - paneId: nonEmptyString - .optional() - .describe( - "Optional pane ID. When provided, launches relative to the tab containing this pane.", - ), - agent: z - .enum(STARTABLE_AGENT_TYPES) - .optional() - .describe(describeSupportedAgents()), -}; - -export const taskInputSchemaShape = { - ...commonInputSchemaShape, - taskId: nonEmptyString.describe("Task ID to work on."), -}; - -export const promptInputSchemaShape = { - ...commonInputSchemaShape, - prompt: nonEmptyString.describe( - "Direct prompt to start the agent with for task-free launches.", - ), -}; - -export const taskInputSchema = z.object(taskInputSchemaShape); -export const promptInputSchema = z.object(promptInputSchemaShape); - -export const ERROR_TASK_NOT_FOUND = { - content: [{ type: "text" as const, text: "Error: Task not found" }], - isError: true, -}; - -export function createValidationErrorResult(error: ZodError) { - return { - content: [ - { - type: "text" as const, - text: `Error: ${error.issues.map((issue) => issue.message).join(", ")}`, - }, - ], - isError: true, - }; -} - -async function fetchTask({ - taskId, - organizationId, -}: { - taskId: string; - organizationId: string; -}) { - const status = alias(taskStatuses, "status"); - const [task] = await db - .select({ - id: tasks.id, - slug: tasks.slug, - title: tasks.title, - description: tasks.description, - priority: tasks.priority, - statusName: status.name, - labels: tasks.labels, - }) - .from(tasks) - .leftJoin(status, eq(tasks.statusId, status.id)) - .where( - and( - eq(tasks.id, taskId), - eq(tasks.organizationId, organizationId), - isNull(tasks.deletedAt), - ), - ) - .limit(1); - - return task ?? null; -} - -type TaskRecord = NonNullable>>; - -export async function fetchTaskForOrganization({ - taskId, - organizationId, -}: { - taskId: string; - organizationId: string; -}): Promise { - return fetchTask({ taskId, organizationId }); -} - -export function buildTaskLaunchRequest({ - workspaceId, - paneId, - agent, - task, -}: { - workspaceId: string; - paneId?: string; - agent: (typeof STARTABLE_AGENT_TYPES)[number]; - task: TaskRecord; -}): AgentLaunchRequest { - if (agent === "superset") { - return { - kind: "chat", - workspaceId, - agentType: "superset", - source: "mcp", - chat: { - ...(paneId ? { paneId } : {}), - initialPrompt: buildAgentTaskPrompt(task), - retryCount: 1, - }, - }; - } - - return { - kind: "terminal", - workspaceId, - agentType: agent, - source: "mcp", - terminal: { - command: buildAgentCommand({ - task, - randomId: crypto.randomUUID(), - agent: agent as (typeof AGENT_TYPES)[number], - }), - name: task.slug, - ...(paneId ? { paneId } : {}), - }, - }; -} - -export function buildPromptLaunchRequest({ - workspaceId, - paneId, - agent, - prompt, -}: { - workspaceId: string; - paneId?: string; - agent: (typeof STARTABLE_AGENT_TYPES)[number]; - prompt: string; -}): AgentLaunchRequest { - if (agent === "superset") { - return { - kind: "chat", - workspaceId, - agentType: "superset", - source: "mcp", - chat: { - ...(paneId ? { paneId } : {}), - initialPrompt: prompt, - retryCount: 1, - }, - }; - } - - return { - kind: "terminal", - workspaceId, - agentType: agent, - source: "mcp", - terminal: { - command: buildAgentPromptCommand({ - prompt, - randomId: crypto.randomUUID(), - agent: agent as (typeof AGENT_TYPES)[number], - }), - name: STARTABLE_AGENT_LABELS[agent], - ...(paneId ? { paneId } : {}), - }, - }; -} - -function buildExecuteParams({ - workspaceId, - paneId, - agent, - request, -}: { - workspaceId: string; - paneId?: string; - agent: (typeof STARTABLE_AGENT_TYPES)[number]; - request: AgentLaunchRequest; -}): Record { - const params: Record = { - workspaceId, - request, - agentType: agent, - ...(paneId ? { paneId } : {}), - }; - - if (request.kind === "terminal") { - params.command = request.terminal.command; - if (request.terminal.name) { - params.name = request.terminal.name; - } - } else { - params.openChatPane = true; - params.chatLaunchConfig = { - initialPrompt: request.chat.initialPrompt, - retryCount: request.chat.retryCount, - ...(request.chat.model ? { model: request.chat.model } : {}), - }; - } - - return params; -} - -export function executeLaunchOnDevice({ - ctx, - deviceId, - tool, - workspaceId, - paneId, - agent, - request, -}: { - ctx: McpContext; - deviceId: string; - tool: StartAgentSessionToolName; - workspaceId: string; - paneId?: string; - agent: (typeof STARTABLE_AGENT_TYPES)[number]; - request: AgentLaunchRequest; -}) { - return executeOnDevice({ - ctx, - deviceId, - tool, - params: buildExecuteParams({ - workspaceId, - paneId, - agent, - request, - }), - }); -} diff --git a/packages/mcp/src/tools/devices/start-agent-session/start-agent-session-with-prompt.ts b/packages/mcp/src/tools/devices/start-agent-session/start-agent-session-with-prompt.ts deleted file mode 100644 index b5230d6e001..00000000000 --- a/packages/mcp/src/tools/devices/start-agent-session/start-agent-session-with-prompt.ts +++ /dev/null @@ -1,47 +0,0 @@ -import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; -import { getMcpContext } from "../../utils"; -import { - buildPromptLaunchRequest, - createValidationErrorResult, - executeLaunchOnDevice, - promptInputSchema, - promptInputSchemaShape, - START_AGENT_SESSION_WITH_PROMPT_TOOL_NAME, -} from "./shared"; - -export function registerPromptLaunchTool(server: McpServer) { - server.registerTool( - START_AGENT_SESSION_WITH_PROMPT_TOOL_NAME, - { - description: - "Start an autonomous AI session in an existing workspace using a direct prompt instead of a task. Supports terminal agents and Superset. When paneId is provided, launch behavior is scoped to the tab containing that pane.", - inputSchema: promptInputSchemaShape, - }, - async (args, extra) => { - const parsed = promptInputSchema.safeParse(args); - if (!parsed.success) { - return createValidationErrorResult(parsed.error); - } - - const ctx = getMcpContext(extra); - const input = parsed.data; - const agent = input.agent ?? "claude"; - const request = buildPromptLaunchRequest({ - workspaceId: input.workspaceId, - paneId: input.paneId, - agent, - prompt: input.prompt, - }); - - return executeLaunchOnDevice({ - ctx, - deviceId: input.deviceId, - tool: START_AGENT_SESSION_WITH_PROMPT_TOOL_NAME, - workspaceId: input.workspaceId, - paneId: input.paneId, - agent, - request, - }); - }, - ); -} diff --git a/packages/mcp/src/tools/devices/start-agent-session/start-agent-session.test.ts b/packages/mcp/src/tools/devices/start-agent-session/start-agent-session.test.ts deleted file mode 100644 index 8dbf969a757..00000000000 --- a/packages/mcp/src/tools/devices/start-agent-session/start-agent-session.test.ts +++ /dev/null @@ -1,208 +0,0 @@ -import { beforeEach, describe, expect, it, mock } from "bun:test"; - -const executeOnDeviceMock = mock( - async (input: Record) => input, -); -const getMcpContextMock = mock(() => ({ organizationId: "org-1" })); - -const TASK = { - id: "task-1", - slug: "demo-task", - title: "Demo Task", - description: null, - priority: "medium", - statusName: "Todo", - labels: ["desktop"], -}; - -let fetchedTask: typeof TASK | null = TASK; - -const selectMock = mock(() => ({ - from: () => ({ - leftJoin: () => ({ - where: () => ({ - limit: async () => (fetchedTask ? [fetchedTask] : []), - }), - }), - }), -})); - -mock.module("@superset/db/client", () => ({ - db: { - select: selectMock, - }, -})); - -mock.module("../../utils", () => ({ - executeOnDevice: executeOnDeviceMock, - getMcpContext: getMcpContextMock, -})); - -const { register } = await import("./index"); - -type RegisteredToolHandler = ( - args: Record, - extra: unknown, -) => Promise<{ - content?: Array<{ text?: string }>; - isError?: boolean; -}>; - -function createHandlers() { - const handlers = new Map(); - - register({ - registerTool: ( - name: string, - _config: unknown, - nextHandler: RegisteredToolHandler, - ) => { - handlers.set(name, nextHandler); - }, - } as never); - - const taskHandler = handlers.get("start_agent_session"); - const promptHandler = handlers.get("start_agent_session_with_prompt"); - if (!taskHandler || !promptHandler) { - throw new Error("session launch handlers were not registered"); - } - - return { - taskHandler, - promptHandler, - }; -} - -describe("session launch MCP tools", () => { - beforeEach(() => { - fetchedTask = TASK; - executeOnDeviceMock.mockClear(); - getMcpContextMock.mockClear(); - selectMock.mockClear(); - }); - - it("registers task and prompt launch tools", () => { - const handlers = createHandlers(); - - expect(handlers.taskHandler).toBeDefined(); - expect(handlers.promptHandler).toBeDefined(); - }); - - it("launches task-based sessions after fetching the task", async () => { - const { taskHandler } = createHandlers(); - - await taskHandler( - { - deviceId: "device-1", - workspaceId: "workspace-1", - taskId: "task-1", - agent: "claude", - }, - {}, - ); - - expect(selectMock).toHaveBeenCalledTimes(1); - expect(executeOnDeviceMock).toHaveBeenCalledTimes(1); - - const launchInput = executeOnDeviceMock.mock.calls[0]?.[0] as { - tool: string; - params: { - request: { - kind: string; - terminal?: { name?: string; command: string }; - }; - }; - }; - - expect(launchInput.tool).toBe("start_agent_session"); - expect(launchInput.params.request).toMatchObject({ - kind: "terminal", - terminal: { - name: "demo-task", - }, - }); - }); - - it("launches prompt-only terminal sessions without fetching a task", async () => { - const { promptHandler } = createHandlers(); - - await promptHandler( - { - deviceId: "device-1", - workspaceId: "workspace-1", - agent: "codex", - prompt: " Fix the failing tests ", - }, - {}, - ); - - expect(selectMock).not.toHaveBeenCalled(); - expect(executeOnDeviceMock).toHaveBeenCalledTimes(1); - - const launchInput = executeOnDeviceMock.mock.calls[0]?.[0] as { - tool: string; - params: { - name?: string; - request: { - kind: string; - agentType: string; - terminal?: { name?: string; command: string }; - }; - }; - }; - - expect(launchInput.tool).toBe("start_agent_session_with_prompt"); - expect(launchInput.params.name).toBe("Codex"); - expect(launchInput.params.request).toMatchObject({ - kind: "terminal", - agentType: "codex", - terminal: { - name: "Codex", - }, - }); - expect(launchInput.params.request.terminal?.command).toContain( - "Fix the failing tests", - ); - expect(launchInput.params.request.terminal?.command).not.toContain( - " Fix the failing tests ", - ); - }); - - it("rejects whitespace-only prompt launches", async () => { - const { promptHandler } = createHandlers(); - - const result = await promptHandler( - { - deviceId: "device-1", - workspaceId: "workspace-1", - prompt: " ", - }, - {}, - ); - - expect(result.isError).toBe(true); - expect(result.content?.[0]?.text).toContain( - "expected string to have >=1 characters", - ); - expect(executeOnDeviceMock).not.toHaveBeenCalled(); - expect(selectMock).not.toHaveBeenCalled(); - }); - - it("requires taskId for the task-based tool", async () => { - const { taskHandler } = createHandlers(); - - const result = await taskHandler( - { - deviceId: "device-1", - workspaceId: "workspace-1", - prompt: "Do work without a task", - }, - {}, - ); - - expect(result.isError).toBe(true); - expect(result.content?.[0]?.text).toContain("expected string"); - expect(selectMock).not.toHaveBeenCalled(); - expect(executeOnDeviceMock).not.toHaveBeenCalled(); - }); -}); diff --git a/packages/mcp/src/tools/devices/start-agent-session/start-agent-session.ts b/packages/mcp/src/tools/devices/start-agent-session/start-agent-session.ts deleted file mode 100644 index c31e60a5e96..00000000000 --- a/packages/mcp/src/tools/devices/start-agent-session/start-agent-session.ts +++ /dev/null @@ -1,55 +0,0 @@ -import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; -import { getMcpContext } from "../../utils"; -import { - buildTaskLaunchRequest, - createValidationErrorResult, - ERROR_TASK_NOT_FOUND, - executeLaunchOnDevice, - fetchTaskForOrganization, - START_AGENT_SESSION_TOOL_NAME, - taskInputSchema, - taskInputSchemaShape, -} from "./shared"; - -export function registerTaskLaunchTool(server: McpServer) { - server.registerTool( - START_AGENT_SESSION_TOOL_NAME, - { - description: - "Start an autonomous AI session for a task in an existing workspace. Supports terminal agents and Superset. When paneId is provided, launch behavior is scoped to the tab containing that pane.", - inputSchema: taskInputSchemaShape, - }, - async (args, extra) => { - const parsed = taskInputSchema.safeParse(args); - if (!parsed.success) { - return createValidationErrorResult(parsed.error); - } - - const ctx = getMcpContext(extra); - const input = parsed.data; - const agent = input.agent ?? "claude"; - const task = await fetchTaskForOrganization({ - taskId: input.taskId, - organizationId: ctx.organizationId, - }); - if (!task) return ERROR_TASK_NOT_FOUND; - - const request = buildTaskLaunchRequest({ - workspaceId: input.workspaceId, - paneId: input.paneId, - agent, - task, - }); - - return executeLaunchOnDevice({ - ctx, - deviceId: input.deviceId, - tool: START_AGENT_SESSION_TOOL_NAME, - workspaceId: input.workspaceId, - paneId: input.paneId, - agent, - request, - }); - }, - ); -} diff --git a/packages/mcp/src/tools/devices/switch-workspace/index.ts b/packages/mcp/src/tools/devices/switch-workspace/index.ts deleted file mode 100644 index c5b8198ee6a..00000000000 --- a/packages/mcp/src/tools/devices/switch-workspace/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { register } from "./switch-workspace"; diff --git a/packages/mcp/src/tools/devices/switch-workspace/switch-workspace.ts b/packages/mcp/src/tools/devices/switch-workspace/switch-workspace.ts deleted file mode 100644 index 4e20679b731..00000000000 --- a/packages/mcp/src/tools/devices/switch-workspace/switch-workspace.ts +++ /dev/null @@ -1,57 +0,0 @@ -import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; -import { z } from "zod"; -import { executeOnDevice, getMcpContext } from "../../utils"; - -export function register(server: McpServer) { - server.registerTool( - "switch_workspace", - { - description: - "Switch to a different workspace (git worktree) on a device. The target device must belong to the current user.", - inputSchema: { - deviceId: z.string().describe("Target device ID"), - workspaceId: z - .string() - .uuid() - .optional() - .describe("Workspace ID to switch to"), - workspaceName: z - .string() - .optional() - .describe("Workspace name to switch to"), - }, - }, - async (args, extra) => { - const ctx = getMcpContext(extra); - const deviceId = args.deviceId as string; - const workspaceId = args.workspaceId as string | undefined; - const workspaceName = args.workspaceName as string | undefined; - - if (!deviceId) { - return { - content: [{ type: "text", text: "Error: deviceId is required" }], - isError: true, - }; - } - - if (!workspaceId && !workspaceName) { - return { - content: [ - { - type: "text", - text: "Error: Either workspaceId or workspaceName must be provided", - }, - ], - isError: true, - }; - } - - return executeOnDevice({ - ctx, - deviceId, - tool: "switch_workspace", - params: { workspaceId, workspaceName }, - }); - }, - ); -} diff --git a/packages/mcp/src/tools/devices/update-workspace/index.ts b/packages/mcp/src/tools/devices/update-workspace/index.ts deleted file mode 100644 index 532d18f466c..00000000000 --- a/packages/mcp/src/tools/devices/update-workspace/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { register } from "./update-workspace"; diff --git a/packages/mcp/src/tools/devices/update-workspace/update-workspace.ts b/packages/mcp/src/tools/devices/update-workspace/update-workspace.ts deleted file mode 100644 index f59b75b6f4a..00000000000 --- a/packages/mcp/src/tools/devices/update-workspace/update-workspace.ts +++ /dev/null @@ -1,45 +0,0 @@ -import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; -import { z } from "zod"; -import { executeOnDevice, getMcpContext } from "../../utils"; - -const workspaceUpdateSchema = z.object({ - workspaceId: z.string().uuid().describe("Workspace ID to update"), - name: z.string().min(1).describe("New workspace name"), -}); - -export function register(server: McpServer) { - server.registerTool( - "update_workspace", - { - description: - "Update one or more workspaces (git worktrees) on a device. Currently supports renaming.", - inputSchema: { - deviceId: z.string().describe("Target device ID"), - updates: z - .array(workspaceUpdateSchema) - .min(1) - .max(5) - .describe("Array of workspace updates (1-5)"), - }, - }, - async (args, extra) => { - const ctx = getMcpContext(extra); - const deviceId = args.deviceId as string; - const updates = args.updates as z.infer[]; - - if (!deviceId) { - return { - content: [{ type: "text", text: "Error: deviceId is required" }], - isError: true, - }; - } - - return executeOnDevice({ - ctx, - deviceId, - tool: "update_workspace", - params: { updates }, - }); - }, - ); -} diff --git a/packages/mcp/src/tools/index.ts b/packages/mcp/src/tools/index.ts deleted file mode 100644 index 8b826fe0840..00000000000 --- a/packages/mcp/src/tools/index.ts +++ /dev/null @@ -1,44 +0,0 @@ -import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; -import { register as createWorkspace } from "./devices/create-workspace"; -import { register as deleteWorkspace } from "./devices/delete-workspace"; -import { register as getAppContext } from "./devices/get-app-context"; -import { register as getWorkspaceDetails } from "./devices/get-workspace-details"; -import { register as listDevices } from "./devices/list-devices"; -import { register as listProjects } from "./devices/list-projects"; -import { register as listWorkspaces } from "./devices/list-workspaces"; -import { register as startAgentSession } from "./devices/start-agent-session"; -import { register as switchWorkspace } from "./devices/switch-workspace"; -import { register as updateWorkspace } from "./devices/update-workspace"; -import { register as listMembers } from "./organizations/list-members"; -import { register as createTask } from "./tasks/create-task"; -import { register as deleteTask } from "./tasks/delete-task"; -import { register as getTask } from "./tasks/get-task"; -import { register as listTaskStatuses } from "./tasks/list-task-statuses"; -import { register as listTasks } from "./tasks/list-tasks"; -import { register as updateTask } from "./tasks/update-task"; - -const allTools = [ - createTask, - updateTask, - listTasks, - getTask, - deleteTask, - listTaskStatuses, - listMembers, - listDevices, - listWorkspaces, - listProjects, - getAppContext, - getWorkspaceDetails, - createWorkspace, - switchWorkspace, - deleteWorkspace, - updateWorkspace, - startAgentSession, -]; - -export function registerTools(server: McpServer) { - for (const register of allTools) { - register(server); - } -} diff --git a/packages/mcp/src/tools/organizations/list-members/index.ts b/packages/mcp/src/tools/organizations/list-members/index.ts deleted file mode 100644 index e0ba8ee420c..00000000000 --- a/packages/mcp/src/tools/organizations/list-members/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { register } from "./list-members"; diff --git a/packages/mcp/src/tools/organizations/list-members/list-members.ts b/packages/mcp/src/tools/organizations/list-members/list-members.ts deleted file mode 100644 index bc55c189442..00000000000 --- a/packages/mcp/src/tools/organizations/list-members/list-members.ts +++ /dev/null @@ -1,84 +0,0 @@ -import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; -import { db } from "@superset/db/client"; -import { members, users } from "@superset/db/schema"; -import { and, eq, ilike, or } from "drizzle-orm"; -import { z } from "zod"; -import { getMcpContext } from "../../utils"; - -export function register(server: McpServer) { - server.registerTool( - "list_members", - { - description: "List members in the organization", - inputSchema: { - search: z.string().optional().describe("Search by name or email"), - limit: z.number().int().min(1).max(100).default(50), - }, - outputSchema: { - members: z.array( - z.object({ - id: z.string(), - name: z.string().nullable(), - email: z.string(), - image: z.string().nullable(), - role: z.string(), - }), - ), - }, - }, - async (args, extra) => { - const ctx = getMcpContext(extra); - const limit = args.limit as number; - const search = args.search as string | undefined; - const conditions = [eq(members.organizationId, ctx.organizationId)]; - - let query = db - .select({ - id: users.id, - name: users.name, - email: users.email, - image: users.image, - role: members.role, - }) - .from(members) - .innerJoin(users, eq(members.userId, users.id)) - .where(and(...conditions)) - .limit(limit); - - if (search) { - query = db - .select({ - id: users.id, - name: users.name, - email: users.email, - image: users.image, - role: members.role, - }) - .from(members) - .innerJoin(users, eq(members.userId, users.id)) - .where( - and( - ...conditions, - or( - ilike(users.name, `%${search}%`), - ilike(users.email, `%${search}%`), - ), - ), - ) - .limit(limit); - } - - const membersList = await query; - - return { - structuredContent: { members: membersList }, - content: [ - { - type: "text", - text: JSON.stringify({ members: membersList }, null, 2), - }, - ], - }; - }, - ); -} diff --git a/packages/mcp/src/tools/tasks/create-task/create-task.ts b/packages/mcp/src/tools/tasks/create-task/create-task.ts deleted file mode 100644 index c3d9a73b3e6..00000000000 --- a/packages/mcp/src/tools/tasks/create-task/create-task.ts +++ /dev/null @@ -1,161 +0,0 @@ -import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; -import { db, dbWs } from "@superset/db/client"; -import { tasks } from "@superset/db/schema"; -import { seedDefaultStatuses } from "@superset/db/seed-default-statuses"; -import { - generateBaseTaskSlug, - generateUniqueTaskSlug, -} from "@superset/shared/task-slug"; -import { and, eq, ilike, or } from "drizzle-orm"; -import { z } from "zod"; -import { getMcpContext } from "../../utils"; - -const PRIORITIES = ["urgent", "high", "medium", "low", "none"] as const; -type TaskPriority = (typeof PRIORITIES)[number]; - -function isPriority(value: unknown): value is TaskPriority { - return PRIORITIES.includes(value as TaskPriority); -} - -const taskInputSchema = z.object({ - title: z.string().min(1).describe("Task title"), - description: z.string().optional().describe("Task description (markdown)"), - priority: z - .enum(["urgent", "high", "medium", "low", "none"]) - .default("none") - .describe("Task priority"), - assigneeId: z.string().uuid().optional().describe("User ID to assign to"), - statusId: z - .string() - .uuid() - .optional() - .describe("Status ID (defaults to backlog)"), - labels: z.array(z.string()).optional().describe("Array of label strings"), - dueDate: z.string().datetime().optional().describe("Due date in ISO format"), - estimate: z - .number() - .int() - .positive() - .optional() - .describe("Estimate in points/hours"), -}); - -type TaskInput = z.infer; - -export function register(server: McpServer) { - server.registerTool( - "create_task", - { - description: "Create one or more tasks in the organization", - inputSchema: { - tasks: z - .array(taskInputSchema) - .min(1) - .max(25) - .describe("Array of tasks to create (1-25)"), - }, - outputSchema: { - created: z.array( - z.object({ - id: z.string(), - slug: z.string(), - title: z.string(), - }), - ), - }, - }, - async (args, extra) => { - const ctx = getMcpContext(extra); - const taskInputs = args.tasks as TaskInput[]; - - let defaultStatusId: string | undefined; - const needsDefaultStatus = taskInputs.some((t) => !t.statusId); - - if (needsDefaultStatus) { - defaultStatusId = await seedDefaultStatuses(ctx.organizationId); - } - - const baseSlugs = taskInputs.map((t) => generateBaseTaskSlug(t.title)); - const uniqueBaseSlugs = [...new Set(baseSlugs)]; - - const slugConditions = uniqueBaseSlugs.map((baseSlug) => - ilike(tasks.slug, `${baseSlug}%`), - ); - - const existingTasks = await db - .select({ slug: tasks.slug }) - .from(tasks) - .where( - and( - eq(tasks.organizationId, ctx.organizationId), - or(...slugConditions), - ), - ); - - const usedSlugs = new Set(existingTasks.map((t) => t.slug)); - - const taskValues: Array<{ - slug: string; - title: string; - description: string | null; - priority: TaskPriority; - statusId: string; - organizationId: string; - creatorId: string; - assigneeId: string | null; - assigneeExternalId: string | null; - assigneeDisplayName: string | null; - assigneeAvatarUrl: string | null; - labels: string[]; - dueDate: Date | null; - estimate: number | null; - }> = []; - - for (const [i, input] of taskInputs.entries()) { - const baseSlug = baseSlugs[i] ?? ""; - const slug = generateUniqueTaskSlug(baseSlug, usedSlugs); - usedSlugs.add(slug); - - const priority: TaskPriority = isPriority(input.priority) - ? input.priority - : "none"; - - const statusId = input.statusId ?? (defaultStatusId as string); - - taskValues.push({ - slug, - title: input.title, - description: input.description ?? null, - priority, - statusId, - organizationId: ctx.organizationId, - creatorId: ctx.userId, - assigneeId: input.assigneeId ?? null, - assigneeExternalId: null, - assigneeDisplayName: null, - assigneeAvatarUrl: null, - labels: input.labels ?? [], - dueDate: input.dueDate ? new Date(input.dueDate) : null, - estimate: input.estimate ?? null, - }); - } - - const createdTasks = await dbWs.transaction(async (tx) => { - return tx - .insert(tasks) - .values(taskValues) - .returning({ id: tasks.id, slug: tasks.slug, title: tasks.title }); - }); - - return { - structuredContent: { created: createdTasks }, - content: [ - { - type: "text", - text: JSON.stringify({ created: createdTasks }, null, 2), - }, - ], - }; - }, - ); -} diff --git a/packages/mcp/src/tools/tasks/create-task/index.ts b/packages/mcp/src/tools/tasks/create-task/index.ts deleted file mode 100644 index c1e16d05717..00000000000 --- a/packages/mcp/src/tools/tasks/create-task/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { register } from "./create-task"; diff --git a/packages/mcp/src/tools/tasks/delete-task/delete-task.ts b/packages/mcp/src/tools/tasks/delete-task/delete-task.ts deleted file mode 100644 index bde21e1da13..00000000000 --- a/packages/mcp/src/tools/tasks/delete-task/delete-task.ts +++ /dev/null @@ -1,72 +0,0 @@ -import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; -import { db, dbWs } from "@superset/db/client"; -import { tasks } from "@superset/db/schema"; -import { and, eq, inArray, isNull } from "drizzle-orm"; -import { z } from "zod"; -import { getMcpContext } from "../../utils"; - -export function register(server: McpServer) { - server.registerTool( - "delete_task", - { - description: "Soft delete one or more tasks", - inputSchema: { - taskIds: z - .array(z.string()) - .min(1) - .max(25) - .describe("Task IDs (uuid or slug) to delete (1-25)"), - }, - outputSchema: { - deleted: z.array(z.string()), - }, - }, - async (args, extra) => { - const ctx = getMcpContext(extra); - const taskIds = args.taskIds as string[]; - - const resolvedTasks: { id: string; identifier: string }[] = []; - - for (const taskId of taskIds) { - const isUuid = z.string().uuid().safeParse(taskId).success; - - const [existingTask] = await db - .select({ id: tasks.id }) - .from(tasks) - .where( - and( - isUuid ? eq(tasks.id, taskId) : eq(tasks.slug, taskId), - eq(tasks.organizationId, ctx.organizationId), - isNull(tasks.deletedAt), - ), - ) - .limit(1); - - if (!existingTask) { - return { - content: [ - { type: "text", text: `Error: Task not found: ${taskId}` }, - ], - isError: true, - }; - } - - resolvedTasks.push({ id: existingTask.id, identifier: taskId }); - } - - const taskIdsToDelete = resolvedTasks.map((t) => t.id); - const deletedAt = new Date(); - - await dbWs - .update(tasks) - .set({ deletedAt }) - .where(inArray(tasks.id, taskIdsToDelete)); - - const data = { deleted: taskIdsToDelete }; - return { - structuredContent: data, - content: [{ type: "text", text: JSON.stringify(data, null, 2) }], - }; - }, - ); -} diff --git a/packages/mcp/src/tools/tasks/delete-task/index.ts b/packages/mcp/src/tools/tasks/delete-task/index.ts deleted file mode 100644 index 2c8e677e9eb..00000000000 --- a/packages/mcp/src/tools/tasks/delete-task/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { register } from "./delete-task"; diff --git a/packages/mcp/src/tools/tasks/get-task/get-task.ts b/packages/mcp/src/tools/tasks/get-task/get-task.ts deleted file mode 100644 index e1cd4866799..00000000000 --- a/packages/mcp/src/tools/tasks/get-task/get-task.ts +++ /dev/null @@ -1,123 +0,0 @@ -import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; -import { db } from "@superset/db/client"; -import { taskStatuses, tasks, users } from "@superset/db/schema"; -import { and, eq, isNull, sql } from "drizzle-orm"; -import { alias } from "drizzle-orm/pg-core"; -import { z } from "zod"; -import { getMcpContext } from "../../utils"; - -export function register(server: McpServer) { - server.registerTool( - "get_task", - { - description: "Get a single task by ID or slug", - inputSchema: { - taskId: z.string().describe("Task ID (uuid) or slug"), - }, - outputSchema: { - task: z.object({ - id: z.string(), - slug: z.string(), - title: z.string(), - description: z.string().nullable(), - priority: z.string(), - statusId: z.string().nullable(), - statusName: z.string().nullable(), - statusType: z.string().nullable(), - statusColor: z.string().nullable(), - assigneeId: z.string().nullable(), - assigneeName: z.string().nullable(), - assigneeEmail: z.string().nullable(), - assigneeExternalId: z.string().nullable(), - assigneeDisplayName: z.string().nullable(), - assigneeAvatarUrl: z.string().nullable(), - creatorId: z.string().nullable(), - creatorName: z.string().nullable(), - labels: z.array(z.string()), - dueDate: z.string().nullable(), - estimate: z.number().nullable(), - branch: z.string().nullable(), - prUrl: z.string().nullable(), - externalProjectId: z.string().nullable(), - externalProjectName: z.string().nullable(), - externalCycleId: z.string().nullable(), - externalCycleName: z.string().nullable(), - }), - }, - }, - async (args, extra) => { - const ctx = getMcpContext(extra); - const taskId = args.taskId as string; - const isUuid = z.string().uuid().safeParse(taskId).success; - - const assignee = alias(users, "assignee"); - const creator = alias(users, "creator"); - const status = alias(taskStatuses, "status"); - - const [task] = await db - .select({ - id: tasks.id, - slug: tasks.slug, - title: tasks.title, - description: tasks.description, - priority: tasks.priority, - statusId: tasks.statusId, - statusName: status.name, - statusType: status.type, - statusColor: status.color, - assigneeId: tasks.assigneeId, - assigneeName: sql< - string | null - >`coalesce(${assignee.name}, ${tasks.assigneeDisplayName})`, - assigneeEmail: assignee.email, - assigneeExternalId: tasks.assigneeExternalId, - assigneeDisplayName: tasks.assigneeDisplayName, - assigneeAvatarUrl: tasks.assigneeAvatarUrl, - creatorId: tasks.creatorId, - creatorName: creator.name, - labels: tasks.labels, - dueDate: tasks.dueDate, - estimate: tasks.estimate, - branch: tasks.branch, - prUrl: tasks.prUrl, - externalProjectId: tasks.externalProjectId, - externalProjectName: tasks.externalProjectName, - externalCycleId: tasks.externalCycleId, - externalCycleName: tasks.externalCycleName, - }) - .from(tasks) - .leftJoin(assignee, eq(tasks.assigneeId, assignee.id)) - .leftJoin(creator, eq(tasks.creatorId, creator.id)) - .leftJoin(status, eq(tasks.statusId, status.id)) - .where( - and( - isUuid ? eq(tasks.id, taskId) : eq(tasks.slug, taskId), - eq(tasks.organizationId, ctx.organizationId), - isNull(tasks.deletedAt), - ), - ) - .limit(1); - - if (!task) { - return { - content: [{ type: "text", text: "Error: Task not found" }], - isError: true, - }; - } - - const serializedTask = { - ...task, - dueDate: task.dueDate?.toISOString() ?? null, - }; - return { - structuredContent: { task: serializedTask }, - content: [ - { - type: "text", - text: JSON.stringify({ task: serializedTask }, null, 2), - }, - ], - }; - }, - ); -} diff --git a/packages/mcp/src/tools/tasks/get-task/index.ts b/packages/mcp/src/tools/tasks/get-task/index.ts deleted file mode 100644 index b15e7a43fbb..00000000000 --- a/packages/mcp/src/tools/tasks/get-task/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { register } from "./get-task"; diff --git a/packages/mcp/src/tools/tasks/list-task-statuses/index.ts b/packages/mcp/src/tools/tasks/list-task-statuses/index.ts deleted file mode 100644 index 01cc2e1f0c7..00000000000 --- a/packages/mcp/src/tools/tasks/list-task-statuses/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { register } from "./list-task-statuses"; diff --git a/packages/mcp/src/tools/tasks/list-task-statuses/list-task-statuses.ts b/packages/mcp/src/tools/tasks/list-task-statuses/list-task-statuses.ts deleted file mode 100644 index 00103903472..00000000000 --- a/packages/mcp/src/tools/tasks/list-task-statuses/list-task-statuses.ts +++ /dev/null @@ -1,49 +0,0 @@ -import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; -import { db } from "@superset/db/client"; -import { taskStatuses } from "@superset/db/schema"; -import { eq } from "drizzle-orm"; -import { z } from "zod"; -import { getMcpContext } from "../../utils"; - -export function register(server: McpServer) { - server.registerTool( - "list_task_statuses", - { - description: "List available task statuses for the organization", - inputSchema: {}, - outputSchema: { - statuses: z.array( - z.object({ - id: z.string(), - name: z.string(), - color: z.string(), - type: z.string(), - position: z.number(), - }), - ), - }, - }, - async (_args, extra) => { - const ctx = getMcpContext(extra); - - const statuses = await db - .select({ - id: taskStatuses.id, - name: taskStatuses.name, - color: taskStatuses.color, - type: taskStatuses.type, - position: taskStatuses.position, - }) - .from(taskStatuses) - .where(eq(taskStatuses.organizationId, ctx.organizationId)) - .orderBy(taskStatuses.position); - - return { - structuredContent: { statuses }, - content: [ - { type: "text", text: JSON.stringify({ statuses }, null, 2) }, - ], - }; - }, - ); -} diff --git a/packages/mcp/src/tools/tasks/list-tasks/index.ts b/packages/mcp/src/tools/tasks/list-tasks/index.ts deleted file mode 100644 index c8d14b96620..00000000000 --- a/packages/mcp/src/tools/tasks/list-tasks/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { register } from "./list-tasks"; diff --git a/packages/mcp/src/tools/tasks/list-tasks/list-tasks.ts b/packages/mcp/src/tools/tasks/list-tasks/list-tasks.ts deleted file mode 100644 index a3d80f472a2..00000000000 --- a/packages/mcp/src/tools/tasks/list-tasks/list-tasks.ts +++ /dev/null @@ -1,236 +0,0 @@ -import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; -import { db } from "@superset/db/client"; -import { type TaskPriority, taskPriorityValues } from "@superset/db/enums"; -import { taskStatuses, tasks, users } from "@superset/db/schema"; -import { - buildTaskListConditions, - buildTaskListOrderBy, - InvalidDueDateRangeError, - normalizeDueDateRange, - type TaskListSortBy, - type TaskListSortOrder, - type TaskStatusType, - taskListSortByValues, - taskListSortOrderValues, - taskStatusTypeValues, -} from "@superset/db/task-list-query"; -import { and, eq, sql } from "drizzle-orm"; -import { alias } from "drizzle-orm/pg-core"; -import { z } from "zod"; -import { getMcpContext } from "../../utils"; - -function isPriority(value: unknown): value is TaskPriority { - return (taskPriorityValues as readonly string[]).includes(value as string); -} - -export function register(server: McpServer) { - server.registerTool( - "list_tasks", - { - description: "List tasks with optional filters", - inputSchema: { - statusId: z.string().uuid().optional().describe("Filter by status ID"), - statusType: z - .enum(taskStatusTypeValues) - .optional() - .describe("Filter by status type"), - assigneeId: z.string().uuid().optional().describe("Filter by assignee"), - assignedToMe: z - .boolean() - .optional() - .describe("Filter to tasks assigned to current user"), - creatorId: z.string().uuid().optional().describe("Filter by creator"), - createdByMe: z - .boolean() - .optional() - .describe("Filter to tasks created by current user"), - priority: z.enum(taskPriorityValues).optional(), - labels: z - .array(z.string()) - .optional() - .describe("Filter by labels (tasks must have ALL specified labels)"), - search: z.string().optional().describe("Search in title/description"), - externalProjectId: z - .string() - .optional() - .describe("Filter by Linear project ID"), - externalProjectName: z - .string() - .optional() - .describe("Filter by Linear project name (prefix, case-insensitive)"), - externalCycleId: z - .string() - .optional() - .describe("Filter by Linear cycle ID"), - dueDateFrom: z - .string() - .datetime({ offset: true }) - .optional() - .describe( - "Tasks due on or after this date (ISO datetime, normalized to UTC day start)", - ), - dueDateTo: z - .string() - .datetime({ offset: true }) - .optional() - .describe( - "Tasks due on or before this date (ISO datetime, normalized to UTC day end)", - ), - sortBy: z - .enum(taskListSortByValues) - .optional() - .describe("Sort field (default: createdAt)"), - sortOrder: z - .enum(taskListSortOrderValues) - .optional() - .describe("Sort direction (default: desc)"), - includeDeleted: z - .boolean() - .optional() - .describe("Include deleted tasks in results"), - limit: z.number().int().min(1).max(100).default(50), - offset: z.number().int().min(0).default(0), - }, - outputSchema: { - tasks: z.array( - z.object({ - id: z.string(), - slug: z.string(), - title: z.string(), - description: z.string().nullable(), - priority: z.string(), - statusId: z.string().nullable(), - statusName: z.string().nullable(), - statusType: z.string().nullable(), - assigneeId: z.string().nullable(), - assigneeName: z.string().nullable(), - assigneeExternalId: z.string().nullable(), - assigneeDisplayName: z.string().nullable(), - assigneeAvatarUrl: z.string().nullable(), - creatorId: z.string().nullable(), - creatorName: z.string().nullable(), - labels: z.array(z.string()), - dueDate: z.string().nullable(), - estimate: z.number().nullable(), - externalProjectId: z.string().nullable(), - externalProjectName: z.string().nullable(), - externalCycleId: z.string().nullable(), - externalCycleName: z.string().nullable(), - deletedAt: z.string().nullable(), - }), - ), - count: z.number(), - hasMore: z.boolean(), - }, - }, - async (args, extra) => { - const ctx = getMcpContext(extra); - const statusId = args.statusId as string | undefined; - const statusType = args.statusType as TaskStatusType | undefined; - const assigneeId = args.assigneeId as string | undefined; - const assignedToMe = args.assignedToMe as boolean | undefined; - const creatorId = args.creatorId as string | undefined; - const createdByMe = args.createdByMe as boolean | undefined; - const priority = args.priority; - const labels = args.labels as string[] | undefined; - const search = args.search as string | undefined; - const externalProjectId = args.externalProjectId as string | undefined; - const externalProjectName = args.externalProjectName as - | string - | undefined; - const externalCycleId = args.externalCycleId as string | undefined; - const dueDateFrom = args.dueDateFrom as string | undefined; - const dueDateTo = args.dueDateTo as string | undefined; - const sortBy = args.sortBy as TaskListSortBy | undefined; - const sortOrder = args.sortOrder as TaskListSortOrder | undefined; - const includeDeleted = args.includeDeleted as boolean | undefined; - const limit = args.limit as number; - const offset = args.offset as number; - - let dueDateRange: { from?: Date; to?: Date }; - try { - dueDateRange = normalizeDueDateRange(dueDateFrom, dueDateTo); - } catch (error) { - if (error instanceof InvalidDueDateRangeError) { - return { - content: [{ type: "text", text: `Error: ${error.message}` }], - isError: true, - }; - } - throw error; - } - - const assignee = alias(users, "assignee"); - const creator = alias(users, "creator"); - const status = alias(taskStatuses, "status"); - - const conditions = buildTaskListConditions({ - organizationId: ctx.organizationId, - includeDeleted, - statusId, - statusType, - assigneeId: assigneeId ?? (assignedToMe ? ctx.userId : undefined), - creatorId: creatorId ?? (createdByMe ? ctx.userId : undefined), - priority: isPriority(priority) ? priority : undefined, - labels, - search, - externalProjectId, - externalProjectName, - externalCycleId, - dueDateFrom: dueDateRange.from, - dueDateTo: dueDateRange.to, - }); - - const tasksList = await db - .select({ - id: tasks.id, - slug: tasks.slug, - title: tasks.title, - description: tasks.description, - priority: tasks.priority, - statusId: tasks.statusId, - statusName: status.name, - statusType: status.type, - assigneeId: tasks.assigneeId, - assigneeName: sql< - string | null - >`coalesce(${assignee.name}, ${tasks.assigneeDisplayName})`, - assigneeExternalId: tasks.assigneeExternalId, - assigneeDisplayName: tasks.assigneeDisplayName, - assigneeAvatarUrl: tasks.assigneeAvatarUrl, - creatorId: tasks.creatorId, - creatorName: creator.name, - labels: tasks.labels, - dueDate: tasks.dueDate, - estimate: tasks.estimate, - externalProjectId: tasks.externalProjectId, - externalProjectName: tasks.externalProjectName, - externalCycleId: tasks.externalCycleId, - externalCycleName: tasks.externalCycleName, - deletedAt: tasks.deletedAt, - }) - .from(tasks) - .leftJoin(assignee, eq(tasks.assigneeId, assignee.id)) - .leftJoin(creator, eq(tasks.creatorId, creator.id)) - .leftJoin(status, eq(tasks.statusId, status.id)) - .where(and(...conditions)) - .orderBy(...buildTaskListOrderBy(sortBy, sortOrder)) - .limit(limit) - .offset(offset); - - const data = { - tasks: tasksList.map((t) => ({ - ...t, - dueDate: t.dueDate?.toISOString() ?? null, - deletedAt: t.deletedAt?.toISOString() ?? null, - })), - count: tasksList.length, - hasMore: tasksList.length === limit, - }; - return { - structuredContent: data, - content: [{ type: "text", text: JSON.stringify(data, null, 2) }], - }; - }, - ); -} diff --git a/packages/mcp/src/tools/tasks/update-task/index.ts b/packages/mcp/src/tools/tasks/update-task/index.ts deleted file mode 100644 index 34209aab857..00000000000 --- a/packages/mcp/src/tools/tasks/update-task/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { register } from "./update-task"; diff --git a/packages/mcp/src/tools/tasks/update-task/update-task.ts b/packages/mcp/src/tools/tasks/update-task/update-task.ts deleted file mode 100644 index 003f0ddf3da..00000000000 --- a/packages/mcp/src/tools/tasks/update-task/update-task.ts +++ /dev/null @@ -1,151 +0,0 @@ -import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; -import { db, dbWs } from "@superset/db/client"; -import { tasks } from "@superset/db/schema"; -import { and, eq, isNull } from "drizzle-orm"; -import { z } from "zod"; -import { getMcpContext } from "../../utils"; - -const updateSchema = z.object({ - taskId: z.string().describe("Task ID (uuid) or slug"), - title: z.string().min(1).optional().describe("New title"), - description: z.string().optional().describe("New description"), - priority: z.enum(["urgent", "high", "medium", "low", "none"]).optional(), - assigneeId: z - .string() - .uuid() - .nullable() - .optional() - .describe("New assignee (null to unassign)"), - statusId: z.string().uuid().optional().describe("New status ID"), - labels: z.array(z.string()).optional().describe("Replace labels"), - dueDate: z - .string() - .datetime() - .nullable() - .optional() - .describe("New due date (null to clear)"), - estimate: z.number().int().positive().nullable().optional(), -}); - -type UpdateInput = z.infer; - -export function register(server: McpServer) { - server.registerTool( - "update_task", - { - description: "Update one or more existing tasks", - inputSchema: { - updates: z - .array(updateSchema) - .min(1) - .max(25) - .describe("Array of task updates (1-25)"), - }, - outputSchema: { - updated: z.array( - z.object({ - id: z.string(), - slug: z.string(), - title: z.string(), - }), - ), - }, - }, - async (args, extra) => { - const ctx = getMcpContext(extra); - const updates = args.updates as UpdateInput[]; - - const resolvedUpdates: { - taskId: string; - updateData: Record; - }[] = []; - - for (const [i, update] of updates.entries()) { - const taskId = update.taskId; - const isUuid = z.string().uuid().safeParse(taskId).success; - - const [existingTask] = await db - .select({ id: tasks.id }) - .from(tasks) - .where( - and( - isUuid ? eq(tasks.id, taskId) : eq(tasks.slug, taskId), - eq(tasks.organizationId, ctx.organizationId), - isNull(tasks.deletedAt), - ), - ) - .limit(1); - - if (!existingTask) { - return { - content: [ - { - type: "text", - text: `Error: Task not found: ${taskId} (index ${i})`, - }, - ], - isError: true, - }; - } - - const updateData: Record = {}; - if (update.title !== undefined) updateData.title = update.title; - if (update.description !== undefined) - updateData.description = update.description; - if (update.priority !== undefined) - updateData.priority = update.priority; - if (update.assigneeId !== undefined) { - updateData.assigneeId = update.assigneeId; - updateData.assigneeExternalId = null; - updateData.assigneeDisplayName = null; - updateData.assigneeAvatarUrl = null; - } - if (update.statusId !== undefined) - updateData.statusId = update.statusId; - if (update.labels !== undefined) updateData.labels = update.labels; - if (update.dueDate !== undefined) - updateData.dueDate = update.dueDate ? new Date(update.dueDate) : null; - if (update.estimate !== undefined) - updateData.estimate = update.estimate; - - if (Object.keys(updateData).length === 0) { - return { - content: [ - { - type: "text", - text: `Error: No updatable fields provided for task: ${taskId} (index ${i})`, - }, - ], - isError: true, - }; - } - - resolvedUpdates.push({ taskId: existingTask.id, updateData }); - } - - const updatedTasks: { id: string; slug: string; title: string }[] = []; - - for (const { taskId, updateData } of resolvedUpdates) { - const [task] = await dbWs - .update(tasks) - .set(updateData) - .where(eq(tasks.id, taskId)) - .returning({ - id: tasks.id, - slug: tasks.slug, - title: tasks.title, - }); - - if (task) { - updatedTasks.push(task); - } - } - - const data = { updated: updatedTasks }; - return { - structuredContent: data, - content: [{ type: "text", text: JSON.stringify(data, null, 2) }], - }; - }, - ); -} diff --git a/packages/mcp/src/tools/utils/index.ts b/packages/mcp/src/tools/utils/index.ts deleted file mode 100644 index b06def7d42a..00000000000 --- a/packages/mcp/src/tools/utils/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { executeOnDevice, getMcpContext } from "./utils"; diff --git a/packages/mcp/src/tools/utils/utils.ts b/packages/mcp/src/tools/utils/utils.ts deleted file mode 100644 index 42d9f46fb0f..00000000000 --- a/packages/mcp/src/tools/utils/utils.ts +++ /dev/null @@ -1,145 +0,0 @@ -import type { AuthInfo } from "@modelcontextprotocol/sdk/server/auth/types.js"; -import type { RequestHandlerExtra } from "@modelcontextprotocol/sdk/shared/protocol.js"; -import type { - ServerNotification, - ServerRequest, -} from "@modelcontextprotocol/sdk/types.js"; -import { db } from "@superset/db/client"; -import { agentCommands, devicePresence } from "@superset/db/schema"; -import { and, eq } from "drizzle-orm"; -import type { McpContext } from "../../auth"; - -// --- Auth context --- - -type ToolExtra = RequestHandlerExtra & { - authInfo?: AuthInfo & { extra?: { mcpContext?: McpContext } }; -}; - -export function getMcpContext( - extra: RequestHandlerExtra, -): McpContext { - const ctx = (extra as ToolExtra).authInfo?.extra?.mcpContext; - if (!ctx) { - throw new Error("No MCP context available - authentication required"); - } - return ctx; -} - -// --- Device execution --- - -const POLL_INTERVAL_MS = 500; -const DEFAULT_TIMEOUT_MS = 30_000; - -export async function executeOnDevice({ - ctx, - deviceId, - tool, - params, - timeout = DEFAULT_TIMEOUT_MS, -}: { - ctx: McpContext; - deviceId: string; - tool: string; - params: Record; - timeout?: number; -}) { - // Verify device exists and belongs to this user - const [device] = await db - .select() - .from(devicePresence) - .where( - and( - eq(devicePresence.deviceId, deviceId), - eq(devicePresence.organizationId, ctx.organizationId), - eq(devicePresence.userId, ctx.userId), - ), - ) - .limit(1); - - if (!device) { - return { - content: [ - { - type: "text" as const, - text: `Error: Device ${deviceId} not found or you don't have access to it.`, - }, - ], - isError: true, - }; - } - - const [cmd] = await db - .insert(agentCommands) - .values({ - userId: ctx.userId, - organizationId: ctx.organizationId, - targetDeviceId: deviceId, - targetDeviceType: device.deviceType, - tool, - params, - status: "pending", - timeoutAt: new Date(Date.now() + timeout), - }) - .returning(); - - if (!cmd) { - return { - content: [ - { type: "text" as const, text: "Error: Failed to create command" }, - ], - isError: true, - }; - } - - const startTime = Date.now(); - while (Date.now() - startTime < timeout) { - const [updated] = await db - .select() - .from(agentCommands) - .where(eq(agentCommands.id, cmd.id)) - .limit(1); - - if (updated?.status === "completed") { - return { - content: [ - { - type: "text" as const, - text: JSON.stringify(updated.result ?? { success: true }, null, 2), - }, - ], - }; - } - - if (updated?.status === "failed") { - return { - content: [ - { - type: "text" as const, - text: `Error: ${updated.error ?? "Command failed"}`, - }, - ], - isError: true, - }; - } - - await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS)); - } - - // Only update to timeout if still pending (avoid race with desktop completing it) - await db - .update(agentCommands) - .set({ status: "timeout" }) - .where( - and(eq(agentCommands.id, cmd.id), eq(agentCommands.status, "pending")), - ); - - return { - content: [ - { - type: "text" as const, - text: `Error: Command timed out after ${timeout}ms`, - }, - ], - isError: true, - }; -} diff --git a/packages/mcp/tsconfig.json b/packages/mcp/tsconfig.json deleted file mode 100644 index fc8e66495ae..00000000000 --- a/packages/mcp/tsconfig.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "extends": "@superset/typescript/internal-package.json", - "compilerOptions": { - "types": ["bun-types"], - "paths": { - "@/*": ["./src/*"] - } - }, - "include": ["src"], - "exclude": ["node_modules"] -}