diff --git a/README.md b/README.md index 1e6b13c..2984fc4 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,7 @@ in the [tools doc](/docs/tools.md). | [Conversation Sentiment](/docs/tools.md#conversation-sentiment) | Retrieves the sentiment for one or more conversations by ID | | [Conversation Topics](/docs/tools.md#conversation-topics) | Retrieves the topics for a conversation by ID | | [Search Voice Conversation](/docs/tools.md#search-voice-conversations) | Searches voice conversations by optional criteria | +| [Conversation Transcript](/docs/tools.md#conversation-transcript) | Retrieves conversation transcript | ## Authentication diff --git a/docs/tools.md b/docs/tools.md index 52d8c52..a9a12c0 100644 --- a/docs/tools.md +++ b/docs/tools.md @@ -37,7 +37,7 @@ Platform API endpoint used: Returns a breakdown of how many conversations occurred in each specified queue between two dates. Useful for comparing workload across queues. -[Source file](/src/tools/queryQueueVolumes.ts). +[Source file](/src/tools/queryQueueVolumes/queryQueueVolumes.ts). ### Inputs @@ -69,7 +69,7 @@ Retrieves conversation analytics for a specific queue between two dates, returni ### Inputs - `queueId` - - The UUID ID of the queue to filter conversations by. (e.g., 00000000-0000-0000-0000-000000000000) + - The UUID of the queue to filter conversations by. (e.g., 00000000-0000-0000-0000-000000000000) - `startDate` - The start date/time in ISO-8601 format (e.g., '2024-01-01T00:00:00Z') - `endDate` @@ -118,7 +118,7 @@ Platform API endpoint used: Retrieves sentiment analysis scores for one or more conversations. Sentiment is evaluated based on customer phrases, categorized as positive, neutral, or negative. The result includes both a numeric sentiment score (-100 to 100) and an interpreted sentiment label. -[Source file](/src/tools/conversationSentiment.ts). +[Source file](/src/tools/conversationSentiment/conversationSentiment.ts). ### Inputs @@ -144,12 +144,12 @@ Retrieves Speech and Text Analytics topics detected for a specific conversation. Read more [about programs, topics, and phrases](https://help.mypurecloud.com/articles/about-programs-topics-and-phrases/). -[Source file](/src/tools/conversationTopics.ts). +[Source file](/src/tools/conversationTopics/conversationTopics.ts). ### Input - `conversationId` - - A UUID ID for a conversation. (e.g., 00000000-0000-0000-0000-000000000000) + - A UUID for a conversation. (e.g., 00000000-0000-0000-0000-000000000000) ### Security @@ -195,3 +195,28 @@ Required Permissions: Platform API endpoints used: - [POST /api/v2/analytics/conversations/details/query](https://developer.genesys.cloud/devapps/api-explorer-standalone#post-api-v2-analytics-conversations-details-query) + +## Conversation Transcript + +**Tool name:** `conversation_transcript` + +Retrieves a structured transcript of the conversation, including speaker labels, utterance timestamps, and sentiment annotations where available. The transcript is formatted as a time-aligned list of utterances attributed to each participant (e.g., customer or agent) + +[Source file](/src/tools/conversationTranscription/conversationTranscription.ts). + +### Input + +- `conversationId` + - The UUID of the conversation to retrieve the transcript for (e.g., 00000000-0000-0000-0000-000000000000) + +### Security + +Required Permissions: + +- `recording:recording:view` +- `speechAndTextAnalytics:data:view` + +Platform API endpoints used: + +- [GET /api/v2/conversations/{conversationId}/recordings](https://developer.genesys.cloud/devapps/api-explorer-standalone#get-api-v2-conversations--conversationId--recordings) +- [GET /api/v2/speechandtextanalytics/conversations/{conversationId}/communications/{communicationId}/transcripturl](https://developer.genesys.cloud/devapps/api-explorer-standalone#get-api-v2-speechandtextanalytics-conversations--conversationId--communications--communicationId--transcripturl) diff --git a/package-lock.json b/package-lock.json index b082e1c..c25db42 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,18 +1,19 @@ { "name": "@makingchatbots/genesys-cloud-mcp-server", - "version": "0.0.8", + "version": "0.0.9", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@makingchatbots/genesys-cloud-mcp-server", - "version": "0.0.8", + "version": "0.0.9", "license": "ISC", "dependencies": { "@modelcontextprotocol/sdk": "^1.12.1", "date-fns": "^4.1.0", "purecloud-platform-client-v2": "^223.0.0", - "zod": "^3.25.48" + "table": "^6.9.0", + "zod": "^3.25.56" }, "devDependencies": { "@eslint/eslintrc": "^3.3.1", @@ -1858,7 +1859,6 @@ "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, "license": "MIT", "dependencies": { "color-convert": "^2.0.1" @@ -2019,6 +2019,15 @@ "node": ">=12" } }, + "node_modules/astral-regex": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/astral-regex/-/astral-regex-2.0.0.tgz", + "integrity": "sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/async": { "version": "3.2.6", "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", @@ -2289,7 +2298,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, "license": "MIT", "dependencies": { "color-name": "~1.1.4" @@ -3498,6 +3506,22 @@ "dev": true, "license": "MIT" }, + "node_modules/fast-uri": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.0.6.tgz", + "integrity": "sha512-Atfo14OibSv5wAp4VWNsFYE1AchQRTv9cBGWET4pZWHzYshFSS9NQI6I57rdKn9croWVMbYFbLhJ+yJvmZIIHw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, "node_modules/fastq": { "version": "1.19.1", "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.1.tgz", @@ -4738,6 +4762,12 @@ "dev": true, "license": "MIT" }, + "node_modules/lodash.truncate": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/lodash.truncate/-/lodash.truncate-4.4.2.tgz", + "integrity": "sha512-jttmRe7bRse52OsWIMDLaXxWqRAmtIUccAQ3garviCqJjafXOfNMO0yMfNpdD6zbGaTU0P5Nz7e7gAT6cKmJRw==", + "license": "MIT" + }, "node_modules/log-update": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/log-update/-/log-update-6.1.0.tgz", @@ -5602,6 +5632,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/resolve": { "version": "1.22.10", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.10.tgz", @@ -6332,6 +6371,111 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/table": { + "version": "6.9.0", + "resolved": "https://registry.npmjs.org/table/-/table-6.9.0.tgz", + "integrity": "sha512-9kY+CygyYM6j02t5YFHbNz2FN5QmYGv9zAjVp4lCDjlCw7amdckXlEt/bjMhUIfj4ThGRE4gCUH5+yGnNuPo5A==", + "license": "BSD-3-Clause", + "dependencies": { + "ajv": "^8.0.1", + "lodash.truncate": "^4.4.2", + "slice-ansi": "^4.0.0", + "string-width": "^4.2.3", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/table/node_modules/ajv": { + "version": "8.17.1", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", + "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/table/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/table/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/table/node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/table/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, + "node_modules/table/node_modules/slice-ansi": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-4.0.0.tgz", + "integrity": "sha512-qMCMfhY040cVHT43K9BFygqYbUPFZKHOg7K73mtTWJRb8pyP3fzf4Ixd5SzdEJQ6MRUg/WBnOLxghZtKKurENQ==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "astral-regex": "^2.0.0", + "is-fullwidth-code-point": "^3.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/slice-ansi?sponsor=1" + } + }, + "node_modules/table/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/table/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/text-hex": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/text-hex/-/text-hex-1.0.0.tgz", @@ -7201,9 +7345,9 @@ } }, "node_modules/zod": { - "version": "3.25.48", - "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.48.tgz", - "integrity": "sha512-0X1mz8FtgEIvaxGjdIImYpZEaZMrund9pGXm3M6vM7Reba0e2eI71KPjSCGXBfwKDPwPoywf6waUKc3/tFvX2Q==", + "version": "3.25.56", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.56.tgz", + "integrity": "sha512-rd6eEF3BTNvQnR2e2wwolfTmUTnp70aUTqr0oaGbHifzC3BKJsoV+Gat8vxUMR1hwOKBs6El+qWehrHbCpW6SQ==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/colinhacks" diff --git a/package.json b/package.json index 3f8ebaa..3dc5c04 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@makingchatbots/genesys-cloud-mcp-server", - "version": "0.0.8", + "version": "0.0.9", "description": "A Model Context Protocol (MCP) server exposing Genesys Cloud tools for LLMs, including sentiment analysis, conversation search, topic detection and more.", "exports": "./dist/index.js", "type": "module", @@ -38,7 +38,8 @@ "@modelcontextprotocol/sdk": "^1.12.1", "date-fns": "^4.1.0", "purecloud-platform-client-v2": "^223.0.0", - "zod": "^3.25.48" + "table": "^6.9.0", + "zod": "^3.25.56" }, "devDependencies": { "@eslint/eslintrc": "^3.3.1", diff --git a/src/index.ts b/src/index.ts index ad11f45..d4ec999 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,15 +1,16 @@ +import platformClient from "purecloud-platform-client-v2"; import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; -import platformClient from "purecloud-platform-client-v2"; import { withAuth } from "./withAuth.js"; -import { searchQueues } from "./tools/searchQueues.js"; import { loadConfig } from "./loadConfig.js"; -import { sampleConversationsByQueue } from "./tools/sampleConversationsByQueue.js"; -import { queryQueueVolumes } from "./tools/queryQueueVolumes.js"; +import { searchQueues } from "./tools/searchQueues.js"; +import { sampleConversationsByQueue } from "./tools/sampleConversationsByQueue/sampleConversationsByQueue.js"; +import { queryQueueVolumes } from "./tools/queryQueueVolumes/queryQueueVolumes.js"; import { voiceCallQuality } from "./tools/voiceCallQuality.js"; -import { conversationSentiment } from "./tools/conversationSentiment.js"; -import { conversationTopics } from "./tools/conversationTopics.js"; +import { conversationSentiment } from "./tools/conversationSentiment/conversationSentiment.js"; +import { conversationTopics } from "./tools/conversationTopics/conversationTopics.js"; import { searchVoiceConversations } from "./tools/searchVoiceConversations.js"; +import { conversationTranscription } from "./tools/conversationTranscription/conversationTranscription.js"; const configResult = loadConfig(process.env); if (!configResult.success) { @@ -27,6 +28,7 @@ const server: McpServer = new McpServer({ const routingApi = new platformClient.RoutingApi(); const analyticsApi = new platformClient.AnalyticsApi(); const speechTextAnalyticsApi = new platformClient.SpeechTextAnalyticsApi(); +const recordingApi = new platformClient.RecordingApi(); const searchQueuesTool = searchQueues({ routingApi }); server.tool( @@ -121,6 +123,22 @@ server.tool( ), ); +const conversationTranscriptTool = conversationTranscription({ + recordingApi, + speechTextAnalyticsApi, + fetchUrl: fetch, +}); +server.tool( + conversationTranscriptTool.schema.name, + conversationTranscriptTool.schema.description, + conversationTranscriptTool.schema.paramsSchema.shape, + withAuth( + conversationTranscriptTool.call, + config.genesysCloud, + platformClient.ApiClient.instance, + ), +); + const transport = new StdioServerTransport(); await server.connect(transport); console.log("Started..."); diff --git a/src/tools/conversationSentiment.ts b/src/tools/conversationSentiment.ts deleted file mode 100644 index b8d7df9..0000000 --- a/src/tools/conversationSentiment.ts +++ /dev/null @@ -1,99 +0,0 @@ -import { z } from "zod"; -import { createTool, type ToolFactory } from "./utils/createTool.js"; -import { isUnauthorisedError } from "./utils/genesys/isUnauthorisedError.js"; -import type { - SpeechTextAnalyticsApi, - Models, -} from "purecloud-platform-client-v2"; -import { errorResult } from "./utils/errorResult.js"; - -export interface ToolDependencies { - readonly speechTextAnalyticsApi: Pick< - SpeechTextAnalyticsApi, - "getSpeechandtextanalyticsConversation" - >; -} - -const paramsSchema = z.object({ - conversationIds: z - .array( - z - .string() - .uuid() - .describe( - "A UUID ID for a conversation. (e.g., 00000000-0000-0000-0000-000000000000)", - ), - ) - .min(1) - .max(100) - .describe("A list of up to 100 conversation IDs to retrieve sentiment for"), -}); - -function interpretSentiment(score?: number): string { - if (score === undefined) return "Unknown"; - if (score > 55) return "Positive"; - if (score >= 20 && score <= 55) return "Slightly Positive"; - if (score > -20 && score < 20) return "Neutral"; - if (score >= -55 && score <= -20) return "Slightly Negative"; - return "Negative"; -} - -export const conversationSentiment: ToolFactory< - ToolDependencies, - typeof paramsSchema -> = ({ speechTextAnalyticsApi }) => - createTool({ - schema: { - name: "conversation_sentiment", - description: - "Retrieves sentiment analysis scores for one or more conversations. Sentiment is evaluated based on customer phrases, categorized as positive, neutral, or negative. The result includes both a numeric sentiment score (-100 to 100) and an interpreted sentiment label.", - paramsSchema, - }, - call: async ({ conversationIds }) => { - const conversations: Models.ConversationMetrics[] = []; - try { - conversations.push( - ...(await Promise.all( - conversationIds.map((id) => - speechTextAnalyticsApi.getSpeechandtextanalyticsConversation(id), - ), - )), - ); - } catch (error: unknown) { - const message = isUnauthorisedError(error) - ? "Failed to retrieve sentiment analysis: Unauthorised access. Please check API credentials or permissions." - : `Failed to retrieve sentiment analysis: ${error instanceof Error ? error.message : JSON.stringify(error)}`; - - return errorResult(message); - } - - const output: string[] = []; - - for (const convo of conversations) { - const id = convo.conversation?.id; - const score = convo.sentimentScore; - - if (id === undefined || score === undefined) continue; - const scaledScore = Math.round(score * 100); - - output.push( - `• Conversation ID: ${id}\n • Sentiment Score: ${String(scaledScore)} (${interpretSentiment(scaledScore)})`, - ); - } - - return { - content: [ - { - type: "text", - text: - output.length > 0 - ? [ - `Sentiment results for ${String(output.length)} conversation(s):`, - ...output, - ].join("\n\n") - : "No sentiment data found for the given conversation IDs.", - }, - ], - }; - }, - }); diff --git a/src/tools/conversationSentiment.test.ts b/src/tools/conversationSentiment/conversationSentiment.test.ts similarity index 85% rename from src/tools/conversationSentiment.test.ts rename to src/tools/conversationSentiment/conversationSentiment.test.ts index 58c0029..468026a 100644 --- a/src/tools/conversationSentiment.test.ts +++ b/src/tools/conversationSentiment/conversationSentiment.test.ts @@ -1,14 +1,14 @@ import { beforeEach, describe, expect, test, vi } from "vitest"; -import { - conversationSentiment, - ToolDependencies, -} from "./conversationSentiment.js"; import { MockedObjectDeep } from "@vitest/spy"; +import { randomUUID } from "node:crypto"; 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"; +import { + conversationSentiment, + ToolDependencies, +} from "./conversationSentiment.js"; describe("Conversation Sentiment Tool", () => { let toolDeps: MockedObjectDeep; @@ -57,7 +57,7 @@ describe("Conversation Sentiment Tool", () => { type: "string", format: "uuid", description: - "A UUID ID for a conversation. (e.g., 00000000-0000-0000-0000-000000000000)", + "A UUID for a conversation. (e.g., 00000000-0000-0000-0000-000000000000)", }, minItems: 1, maxItems: 100, @@ -107,7 +107,7 @@ describe("Conversation Sentiment Tool", () => { test("error from Genesys Cloud's Platform SDK returned", async () => { toolDeps.speechTextAnalyticsApi.getSpeechandtextanalyticsConversation.mockRejectedValue( - new Error("Test Error Message"), + { code: "not.authorized", status: 403 }, ); const result = await client.callTool({ @@ -122,7 +122,40 @@ describe("Conversation Sentiment Tool", () => { content: [ { type: "text", - text: "Failed to retrieve sentiment analysis: Test Error Message", + text: "Failed to retrieve sentiment analysis: Unauthorised access. Please check API credentials or permissions.", + }, + ], + }); + }); + + test("conversations not found are included in results", async () => { + const conversationId = randomUUID(); + toolDeps.speechTextAnalyticsApi.getSpeechandtextanalyticsConversation.mockRejectedValue( + { + code: "resource.not.found", + messageParams: { + id: conversationId, + }, + }, + ); + + const result = await client.callTool({ + name: toolName, + arguments: { + conversationIds: [conversationId], + }, + }); + + expect(result).toStrictEqual({ + content: [ + { + type: "text", + text: ` +Sentiment results for 1 conversation(s): + +• Conversation ID: ${conversationId} + • Error: Conversation not found +`.trim(), }, ], }); diff --git a/src/tools/conversationSentiment/conversationSentiment.ts b/src/tools/conversationSentiment/conversationSentiment.ts new file mode 100644 index 0000000..6c45869 --- /dev/null +++ b/src/tools/conversationSentiment/conversationSentiment.ts @@ -0,0 +1,101 @@ +import { z } from "zod"; +import type { + SpeechTextAnalyticsApi, + Models, +} from "purecloud-platform-client-v2"; +import { createTool, type ToolFactory } from "../utils/createTool.js"; +import { isUnauthorisedError } from "../utils/genesys/isUnauthorisedError.js"; +import { errorResult } from "../utils/errorResult.js"; +import { isConversationNotFoundError } from "./isConversationNotFoundError.js"; +import { interpretSentiment } from "./interpretSentiment.js"; + +export interface ToolDependencies { + readonly speechTextAnalyticsApi: Pick< + SpeechTextAnalyticsApi, + "getSpeechandtextanalyticsConversation" + >; +} + +const paramsSchema = z.object({ + conversationIds: z + .array( + z + .string() + .uuid() + .describe( + "A UUID for a conversation. (e.g., 00000000-0000-0000-0000-000000000000)", + ), + ) + .min(1) + .max(100) + .describe("A list of up to 100 conversation IDs to retrieve sentiment for"), +}); + +export const conversationSentiment: ToolFactory< + ToolDependencies, + typeof paramsSchema +> = ({ speechTextAnalyticsApi }) => + createTool({ + schema: { + name: "conversation_sentiment", + description: + "Retrieves sentiment analysis scores for one or more conversations. Sentiment is evaluated based on customer phrases, categorized as positive, neutral, or negative. The result includes both a numeric sentiment score (-100 to 100) and an interpreted sentiment label.", + paramsSchema, + }, + call: async ({ conversationIds }) => { + const conversations: PromiseSettledResult[] = + []; + + conversations.push( + ...(await Promise.allSettled( + conversationIds.map((id) => + speechTextAnalyticsApi.getSpeechandtextanalyticsConversation(id), + ), + )), + ); + + const output: string[] = []; + + for (const convo of conversations) { + if (convo.status === "fulfilled") { + const id = convo.value.conversation?.id; + const score = convo.value.sentimentScore; + + if (id === undefined || score === undefined) continue; + const scaledScore = Math.round(score * 100); + + output.push( + `• Conversation ID: ${id}\n • Sentiment Score: ${String(scaledScore)} (${interpretSentiment(scaledScore)})`, + ); + } else { + const result = isConversationNotFoundError(convo.reason); + if (result.isResourceNotFoundError && result.conversationId) { + output.push( + `• Conversation ID: ${result.conversationId}\n • Error: Conversation not found`, + ); + } else if (isUnauthorisedError(convo.reason)) { + return errorResult( + "Failed to retrieve sentiment analysis: Unauthorised access. Please check API credentials or permissions.", + ); + } else { + // Ignore conversation + } + } + } + + return { + content: [ + { + type: "text", + text: + output.length > 0 + ? [ + `Sentiment results for ${String(output.length)} conversation(s):`, + ...output, + ].join("\n\n") + : "No sentiment data found for the given conversation IDs.", + }, + ], + }; + }, + }); diff --git a/src/tools/conversationSentiment/interpretSentiment.ts b/src/tools/conversationSentiment/interpretSentiment.ts new file mode 100644 index 0000000..8e3c012 --- /dev/null +++ b/src/tools/conversationSentiment/interpretSentiment.ts @@ -0,0 +1,8 @@ +export function interpretSentiment(score?: number): string { + if (score === undefined) return "Unknown"; + if (score > 55) return "Positive"; + if (score >= 20 && score <= 55) return "Slightly Positive"; + if (score > -20 && score < 20) return "Neutral"; + if (score >= -55 && score <= -20) return "Slightly Negative"; + return "Negative"; +} diff --git a/src/tools/conversationSentiment/isConversationNotFoundError.ts b/src/tools/conversationSentiment/isConversationNotFoundError.ts new file mode 100644 index 0000000..26f7634 --- /dev/null +++ b/src/tools/conversationSentiment/isConversationNotFoundError.ts @@ -0,0 +1,28 @@ +export function isConversationNotFoundError( + obj: unknown, +): + | { isResourceNotFoundError: true; conversationId: string | undefined } + | { isResourceNotFoundError: false } { + if (typeof obj === "object" && obj !== null) { + const maybe = obj as { + code?: unknown; + messageParams?: { id?: unknown }; + }; + + const resourceNotFoundError = + typeof maybe.code === "string" && maybe.code === "resource.not.found"; + + const subjectConversationId = + typeof maybe.messageParams?.id === "string" + ? maybe.messageParams.id + : undefined; + + if (resourceNotFoundError) { + return { + isResourceNotFoundError: true, + conversationId: subjectConversationId, + }; + } + } + return { isResourceNotFoundError: false }; +} diff --git a/src/tools/utils/chunks.ts b/src/tools/conversationTopics/chunks.ts similarity index 100% rename from src/tools/utils/chunks.ts rename to src/tools/conversationTopics/chunks.ts diff --git a/src/tools/conversationTopics.test.ts b/src/tools/conversationTopics/conversationTopics.test.ts similarity index 97% rename from src/tools/conversationTopics.test.ts rename to src/tools/conversationTopics/conversationTopics.test.ts index 6a9754c..0971671 100644 --- a/src/tools/conversationTopics.test.ts +++ b/src/tools/conversationTopics/conversationTopics.test.ts @@ -1,11 +1,11 @@ import { beforeEach, describe, expect, test, vi } from "vitest"; -import { conversationTopics, ToolDependencies } from "./conversationTopics.js"; import { MockedObjectDeep } from "@vitest/spy"; +import { randomUUID } from "node:crypto"; 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"; +import { conversationTopics, ToolDependencies } from "./conversationTopics.js"; describe("Conversation Topics Tool", () => { let toolDeps: MockedObjectDeep; @@ -54,7 +54,7 @@ describe("Conversation Topics Tool", () => { properties: { conversationId: { description: - "A UUID ID for a conversation. (e.g., 00000000-0000-0000-0000-000000000000)", + "A UUID for a conversation. (e.g., 00000000-0000-0000-0000-000000000000)", format: "uuid", type: "string", }, diff --git a/src/tools/conversationTopics.ts b/src/tools/conversationTopics/conversationTopics.ts similarity index 94% rename from src/tools/conversationTopics.ts rename to src/tools/conversationTopics/conversationTopics.ts index 7aa1c83..33d9e3d 100644 --- a/src/tools/conversationTopics.ts +++ b/src/tools/conversationTopics/conversationTopics.ts @@ -4,10 +4,10 @@ import type { 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"; +import { createTool, type ToolFactory } from "../utils/createTool.js"; +import { errorResult } from "../utils/errorResult.js"; +import { isUnauthorisedError } from "../utils/genesys/isUnauthorisedError.js"; +import { chunks } from "./chunks.js"; export interface ToolDependencies { readonly speechTextAnalyticsApi: Pick< @@ -28,7 +28,7 @@ const paramsSchema = z.object({ .string() .uuid() .describe( - "A UUID ID for a conversation. (e.g., 00000000-0000-0000-0000-000000000000)", + "A UUID for a conversation. (e.g., 00000000-0000-0000-0000-000000000000)", ), }); diff --git a/src/tools/conversationTranscription/TranscriptResponse.ts b/src/tools/conversationTranscription/TranscriptResponse.ts new file mode 100644 index 0000000..9fed251 --- /dev/null +++ b/src/tools/conversationTranscription/TranscriptResponse.ts @@ -0,0 +1,122 @@ +// Transcript Schema: https://developer.genesys.cloud/analyticsdatamanagement/speechtextanalytics/transcript-url#transcript-schema +// Converted to types using: https://transform.tools/json-schema-to-typescript +// Then manually modified because of some odd typing choices by autogen and added missing properties + +export type Duration = Record; + +export interface Phrase { + phraseIndex?: number; + participantPurpose?: string; + text?: string; + decoratedText?: string; // Manually added. Missing from Genesys Cloud schema + stability?: number; + confidence?: number; + offset?: Offset; + startTimeMs?: number; + duration?: Duration; + words?: Words[]; + alternatives?: Alternatives[]; + type?: string; +} + +export type Offset = Record; + +export interface Words { + word?: string; + confidence?: number; + offset?: Offset; + startTimeMs?: number; + duration?: Duration; +} + +export interface Alternatives { + text?: string; + confidence?: number; + offset?: Offset; + startTimeMs?: number; + duration?: Duration; + words?: Words; +} + +export interface Sentiment { + participant?: string; + phrase?: string; + offset?: Offset; + startTimeMs?: number; + duration?: Duration; + sentiment?: number; + phraseIndex?: number; + type?: string; +} + +export interface Topics { + participant?: string; + topicId?: string; + topicName?: string; + topicPhrase?: string; + transcriptPhrase?: string; + confidence?: number; + offset?: Offset; + startTimeMs?: number; + duration?: Duration; + type?: string; +} + +export interface Acoustic { + eventType?: string; + offsetMs?: number; + startTimeMs?: number; + durationMs?: number; + participant?: string; +} + +/** + * This is the schema for the collection of transcripts associated with a communication. + */ +export interface TranscriptResponseFormat { + organizationId?: string; + conversationId?: string; + communicationId?: string; + mediaType?: string; + conversationStartTime?: number; + startTime?: number; + duration?: Duration; + transcripts?: Transcript[]; + participants?: Participant[]; + uri?: string; +} + +export interface Transcript { + transcriptId?: string; + language?: string; + programId?: string; + engineId?: string; + startTime?: number; + phrases?: Phrase[]; + duration?: Duration; + subject?: string; + messageType?: string; + analytics?: { + sentiment?: Sentiment[]; + topics?: Topics[]; + acoustic?: Acoustic[]; + }; +} + +export interface Participant { + participantPurpose?: string; + userId?: string; + teamId?: string; + initialDirection?: string; + messageType?: string; + queueId?: string; + flowId?: string; + flowVersion?: string; + divisionId?: string; + ani?: string; + dnis?: string; + to?: string; + from?: string; + startTimeMs?: number; + endTimeMs?: number; +} diff --git a/src/tools/conversationTranscription/Utterance.ts b/src/tools/conversationTranscription/Utterance.ts new file mode 100644 index 0000000..e8d6873 --- /dev/null +++ b/src/tools/conversationTranscription/Utterance.ts @@ -0,0 +1,9 @@ +export interface Utterance { + speaker: string; + utterance: string; + sentiment?: number; + times: { + conversationStartInMs: number; + utteranceStartInMs: number; + } | null; +} diff --git a/src/tools/conversationTranscription/conversationTranscription.test.ts b/src/tools/conversationTranscription/conversationTranscription.test.ts new file mode 100644 index 0000000..d4fd2cf --- /dev/null +++ b/src/tools/conversationTranscription/conversationTranscription.test.ts @@ -0,0 +1,216 @@ +import { beforeEach, describe, expect, test, vi } from "vitest"; +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 { + conversationTranscription, + ToolDependencies, +} from "./conversationTranscription.js"; +import { TranscriptResponseFormat } from "./TranscriptResponse.js"; + +describe("Conversation Transcription Tool", () => { + let toolDeps: MockedObjectDeep; + let client: Client; + let toolName: string; + + beforeEach(async () => { + toolDeps = { + recordingApi: { + getConversationRecordings: vi.fn(), + }, + speechTextAnalyticsApi: { + getSpeechandtextanalyticsConversationCommunicationTranscripturl: + vi.fn(), + }, + fetchUrl: vi.fn(), + }; + + const toolDefinition = conversationTranscription(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_transcript", + description: + "Retrieves a structured transcript of the conversation, including speaker labels, utterance timestamps, and sentiment annotations where available. The transcript is formatted as a time-aligned list of utterances attributed to each participant (e.g., customer or agent)", + inputSchema: { + type: "object", + properties: { + conversationId: { + description: + "The UUID of the conversation to retrieve the transcript for (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("error from Genesys Cloud's Platform SDK call 1 returned", async () => { + toolDeps.recordingApi.getConversationRecordings.mockRejectedValue( + new Error("Test Error Message"), + ); + + await expect( + client.callTool({ + name: toolName, + arguments: { + conversationId: randomUUID(), + }, + }), + ).resolves.toStrictEqual({ + isError: true, + content: [ + { + type: "text", + text: "Failed to retrieve transcript: Test Error Message", + }, + ], + }); + }); + + test("error from Genesys Cloud's Platform SDK call 2 returned2", async () => { + toolDeps.recordingApi.getConversationRecordings.mockResolvedValue([ + { + sessionId: randomUUID(), + }, + ]); + + toolDeps.speechTextAnalyticsApi.getSpeechandtextanalyticsConversationCommunicationTranscripturl.mockRejectedValue( + new Error("Test Error Message"), + ); + + await expect( + client.callTool({ + name: toolName, + arguments: { + conversationId: randomUUID(), + }, + }), + ).resolves.toStrictEqual({ + isError: true, + content: [ + { + type: "text", + text: "Failed to retrieve transcript: Test Error Message", + }, + ], + }); + }); + + test("transcript returned", async () => { + const conversationId = randomUUID(); + const sessionId = randomUUID(); + + toolDeps.recordingApi.getConversationRecordings.mockResolvedValue([ + { + sessionId, + }, + ]); + + toolDeps.speechTextAnalyticsApi.getSpeechandtextanalyticsConversationCommunicationTranscripturl.mockResolvedValue( + { url: "https://test.test/transcript" }, + ); + + toolDeps.fetchUrl.mockResolvedValue({ + json: vi.fn<() => Promise>().mockResolvedValue({ + conversationStartTime: new Date("2025-06-06T21:00:00.000Z").getTime(), + participants: [ + { + startTimeMs: new Date("2025-06-06T21:00:00.000Z").getTime(), + endTimeMs: new Date("2025-06-06T21:00:05.000Z").getTime(), + participantPurpose: "ivr", + }, + { + startTimeMs: new Date("2025-06-06T21:00:05.000Z").getTime(), + endTimeMs: new Date("2025-06-06T21:00:10.000Z").getTime(), + participantPurpose: "customer", + }, + ], + transcripts: [ + { + phrases: [ + { + startTimeMs: new Date("2025-06-06T21:00:00.000Z").getTime(), + participantPurpose: "internal", + decoratedText: "I'm an IVR", + phraseIndex: 0, + }, + { + startTimeMs: new Date("2025-06-06T21:00:05.000Z").getTime(), + participantPurpose: "external", + decoratedText: "I'm a customer", + phraseIndex: 1, + }, + ], + analytics: { + sentiment: [ + { + phraseIndex: 1, + sentiment: 1, + }, + ], + }, + }, + ], + }), + }); + + const result = await client.callTool({ + name: toolName, + arguments: { + conversationId, + }, + }); + + expect(toolDeps.recordingApi.getConversationRecordings).toBeCalledWith( + conversationId, + ); + + expect( + toolDeps.speechTextAnalyticsApi + .getSpeechandtextanalyticsConversationCommunicationTranscripturl, + ).toBeCalledWith(conversationId, sessionId); + + expect(toolDeps.fetchUrl).toBeCalledWith("https://test.test/transcript"); + + expect(result).toStrictEqual({ + content: [ + { + text: ` +Time Who Sentiment Utterance +00:00 IVR I'm an IVR +00:05 customer Positive I'm a customer +`.trim(), + type: "text", + }, + ], + }); + }); +}); diff --git a/src/tools/conversationTranscription/conversationTranscription.ts b/src/tools/conversationTranscription/conversationTranscription.ts new file mode 100644 index 0000000..034fd38 --- /dev/null +++ b/src/tools/conversationTranscription/conversationTranscription.ts @@ -0,0 +1,300 @@ +import { z } from "zod"; +import { + Models, + RecordingApi, + SpeechTextAnalyticsApi, +} from "purecloud-platform-client-v2"; +import { isWithinInterval } from "date-fns/isWithinInterval"; +import { getBorderCharacters, table } from "table"; +import { createTool, type ToolFactory } from "../utils/createTool.js"; +import { isUnauthorisedError } from "../utils/genesys/isUnauthorisedError.js"; +import { errorResult } from "../utils/errorResult.js"; +import { + Participant, + Transcript, + TranscriptResponseFormat, +} from "./TranscriptResponse.js"; +import { Utterance } from "./Utterance.js"; +import { formatTimeUtteranceStarted } from "./formatTimeUtteranceStarted.js"; + +export interface ToolDependencies { + readonly recordingApi: Pick; + readonly speechTextAnalyticsApi: Pick< + SpeechTextAnalyticsApi, + "getSpeechandtextanalyticsConversationCommunicationTranscripturl" + >; + readonly fetchUrl: ( + url: string | URL | Request, + ) => Promise>; +} + +function waitSeconds(seconds: number): Promise { + return new Promise((resolve) => setTimeout(resolve, seconds * 1000)); +} + +export function friendlyPurposeName( + participantPurpose: string | undefined, +): string { + switch (participantPurpose?.toLowerCase()) { + case "internal": + return "Agent"; + case "external": + return "Customer"; + case "acd": + return "ACD"; + case "ivr": + return "IVR"; + default: + return participantPurpose ?? "Unknown"; + } +} + +function friendlySentiment(sentiment: number | undefined): string { + if (sentiment === 1) { + return "Positive"; + } + + if (sentiment === 0) { + return "Neutral"; + } + + if (sentiment === -1) { + return "Negative"; + } + + return ""; +} + +function isNonHuman(participant: Participant | undefined) { + if (!participant?.participantPurpose) { + return false; + } + + return ["acd", "ivr", "voicemail", "fax"].includes( + participant.participantPurpose.toLowerCase(), + ); +} + +function isInternalParticipant(participant: Participant) { + if (participant.participantPurpose) { + return ( + participant.participantPurpose.toLowerCase() === "user" || + participant.participantPurpose.toLowerCase() === "agent" || + isNonHuman(participant) + ); + } else if ( + participant.participantPurpose && + participant.participantPurpose.toLowerCase() === "internal" + ) { + return !!participant.userId; + } + return false; +} + +function isExternalParticipant(participant: Participant) { + if (participant.participantPurpose) { + return ( + participant.participantPurpose.toLowerCase() === "external" || + participant.participantPurpose.toLowerCase() === "customer" || + !isNonHuman(participant) + ); + } else if ( + participant.participantPurpose && + participant.participantPurpose.toLowerCase() === "external" + ) { + return true; + } + return false; +} + +const paramsSchema = z.object({ + conversationId: z + .string() + .uuid() + .describe( + "The UUID of the conversation to retrieve the transcript for (e.g., 00000000-0000-0000-0000-000000000000)", + ), +}); + +export const conversationTranscription: ToolFactory< + ToolDependencies, + typeof paramsSchema +> = ({ recordingApi, speechTextAnalyticsApi, fetchUrl }) => + createTool({ + schema: { + name: "conversation_transcript", + description: + "Retrieves a structured transcript of the conversation, including speaker labels, utterance timestamps, and sentiment annotations where available. The transcript is formatted as a time-aligned list of utterances attributed to each participant (e.g., customer or agent)", + paramsSchema, + }, + call: async ({ conversationId }) => { + let recordingSessionIds: string[] | null = null; + + // 1. Unarchive recordings + let retryCounter = 0; + while (!recordingSessionIds) { + let recordings: Models.Recording[] | undefined; + + try { + recordings = (await recordingApi.getConversationRecordings( + conversationId, + )) as Models.Recording[] | undefined; + } catch (error: unknown) { + const message = isUnauthorisedError(error) + ? "Failed to retrieve transcript: Unauthorised access. Please check API credentials or permissions." + : `Failed to retrieve transcript: ${error instanceof Error ? error.message : JSON.stringify(error)}`; + + return errorResult(message); + } + + if (recordings) { + recordingSessionIds = recordings + .filter((s) => s.sessionId) + .map((s) => s.sessionId) as string[]; + } else { + retryCounter++; + if (retryCounter > 5) { + return errorResult("Failed to retrieve transcript."); + } + + await waitSeconds(10); + } + } + + // 2. Download recordings + const transcriptionsForRecordings: TranscriptResponseFormat[] = []; + + for (const recodingSessionId of recordingSessionIds) { + if (!recodingSessionId) { + continue; + } + let transcriptUrl: Models.TranscriptUrl | null = null; + try { + transcriptUrl = + await speechTextAnalyticsApi.getSpeechandtextanalyticsConversationCommunicationTranscripturl( + conversationId, + recodingSessionId, + ); + } catch (error) { + const message = isUnauthorisedError(error) + ? "Failed to retrieve transcript: Unauthorised access. Please check API credentials or permissions." + : `Failed to retrieve transcript: ${error instanceof Error ? error.message : JSON.stringify(error)}`; + + return errorResult(message); + } + if (!transcriptUrl.url) { + return errorResult( + "URL for transcript was not provided for conversation", + ); + } else { + const response = await fetchUrl(transcriptUrl.url); + const transcript = (await response.json()) as Transcript; + + transcriptionsForRecordings.push(transcript); + } + } + + const utterances: Utterance[] = []; + for (const recording of transcriptionsForRecordings) { + for (const transcript of recording.transcripts ?? []) { + const transcriptUtterances = (transcript.phrases ?? []).flatMap( + (p): Utterance => { + const participantDetails = recording.participants?.find((pd) => { + if ( + p.participantPurpose !== "external" && + isExternalParticipant(pd) + ) { + return false; // Ignore + } + + if ( + p.participantPurpose !== "internal" && + isInternalParticipant(pd) + ) { + return false; // Ignore + } + + if (!p.startTimeMs || !pd.startTimeMs || !pd.endTimeMs) { + return false; // Ignore + } + + return isWithinInterval(p.startTimeMs, { + start: pd.startTimeMs, + end: pd.endTimeMs, + }); + }); + + const recordingTimes = + recording.conversationStartTime && p.startTimeMs + ? { + conversationStartInMs: recording.conversationStartTime, + utteranceStartInMs: p.startTimeMs, + } + : null; + + const sentiment = transcript.analytics?.sentiment?.find( + (s) => s.phraseIndex === p.phraseIndex, + ); + + return { + times: recordingTimes, + sentiment: sentiment?.sentiment, + utterance: p.decoratedText ?? p.text ?? "", + speaker: friendlyPurposeName( + participantDetails?.participantPurpose ?? + p.participantPurpose, + ), + } as Utterance; + }, + ); + + if (transcriptUtterances.length > 0) { + utterances.push(...transcriptUtterances); + } + } + } + + const sentimentPresent = utterances.some( + (u) => u.sentiment !== undefined, + ); + + const data = [ + [ + "Time", + "Who", + ...(sentimentPresent ? ["Sentiment"] : []), + "Utterance", + ], + ...utterances.map((u) => { + return [ + formatTimeUtteranceStarted(u), + u.speaker, + ...(sentimentPresent ? [friendlySentiment(u.sentiment)] : []), + u.utterance, + ]; + }), + ]; + + const utteranceTable = table(data, { + border: getBorderCharacters("void"), + columnDefault: { + paddingLeft: 0, + paddingRight: 2, + }, + drawHorizontalLine: () => false, + }) + .split("\n") + .map((line) => line.trimEnd()) + .join("\n") + .trim(); + + return { + content: [ + { + type: "text", + text: utteranceTable, + }, + ], + }; + }, + }); diff --git a/src/tools/conversationTranscription/formatTimeUtteranceStarted.ts b/src/tools/conversationTranscription/formatTimeUtteranceStarted.ts new file mode 100644 index 0000000..7d79b9a --- /dev/null +++ b/src/tools/conversationTranscription/formatTimeUtteranceStarted.ts @@ -0,0 +1,22 @@ +import { Utterance } from "./Utterance.js"; +import { intervalToDuration } from "date-fns/intervalToDuration"; + +function zeroPad(num: number): string { + return String(num).padStart(2, "0"); +} + +export function formatTimeUtteranceStarted( + utterance: Utterance, + defaultFormattedTime = "--:--", +): string { + if (utterance.times) { + const duration = intervalToDuration({ + start: utterance.times.conversationStartInMs, + end: utterance.times.utteranceStartInMs, + }); + + return `${zeroPad(duration.minutes ?? 0)}:${zeroPad(duration.seconds ?? 0)}`; + } + + return defaultFormattedTime; +} diff --git a/src/tools/utils/genesys/isQueueUsedInConvo.ts b/src/tools/queryQueueVolumes/isQueueUsedInConvo.ts similarity index 100% rename from src/tools/utils/genesys/isQueueUsedInConvo.ts rename to src/tools/queryQueueVolumes/isQueueUsedInConvo.ts diff --git a/src/tools/queryQueueVolumes.test.ts b/src/tools/queryQueueVolumes/queryQueueVolumes.test.ts similarity index 98% rename from src/tools/queryQueueVolumes.test.ts rename to src/tools/queryQueueVolumes/queryQueueVolumes.test.ts index 83031bd..1a252a0 100644 --- a/src/tools/queryQueueVolumes.test.ts +++ b/src/tools/queryQueueVolumes/queryQueueVolumes.test.ts @@ -1,11 +1,11 @@ import { beforeEach, describe, expect, test, vi } from "vitest"; import { MockedObjectDeep } from "@vitest/spy"; -import { queryQueueVolumes, ToolDependencies } from "./queryQueueVolumes.js"; +import { randomUUID } from "node:crypto"; import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; import { McpError } from "@modelcontextprotocol/sdk/types.js"; -import { randomUUID } from "node:crypto"; +import { queryQueueVolumes, ToolDependencies } from "./queryQueueVolumes.js"; describe("Query Queue Volumes Tool", () => { let toolDeps: MockedObjectDeep; @@ -56,7 +56,7 @@ describe("Query Queue Volumes Tool", () => { type: "string", format: "uuid", description: - "A UUID ID for a queue. (e.g., 00000000-0000-0000-0000-000000000000)", + "A UUID for a queue. (e.g., 00000000-0000-0000-0000-000000000000)", }, minItems: 1, maxItems: 300, diff --git a/src/tools/queryQueueVolumes.ts b/src/tools/queryQueueVolumes/queryQueueVolumes.ts similarity index 89% rename from src/tools/queryQueueVolumes.ts rename to src/tools/queryQueueVolumes/queryQueueVolumes.ts index 7b4072c..45ab4d9 100644 --- a/src/tools/queryQueueVolumes.ts +++ b/src/tools/queryQueueVolumes/queryQueueVolumes.ts @@ -1,10 +1,10 @@ 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 { isQueueUsedInConvo } from "./utils/genesys/isQueueUsedInConvo.js"; -import { waitFor } from "./utils/waitFor.js"; +import { createTool, type ToolFactory } from "../utils/createTool.js"; +import { isUnauthorisedError } from "../utils/genesys/isUnauthorisedError.js"; +import { isQueueUsedInConvo } from "./isQueueUsedInConvo.js"; +import { waitFor } from "../utils/waitFor.js"; +import { errorResult } from "../utils/errorResult.js"; export interface ToolDependencies { readonly analyticsApi: Pick< @@ -15,6 +15,8 @@ export interface ToolDependencies { >; } +const MAX_ATTEMPTS = 10; + const paramsSchema = z.object({ queueIds: z .array( @@ -22,7 +24,7 @@ const paramsSchema = z.object({ .string() .uuid() .describe( - "A UUID ID for a queue. (e.g., 00000000-0000-0000-0000-000000000000)", + "A UUID for a queue. (e.g., 00000000-0000-0000-0000-000000000000)", ), ) .min(1) @@ -40,20 +42,6 @@ const paramsSchema = z.object({ ), }); -const MAX_ATTEMPTS = 10; - -function errorResult(errorMessage: string): CallToolResult { - return { - isError: true, - content: [ - { - type: "text", - text: errorMessage, - }, - ], - }; -} - export const queryQueueVolumes: ToolFactory< ToolDependencies, typeof paramsSchema diff --git a/src/tools/sampleConversationsByQueue.test.ts b/src/tools/sampleConversationsByQueue/sampleConversationsByQueue.test.ts similarity index 98% rename from src/tools/sampleConversationsByQueue.test.ts rename to src/tools/sampleConversationsByQueue/sampleConversationsByQueue.test.ts index 7a5e712..65e414b 100644 --- a/src/tools/sampleConversationsByQueue.test.ts +++ b/src/tools/sampleConversationsByQueue/sampleConversationsByQueue.test.ts @@ -1,10 +1,10 @@ import { beforeEach, describe, expect, test, vi } from "vitest"; import { MockedObjectDeep } from "@vitest/spy"; +import { randomUUID } from "node:crypto"; import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; import { McpError } from "@modelcontextprotocol/sdk/types.js"; -import { randomUUID } from "node:crypto"; import { ToolDependencies, sampleConversationsByQueue, @@ -56,7 +56,7 @@ describe("Query Queue Volumes Tool", () => { properties: { queueId: { description: - "The UUID ID of the queue to filter conversations by. (e.g., 00000000-0000-0000-0000-000000000000)", + "The UUID of the queue to filter conversations by. (e.g., 00000000-0000-0000-0000-000000000000)", format: "uuid", type: "string", }, diff --git a/src/tools/sampleConversationsByQueue.ts b/src/tools/sampleConversationsByQueue/sampleConversationsByQueue.ts similarity index 92% rename from src/tools/sampleConversationsByQueue.ts rename to src/tools/sampleConversationsByQueue/sampleConversationsByQueue.ts index 2f59852..84d7a7a 100644 --- a/src/tools/sampleConversationsByQueue.ts +++ b/src/tools/sampleConversationsByQueue/sampleConversationsByQueue.ts @@ -1,10 +1,10 @@ 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 { sampleEvenly } from "./utils/sampleEvenly.js"; -import { waitFor } from "./utils/waitFor.js"; -import { errorResult } from "./utils/errorResult.js"; +import { createTool, type ToolFactory } from "../utils/createTool.js"; +import { isUnauthorisedError } from "../utils/genesys/isUnauthorisedError.js"; +import { sampleEvenly } from "./sampleEvenly.js"; +import { waitFor } from "../utils/waitFor.js"; +import { errorResult } from "../utils/errorResult.js"; export interface ToolDependencies { readonly analyticsApi: Pick< @@ -15,12 +15,14 @@ export interface ToolDependencies { >; } +const MAX_ATTEMPTS = 10; + const paramsSchema = z.object({ queueId: z .string() .uuid() .describe( - "The UUID ID of the queue to filter conversations by. (e.g., 00000000-0000-0000-0000-000000000000)", + "The UUID of the queue to filter conversations by. (e.g., 00000000-0000-0000-0000-000000000000)", ), startDate: z .string() @@ -34,8 +36,6 @@ const paramsSchema = z.object({ ), }); -const MAX_ATTEMPTS = 10; - export const sampleConversationsByQueue: ToolFactory< ToolDependencies, typeof paramsSchema diff --git a/src/tools/utils/sampleEvenly.ts b/src/tools/sampleConversationsByQueue/sampleEvenly.ts similarity index 100% rename from src/tools/utils/sampleEvenly.ts rename to src/tools/sampleConversationsByQueue/sampleEvenly.ts diff --git a/src/tools/searchQueues.test.ts b/src/tools/searchQueues.test.ts index 01fa8f4..95e7315 100644 --- a/src/tools/searchQueues.test.ts +++ b/src/tools/searchQueues.test.ts @@ -1,11 +1,11 @@ import { beforeEach, describe, expect, test, vi } from "vitest"; import { MockedObjectDeep } from "@vitest/spy"; +import { randomUUID } from "node:crypto"; import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; import { McpError } from "@modelcontextprotocol/sdk/types.js"; import { searchQueues, ToolDependencies } from "./searchQueues.js"; -import { randomUUID } from "node:crypto"; describe("Search Queues Tool", () => { let toolDeps: MockedObjectDeep; diff --git a/src/tools/searchVoiceConversations.test.ts b/src/tools/searchVoiceConversations.test.ts index 15a9ca1..3d52884 100644 --- a/src/tools/searchVoiceConversations.test.ts +++ b/src/tools/searchVoiceConversations.test.ts @@ -1,13 +1,13 @@ import { beforeEach, describe, expect, test, vi } from "vitest"; import { MockedObjectDeep } from "@vitest/spy"; -import { - searchVoiceConversations, - ToolDependencies, -} from "./searchVoiceConversations.js"; 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 { + searchVoiceConversations, + ToolDependencies, +} from "./searchVoiceConversations.js"; describe("Search Voice Conversations Tool", () => { let toolDeps: MockedObjectDeep; diff --git a/src/tools/searchVoiceConversations.ts b/src/tools/searchVoiceConversations.ts index 97f95dd..b3f6a30 100644 --- a/src/tools/searchVoiceConversations.ts +++ b/src/tools/searchVoiceConversations.ts @@ -1,8 +1,8 @@ import { z } from "zod"; import type { AnalyticsApi, Models } from "purecloud-platform-client-v2"; +import { formatDistanceStrict } from "date-fns/formatDistanceStrict"; import { isUnauthorisedError } from "./utils/genesys/isUnauthorisedError.js"; import { createTool, type ToolFactory } from "./utils/createTool.js"; -import { formatDistanceStrict } from "date-fns/formatDistanceStrict"; import { paginationSection } from "./utils/paginationSection.js"; import { errorResult } from "./utils/errorResult.js"; diff --git a/src/tools/utils/paginationSection.test.ts b/src/tools/utils/paginationSection.test.ts index 65f70d3..5112ae4 100644 --- a/src/tools/utils/paginationSection.test.ts +++ b/src/tools/utils/paginationSection.test.ts @@ -1,5 +1,5 @@ import { expect, test } from "vitest"; -import { PaginationArgs, paginationSection } from "./paginationSection"; +import { PaginationArgs, paginationSection } from "./paginationSection.js"; const testCases: { name: string; @@ -50,6 +50,21 @@ const testCases: { "Total Conversations returned: 200", ], }, + { + name: "Non-divisible hit count", + input: { + pageSize: 100, + pageNumber: 1, + totalHits: 201, + }, + expected: [ + "--- Pagination Info ---", + "Page Number: 1", + "Page Size: 100", + "Total Pages: 3", + "Total Conversations returned: 201", + ], + }, ]; test.each(testCases)("should correctly parse: $name", ({ input, expected }) => { diff --git a/src/tools/utils/paginationSection.ts b/src/tools/utils/paginationSection.ts index cade299..a113d3e 100644 --- a/src/tools/utils/paginationSection.ts +++ b/src/tools/utils/paginationSection.ts @@ -16,7 +16,7 @@ export function paginationSection( if (totalHits === 0 && pageSize === 0) { return 1; } else { - return Math.max(1, Math.ceil(totalHits ?? 0) / pageSize); + return Math.max(1, Math.ceil((totalHits ?? 0) / pageSize)); } }; diff --git a/src/tools/voiceCallQuality.test.ts b/src/tools/voiceCallQuality.test.ts index 69ce658..72d1434 100644 --- a/src/tools/voiceCallQuality.test.ts +++ b/src/tools/voiceCallQuality.test.ts @@ -1,11 +1,11 @@ import { beforeEach, describe, expect, test, vi } from "vitest"; -import { ToolDependencies, voiceCallQuality } from "./voiceCallQuality.js"; import { MockedObjectDeep } from "@vitest/spy"; +import { randomUUID } from "node:crypto"; import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; import { McpError } from "@modelcontextprotocol/sdk/types.js"; -import { randomUUID } from "node:crypto"; +import { ToolDependencies, voiceCallQuality } from "./voiceCallQuality.js"; describe("Voice Call Quality Tool", () => { let toolDeps: MockedObjectDeep; @@ -52,7 +52,7 @@ describe("Voice Call Quality Tool", () => { "A list of up to 100 conversation IDs to evaluate voice call quality for", items: { description: - "A UUID ID for a conversation. (e.g., 00000000-0000-0000-0000-000000000000)", + "A UUID for a conversation. (e.g., 00000000-0000-0000-0000-000000000000)", format: "uuid", type: "string", }, diff --git a/src/tools/voiceCallQuality.ts b/src/tools/voiceCallQuality.ts index 4a4f27a..a44c789 100644 --- a/src/tools/voiceCallQuality.ts +++ b/src/tools/voiceCallQuality.ts @@ -1,7 +1,7 @@ import { z } from "zod"; +import type { AnalyticsApi, Models } from "purecloud-platform-client-v2"; import { createTool, type ToolFactory } from "./utils/createTool.js"; import { isUnauthorisedError } from "./utils/genesys/isUnauthorisedError.js"; -import type { AnalyticsApi, Models } from "purecloud-platform-client-v2"; import { errorResult } from "./utils/errorResult.js"; export interface ToolDependencies { @@ -15,7 +15,7 @@ const paramsSchema = z.object({ .string() .uuid() .describe( - "A UUID ID for a conversation. (e.g., 00000000-0000-0000-0000-000000000000)", + "A UUID for a conversation. (e.g., 00000000-0000-0000-0000-000000000000)", ), ) .min(1) diff --git a/tests/serverRuns.test.ts b/tests/serverRuns.test.ts index 69fa5d6..7de594c 100644 --- a/tests/serverRuns.test.ts +++ b/tests/serverRuns.test.ts @@ -1,13 +1,16 @@ +import { afterEach, beforeAll, describe, expect, test } from "vitest"; +import { join } from "path"; +import { execSync } from "node:child_process"; import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"; -import { join } from "path"; -import { afterEach, describe, expect, test } from "vitest"; -import { randomUUID } from "node:crypto"; -import { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; describe("Server Runs", () => { let client: Client | null = null; + beforeAll(() => { + execSync("npm run build", { stdio: "inherit" }); + }); + afterEach(async () => { if (client) await client.close(); }); @@ -41,45 +44,7 @@ describe("Server Runs", () => { "conversation_sentiment", "conversation_topics", "search_voice_conversations", + "conversation_transcript", ]); }); - - // Skipped as used for local testing - test.skip("tool call succeeds", async () => { - const transport = new StdioClientTransport({ - command: "node", - args: ["--inspect", join(__dirname, "../dist/index.js")], - env: { - // Provides path for node binary to be used in test - PATH: process.env.PATH!, - GENESYSCLOUD_REGION: process.env.GENESYSCLOUD_REGION!, - GENESYSCLOUD_OAUTHCLIENT_ID: process.env.GENESYSCLOUD_OAUTHCLIENT_ID!, - GENESYSCLOUD_OAUTHCLIENT_SECRET: - process.env.GENESYSCLOUD_OAUTHCLIENT_SECRET!, - }, - }); - - client = new Client({ - name: "test-client", - version: "1.0.0", - }); - - await client.connect(transport); - - const result = await client.callTool({ - name: "voice_call_quality", - arguments: { - conversationIds: [randomUUID()], - }, - }); - - const toolCallResult = result as CallToolResult; - - if (!toolCallResult.content) { - expect.fail("Tool call expected to contain content"); - } - - const text = toolCallResult.content[0].text; - expect(text).toStrictEqual({}); - }); });