diff --git a/README.md b/README.md index 1f8d40c..a0e59d6 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,7 @@ The Model Context Protocol (MCP) is an open protocol that enables seamless integ ## Features -- **151 Tools** across 33 categories for comprehensive Countly operations +- **153 Tools** across 34 categories for comprehensive Countly operations - **Resources** for AI context - Access read-only Countly data (app configs, event schemas, analytics overviews) - **Prompts** for common tasks - Pre-built templates for crash analysis, engagement reports, and more - **Multiple Transport Options**: Supports both stdio (recommended) and HTTP/SSE connections @@ -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 (153 available) Execute Countly operations like analytics queries, app management, crash analysis, etc. ### Resources @@ -282,7 +282,7 @@ COUNTLY_TOOLS_ALERTS=NONE # Alerts: Completely disabled COUNTLY_TOOLS_ALL=R # Read-only mode for all tools ``` -**Available Categories** (subset — see TOOLS_CONFIGURATION.md for all 33): +**Available Categories** (subset — see TOOLS_CONFIGURATION.md for all 34): - `CORE` - Core tools (ping, get_version, get_plugins) (3 tools) - `APPS` - Application management (6 tools) - `ANALYTICS` - Analytics data retrieval (7 tools) @@ -555,7 +555,7 @@ For HTTP mode, clients should connect to: `http://your-server:3000/mcp` ## Available Tools -The server provides 151 tools across 33 categories for comprehensive Countly integration: +The server provides 153 tools across 34 categories for comprehensive Countly integration: ### Core Tools (OpenAI/ChatGPT Compatible) - **`ping`** - Check if Countly server is healthy and reachable @@ -767,6 +767,10 @@ 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. +### Knowledge Base (requires `knowledge-base` plugin) +- **`knowledge_base_spaces`** - List knowledge base spaces the user can read (discover space ids first). +- **`knowledge_base_write`** - Write a page from Markdown; pass a stable `external_ref` for idempotent upserts (ideal for AI agents logging decisions). + All tools support flexible app identification via either `app_id` or `app_name` parameter. ## Health Check diff --git a/TOOLS_CONFIGURATION.md b/TOOLS_CONFIGURATION.md index 06c1904..60c863a 100644 --- a/TOOLS_CONFIGURATION.md +++ b/TOOLS_CONFIGURATION.md @@ -39,6 +39,7 @@ The following categories are **only available if their corresponding plugin is i - **funnels** → requires `funnels` plugin - **journeys** → requires `journey_engine` plugin (Countly Enterprise) - **content** → requires `content` plugin (Countly Enterprise) +- **knowledge_base** → requires `knowledge-base` plugin ### Categories Available by Default @@ -644,6 +645,29 @@ async function contentExamples() { } ``` +### knowledge_base +**Tools**: `knowledge_base_spaces`, `knowledge_base_write` + +**Requires plugin**: `knowledge-base` + +Discover documentation spaces and author pages in the server's built-in knowledge base. Searching documentation is not here — retrieval across all knowledge sources is owned by the `build` plugin. Reads are permission-scoped to the spaces the authenticated user can see; writes require create rights on the target space and accept Markdown. + +**Examples:** +```typescript +async function knowledgeBaseExamples() { + // Discover space ids first + const spaces = await tools.knowledge_base_spaces({}); + + // Record a decision; same external_ref updates the same page next time + await tools.knowledge_base_write({ + space_id: '507f1f77bcf86cd799439011', + title: 'Feature X decisions', + markdown: '# Feature X\n\n- Chose approach A because ...', + external_ref: 'feature-x' + }); +} +``` + ## Verification The server will log the active configuration on startup: diff --git a/src/lib/tools-config.ts b/src/lib/tools-config.ts index 72933cc..8480c7a 100644 --- a/src/lib/tools-config.ts +++ b/src/lib/tools-config.ts @@ -369,6 +369,14 @@ export const TOOL_CATEGORIES: Record = { requiresPlugin: 'content', availableByDefault: false, }, + knowledge_base: { + operations: { + 'knowledge_base_spaces': 'R', + 'knowledge_base_write': 'C', + }, + requiresPlugin: 'knowledge-base', + availableByDefault: false, + }, }; /** diff --git a/src/tools/index.ts b/src/tools/index.ts index 8e8234c..1acf02f 100644 --- a/src/tools/index.ts +++ b/src/tools/index.ts @@ -163,6 +163,11 @@ import { contentToolDefinitions, contentToolHandlers, contentToolMetadata, Conte export { contentToolDefinitions, contentToolHandlers, contentToolMetadata, ContentTools }; +// Knowledge Base +import { knowledgeBaseToolDefinitions, knowledgeBaseToolHandlers, knowledgeBaseToolMetadata, KnowledgeBaseTools } from './knowledge-base.js'; + +export { knowledgeBaseToolDefinitions, knowledgeBaseToolHandlers, knowledgeBaseToolMetadata, KnowledgeBaseTools }; + // Type definitions export type { ToolContext, ToolResult } from './types.js'; @@ -204,6 +209,7 @@ export function getAllToolDefinitions() { ...hooksToolDefinitions, ...journeysToolDefinitions, ...contentToolDefinitions, + ...knowledgeBaseToolDefinitions, ]; } @@ -245,6 +251,7 @@ export function getAllToolHandlers() { ...hooksToolHandlers, ...journeysToolHandlers, ...contentToolHandlers, + ...knowledgeBaseToolHandlers, }; } @@ -286,5 +293,6 @@ export function getAllToolMetadata() { hooksToolMetadata, journeysToolMetadata, contentToolMetadata, + knowledgeBaseToolMetadata, ]; } diff --git a/src/tools/knowledge-base.ts b/src/tools/knowledge-base.ts new file mode 100644 index 0000000..ee1eb0d --- /dev/null +++ b/src/tools/knowledge-base.ts @@ -0,0 +1,136 @@ +import { ToolContext, ToolResult } from './types.js'; +import { safeApiCall } from '../lib/error-handler.js'; + +/* + * Knowledge base authoring tools. + * + * Searching documentation is deliberately NOT here: retrieval across every + * knowledge source (documentation, support tickets, work sessions) is owned by + * the Countly `build` plugin and will be exposed as `build_recall` / + * `build_ask` against `/o/build/*`. These two tools cover what is genuinely + * knowledge-base-specific — discovering spaces and authoring pages. + */ + +// ============================================================================ +// KNOWLEDGE_BASE_SPACES TOOL +// ============================================================================ + +export const listKnowledgeBaseSpacesToolDefinition = { + name: 'knowledge_base_spaces', + description: 'List knowledge base spaces the current user can read via /o/kb/spaces. Call this first to discover the space id needed by knowledge_base_write. Requires the knowledge-base plugin.', + inputSchema: { + type: 'object', + properties: {}, + }, +}; + +export async function handleListKnowledgeBaseSpaces(context: ToolContext, _args: any): Promise { + const params = { + ...context.getAuthParams(), + }; + + const response = await safeApiCall( + () => context.httpClient.get('/o/kb/spaces', { params }), + 'Failed to execute request to /o/kb/spaces' + ); + + const spaces = Array.isArray(response.data) ? response.data : []; + + return { + content: [ + { + type: 'text', + text: `Found ${spaces.length} knowledge base space(s):\n${JSON.stringify(response.data, null, 2)}`, + }, + ], + }; +} + +// ============================================================================ +// KNOWLEDGE_BASE_WRITE TOOL +// ============================================================================ + +export const writeKnowledgeBasePageToolDefinition = { + name: 'knowledge_base_write', + description: 'Write documentation into the Countly knowledge base from Markdown via /i/kb/page-write. The server converts the Markdown to sanitized page content and publishes it. Pass a stable external_ref to update the same page on later calls instead of creating duplicates. Requires create rights on the target space.', + inputSchema: { + type: 'object', + properties: { + space_id: { type: 'string', description: 'Target space id. Call knowledge_base_spaces first if unknown.' }, + markdown: { type: 'string', description: 'Page body as Markdown (headings, lists, tables, fenced code).' }, + title: { type: 'string', description: 'Page title. Required when creating; optional when updating an existing page by external_ref.' }, + external_ref: { type: 'string', description: 'Stable key (feature/branch/ticket id) for idempotent upsert. Same ref updates the same page on later calls; omit and every call creates a new page.' }, + parent_id: { type: 'string', description: 'Optional parent page id to nest the new page under.' }, + }, + required: ['space_id', 'markdown'], + }, +}; + +export async function handleWriteKnowledgeBasePage(context: ToolContext, args: any): Promise { + const { space_id, markdown, title, external_ref, parent_id } = args; + + const params: any = { + ...context.getAuthParams(), + space_id, + markdown, + }; + if (title) { + params.title = title; + } + if (external_ref) { + params.external_ref = external_ref; + } + if (parent_id) { + params.parent_id = parent_id; + } + + const response = await safeApiCall( + () => context.httpClient.post('/i/kb/page-write', null, { params }), + 'Failed to execute request to /i/kb/page-write' + ); + + const created = response.data?.created; + const action = created === false ? 'updated' : 'created'; + + return { + content: [ + { + type: 'text', + text: `Knowledge base page ${action}:\n${JSON.stringify(response.data, null, 2)}`, + }, + ], + }; +} + +// ============================================================================ +// EXPORTS +// ============================================================================ + +export const knowledgeBaseToolDefinitions = [ + listKnowledgeBaseSpacesToolDefinition, + writeKnowledgeBasePageToolDefinition, +]; + +export const knowledgeBaseToolHandlers = { + 'knowledge_base_spaces': 'listSpaces', + 'knowledge_base_write': 'writePage', +} as const; + +export class KnowledgeBaseTools { + constructor(private context: ToolContext) {} + + async listSpaces(args: any): Promise { + return handleListKnowledgeBaseSpaces(this.context, args); + } + + async writePage(args: any): Promise { + return handleWriteKnowledgeBasePage(this.context, args); + } +} + +// Metadata for dynamic routing (must be after class declaration) +export const knowledgeBaseToolMetadata = { + instanceKey: 'knowledgeBase', + toolClass: KnowledgeBaseTools, + handlers: knowledgeBaseToolHandlers, +} as const; diff --git a/tests/knowledge-base.test.ts b/tests/knowledge-base.test.ts new file mode 100644 index 0000000..be8af0e --- /dev/null +++ b/tests/knowledge-base.test.ts @@ -0,0 +1,103 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { + handleListKnowledgeBaseSpaces, + handleWriteKnowledgeBasePage, +} from '../src/tools/knowledge-base.js'; +import { ToolContext } from '../src/tools/types.js'; + +describe('Knowledge Base Tools', () => { + let mockContext: ToolContext; + + beforeEach(() => { + mockContext = { + httpClient: { + post: vi.fn(), + get: vi.fn(), + } as any, + appCache: vi.fn() as any, + getAuthParams: vi.fn().mockReturnValue({ auth_token: 'token123' }), + resolveAppId: vi.fn().mockResolvedValue('app123'), + getApps: vi.fn(), + }; + }); + + describe('handleListKnowledgeBaseSpaces', () => { + it('should list readable spaces', async () => { + mockContext.httpClient.get = vi.fn().mockResolvedValue({ + data: [ + { _id: 'space1', name: 'Engineering', visibility: 'members' }, + { _id: 'space2', name: 'Public Docs', visibility: 'public' }, + ], + }); + + const result = await handleListKnowledgeBaseSpaces(mockContext, {}); + + expect(mockContext.httpClient.get).toHaveBeenCalledWith( + '/o/kb/spaces', + { params: { auth_token: 'token123' } } + ); + expect(result.content[0].text).toContain('Found 2 knowledge base space(s)'); + expect(result.content[0].text).toContain('Engineering'); + }); + }); + + describe('handleWriteKnowledgeBasePage', () => { + it('should create a page from markdown', async () => { + mockContext.httpClient.post = vi.fn().mockResolvedValue({ + data: { result: 'Success', _id: 'page1', path: '/docs/space/feature-x', created: true }, + }); + + const result = await handleWriteKnowledgeBasePage(mockContext, { + space_id: 'space1', + title: 'Feature X decisions', + markdown: '# Feature X\n\n- decision one', + external_ref: 'feature-x', + }); + + expect(mockContext.httpClient.post).toHaveBeenCalledWith( + '/i/kb/page-write', + null, + { + params: { + auth_token: 'token123', + space_id: 'space1', + markdown: '# Feature X\n\n- decision one', + title: 'Feature X decisions', + external_ref: 'feature-x', + }, + } + ); + expect(result.content[0].text).toContain('Knowledge base page created'); + }); + + it('should report an update when the same external_ref matched an existing page', async () => { + mockContext.httpClient.post = vi.fn().mockResolvedValue({ + data: { result: 'Success', _id: 'page1', path: '/docs/space/feature-x', created: false }, + }); + + const result = await handleWriteKnowledgeBasePage(mockContext, { + space_id: 'space1', + markdown: 'updated body', + external_ref: 'feature-x', + }); + + expect(result.content[0].text).toContain('Knowledge base page updated'); + }); + + it('should pass parent_id when provided', async () => { + mockContext.httpClient.post = vi.fn().mockResolvedValue({ + data: { result: 'Success', _id: 'page2', path: '/p', created: true }, + }); + + await handleWriteKnowledgeBasePage(mockContext, { + space_id: 'space1', + title: 'Child', + markdown: 'body', + parent_id: 'parent1', + }); + + const call = (mockContext.httpClient.post as any).mock.calls[0]; + expect(call[2].params.parent_id).toBe('parent1'); + }); + }); +}); diff --git a/tests/tools-config.test.ts b/tests/tools-config.test.ts index dae400f..57c19aa 100644 --- a/tests/tools-config.test.ts +++ b/tests/tools-config.test.ts @@ -50,6 +50,7 @@ describe('Tools Configuration', () => { 'hooks', 'journeys', 'content', + 'knowledge_base', 'metadata', ]; const actualCategories = Object.keys(TOOL_CATEGORIES); @@ -90,6 +91,7 @@ describe('Tools Configuration', () => { hooks: 5, journeys: 13, content: 11, + knowledge_base: 2, metadata: 1, }; for (const [category, config] of Object.entries(TOOL_CATEGORIES)) { @@ -108,12 +110,12 @@ describe('Tools Configuration', () => { } }); - it('should have total of 151 tools', () => { + it('should have total of 153 tools', () => { const totalTools = Object.values(TOOL_CATEGORIES).reduce( (sum, config) => sum + Object.keys(config.operations).length, 0 ); - expect(totalTools).toBe(151); + expect(totalTools).toBe(153); }); });