From af8cc0732d071ef3e26e74226dd9d4d3c6eb3f6a Mon Sep 17 00:00:00 2001 From: Naseem AlNaji Date: Mon, 24 Nov 2025 17:36:41 -0500 Subject: [PATCH] feat: add publishCustomEvent function for custom analytics events Add publishCustomEvent() function that allows publishing arbitrary events to MCPCat outside of standard tool call tracking. This enables tracking custom business logic, user actions, and errors. Features: - Works with tracked MCP servers or custom session IDs - Supports flexible event metadata (resourceName, parameters, response, etc.) - Includes error tracking with isError and error fields - Derives session IDs deterministically for custom sessions --- src/index.ts | 141 +++++++++++- src/modules/constants.ts | 1 + src/tests/publishCustomEvent.test.ts | 309 +++++++++++++++++++++++++++ src/types.ts | 11 + 4 files changed, 460 insertions(+), 2 deletions(-) create mode 100644 src/tests/publishCustomEvent.test.ts 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; +}