Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
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
12 changes: 8 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
24 changes: 24 additions & 0 deletions TOOLS_CONFIGURATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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:
Expand Down
8 changes: 8 additions & 0 deletions src/lib/tools-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -369,6 +369,14 @@ export const TOOL_CATEGORIES: Record<string, ToolCategoryConfig> = {
requiresPlugin: 'content',
availableByDefault: false,
},
knowledge_base: {
operations: {
'knowledge_base_spaces': 'R',
'knowledge_base_write': 'C',
},
requiresPlugin: 'knowledge-base',
availableByDefault: false,
},
};

/**
Expand Down
8 changes: 8 additions & 0 deletions src/tools/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

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

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

Expand Down Expand Up @@ -286,5 +293,6 @@ export function getAllToolMetadata() {
hooksToolMetadata,
journeysToolMetadata,
contentToolMetadata,
knowledgeBaseToolMetadata,
];
}
136 changes: 136 additions & 0 deletions src/tools/knowledge-base.ts
Original file line number Diff line number Diff line change
@@ -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<ToolResult> {
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<ToolResult> {
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<ToolResult> {
return handleListKnowledgeBaseSpaces(this.context, args);
}

async writePage(args: any): Promise<ToolResult> {
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;
103 changes: 103 additions & 0 deletions tests/knowledge-base.test.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});
});
Loading
Loading