mirror of
https://github.com/elastic/kibana.git
synced 2025-04-24 01:38:56 -04:00
[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:
parent
c2113c44b3
commit
fe4bf85f5a
3 changed files with 343 additions and 33 deletions
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
@ -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 } };
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}>;
|
||||
}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue