From e4c49eb0450bfbe5556a50c224688eb71eb0d0dd Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Mon, 26 Jan 2026 19:10:16 -0500 Subject: [PATCH 1/2] feat: add MCP tools for get_pulse and update_pulse - Add registerGetPulseTool for retrieving pulse status via MCP - Add registerUpdatePulseTool for updating pulse status via MCP - Reuse existing selectPulseAccount and upsertPulseAccount libs (DRY) - Support account_id override for organization API keys - Include auth validation via resolveAccountId Co-Authored-By: Claude Opus 4.5 --- lib/mcp/tools/index.ts | 2 + .../__tests__/registerGetPulseTool.test.ts | 158 ++++++++++++++ .../__tests__/registerUpdatePulseTool.test.ts | 196 ++++++++++++++++++ lib/mcp/tools/pulse/index.ts | 13 ++ lib/mcp/tools/pulse/registerGetPulseTool.ts | 63 ++++++ .../tools/pulse/registerUpdatePulseTool.ts | 69 ++++++ 6 files changed, 501 insertions(+) create mode 100644 lib/mcp/tools/pulse/__tests__/registerGetPulseTool.test.ts create mode 100644 lib/mcp/tools/pulse/__tests__/registerUpdatePulseTool.test.ts create mode 100644 lib/mcp/tools/pulse/index.ts create mode 100644 lib/mcp/tools/pulse/registerGetPulseTool.ts create mode 100644 lib/mcp/tools/pulse/registerUpdatePulseTool.ts diff --git a/lib/mcp/tools/index.ts b/lib/mcp/tools/index.ts index 0b60c5f..3ddd45e 100644 --- a/lib/mcp/tools/index.ts +++ b/lib/mcp/tools/index.ts @@ -17,6 +17,7 @@ import { registerAllYouTubeTools } from "./youtube"; import { registerTranscribeTools } from "./transcribe"; import { registerSendEmailTool } from "./registerSendEmailTool"; import { registerAllArtistTools } from "./artists"; +import { registerAllPulseTools } from "./pulse"; /** * Registers all MCP tools on the server. @@ -33,6 +34,7 @@ export const registerAllTools = (server: McpServer): void => { registerAllCatalogTools(server); registerAllFileTools(server); registerAllImageTools(server); + registerAllPulseTools(server); registerAllSora2Tools(server); registerAllSpotifyTools(server); registerAllTaskTools(server); diff --git a/lib/mcp/tools/pulse/__tests__/registerGetPulseTool.test.ts b/lib/mcp/tools/pulse/__tests__/registerGetPulseTool.test.ts new file mode 100644 index 0000000..d91aa60 --- /dev/null +++ b/lib/mcp/tools/pulse/__tests__/registerGetPulseTool.test.ts @@ -0,0 +1,158 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import type { RequestHandlerExtra } from "@modelcontextprotocol/sdk/shared/protocol.js"; +import type { ServerRequest, ServerNotification } from "@modelcontextprotocol/sdk/types.js"; + +import { registerGetPulseTool } from "../registerGetPulseTool"; + +const mockSelectPulseAccount = vi.fn(); +const mockCanAccessAccount = vi.fn(); + +vi.mock("@/lib/supabase/pulse_accounts/selectPulseAccount", () => ({ + selectPulseAccount: (...args: unknown[]) => mockSelectPulseAccount(...args), +})); + +vi.mock("@/lib/organizations/canAccessAccount", () => ({ + canAccessAccount: (...args: unknown[]) => mockCanAccessAccount(...args), +})); + +type ServerRequestHandlerExtra = RequestHandlerExtra; + +/** + * Creates a mock extra object with optional authInfo. + * + * @param authInfo + * @param authInfo.accountId + * @param authInfo.orgId + */ +function createMockExtra(authInfo?: { + accountId?: string; + orgId?: string | null; +}): ServerRequestHandlerExtra { + return { + authInfo: authInfo + ? { + token: "test-token", + scopes: ["mcp:tools"], + clientId: authInfo.accountId, + extra: { + accountId: authInfo.accountId, + orgId: authInfo.orgId ?? null, + }, + } + : undefined, + } as unknown as ServerRequestHandlerExtra; +} + +describe("registerGetPulseTool", () => { + let mockServer: McpServer; + let registeredHandler: (args: unknown, extra: ServerRequestHandlerExtra) => Promise; + + beforeEach(() => { + vi.clearAllMocks(); + + mockServer = { + registerTool: vi.fn((name, config, handler) => { + registeredHandler = handler; + }), + } as unknown as McpServer; + + registerGetPulseTool(mockServer); + }); + + it("registers the get_pulse tool", () => { + expect(mockServer.registerTool).toHaveBeenCalledWith( + "get_pulse", + expect.objectContaining({ + description: expect.stringContaining("Get the pulse status"), + }), + expect.any(Function), + ); + }); + + it("returns pulse with active: false when no record exists", async () => { + mockSelectPulseAccount.mockResolvedValue(null); + + const result = await registeredHandler({}, createMockExtra({ accountId: "account-123" })); + + expect(mockSelectPulseAccount).toHaveBeenCalledWith("account-123"); + expect(result).toEqual({ + content: [ + { + type: "text", + text: expect.stringContaining('"active":false'), + }, + ], + }); + }); + + it("returns pulse with active: true when record exists", async () => { + mockSelectPulseAccount.mockResolvedValue({ + id: "pulse-456", + account_id: "account-123", + active: true, + }); + + const result = await registeredHandler({}, createMockExtra({ accountId: "account-123" })); + + expect(result).toEqual({ + content: [ + { + type: "text", + text: expect.stringContaining('"active":true'), + }, + ], + }); + }); + + it("allows account_id override for org auth with access", async () => { + mockCanAccessAccount.mockResolvedValue(true); + mockSelectPulseAccount.mockResolvedValue({ + id: "pulse-456", + account_id: "target-account-789", + active: true, + }); + + await registeredHandler( + { account_id: "target-account-789" }, + createMockExtra({ accountId: "org-account-id", orgId: "org-account-id" }), + ); + + expect(mockCanAccessAccount).toHaveBeenCalledWith({ + orgId: "org-account-id", + targetAccountId: "target-account-789", + }); + expect(mockSelectPulseAccount).toHaveBeenCalledWith("target-account-789"); + }); + + it("returns error when org auth lacks access to account_id", async () => { + mockCanAccessAccount.mockResolvedValue(false); + + const result = await registeredHandler( + { account_id: "target-account-789" }, + createMockExtra({ accountId: "org-account-id", orgId: "org-account-id" }), + ); + + expect(result).toEqual({ + content: [ + { + type: "text", + text: expect.stringContaining("Access denied"), + }, + ], + }); + }); + + it("returns error when neither auth nor account_id is provided", async () => { + const result = await registeredHandler({}, createMockExtra()); + + expect(result).toEqual({ + content: [ + { + type: "text", + text: expect.stringContaining("Authentication required"), + }, + ], + }); + }); +}); diff --git a/lib/mcp/tools/pulse/__tests__/registerUpdatePulseTool.test.ts b/lib/mcp/tools/pulse/__tests__/registerUpdatePulseTool.test.ts new file mode 100644 index 0000000..d09330f --- /dev/null +++ b/lib/mcp/tools/pulse/__tests__/registerUpdatePulseTool.test.ts @@ -0,0 +1,196 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import type { RequestHandlerExtra } from "@modelcontextprotocol/sdk/shared/protocol.js"; +import type { ServerRequest, ServerNotification } from "@modelcontextprotocol/sdk/types.js"; + +import { registerUpdatePulseTool } from "../registerUpdatePulseTool"; + +const mockUpsertPulseAccount = vi.fn(); +const mockCanAccessAccount = vi.fn(); + +vi.mock("@/lib/supabase/pulse_accounts/upsertPulseAccount", () => ({ + upsertPulseAccount: (...args: unknown[]) => mockUpsertPulseAccount(...args), +})); + +vi.mock("@/lib/organizations/canAccessAccount", () => ({ + canAccessAccount: (...args: unknown[]) => mockCanAccessAccount(...args), +})); + +type ServerRequestHandlerExtra = RequestHandlerExtra; + +/** + * Creates a mock extra object with optional authInfo. + * + * @param authInfo + * @param authInfo.accountId + * @param authInfo.orgId + */ +function createMockExtra(authInfo?: { + accountId?: string; + orgId?: string | null; +}): ServerRequestHandlerExtra { + return { + authInfo: authInfo + ? { + token: "test-token", + scopes: ["mcp:tools"], + clientId: authInfo.accountId, + extra: { + accountId: authInfo.accountId, + orgId: authInfo.orgId ?? null, + }, + } + : undefined, + } as unknown as ServerRequestHandlerExtra; +} + +describe("registerUpdatePulseTool", () => { + let mockServer: McpServer; + let registeredHandler: (args: unknown, extra: ServerRequestHandlerExtra) => Promise; + + beforeEach(() => { + vi.clearAllMocks(); + + mockServer = { + registerTool: vi.fn((name, config, handler) => { + registeredHandler = handler; + }), + } as unknown as McpServer; + + registerUpdatePulseTool(mockServer); + }); + + it("registers the update_pulse tool", () => { + expect(mockServer.registerTool).toHaveBeenCalledWith( + "update_pulse", + expect.objectContaining({ + description: expect.stringContaining("Update the pulse status"), + }), + expect.any(Function), + ); + }); + + it("updates pulse with active: true", async () => { + mockUpsertPulseAccount.mockResolvedValue({ + id: "pulse-456", + account_id: "account-123", + active: true, + }); + + const result = await registeredHandler( + { active: true }, + createMockExtra({ accountId: "account-123" }), + ); + + expect(mockUpsertPulseAccount).toHaveBeenCalledWith({ + account_id: "account-123", + active: true, + }); + expect(result).toEqual({ + content: [ + { + type: "text", + text: expect.stringContaining('"active":true'), + }, + ], + }); + }); + + it("updates pulse with active: false", async () => { + mockUpsertPulseAccount.mockResolvedValue({ + id: "pulse-456", + account_id: "account-123", + active: false, + }); + + const result = await registeredHandler( + { active: false }, + createMockExtra({ accountId: "account-123" }), + ); + + expect(mockUpsertPulseAccount).toHaveBeenCalledWith({ + account_id: "account-123", + active: false, + }); + expect(result).toEqual({ + content: [ + { + type: "text", + text: expect.stringContaining('"active":false'), + }, + ], + }); + }); + + it("allows account_id override for org auth with access", async () => { + mockCanAccessAccount.mockResolvedValue(true); + mockUpsertPulseAccount.mockResolvedValue({ + id: "pulse-456", + account_id: "target-account-789", + active: true, + }); + + await registeredHandler( + { active: true, account_id: "target-account-789" }, + createMockExtra({ accountId: "org-account-id", orgId: "org-account-id" }), + ); + + expect(mockCanAccessAccount).toHaveBeenCalledWith({ + orgId: "org-account-id", + targetAccountId: "target-account-789", + }); + expect(mockUpsertPulseAccount).toHaveBeenCalledWith({ + account_id: "target-account-789", + active: true, + }); + }); + + it("returns error when org auth lacks access to account_id", async () => { + mockCanAccessAccount.mockResolvedValue(false); + + const result = await registeredHandler( + { active: true, account_id: "target-account-789" }, + createMockExtra({ accountId: "org-account-id", orgId: "org-account-id" }), + ); + + expect(result).toEqual({ + content: [ + { + type: "text", + text: expect.stringContaining("Access denied"), + }, + ], + }); + }); + + it("returns error when neither auth nor account_id is provided", async () => { + const result = await registeredHandler({ active: true }, createMockExtra()); + + expect(result).toEqual({ + content: [ + { + type: "text", + text: expect.stringContaining("Authentication required"), + }, + ], + }); + }); + + it("returns error when upsert fails", async () => { + mockUpsertPulseAccount.mockResolvedValue(null); + + const result = await registeredHandler( + { active: true }, + createMockExtra({ accountId: "account-123" }), + ); + + expect(result).toEqual({ + content: [ + { + type: "text", + text: expect.stringContaining("Failed to update pulse status"), + }, + ], + }); + }); +}); diff --git a/lib/mcp/tools/pulse/index.ts b/lib/mcp/tools/pulse/index.ts new file mode 100644 index 0000000..70042af --- /dev/null +++ b/lib/mcp/tools/pulse/index.ts @@ -0,0 +1,13 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { registerGetPulseTool } from "./registerGetPulseTool"; +import { registerUpdatePulseTool } from "./registerUpdatePulseTool"; + +/** + * Registers all pulse-related MCP tools on the server. + * + * @param server - The MCP server instance to register tools on. + */ +export const registerAllPulseTools = (server: McpServer): void => { + registerGetPulseTool(server); + registerUpdatePulseTool(server); +}; diff --git a/lib/mcp/tools/pulse/registerGetPulseTool.ts b/lib/mcp/tools/pulse/registerGetPulseTool.ts new file mode 100644 index 0000000..eae1f4e --- /dev/null +++ b/lib/mcp/tools/pulse/registerGetPulseTool.ts @@ -0,0 +1,63 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import type { RequestHandlerExtra } from "@modelcontextprotocol/sdk/shared/protocol.js"; +import type { ServerRequest, ServerNotification } from "@modelcontextprotocol/sdk/types.js"; +import { z } from "zod"; +import type { McpAuthInfo } from "@/lib/mcp/verifyApiKey"; +import { resolveAccountId } from "@/lib/mcp/resolveAccountId"; +import { selectPulseAccount } from "@/lib/supabase/pulse_accounts/selectPulseAccount"; +import { getToolResultSuccess } from "@/lib/mcp/getToolResultSuccess"; +import { getToolResultError } from "@/lib/mcp/getToolResultError"; + +const getPulseSchema = z.object({ + account_id: z + .string() + .optional() + .describe( + "The account ID to get pulse status for. Only required for organization API keys querying on behalf of other accounts. " + + "If not provided, the account ID will be resolved from the authenticated API key.", + ), +}); + +export type GetPulseArgs = z.infer; + +/** + * Registers the "get_pulse" tool on the MCP server. + * Retrieves the pulse status for an account. + * + * @param server - The MCP server instance to register the tool on. + */ +export function registerGetPulseTool(server: McpServer): void { + server.registerTool( + "get_pulse", + { + description: + "Get the pulse status for an account. " + + "Requires authentication via API key (Authorization: Bearer header). " + + "The account_id parameter is optional — only provide it when using an organization API key to query on behalf of other accounts.", + inputSchema: getPulseSchema, + }, + async (args: GetPulseArgs, extra: RequestHandlerExtra) => { + const { account_id } = args; + + const authInfo = extra.authInfo as McpAuthInfo | undefined; + const { accountId, error } = await resolveAccountId({ + authInfo, + accountIdOverride: account_id, + }); + + if (error) { + return getToolResultError(error); + } + + if (!accountId) { + return getToolResultError("Failed to resolve account ID"); + } + + const pulseAccount = await selectPulseAccount(accountId); + + return getToolResultSuccess({ + pulse: pulseAccount ?? { id: null, account_id: accountId, active: false }, + }); + }, + ); +} diff --git a/lib/mcp/tools/pulse/registerUpdatePulseTool.ts b/lib/mcp/tools/pulse/registerUpdatePulseTool.ts new file mode 100644 index 0000000..87ca0ee --- /dev/null +++ b/lib/mcp/tools/pulse/registerUpdatePulseTool.ts @@ -0,0 +1,69 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import type { RequestHandlerExtra } from "@modelcontextprotocol/sdk/shared/protocol.js"; +import type { ServerRequest, ServerNotification } from "@modelcontextprotocol/sdk/types.js"; +import { z } from "zod"; +import type { McpAuthInfo } from "@/lib/mcp/verifyApiKey"; +import { resolveAccountId } from "@/lib/mcp/resolveAccountId"; +import { upsertPulseAccount } from "@/lib/supabase/pulse_accounts/upsertPulseAccount"; +import { getToolResultSuccess } from "@/lib/mcp/getToolResultSuccess"; +import { getToolResultError } from "@/lib/mcp/getToolResultError"; + +const updatePulseSchema = z.object({ + active: z.boolean().describe("Whether pulse is active for this account"), + account_id: z + .string() + .optional() + .describe( + "The account ID to update pulse status for. Only required for organization API keys updating on behalf of other accounts. " + + "If not provided, the account ID will be resolved from the authenticated API key.", + ), +}); + +export type UpdatePulseArgs = z.infer; + +/** + * Registers the "update_pulse" tool on the MCP server. + * Updates the pulse status for an account. + * + * @param server - The MCP server instance to register the tool on. + */ +export function registerUpdatePulseTool(server: McpServer): void { + server.registerTool( + "update_pulse", + { + description: + "Update the pulse status for an account. " + + "Requires authentication via API key (Authorization: Bearer header). " + + "The account_id parameter is optional — only provide it when using an organization API key to update on behalf of other accounts.", + inputSchema: updatePulseSchema, + }, + async ( + args: UpdatePulseArgs, + extra: RequestHandlerExtra, + ) => { + const { active, account_id } = args; + + const authInfo = extra.authInfo as McpAuthInfo | undefined; + const { accountId, error } = await resolveAccountId({ + authInfo, + accountIdOverride: account_id, + }); + + if (error) { + return getToolResultError(error); + } + + if (!accountId) { + return getToolResultError("Failed to resolve account ID"); + } + + const pulseAccount = await upsertPulseAccount({ account_id: accountId, active }); + + if (!pulseAccount) { + return getToolResultError("Failed to update pulse status"); + } + + return getToolResultSuccess({ pulse: pulseAccount }); + }, + ); +} From 70b41039b2a6c95e85cc4ff7c989c65726f5ac72 Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Mon, 26 Jan 2026 19:27:52 -0500 Subject: [PATCH 2/2] refactor: simplify pulse tool schemas (KISS) Remove verbose authentication explanations from schema descriptions. Co-Authored-By: Claude Opus 4.5 --- .../pulse/__tests__/registerGetPulseTool.test.ts | 2 +- .../pulse/__tests__/registerUpdatePulseTool.test.ts | 2 +- lib/mcp/tools/pulse/registerGetPulseTool.ts | 13 ++----------- lib/mcp/tools/pulse/registerUpdatePulseTool.ts | 13 ++----------- 4 files changed, 6 insertions(+), 24 deletions(-) diff --git a/lib/mcp/tools/pulse/__tests__/registerGetPulseTool.test.ts b/lib/mcp/tools/pulse/__tests__/registerGetPulseTool.test.ts index d91aa60..ceacbf6 100644 --- a/lib/mcp/tools/pulse/__tests__/registerGetPulseTool.test.ts +++ b/lib/mcp/tools/pulse/__tests__/registerGetPulseTool.test.ts @@ -64,7 +64,7 @@ describe("registerGetPulseTool", () => { expect(mockServer.registerTool).toHaveBeenCalledWith( "get_pulse", expect.objectContaining({ - description: expect.stringContaining("Get the pulse status"), + description: "Get the pulse status for an account.", }), expect.any(Function), ); diff --git a/lib/mcp/tools/pulse/__tests__/registerUpdatePulseTool.test.ts b/lib/mcp/tools/pulse/__tests__/registerUpdatePulseTool.test.ts index d09330f..cea2959 100644 --- a/lib/mcp/tools/pulse/__tests__/registerUpdatePulseTool.test.ts +++ b/lib/mcp/tools/pulse/__tests__/registerUpdatePulseTool.test.ts @@ -64,7 +64,7 @@ describe("registerUpdatePulseTool", () => { expect(mockServer.registerTool).toHaveBeenCalledWith( "update_pulse", expect.objectContaining({ - description: expect.stringContaining("Update the pulse status"), + description: "Update the pulse status for an account.", }), expect.any(Function), ); diff --git a/lib/mcp/tools/pulse/registerGetPulseTool.ts b/lib/mcp/tools/pulse/registerGetPulseTool.ts index eae1f4e..d60fda9 100644 --- a/lib/mcp/tools/pulse/registerGetPulseTool.ts +++ b/lib/mcp/tools/pulse/registerGetPulseTool.ts @@ -9,13 +9,7 @@ import { getToolResultSuccess } from "@/lib/mcp/getToolResultSuccess"; import { getToolResultError } from "@/lib/mcp/getToolResultError"; const getPulseSchema = z.object({ - account_id: z - .string() - .optional() - .describe( - "The account ID to get pulse status for. Only required for organization API keys querying on behalf of other accounts. " + - "If not provided, the account ID will be resolved from the authenticated API key.", - ), + account_id: z.string().optional().describe("The account ID to get pulse status for."), }); export type GetPulseArgs = z.infer; @@ -30,10 +24,7 @@ export function registerGetPulseTool(server: McpServer): void { server.registerTool( "get_pulse", { - description: - "Get the pulse status for an account. " + - "Requires authentication via API key (Authorization: Bearer header). " + - "The account_id parameter is optional — only provide it when using an organization API key to query on behalf of other accounts.", + description: "Get the pulse status for an account.", inputSchema: getPulseSchema, }, async (args: GetPulseArgs, extra: RequestHandlerExtra) => { diff --git a/lib/mcp/tools/pulse/registerUpdatePulseTool.ts b/lib/mcp/tools/pulse/registerUpdatePulseTool.ts index 87ca0ee..d96cd1d 100644 --- a/lib/mcp/tools/pulse/registerUpdatePulseTool.ts +++ b/lib/mcp/tools/pulse/registerUpdatePulseTool.ts @@ -10,13 +10,7 @@ import { getToolResultError } from "@/lib/mcp/getToolResultError"; const updatePulseSchema = z.object({ active: z.boolean().describe("Whether pulse is active for this account"), - account_id: z - .string() - .optional() - .describe( - "The account ID to update pulse status for. Only required for organization API keys updating on behalf of other accounts. " + - "If not provided, the account ID will be resolved from the authenticated API key.", - ), + account_id: z.string().optional().describe("The account ID to update pulse status for."), }); export type UpdatePulseArgs = z.infer; @@ -31,10 +25,7 @@ export function registerUpdatePulseTool(server: McpServer): void { server.registerTool( "update_pulse", { - description: - "Update the pulse status for an account. " + - "Requires authentication via API key (Authorization: Bearer header). " + - "The account_id parameter is optional — only provide it when using an organization API key to update on behalf of other accounts.", + description: "Update the pulse status for an account.", inputSchema: updatePulseSchema, }, async (