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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions lib/mcp/tools/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -33,6 +34,7 @@ export const registerAllTools = (server: McpServer): void => {
registerAllCatalogTools(server);
registerAllFileTools(server);
registerAllImageTools(server);
registerAllPulseTools(server);
registerAllSora2Tools(server);
registerAllSpotifyTools(server);
registerAllTaskTools(server);
Expand Down
158 changes: 158 additions & 0 deletions lib/mcp/tools/pulse/__tests__/registerGetPulseTool.test.ts
Original file line number Diff line number Diff line change
@@ -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<ServerRequest, ServerNotification>;

/**
* 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<unknown>;

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: "Get the pulse status for an account.",
}),
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"),
},
],
});
});
});
196 changes: 196 additions & 0 deletions lib/mcp/tools/pulse/__tests__/registerUpdatePulseTool.test.ts
Original file line number Diff line number Diff line change
@@ -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<ServerRequest, ServerNotification>;

/**
* 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<unknown>;

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: "Update the pulse status for an account.",
}),
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"),
},
],
});
});
});
13 changes: 13 additions & 0 deletions lib/mcp/tools/pulse/index.ts
Original file line number Diff line number Diff line change
@@ -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);
};
Loading