diff --git a/src/index.ts b/src/index.ts index 50b6c9f..443fbf4 100644 --- a/src/index.ts +++ b/src/index.ts @@ -5,6 +5,8 @@ import { UserIdentity, MCPServerLike, HighLevelMCPServerLike, + CustomEventData, + UnredactedEvent, } from "./types.js"; // Import from modules @@ -15,14 +17,23 @@ import { import { writeToLog } from "./modules/logging.js"; import { setupMCPCatTools } from "./modules/tools.js"; import { setupToolCallTracing } from "./modules/tracing.js"; -import { getSessionInfo, newSessionId } from "./modules/session.js"; +import { + getSessionInfo, + newSessionId, + deriveSessionIdFromMCPSession, +} from "./modules/session.js"; import { setServerTrackingData, getServerTrackingData, } from "./modules/internal.js"; import { setupTracking } from "./modules/tracingV2.js"; import { TelemetryManager } from "./modules/telemetry.js"; -import { setTelemetryManager } from "./modules/eventQueue.js"; +import { + setTelemetryManager, + publishEvent as publishEventToQueue, +} from "./modules/eventQueue.js"; +import { MCPCAT_CUSTOM_EVENT_TYPE } from "./modules/constants.js"; +import { eventQueue } from "./modules/eventQueue.js"; /** * Integrates MCPCat analytics into an MCP server to track tool usage patterns and user interactions. @@ -212,12 +223,138 @@ function track( } } +/** + * Publishes a custom event to MCPCat with flexible session management. + * + * @param serverOrSessionId - Either a tracked MCP server instance or a MCP session ID string + * @param projectId - Your MCPCat project ID (required) + * @param eventData - Optional event data to include with the custom event + * + * @returns Promise that resolves when the event is queued for publishing + * + * @example + * ```typescript + * // With a tracked server + * await mcpcat.publishCustomEvent( + * server, + * "proj_abc123xyz", + * { + * resourceName: "custom-action", + * parameters: { action: "user-feedback", rating: 5 }, + * message: "User provided feedback" + * } + * ); + * ``` + * + * @example + * ```typescript + * // With a MCP session ID + * await mcpcat.publishCustomEvent( + * "user-session-12345", + * "proj_abc123xyz", + * { + * isError: true, + * error: { message: "Custom error occurred", code: "ERR_001" } + * } + * ); + * ``` + * + * @example + * ```typescript + * await mcpcat.publishCustomEvent( + * server, + * "proj_abc123xyz", + * { + * resourceName: "feature-usage", + * } + * ); + * ``` + */ +export async function publishCustomEvent( + serverOrSessionId: any | string, + projectId: string, + eventData?: CustomEventData, +): Promise { + // Validate required parameters + if (!projectId) { + throw new Error("projectId is required for publishCustomEvent"); + } + + let sessionId: string; + + // Determine if the first parameter is a tracked server or a session ID string + const isServer = + typeof serverOrSessionId === "object" && serverOrSessionId !== null; + let lowLevelServer: MCPServerLike | null = null; + + if (isServer) { + // Try to get tracking data for the server + lowLevelServer = serverOrSessionId.server + ? serverOrSessionId.server + : serverOrSessionId; + const trackingData = getServerTrackingData(lowLevelServer as MCPServerLike); + + if (trackingData) { + // Use the tracked server's session ID and configuration + sessionId = trackingData.sessionId; + } else { + // Server is not tracked - treat it as an error + throw new Error( + "Server is not tracked. Please call mcpcat.track() first or provide a session ID string.", + ); + } + } else if (typeof serverOrSessionId === "string") { + // Custom session ID provided - derive a deterministic session ID + sessionId = deriveSessionIdFromMCPSession(serverOrSessionId, projectId); + } else { + throw new Error( + "First parameter must be either an MCP server object or a session ID string", + ); + } + + // Build the event object + const event: UnredactedEvent = { + // Core fields + sessionId, + projectId, + + // Fixed event type for custom events + eventType: MCPCAT_CUSTOM_EVENT_TYPE, + + // Timestamp + timestamp: new Date(), + + // Event data from parameters + resourceName: eventData?.resourceName, + parameters: eventData?.parameters, + response: eventData?.response, + userIntent: eventData?.message, + duration: eventData?.duration, + isError: eventData?.isError, + error: eventData?.error, + }; + + // If we have a tracked server, use the publishEvent function + // Otherwise, add directly to the event queue + if (lowLevelServer && getServerTrackingData(lowLevelServer)) { + publishEventToQueue(lowLevelServer, event); + } else { + // For custom sessions, we need to import and use the event queue directly + eventQueue.add(event); + } + + writeToLog( + `Published custom event for session ${sessionId} with type 'mcpcat:custom'`, + ); +} + export type { MCPCatOptions, UserIdentity, RedactFunction, ExporterConfig, Exporter, + CustomEventData, } from "./types.js"; export type IdentifyFunction = MCPCatOptions["identify"]; diff --git a/src/modules/constants.ts b/src/modules/constants.ts index a73e986..2d836bc 100644 --- a/src/modules/constants.ts +++ b/src/modules/constants.ts @@ -1,3 +1,4 @@ // MCPCat Settings export const INACTIVITY_TIMEOUT_IN_MINUTES = 30; export const DEFAULT_CONTEXT_PARAMETER_DESCRIPTION = `Explain why you are calling this tool and how it fits into the user's overall goal. This parameter is used for analytics and user intent tracking. YOU MUST provide 15-25 words (count carefully). NEVER use first person ('I', 'we', 'you') - maintain third-person perspective. NEVER include sensitive information such as credentials, passwords, or personal data. Example (20 words): "Searching across the organization's repositories to find all open issues related to performance complaints and latency issues for team prioritization."`; +export const MCPCAT_CUSTOM_EVENT_TYPE = "mcpcat:custom"; diff --git a/src/tests/publishCustomEvent.test.ts b/src/tests/publishCustomEvent.test.ts new file mode 100644 index 0000000..63089b7 --- /dev/null +++ b/src/tests/publishCustomEvent.test.ts @@ -0,0 +1,309 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { MCPServerLike, CustomEventData } from "../types.js"; +import { setupTestHooks } from "./test-utils.js"; + +// Mock external dependencies +vi.mock("../modules/logging.js"); +vi.mock("../modules/internal.js"); +vi.mock("../modules/session.js"); +vi.mock("../modules/eventQueue.js"); +vi.mock("../modules/constants.js"); +vi.mock("../thirdparty/ksuid/index.js"); + +// Import mocked modules +import { writeToLog } from "../modules/logging.js"; +import { getServerTrackingData } from "../modules/internal.js"; +import { deriveSessionIdFromMCPSession } from "../modules/session.js"; +import { + publishEvent as publishEventToQueue, + eventQueue, +} from "../modules/eventQueue.js"; +import { MCPCAT_CUSTOM_EVENT_TYPE } from "../modules/constants.js"; +import KSUID from "../thirdparty/ksuid/index.js"; + +// Import the function under test +import { publishCustomEvent } from "../index.js"; + +describe("publishCustomEvent", () => { + setupTestHooks(); + + let mockKSUID: any; + let mockEventQueue: any; + + beforeEach(() => { + vi.clearAllMocks(); + + // Mock KSUID + mockKSUID = { + random: vi.fn().mockResolvedValue("evt_test123"), + randomSync: vi.fn().mockReturnValue("ses_test123"), + }; + (KSUID.withPrefix as any) = vi.fn().mockReturnValue(mockKSUID); + + // Mock logging + (writeToLog as any).mockImplementation(() => {}); + + // Mock event queue + mockEventQueue = { + add: vi.fn(), + }; + (eventQueue as any).add = mockEventQueue.add; + + // Mock deriveSessionIdFromMCPSession + (deriveSessionIdFromMCPSession as any).mockImplementation( + (sessionId: string, projectId: string) => { + return `ses_derived_${sessionId}_${projectId}`; + }, + ); + + // Mock publishEventToQueue + (publishEventToQueue as any).mockImplementation(() => {}); + + // Mock MCPCAT_CUSTOM_EVENT_TYPE + (MCPCAT_CUSTOM_EVENT_TYPE as any) = "mcpcat:custom"; + }); + + afterEach(() => { + vi.resetAllMocks(); + }); + + describe("with tracked server", () => { + let mockServer: MCPServerLike; + const projectId = "proj_test123"; + + beforeEach(() => { + mockServer = {} as any; + + // Mock server tracking data + (getServerTrackingData as any).mockReturnValue({ + projectId: "proj_tracked", + sessionId: "ses_tracked123", + options: {}, + }); + }); + + it("should publish custom event with tracked server", async () => { + const eventData: CustomEventData = { + resourceName: "custom-action", + parameters: { action: "test" }, + message: "Testing custom event", + }; + + await publishCustomEvent(mockServer, projectId, eventData); + + expect(getServerTrackingData).toHaveBeenCalledWith(mockServer); + expect(publishEventToQueue).toHaveBeenCalledWith( + mockServer, + expect.objectContaining({ + sessionId: "ses_tracked123", + projectId, + eventType: "mcpcat:custom", + resourceName: "custom-action", + parameters: { action: "test" }, + userIntent: "Testing custom event", // message maps to userIntent + }), + ); + expect(writeToLog).toHaveBeenCalledWith( + expect.stringContaining("Published custom event"), + ); + }); + + it("should handle error data correctly", async () => { + const eventData: CustomEventData = { + isError: true, + error: { message: "Test error", code: "ERR_001" }, + }; + + await publishCustomEvent(mockServer, projectId, eventData); + + expect(publishEventToQueue).toHaveBeenCalledWith( + mockServer, + expect.objectContaining({ + isError: true, + error: { message: "Test error", code: "ERR_001" }, + }), + ); + }); + + it("should throw error if server is not tracked", async () => { + (getServerTrackingData as any).mockReturnValue(undefined); + + await expect(publishCustomEvent(mockServer, projectId)).rejects.toThrow( + "Server is not tracked", + ); + }); + + it("should handle high-level server objects", async () => { + const highLevelServer = { + server: mockServer, + }; + + await publishCustomEvent(highLevelServer, projectId); + + expect(getServerTrackingData).toHaveBeenCalledWith(mockServer); + }); + }); + + describe("with custom session ID", () => { + const customSessionId = "user-session-12345"; + const projectId = "proj_test123"; + + it("should publish custom event with derived session ID", async () => { + const eventData: CustomEventData = { + resourceName: "custom-action", + parameters: { action: "test" }, + }; + + await publishCustomEvent(customSessionId, projectId, eventData); + + expect(deriveSessionIdFromMCPSession).toHaveBeenCalledWith( + customSessionId, + projectId, + ); + expect(mockEventQueue.add).toHaveBeenCalledWith( + expect.objectContaining({ + sessionId: `ses_derived_${customSessionId}_${projectId}`, + projectId, + eventType: "mcpcat:custom", + resourceName: "custom-action", + parameters: { action: "test" }, + }), + ); + }); + + it("should handle all event data fields", async () => { + const eventData: CustomEventData = { + resourceName: "full-test", + parameters: { key: "value" }, + response: { result: "success" }, + message: "Complete test", + duration: 1500, + isError: false, + error: null, + }; + + await publishCustomEvent(customSessionId, projectId, eventData); + + expect(mockEventQueue.add).toHaveBeenCalledWith( + expect.objectContaining({ + resourceName: "full-test", + parameters: { key: "value" }, + response: { result: "success" }, + userIntent: "Complete test", // message maps to userIntent + duration: 1500, + isError: false, + error: null, + }), + ); + }); + }); + + describe("parameter validation", () => { + it("should throw error if projectId is not provided", async () => { + await expect(publishCustomEvent("session-id", "")).rejects.toThrow( + "projectId is required", + ); + + await expect( + publishCustomEvent("session-id", null as any), + ).rejects.toThrow("projectId is required"); + + await expect( + publishCustomEvent("session-id", undefined as any), + ).rejects.toThrow("projectId is required"); + }); + + it("should throw error if first parameter is invalid", async () => { + await expect(publishCustomEvent(123 as any, "proj_123")).rejects.toThrow( + "First parameter must be either an MCP server object or a session ID string", + ); + + await expect(publishCustomEvent(null as any, "proj_123")).rejects.toThrow( + "First parameter must be either an MCP server object or a session ID string", + ); + + await expect( + publishCustomEvent(undefined as any, "proj_123"), + ).rejects.toThrow( + "First parameter must be either an MCP server object or a session ID string", + ); + }); + }); + + describe("event structure", () => { + it("should always use 'mcpcat:custom' as event type", async () => { + const customSessionId = "test-session"; + const projectId = "proj_test"; + + await publishCustomEvent(customSessionId, projectId); + + expect(mockEventQueue.add).toHaveBeenCalledWith( + expect.objectContaining({ + eventType: "mcpcat:custom", + }), + ); + }); + + it("should include timestamp", async () => { + const customSessionId = "test-session"; + const projectId = "proj_test"; + const beforeTime = new Date(); + + await publishCustomEvent(customSessionId, projectId); + + const afterTime = new Date(); + + expect(mockEventQueue.add).toHaveBeenCalledWith( + expect.objectContaining({ + timestamp: expect.any(Date), + }), + ); + + const calledTimestamp = mockEventQueue.add.mock.calls[0][0].timestamp; + expect(calledTimestamp.getTime()).toBeGreaterThanOrEqual( + beforeTime.getTime(), + ); + expect(calledTimestamp.getTime()).toBeLessThanOrEqual( + afterTime.getTime(), + ); + }); + + it("should handle undefined event data gracefully", async () => { + const customSessionId = "test-session"; + const projectId = "proj_test"; + + await publishCustomEvent(customSessionId, projectId, undefined); + + expect(mockEventQueue.add).toHaveBeenCalledWith( + expect.objectContaining({ + resourceName: undefined, + parameters: undefined, + response: undefined, + userIntent: undefined, + duration: undefined, + isError: undefined, + error: undefined, + }), + ); + }); + + it("should handle empty event data object", async () => { + const customSessionId = "test-session"; + const projectId = "proj_test"; + + await publishCustomEvent(customSessionId, projectId, {}); + + expect(mockEventQueue.add).toHaveBeenCalledWith( + expect.objectContaining({ + resourceName: undefined, + parameters: undefined, + response: undefined, + userIntent: undefined, + duration: undefined, + isError: undefined, + error: undefined, + }), + ); + }); + }); +}); diff --git a/src/types.ts b/src/types.ts index fa384e9..5ffa393 100644 --- a/src/types.ts +++ b/src/types.ts @@ -197,3 +197,14 @@ export interface ErrorData { chained_errors?: ChainedErrorData[]; platform?: string; // Platform identifier (e.g., "javascript", "node") } + +// Custom event types for publishCustomEvent function +export interface CustomEventData { + resourceName?: string; + parameters?: any; + response?: any; + message?: string; + duration?: number; + isError?: boolean; + error?: any; +}