Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ The Model Context Protocol (MCP) is an open protocol that enables seamless integ

This server implements the full MCP specification with support for:

### Tools (151 available)
### Tools (152 available)
Execute Countly operations like analytics queries, app management, crash analysis, etc.

### Resources
Expand Down Expand Up @@ -767,6 +767,9 @@ The server provides 151 tools across 33 categories for comprehensive Countly int
- **`content_assets_delete`** - Delete an uploaded content asset.
- **`content_langs_list`** - List languages eligible for content translations.

### Support Tickets (requires `tickets` plugin)
- **`tickets_rag_search`** - Semantic (RAG) search over indexed support-ticket conversations. Returns relevant, PII-redacted transcript snippets for a natural-language query, scoped to the apps the API key can read. Requires the server's `tickets.rag_enabled` setting to be on.

All tools support flexible app identification via either `app_id` or `app_name` parameter.

## Health Check
Expand Down
7 changes: 7 additions & 0 deletions src/lib/tools-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -369,6 +369,13 @@ export const TOOL_CATEGORIES: Record<string, ToolCategoryConfig> = {
requiresPlugin: 'content',
availableByDefault: false,
},
tickets: {
operations: {
'tickets_rag_search': 'R',
},
requiresPlugin: 'tickets',
availableByDefault: false,
},
};

/**
Expand Down
7 changes: 7 additions & 0 deletions src/tools/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -160,9 +160,13 @@ export { journeysToolDefinitions, journeysToolHandlers, journeysToolMetadata, Jo

// Content Blocks
import { contentToolDefinitions, contentToolHandlers, contentToolMetadata, ContentTools } from './content.js';
import { ticketsToolDefinitions, ticketsToolHandlers, ticketsToolMetadata, TicketsTools } from './tickets.js';

export { contentToolDefinitions, contentToolHandlers, contentToolMetadata, ContentTools };

// Tickets (support)
export { ticketsToolDefinitions, ticketsToolHandlers, ticketsToolMetadata, TicketsTools };

// Type definitions
export type { ToolContext, ToolResult } from './types.js';

Expand Down Expand Up @@ -204,6 +208,7 @@ export function getAllToolDefinitions() {
...hooksToolDefinitions,
...journeysToolDefinitions,
...contentToolDefinitions,
...ticketsToolDefinitions,
];
}

Expand Down Expand Up @@ -245,6 +250,7 @@ export function getAllToolHandlers() {
...hooksToolHandlers,
...journeysToolHandlers,
...contentToolHandlers,
...ticketsToolHandlers,
};
}

Expand Down Expand Up @@ -286,5 +292,6 @@ export function getAllToolMetadata() {
hooksToolMetadata,
journeysToolMetadata,
contentToolMetadata,
ticketsToolMetadata,
];
}
95 changes: 95 additions & 0 deletions src/tools/tickets.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import { ToolContext, ToolResult } from './types.js';
import { safeApiCall } from '../lib/error-handler.js';

// ============================================================================
// TICKETS_RAG_SEARCH TOOL
// ============================================================================

export const ticketsRagSearchToolDefinition = {
name: 'tickets_rag_search',
description: 'Semantic (RAG) search over indexed support-ticket conversations via GET /v2/tickets/rag/search. Returns the most relevant ticket transcript snippets for a natural-language query, ranked by vector similarity — use it to ground answers in how similar customer issues were actually resolved. Results are permission-scoped to the apps the API key can read, and PII in snippets is redacted according to the server\'s tickets.rag_pii_mode setting. Requires the tickets plugin with rag_enabled turned on; returns an error when the feature is disabled.',
inputSchema: {
type: 'object',
properties: {
q: { type: 'string', description: 'Natural-language search query, e.g. "export to CSV times out".' },
app_id: { type: 'string', description: 'Optional application ID to restrict results to one app. Omit to search across all readable apps. Call apps_list first if you do not know it.' },
app_name: { type: 'string', description: 'Optional application name (alternative to app_id). Must match an existing app exactly.' },
limit: { type: 'number', description: 'Maximum number of hits to return (default 5, max 20).' },
},
required: ['q'],
},
};

export async function handleTicketsRagSearch(context: ToolContext, args: any): Promise<ToolResult> {
const { q, limit } = args;

const params: any = {
...context.getAuthParams(),
q,
};
// App scoping is optional — the endpoint searches every app the member can
// read when app_id is omitted, so only resolve when the caller asked for it.
if (args.app_id || args.app_name) {
params.app_id = await context.resolveAppId(args);
}
if (limit !== undefined) {
params.limit = limit;
}

const response = await safeApiCall(
() => context.httpClient.get('/v2/tickets/rag/search', { params }),
'Failed to execute request to /v2/tickets/rag/search'
);

const hits = (response.data && response.data.data) || [];
if (!Array.isArray(hits) || hits.length === 0) {
return {
content: [
{
type: 'text',
text: `No ticket conversations matched "${q}". The index only contains tickets opted into AI search on apps where it is enabled.`,
},
],
};
}

const lines = hits.map((hit: any) =>
`#${hit.number} — ${hit.title} (score ${typeof hit.score === 'number' ? hit.score.toFixed(3) : hit.score}, app ${hit.app_id})\n${hit.snippet}`
);

return {
content: [
{
type: 'text',
text: `Found ${hits.length} matching ticket conversation(s) for "${q}":\n\n${lines.join('\n\n---\n\n')}`,
},
],
};
}

// ============================================================================
// EXPORTS
// ============================================================================

export const ticketsToolDefinitions = [
ticketsRagSearchToolDefinition,
];

export const ticketsToolHandlers = {
'tickets_rag_search': 'ragSearch',
} as const;

export class TicketsTools {
constructor(private context: ToolContext) {}

async ragSearch(args: any): Promise<ToolResult> {
return handleTicketsRagSearch(this.context, args);
}
}

// Metadata for dynamic routing (must be after class declaration)
export const ticketsToolMetadata = {
instanceKey: 'tickets',
toolClass: TicketsTools,
handlers: ticketsToolHandlers,
} as const;
74 changes: 74 additions & 0 deletions tests/tickets.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { handleTicketsRagSearch } from '../src/tools/tickets.js';
import { ToolContext } from '../src/tools/types.js';

describe('Tickets Tools', () => {
let mockContext: ToolContext;

beforeEach(() => {
mockContext = {
httpClient: {
post: vi.fn(),
get: vi.fn(),
} as any,
appCache: vi.fn() as any,
getAuthParams: vi.fn().mockReturnValue({ api_key: 'testkey' }),
resolveAppId: vi.fn().mockResolvedValue('app123'),
getApps: vi.fn(),
};
});

const sampleHit = {
app_id: 'app123',
ticket_id: 'tick1',
number: 42,
title: 'CSV export timeout',
snippet: 'Ticket #42: CSV export timeout\nCustomer: my export fails with a timeout.',
url: '/support/42',
score: 0.5222,
};

describe('handleTicketsRagSearch', () => {
it('searches across all readable apps when no app is given', async () => {
mockContext.httpClient.get = vi.fn().mockResolvedValue({ data: { data: [sampleHit] } });

const result = await handleTicketsRagSearch(mockContext, { q: 'export timeout' });

expect(mockContext.httpClient.get).toHaveBeenCalledWith(
'/v2/tickets/rag/search',
{ params: { api_key: 'testkey', q: 'export timeout' } }
);
// No app scoping requested — resolveAppId must not be consulted.
expect(mockContext.resolveAppId).not.toHaveBeenCalled();
expect(result.content[0].text).toContain('#42');
expect(result.content[0].text).toContain('CSV export timeout');
expect(result.content[0].text).toContain('0.522');
});

it('scopes to one app and forwards limit when provided', async () => {
mockContext.httpClient.get = vi.fn().mockResolvedValue({ data: { data: [sampleHit] } });

await handleTicketsRagSearch(mockContext, { q: 'export timeout', app_name: 'Test', limit: 3 });

expect(mockContext.resolveAppId).toHaveBeenCalled();
expect(mockContext.httpClient.get).toHaveBeenCalledWith(
'/v2/tickets/rag/search',
{ params: { api_key: 'testkey', q: 'export timeout', app_id: 'app123', limit: 3 } }
);
});

it('reports an empty result set without failing', async () => {
mockContext.httpClient.get = vi.fn().mockResolvedValue({ data: { data: [] } });

const result = await handleTicketsRagSearch(mockContext, { q: 'nothing like this' });

expect(result.content[0].text).toContain('No ticket conversations matched');
});

it('propagates API errors (e.g. feature disabled returns 404)', async () => {
mockContext.httpClient.get = vi.fn().mockRejectedValue(new Error('Request failed with status code 404'));

await expect(handleTicketsRagSearch(mockContext, { q: 'anything' })).rejects.toThrow();
});
});
});
4 changes: 3 additions & 1 deletion tests/tools-config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ describe('Tools Configuration', () => {
'hooks',
'journeys',
'content',
'tickets',
'metadata',
];
const actualCategories = Object.keys(TOOL_CATEGORIES);
Expand Down Expand Up @@ -90,6 +91,7 @@ describe('Tools Configuration', () => {
hooks: 5,
journeys: 13,
content: 11,
tickets: 1,
metadata: 1,
};
for (const [category, config] of Object.entries(TOOL_CATEGORIES)) {
Expand All @@ -113,7 +115,7 @@ describe('Tools Configuration', () => {
(sum, config) => sum + Object.keys(config.operations).length,
0
);
expect(totalTools).toBe(151);
expect(totalTools).toBe(152);
});
});

Expand Down
Loading