From 18cd4acfd986a37e662abc2dc2bdee189c8cbbbf Mon Sep 17 00:00:00 2001 From: Lucas Woodward <31957045+SketchingDev@users.noreply.github.com> Date: Sat, 31 May 2025 22:58:25 +0100 Subject: [PATCH 1/3] Add tool --- docs/tools.md | 29 ++++ src/index.ts | 16 +++ src/tools/conversationSentiment.test.ts | 25 ++-- src/tools/conversationSentiment.ts | 14 +- src/tools/conversationTopics.test.ts | 139 +++++++++++++++++++ src/tools/conversationTopics.ts | 171 ++++++++++++++++++++++++ src/tools/sampleConversationsByQueue.ts | 14 +- src/tools/utils/chunks.ts | 9 ++ src/tools/utils/errorResult.ts | 13 ++ src/tools/voiceCallQuality.ts | 16 +-- 10 files changed, 394 insertions(+), 52 deletions(-) create mode 100644 src/tools/conversationTopics.test.ts create mode 100644 src/tools/conversationTopics.ts create mode 100644 src/tools/utils/chunks.ts create mode 100644 src/tools/utils/errorResult.ts diff --git a/docs/tools.md b/docs/tools.md index b1a42f7..d188acf 100644 --- a/docs/tools.md +++ b/docs/tools.md @@ -131,3 +131,32 @@ Required Permissions: Platform API endpoint used: - [GET /api/v2/speechandtextanalytics/conversations/{conversationId}](https://developer.genesys.cloud/analyticsdatamanagement/speechtextanalytics/#get-api-v2-speechandtextanalytics-conversations--conversationId-) + +## Conversation Topics + +Retrieves Speech and Text Analytics topics detected for a specific conversation. Topics +represent business-level intents (e.g. cancellation, billing enquiry) inferred from recognised +phrases in the customer-agent interaction. + +Read more [about programs, topics, and phrases](https://help.mypurecloud.com/articles/about-programs-topics-and-phrases/). + +[Source file](/src/tools/conversationTopics.ts). + +### Input + +- `conversationId` + - A UUID ID for a conversation. (e.g., 00000000-0000-0000-0000-000000000000) + +### Security + +Required Permissions: + +- `speechAndTextAnalytics:topic:view` +- `analytics:conversationDetail:view` +- `analytics:speechAndTextAnalyticsAggregates:view` + +Platform API endpoints used: + +- [GET /api/v2/speechandtextanalytics/topics](https://developer.genesys.cloud/analyticsdatamanagement/speechtextanalytics/#get-api-v2-speechandtextanalytics-topics) +- [GET /api/v2/analytics/conversations/{conversationId}/details](https://developer.genesys.cloud/analyticsdatamanagement/analytics/analytics-apis#get-api-v2-analytics-conversations--conversationId--details) +- [POST /api/v2/analytics/transcripts/aggregates/query](https://developer.genesys.cloud/analyticsdatamanagement/analytics/analytics-apis#post-api-v2-analytics-transcripts-aggregates-query) diff --git a/src/index.ts b/src/index.ts index 58ea0bb..d5b9c3c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -8,6 +8,7 @@ import { sampleConversationsByQueue } from "./tools/sampleConversationsByQueue.j import { queryQueueVolumes } from "./tools/queryQueueVolumes.js"; import { voiceCallQuality } from "./tools/voiceCallQuality.js"; import { conversationSentiment } from "./tools/conversationSentiment.js"; +import { conversationTopics } from "./tools/conversationTopics.js"; const configResult = loadConfig(process.env); if (!configResult.success) { @@ -90,6 +91,21 @@ server.tool( ), ); +const conversationTopicsTool = conversationTopics({ + speechTextAnalyticsApi, + analyticsApi, +}); +server.tool( + conversationTopicsTool.schema.name, + conversationTopicsTool.schema.description, + conversationTopicsTool.schema.paramsSchema.shape, + withAuth( + conversationTopicsTool.call, + config.genesysCloud, + platformClient.ApiClient.instance, + ), +); + const transport = new StdioServerTransport(); await server.connect(transport); console.log("Started..."); diff --git a/src/tools/conversationSentiment.test.ts b/src/tools/conversationSentiment.test.ts index fcc76a6..58c0029 100644 --- a/src/tools/conversationSentiment.test.ts +++ b/src/tools/conversationSentiment.test.ts @@ -8,6 +8,7 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; import { randomUUID } from "node:crypto"; +import { McpError } from "@modelcontextprotocol/sdk/types.js"; describe("Conversation Sentiment Tool", () => { let toolDeps: MockedObjectDeep; @@ -80,13 +81,12 @@ describe("Conversation Sentiment Tool", () => { conversationIds: [], }, }), - ).rejects.toMatchObject({ - name: "McpError", - code: -32602, - message: expect.stringContaining( - "Array must contain at least 1 element(s)", - ) as string, - }); + ).rejects.toSatisfy( + (error: McpError) => + error.name === "McpError" && + error.message.includes("conversationId") && + error.message.includes("Array must contain at least 1 element(s)"), + ); }); test("errors when conversation ID not UUID", async () => { @@ -97,11 +97,12 @@ describe("Conversation Sentiment Tool", () => { conversationIds: ["invalid-uuid"], }, }), - ).rejects.toMatchObject({ - name: "McpError", - code: -32602, - message: expect.stringContaining("Invalid uuid") as string, - }); + ).rejects.toSatisfy( + (error: McpError) => + error.name === "McpError" && + error.message.includes("conversationIds") && + error.message.includes("Invalid uuid"), + ); }); test("error from Genesys Cloud's Platform SDK returned", async () => { diff --git a/src/tools/conversationSentiment.ts b/src/tools/conversationSentiment.ts index b330f8c..b8d7df9 100644 --- a/src/tools/conversationSentiment.ts +++ b/src/tools/conversationSentiment.ts @@ -5,7 +5,7 @@ import type { SpeechTextAnalyticsApi, Models, } from "purecloud-platform-client-v2"; -import { type CallToolResult } from "@modelcontextprotocol/sdk/types.js"; +import { errorResult } from "./utils/errorResult.js"; export interface ToolDependencies { readonly speechTextAnalyticsApi: Pick< @@ -29,18 +29,6 @@ const paramsSchema = z.object({ .describe("A list of up to 100 conversation IDs to retrieve sentiment for"), }); -function errorResult(errorMessage: string): CallToolResult { - return { - isError: true, - content: [ - { - type: "text", - text: errorMessage, - }, - ], - }; -} - function interpretSentiment(score?: number): string { if (score === undefined) return "Unknown"; if (score > 55) return "Positive"; diff --git a/src/tools/conversationTopics.test.ts b/src/tools/conversationTopics.test.ts new file mode 100644 index 0000000..ec236ee --- /dev/null +++ b/src/tools/conversationTopics.test.ts @@ -0,0 +1,139 @@ +import { beforeEach, describe, expect, test, vi } from "vitest"; +import { conversationTopics, ToolDependencies } from "./conversationTopics.js"; +import { MockedObjectDeep } from "@vitest/spy"; +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; +import { randomUUID } from "node:crypto"; +import { McpError } from "@modelcontextprotocol/sdk/types.js"; + +describe("Conversation Topics Tool", () => { + let toolDeps: MockedObjectDeep; + let client: Client; + let toolName: string; + + beforeEach(async () => { + toolDeps = { + speechTextAnalyticsApi: { + getSpeechandtextanalyticsTopics: vi.fn(), + }, + analyticsApi: { + getAnalyticsConversationDetails: vi.fn(), + postAnalyticsTranscriptsAggregatesQuery: vi.fn(), + }, + }; + + const toolDefinition = conversationTopics(toolDeps); + toolName = toolDefinition.schema.name; + + const server = new McpServer({ name: "TestServer", version: "test" }); + server.tool( + toolDefinition.schema.name, + toolDefinition.schema.description, + toolDefinition.schema.paramsSchema.shape, + toolDefinition.call, + ); + + const [serverTransport, clientTransport] = + InMemoryTransport.createLinkedPair(); + + await server.connect(serverTransport); + + client = new Client({ name: "test-client", version: "1.0.0" }); + await client.connect(clientTransport); + }); + + test("schema describes tool", async () => { + const tools = await client.listTools(); + expect(tools.tools[0]).toStrictEqual({ + name: "conversation_topics", + description: + "Retrieves Speech and Text Analytics topics detected for a specific conversation. Topics represent business-level intents (e.g. cancellation, billing enquiry) inferred from recognised phrases in the customer-agent interaction.", + inputSchema: { + type: "object", + properties: { + conversationId: { + description: + "A UUID ID for a conversation. (e.g., 00000000-0000-0000-0000-000000000000)", + format: "uuid", + type: "string", + }, + }, + required: ["conversationId"], + additionalProperties: false, + $schema: "http://json-schema.org/draft-07/schema#", + }, + annotations: undefined, + }); + }); + + test("errors when no conversation ID provided", async () => { + await expect( + client.callTool({ + name: toolName, + arguments: { + conversationId: "", + }, + }), + ).rejects.toSatisfy( + (error: McpError) => + error.name === "McpError" && + error.message.includes("conversationId") && + error.message.includes("Invalid uuid"), + ); + }); + + test("sentiment returned for single conversation", async () => { + const conversationId = randomUUID(); + + toolDeps.analyticsApi.getAnalyticsConversationDetails.mockResolvedValue({ + conversationStart: "2025-05-19T20:00:07.395Z", + conversationEnd: "2025-05-19T21:00:52.686Z", + }); + toolDeps.analyticsApi.postAnalyticsTranscriptsAggregatesQuery.mockResolvedValue( + { + results: [ + { group: { topicId: "test-topic-id-1" } }, + { group: { topicId: "test-topic-id-2" } }, + ], + }, + ); + toolDeps.speechTextAnalyticsApi.getSpeechandtextanalyticsTopics.mockResolvedValue( + { + entities: [ + { + name: "Test Topic 1", + description: "Test Topic 1 Desc", + }, + { + name: "Test Topic 2", + description: "Test Topic 2 Desc", + }, + ], + }, + ); + + const result = await client.callTool({ + name: toolName, + arguments: { + conversationId: conversationId, + }, + }); + + expect(result).toStrictEqual({ + content: [ + { + type: "text", + text: ` +Conversation ID: ${conversationId} +Detected Topics: + • Test Topic 1 + • Test Topic 1 Desc + • Test Topic 2 + • Test Topic 2 Desc +`.trim(), + }, + ], + }); + }); +}); diff --git a/src/tools/conversationTopics.ts b/src/tools/conversationTopics.ts new file mode 100644 index 0000000..7aa1c83 --- /dev/null +++ b/src/tools/conversationTopics.ts @@ -0,0 +1,171 @@ +import { z } from "zod"; +import type { + AnalyticsApi, + Models, + SpeechTextAnalyticsApi, +} from "purecloud-platform-client-v2"; +import { createTool, type ToolFactory } from "./utils/createTool.js"; +import { errorResult } from "./utils/errorResult.js"; +import { isUnauthorisedError } from "./utils/genesys/isUnauthorisedError.js"; +import { chunks } from "./utils/chunks.js"; + +export interface ToolDependencies { + readonly speechTextAnalyticsApi: Pick< + SpeechTextAnalyticsApi, + "getSpeechandtextanalyticsTopics" + >; + readonly analyticsApi: Pick< + AnalyticsApi, + | "getAnalyticsConversationDetails" + | "postAnalyticsTranscriptsAggregatesQuery" + >; +} + +const MAX_IDS_ALLOWED_BY_API = 50; + +const paramsSchema = z.object({ + conversationId: z + .string() + .uuid() + .describe( + "A UUID ID for a conversation. (e.g., 00000000-0000-0000-0000-000000000000)", + ), +}); + +export const conversationTopics: ToolFactory< + ToolDependencies, + typeof paramsSchema +> = ({ speechTextAnalyticsApi, analyticsApi }) => + createTool({ + schema: { + name: "conversation_topics", + description: + "Retrieves Speech and Text Analytics topics detected for a specific conversation. Topics represent business-level intents (e.g. cancellation, billing enquiry) inferred from recognised phrases in the customer-agent interaction.", + paramsSchema, + }, + call: async ({ conversationId }) => { + let conversationDetails: Models.AnalyticsConversationWithoutAttributes; + + try { + conversationDetails = + await analyticsApi.getAnalyticsConversationDetails(conversationId); + } catch (error: unknown) { + const message = isUnauthorisedError(error) + ? "Failed to retrieve conversation topics: Unauthorised access. Please check API credentials or permissions." + : `Failed to retrieve conversation topics: ${error instanceof Error ? error.message : JSON.stringify(error)}`; + + return errorResult(message); + } + + if ( + !conversationDetails.conversationStart || + !conversationDetails.conversationEnd + ) { + return errorResult( + "Unable to find conversation Start and End date needed for retrieving topics", + ); + } + + // Widen the time range either side to ensure the conversation timeframe is enclosed. + // Conversation not returned if either only partially covered by interval, or matched exactly. + const startDate = new Date(conversationDetails.conversationStart); + startDate.setMinutes(startDate.getMinutes() - 10); + + const endDate = new Date(conversationDetails.conversationEnd); + endDate.setMinutes(endDate.getMinutes() + 10); + + let jobDetails: Models.TranscriptAggregateQueryResponse; + try { + jobDetails = await analyticsApi.postAnalyticsTranscriptsAggregatesQuery( + { + interval: `${startDate.toISOString()}/${endDate.toISOString()}`, + filter: { + type: "and", + predicates: [ + { + dimension: "conversationId", + value: conversationId, + }, + { + dimension: "resultsBy", + value: "communication", + }, + ], + }, + groupBy: ["topicId"], + metrics: ["nTopicCommunications"], + }, + ); + } catch (error: unknown) { + const message = isUnauthorisedError(error) + ? "Failed to retrieve conversation topics: Unauthorised access. Please check API credentials or permissions." + : `Failed to retrieve conversation topics: ${error instanceof Error ? error.message : JSON.stringify(error)}`; + + return errorResult(message); + } + + const topicIds = new Set(); + + for (const result of jobDetails.results ?? []) { + if (result.group?.topicId) { + topicIds.add(result.group.topicId); + } + } + + if (topicIds.size === 0) { + return { + content: [ + { + type: "text", + text: `Conversation ID: ${conversationId}\nNo detected topics for this conversation.`, + }, + ], + }; + } + + const topics: Models.ListedTopic[] = []; + + try { + for (const topicIdChunk of chunks( + Array.from(topicIds.values()), + MAX_IDS_ALLOWED_BY_API, + )) { + const topicsListings = + await speechTextAnalyticsApi.getSpeechandtextanalyticsTopics({ + ids: topicIdChunk, + pageSize: MAX_IDS_ALLOWED_BY_API, + }); + + topics.push(...(topicsListings.entities ?? [])); + } + } catch (error: unknown) { + const message = isUnauthorisedError(error) + ? "Failed to retrieve conversation topics: Unauthorised access. Please check API credentials or permissions." + : `Failed to retrieve conversation topics: ${error instanceof Error ? error.message : JSON.stringify(error)}`; + + return errorResult(message); + } + + const topicNames = topics + .filter((topic) => topic.name && topic.description) + .map(({ name, description }) => ({ + name: name ?? "", + description: description ?? "", + })); + + return { + content: [ + { + type: "text", + text: [ + `Conversation ID: ${conversationId}`, + "Detected Topics:", + ...topicNames.map( + ({ name, description }) => ` • ${name}: ${description}`, + ), + ].join("\n"), + }, + ], + }; + }, + }); diff --git a/src/tools/sampleConversationsByQueue.ts b/src/tools/sampleConversationsByQueue.ts index cb367c9..74eaf60 100644 --- a/src/tools/sampleConversationsByQueue.ts +++ b/src/tools/sampleConversationsByQueue.ts @@ -2,9 +2,9 @@ import { z } from "zod"; import { createTool, type ToolFactory } from "./utils/createTool.js"; import { isUnauthorisedError } from "./utils/genesys/isUnauthorisedError.js"; import { type AnalyticsApi } from "purecloud-platform-client-v2"; -import { type CallToolResult } from "@modelcontextprotocol/sdk/types.js"; import { sampleEvenly } from "./utils/sampleEvenly.js"; import { waitFor } from "./utils/waitFor.js"; +import { errorResult } from "./utils/errorResult.js"; export interface ToolDependencies { readonly analyticsApi: Pick< @@ -36,18 +36,6 @@ const paramsSchema = z.object({ const MAX_ATTEMPTS = 10; -function errorResult(errorMessage: string): CallToolResult { - return { - isError: true, - content: [ - { - type: "text", - text: errorMessage, - }, - ], - }; -} - export const sampleConversationsByQueue: ToolFactory< ToolDependencies, typeof paramsSchema diff --git a/src/tools/utils/chunks.ts b/src/tools/utils/chunks.ts new file mode 100644 index 0000000..0b06291 --- /dev/null +++ b/src/tools/utils/chunks.ts @@ -0,0 +1,9 @@ +export function* chunks(arr: T[], n: number): Generator { + if (!Number.isInteger(n) || n <= 0) { + throw new Error("Chunk size must be a positive integer"); + } + + for (let i = 0; i < arr.length; i += n) { + yield arr.slice(i, i + n); + } +} diff --git a/src/tools/utils/errorResult.ts b/src/tools/utils/errorResult.ts new file mode 100644 index 0000000..72fec74 --- /dev/null +++ b/src/tools/utils/errorResult.ts @@ -0,0 +1,13 @@ +import { type CallToolResult } from "@modelcontextprotocol/sdk/types.js"; + +export function errorResult(errorMessage: string): CallToolResult { + return { + isError: true, + content: [ + { + type: "text", + text: errorMessage, + }, + ], + }; +} diff --git a/src/tools/voiceCallQuality.ts b/src/tools/voiceCallQuality.ts index 2433960..4a4f27a 100644 --- a/src/tools/voiceCallQuality.ts +++ b/src/tools/voiceCallQuality.ts @@ -1,8 +1,8 @@ import { z } from "zod"; import { createTool, type ToolFactory } from "./utils/createTool.js"; import { isUnauthorisedError } from "./utils/genesys/isUnauthorisedError.js"; -import type { Models, AnalyticsApi } from "purecloud-platform-client-v2"; -import { type CallToolResult } from "@modelcontextprotocol/sdk/types.js"; +import type { AnalyticsApi, Models } from "purecloud-platform-client-v2"; +import { errorResult } from "./utils/errorResult.js"; export interface ToolDependencies { readonly analyticsApi: Pick; @@ -25,18 +25,6 @@ const paramsSchema = z.object({ ), }); -function errorResult(errorMessage: string): CallToolResult { - return { - isError: true, - content: [ - { - type: "text", - text: errorMessage, - }, - ], - }; -} - export const voiceCallQuality: ToolFactory< ToolDependencies, typeof paramsSchema From 7158af3cbd0fbcf7bac6fbdad75f4934e0570613 Mon Sep 17 00:00:00 2001 From: Lucas Woodward <31957045+SketchingDev@users.noreply.github.com> Date: Sat, 31 May 2025 23:06:23 +0100 Subject: [PATCH 2/3] Update tests --- src/tools/conversationTopics.test.ts | 6 ++---- tests/serverRuns.test.ts | 1 + 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/src/tools/conversationTopics.test.ts b/src/tools/conversationTopics.test.ts index ec236ee..6a9754c 100644 --- a/src/tools/conversationTopics.test.ts +++ b/src/tools/conversationTopics.test.ts @@ -127,10 +127,8 @@ describe("Conversation Topics Tool", () => { text: ` Conversation ID: ${conversationId} Detected Topics: - • Test Topic 1 - • Test Topic 1 Desc - • Test Topic 2 - • Test Topic 2 Desc + • Test Topic 1: Test Topic 1 Desc + • Test Topic 2: Test Topic 2 Desc `.trim(), }, ], diff --git a/tests/serverRuns.test.ts b/tests/serverRuns.test.ts index 975dc2d..fec0143 100644 --- a/tests/serverRuns.test.ts +++ b/tests/serverRuns.test.ts @@ -39,6 +39,7 @@ describe("Server Runs", () => { "query_queue_volumes", "voice_call_quality", "conversation_sentiment", + "conversation_topics", ]); }); From a1de127a8db0ac7e69a8b8d36c60dac628c98da4 Mon Sep 17 00:00:00 2001 From: Lucas Woodward <31957045+SketchingDev@users.noreply.github.com> Date: Sat, 31 May 2025 23:13:46 +0100 Subject: [PATCH 3/3] Increase version --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 8ab49b1..b4867ce 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@makingchatbots/genesys-cloud-mcp-server", - "version": "0.0.6", + "version": "0.0.7", "description": "A MCP server for connecting LLMs to Genesys Cloud's Platform API", "exports": "./dist/index.js", "type": "module",