[Workchat][Salesforce integration] Support comment search / account search (#216989)

## Summary

Support retrieving cases by comments. 

Support `get_account` tool.

Rename to `get_` tools to stay consistent.


### Checklist

Check the PR satisfies following conditions. 

Reviewers should verify this PR satisfies this list as well.

- [x] Any text added follows [EUI's writing
guidelines](https://elastic.github.io/eui/#/guidelines/writing), uses
sentence case text and includes [i18n
support](https://github.com/elastic/kibana/blob/main/src/platform/packages/shared/kbn-i18n/README.md)
- [x]
[Documentation](https://www.elastic.co/guide/en/kibana/master/development-documentation.html)
was added for features that require explanation or tutorials
- [ ] [Unit or functional
tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)
were updated or added to match the most common scenarios
- [ ] If a plugin configuration key changed, check if it needs to be
allowlisted in the cloud and added to the [docker
list](https://github.com/elastic/kibana/blob/main/src/dev/build/tasks/os_packages/docker_generator/resources/base/bin/kibana-docker)
- [x] This was checked for breaking HTTP API changes, and any breaking
changes have been approved by the breaking-change committee. The
`release_note:breaking` label should be applied in these situations.
- [ ] [Flaky Test
Runner](https://ci-stats.kibana.dev/trigger_flaky_test_runner/1) was
used on any tests changed
- [x] The PR description includes the appropriate Release Notes section,
and the correct `release_note:*` label is applied per the
[guidelines](https://www.elastic.co/guide/en/kibana/master/contributing.html#kibana-release-notes-process)
This commit is contained in:
Jedr Blaszyk 2025-04-03 14:53:52 +02:00 committed by GitHub
parent c2113c44b3
commit fe4bf85f5a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 343 additions and 33 deletions

View file

@ -8,7 +8,7 @@
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import type { ElasticsearchClient, Logger } from '@kbn/core/server';
import { z } from '@kbn/zod';
import { retrieveCases } from './tools';
import { getCases, getAccounts } from './tools';
// Define enum field structure upfront
interface Field {
@ -63,7 +63,7 @@ export async function createMcpServer({
const statusEnum = z.enum(statusValues.length ? (statusValues as [string, ...string[]]) : ['']);
server.tool(
'retrieve_cases',
'get_cases',
`Retrieves Salesforce support cases with flexible filtering options`,
{
caseNumber: z
@ -128,6 +128,18 @@ export async function createMcpServer({
.string()
.optional()
.describe('Return cases updated before this date (format: YYYY-MM-DD)'),
commentAuthorEmail: z
.array(z.string())
.optional()
.describe('Filter cases by the email of the comment author'),
commentCreatedAfter: z
.string()
.optional()
.describe('Filter cases with comments created after this date (format: YYYY-MM-DD)'),
commentCreatedBefore: z
.string()
.optional()
.describe('Filter cases with comments created before this date (format: YYYY-MM-DD)'),
},
async ({
id,
@ -143,8 +155,12 @@ export async function createMcpServer({
status,
updatedAfter,
updatedBefore,
commentAuthorEmail,
commentCreatedAfter,
commentCreatedBefore,
ownerEmail,
}) => {
const caseContent = await retrieveCases(elasticsearchClient, logger, index, {
const caseContent = await getCases(elasticsearchClient, logger, index, {
id,
size,
sortField,
@ -158,16 +174,86 @@ export async function createMcpServer({
status,
updatedAfter,
updatedBefore,
commentAuthorEmail,
commentCreatedAfter,
commentCreatedBefore,
ownerEmail,
});
logger.info(`Retrieved ${caseContent.length} support cases`);
logger.info(`Case content: ${JSON.stringify(caseContent)}`);
return {
content: caseContent,
};
}
);
server.tool(
'get_accounts',
`Retrieves Salesforce accounts with flexible filtering options`,
{
id: z.array(z.string()).optional().describe('Salesforce internal IDs of the accounts'),
size: z
.number()
.int()
.positive()
.default(10)
.describe('Maximum number of accounts to return'),
sortField: z
.string()
.optional()
.describe(`Field to sort results by. Can only be one of these ${sortableFields}`),
sortOrder: z
.string()
.optional()
.describe(
`Sorting order. Can only be 'desc' meaning sort in descending order or 'asc' meaning sort in ascending order`
),
ownerEmail: z
.array(z.string())
.optional()
.describe('Emails of account owners/assignees to filter results'),
isPartner: z.boolean().optional().describe('Filter accounts by partner status (true/false)'),
createdAfter: z
.string()
.optional()
.describe('Return accounts created after this date (format: YYYY-MM-DD)'),
createdBefore: z
.string()
.optional()
.describe('Return accounts created before this date (format: YYYY-MM-DD)'),
},
async ({
id,
size = 10,
sortField,
sortOrder,
isPartner,
createdAfter,
createdBefore,
ownerEmail,
}) => {
const accountContent = await getAccounts(elasticsearchClient, logger, index, {
id,
size,
sortField,
sortOrder,
isPartner,
createdAfter,
createdBefore,
ownerEmail,
});
logger.info(`Retrieved ${accountContent.length} accounts`);
return {
content: accountContent,
};
}
);
return server;
}

View file

@ -11,7 +11,7 @@ import type {
SortOrder,
} from '@elastic/elasticsearch/lib/api/types';
import type { ElasticsearchClient, Logger } from '@kbn/core/server';
import type { SupportCase } from './types';
import type { SupportCase, Account } from './types';
interface CaseRetrievalParams {
id?: string[];
@ -28,6 +28,20 @@ interface CaseRetrievalParams {
status?: string[];
updatedAfter?: string;
updatedBefore?: string;
commentAuthorEmail?: string[];
commentCreatedAfter?: string;
commentCreatedBefore?: string;
}
interface AccountRetrievalParams {
id?: string[];
size?: number;
sortField?: string;
sortOrder?: string;
ownerEmail?: string[];
isPartner?: boolean;
createdAfter?: string;
createdBefore?: string;
}
/**
@ -38,7 +52,7 @@ interface CaseRetrievalParams {
* @param indexName - Index name to query
* @param params - Search parameters including optional sorting configuration
*/
export async function retrieveCases(
export async function getCases(
esClient: ElasticsearchClient,
logger: Logger,
indexName: string,
@ -50,7 +64,7 @@ export async function retrieveCases(
: [];
try {
const query = buildQuery(params);
const query = buildCaseQuery(params);
const searchRequest: SearchRequest = {
index: indexName,
@ -69,10 +83,12 @@ export async function retrieveCases(
const contextFields = [
{ field: 'title', type: 'keyword' },
{ field: 'description', type: 'text' },
{ field: 'content', type: 'text' },
{ field: 'url', type: 'keyword' },
{ field: 'metadata.case_number', type: 'keyword' },
{ field: 'metadata.priority', type: 'keyword' },
{ field: 'metadata.status', type: 'keyword' },
{ field: 'metadata.account_id', type: 'keyword' },
{ field: 'metadata.account_name', type: 'keyword' },
{ field: 'owner.email', type: 'keyword' },
{ field: 'owner.name', type: 'keyword' },
];
@ -91,22 +107,44 @@ export async function retrieveCases(
);
};
// Format comments if they exist
let commentsText = '';
if (source.comments && source.comments.length > 0) {
const limitedComments = source.comments.slice(0, 10);
commentsText =
'\n\nComments:\n' +
limitedComments
.map((comment, index) => {
return (
`Comment ${index + 1}:\n` +
`Author: ${comment.author?.name || 'Unknown'} (${
comment.author?.email || 'No email'
})\n` +
`Created: ${comment.created_at || 'Unknown date'}\n` +
`Content: ${comment.content || 'No content'}\n`
);
})
.join('\n');
}
return {
type: 'text' as const,
text: contextFields
.map(({ field }) => {
const fieldPath = field.split('.');
let value = '';
text:
contextFields
.map(({ field }) => {
const fieldPath = field.split('.');
let value = '';
// Use the helper function for both nested and non-nested fields
value =
fieldPath.length > 1
? getNestedValue(source, fieldPath)
: (source[field as keyof SupportCase] || '').toString();
// Use the helper function for both nested and non-nested fields
value =
fieldPath.length > 1
? getNestedValue(source, fieldPath)
: (source[field as keyof SupportCase] || '').toString();
return `${field}: ${value}`;
})
.join('\n'),
return `${field}: ${value}`;
})
.join('\n') + commentsText,
};
});
@ -123,14 +161,127 @@ export async function retrieveCases(
}
}
function buildQuery(params: CaseRetrievalParams): any {
/**
* Retrieves Salesforce accounts
*
* @param esClient - Elasticsearch client
* @param logger - Logger instance
* @param indexName - Index name to query
* @param params - Search parameters including optional sorting configuration
*/
export async function getAccounts(
esClient: ElasticsearchClient,
logger: Logger,
indexName: string,
params: AccountRetrievalParams = {}
): Promise<Array<{ type: 'text'; text: string }>> {
const size = params.size || 10;
const sort = params.sortField
? [{ [params.sortField as string]: { order: params.sortOrder as SortOrder } }]
: [];
try {
const query = buildAccountQuery(params);
const searchRequest: SearchRequest = {
index: indexName,
query,
sort,
size,
};
logger.info(
`Retrieving accounts from ${indexName} with search request: ${JSON.stringify(searchRequest)}`
);
const response = await esClient.search<SearchResponse<Account>>(searchRequest);
// Define fields to include in the response
const contextFields = [
{ field: 'id', type: 'keyword' },
{ field: 'title', type: 'keyword' },
{ field: 'url', type: 'keyword' },
{ field: 'owner.email', type: 'keyword' },
{ field: 'owner.name', type: 'keyword' },
{ field: 'created_at', type: 'date' },
{ field: 'updated_at', type: 'date' },
];
const contentFragments = response.hits.hits.map((hit) => {
const source = hit._source as Account;
// Helper function to safely get nested values
const getNestedValue = (obj: any, path: string[]): string => {
return (
path
.reduce((prev, curr) => {
return prev && typeof prev === 'object' && curr in prev ? prev[curr] : '';
}, obj)
?.toString() || ''
);
};
// Format contacts if they exist
let contactsText = '';
if (source.contacts && source.contacts.length > 0) {
const limitedContacts = source.contacts.slice(0, 10);
contactsText =
'\n\nContacts:\n' +
limitedContacts
.map((contact, index) => {
return (
`Contact ${index + 1}:\n` +
`Name: ${contact.name || 'Unknown'}\n` +
`Email: ${contact.email || 'No email'}\n` +
`Phone: ${contact.phone || 'No phone'}\n` +
`Title: ${contact.title || 'No title'}\n` +
`Department: ${contact.department || 'No department'}\n`
);
})
.join('\n');
}
return {
type: 'text' as const,
text:
contextFields
.map(({ field }) => {
const fieldPath = field.split('.');
let value = '';
// Use the helper function for both nested and non-nested fields
value =
fieldPath.length > 1
? getNestedValue(source, fieldPath)
: (source[field as keyof Account] || '').toString();
return `${field}: ${value}`;
})
.join('\n') + contactsText,
};
});
return contentFragments;
} catch (error) {
logger.error(`Account search failed: ${error}`);
return [
{
type: 'text' as const,
text: `Error: Account search failed: ${error}`,
},
];
}
}
function buildCaseQuery(params: CaseRetrievalParams): any {
const mustClauses: any[] = [{ term: { object_type: 'support_case' } }];
if (params.id && params.id.length > 0) mustClauses.push({ terms: { id: params.id } });
if (params.caseNumber && params.caseNumber.length > 0)
mustClauses.push({ terms: { 'metadata.case_number': params.caseNumber } });
if (params.ownerEmail && params.ownerEmail.length > 0)
mustClauses.push({ terms: { 'owner.emailaddress': params.ownerEmail } });
mustClauses.push({ terms: { 'owner.email': params.ownerEmail } });
if (params.priority && params.priority.length > 0)
mustClauses.push({ terms: { 'metadata.priority': params.priority } });
if (params.status && params.status.length > 0)
@ -151,6 +302,39 @@ function buildQuery(params: CaseRetrievalParams): any {
mustClauses.push(range);
}
// Add comment-related queries
if (params.commentAuthorEmail || params.commentCreatedAfter || params.commentCreatedBefore) {
const nestedQuery: any = {
nested: {
path: 'comments',
query: {
bool: {
must: [],
},
},
},
};
// Add comment author filter
if (params.commentAuthorEmail && params.commentAuthorEmail.length > 0) {
nestedQuery.nested.query.bool.must.push({
terms: { 'comments.author.email': params.commentAuthorEmail },
});
}
// Add comment date range filters
if (params.commentCreatedAfter || params.commentCreatedBefore) {
const commentDateRange: any = { range: { 'comments.created_at': {} } };
if (params.commentCreatedAfter)
commentDateRange.range['comments.created_at'].gte = params.commentCreatedAfter;
if (params.commentCreatedBefore)
commentDateRange.range['comments.created_at'].lte = params.commentCreatedBefore;
nestedQuery.nested.query.bool.must.push(commentDateRange);
}
mustClauses.push(nestedQuery);
}
if (params.semanticQuery) {
mustClauses.push({
semantic: { field: 'content_semantic', query: params.semanticQuery, boost: 2.0 },
@ -159,3 +343,22 @@ function buildQuery(params: CaseRetrievalParams): any {
return { bool: { must: mustClauses } };
}
function buildAccountQuery(params: AccountRetrievalParams): any {
const mustClauses: any[] = [{ term: { object_type: 'account' } }];
if (params.id && params.id.length > 0) mustClauses.push({ terms: { id: params.id } });
if (params.ownerEmail && params.ownerEmail.length > 0)
mustClauses.push({ terms: { 'owner.email': params.ownerEmail } });
if (params.isPartner !== undefined)
mustClauses.push({ term: { 'metadata.is_partner': params.isPartner } });
if (params.createdAfter || params.createdBefore) {
const range: any = { range: { created_at: {} } };
if (params.createdAfter) range.range.created_at.gte = params.createdAfter;
if (params.createdBefore) range.range.created_at.lte = params.createdBefore;
mustClauses.push(range);
}
return { bool: { must: mustClauses } };
}

View file

@ -5,36 +5,57 @@
* 2.0.
*/
export interface SupportCase {
export interface BaseObject {
id?: string;
title?: string;
description?: string;
content?: string;
content_semantic?: string;
url?: string;
object_type?: string;
owner?: {
name?: string;
emailaddress?: string;
};
owner?: Identity;
created_at?: string | Date;
updated_at?: string | Date;
}
export interface Identity {
email?: string;
name?: string;
}
export interface SupportCase extends BaseObject {
metadata?: {
case_number?: string;
priority?: string;
status?: string;
closed?: boolean;
deleted?: boolean;
account_id?: string;
account_name?: string;
};
comments?: Array<{
id?: string;
author?: {
email?: string;
name?: string;
};
content?: {
text?: string;
};
author?: Identity;
content?: string;
created_at?: string | Date;
updated_at?: string | Date;
}>;
}
export interface Account extends BaseObject {
metadata?: {
record_type_id?: string;
currency?: string;
last_activity_date?: string | Date;
is_partner?: boolean;
is_customer_portal?: boolean;
};
contacts?: Array<{
id?: string;
name?: string;
email?: string;
phone?: string;
title?: string;
department?: string;
}>;
}