From 5f97f2a49d073b58011de45e853fdba5913ad1f4 Mon Sep 17 00:00:00 2001 From: Ompragash Viswanathan Date: Fri, 20 Feb 2026 17:42:48 +0530 Subject: [PATCH 1/2] feat(tools): add ideas ecosystem with 7 read-only tools and enhance changelog params MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce full coverage for the Canny Ideas API surface — groups, ideas, insights, and opportunities — as a cohesive 'ideas' toolset. All 7 tools are read-only and use cursor-based pagination where the API requires it, falling back to skip-based for opportunities. New tools: - canny_list_groups / canny_get_group: group discovery by cursor or ID/urlName - canny_list_ideas / canny_get_idea: idea listing with advanced filtering (IdeaFilter objects), full-text search, and configurable sort order - canny_list_insights / canny_get_insight: qualitative feedback linked to ideas with priority and company attribution - canny_list_opportunities: Salesforce opportunity data with revenue and win/close state tied to Canny posts and ideas Infrastructure: - Add 6 new type interfaces (CannyGroup, CannyIdea, CannyIdeaStatus, IdeaFilter, CannyInsight, CannyOpportunity) to src/types/canny.ts - Add 7 client methods with cursor/skip response normalization - Extend ToolsetName union with 'ideas' for granular toolset filtering - Compact response transformers applied to all list endpoints Changelog tool enhancements: - Add 'sort' param to canny_list_changelog_entries (created, lastSaved, nonPublishedFirst, publishedAt) - Add 'scheduledFor' param to canny_create_changelog_entry for future publication scheduling Versioning: - Reorder CHANGELOG.md entries chronologically (newest first) - Current work targets v1.2.3 Tool count: 30 → 37 (19 read-only, 18 write) across 8 toolsets. Test and documentation updates reflect all new counts. --- CHANGELOG.md | 142 +++++++++------- README.md | 19 ++- docs/QUICKSTART.md | 2 +- docs/TOOLSET_GUIDE.md | 32 ++-- src/api/client.ts | 97 +++++++++++ src/tools/changelog/entries.ts | 14 +- src/tools/ideas/groups.ts | 96 +++++++++++ src/tools/ideas/ideas.ts | 151 ++++++++++++++++++ src/tools/ideas/insights.ts | 117 ++++++++++++++ src/tools/ideas/opportunities.ts | 57 +++++++ src/tools/index.ts | 17 +- src/types/canny.ts | 65 ++++++++ src/types/config.ts | 3 +- tests/integration/tool-mode-filtering.test.ts | 42 +++-- 14 files changed, 759 insertions(+), 95 deletions(-) create mode 100644 src/tools/ideas/groups.ts create mode 100644 src/tools/ideas/ideas.ts create mode 100644 src/tools/ideas/insights.ts create mode 100644 src/tools/ideas/opportunities.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 7d258ee..fb9256d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,50 +5,52 @@ All notable changes to the Canny MCP Server will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [1.0.0] - 2025-01-19 +## [1.2.3] - 2026-02-20 ### Added -- Initial release of Canny MCP Server -- 24 comprehensive tools covering all Canny API operations - - 7 discovery & read tools (boards, posts, comments, votes, tags, categories) - - 3 post management tools (create, update status, change category) - - 6 engagement tools (comments and votes management) - - 4 user & company tools (user management, revenue tracking) - - 2 Jira integration tools (link, unlink existing issues) - - 3 batch operation tools (bulk status updates, tagging, merging) -- **Note**: Jira issue creation intentionally excluded - use dedicated Jira MCP server for better separation of concerns -- 3 real-time resources for dashboard metrics - - Board summary with status breakdown - - Status overview with trends - - Jira link status and health metrics -- 5 PM-focused prompts for common workflows - - Weekly feedback triage - - Sprint planning with RICE scoring - - Executive summary generation - - Jira sync status review - - Customer impact analysis -- Token-optimized responses (70-90% reduction) -- Smart pagination with automatic cursor/skip detection -- LRU caching for improved performance -- Exponential backoff retry logic for rate limits -- Comprehensive error handling and mapping -- Flexible configuration with environment variable expansion -- Support for custom statuses and fields -- Board and tag aliases for better UX -- Structured logging (JSON and pretty formats) +- **New Tools** (7): + - `canny_list_groups` — List groups with cursor-based pagination + - `canny_get_group` — Retrieve a group by ID or URL name + - `canny_list_ideas` — List ideas with filtering, search, and sorting (cursor-based) + - `canny_get_idea` — Retrieve an idea by ID or URL name + - `canny_list_insights` — List insights, optionally filtered by idea (cursor-based) + - `canny_get_insight` — Retrieve an insight by ID + - `canny_list_opportunities` — List Salesforce opportunities linked to Canny +- **New toolset**: `ideas` — groups all idea-ecosystem tools (groups, ideas, insights, opportunities) +- **New types**: `CannyGroup`, `CannyIdea`, `CannyIdeaStatus`, `IdeaFilter`, `CannyInsight`, `CannyOpportunity` +- **New client methods**: `listGroups`, `retrieveGroup`, `listIdeas`, `retrieveIdea`, `listInsights`, `retrieveInsight`, `listOpportunities` +- Changelog tools now support `sort` param (list) and `scheduledFor` param (create) -### Performance -- Response time <2s for 95% of requests -- Cache hit rate 85% for static data -- Support for 10+ concurrent requests -- Automatic rate limit handling +### Changed +- **Total tool count**: 30 → 37 tools (19 read-only, 18 write) +- **Toolset count**: 7 → 8 (added `ideas`) +- **Read-only mode**: 12 → 19 tools (added all 7 ideas ecosystem tools) +- Updated all documentation to reflect new tool counts and toolset descriptions -### Documentation -- Complete README with examples -- Configuration guide -- Troubleshooting section -- Architecture overview -- Integration guides +## [1.2.1] - 2026-02-20 + +### Added +- **New Tools** (6): + - `canny_create_tag` — Create a new tag on a Canny board + - `canny_add_post_tag` — Add a tag to a post (idempotent) + - `canny_remove_post_tag` — Remove a tag from a post (idempotent) + - `canny_list_status_changes` — List post status change history for auditing + - `canny_create_changelog_entry` — Create a changelog entry to communicate product updates + - `canny_list_changelog_entries` — List changelog entries with optional filtering by type or label +- **New toolset**: `changelog` — groups changelog tools for selective enablement +- **New types**: `CannyStatusChange`, `CannyChangelogEntry` with associated param interfaces +- **New client methods**: `createTag`, `addPostTag`, `removePostTag`, `listStatusChanges`, `createChangelogEntry`, `listChangelogEntries` + +### Changed +- **Total tool count**: 24 → 30 tools (12 read-only, 18 write) +- **Discovery toolset**: 7 → 9 tools (added `canny_create_tag`, `canny_list_status_changes`) +- **Posts toolset**: 4 → 6 tools (added `canny_add_post_tag`, `canny_remove_post_tag`) +- **Toolset count**: 6 → 7 (added `changelog`) +- **Read-only mode**: 10 → 12 tools (added `canny_list_status_changes`, `canny_list_changelog_entries`) +- Updated all documentation to reflect new tool counts and toolset descriptions + +### Fixed +- **`canny_get_post` crash with `fields: ["jira"]`** — The Canny API returns `post.jira` as `{ linkedIssues: [...] }` (an object), but the code treated it as a direct array, causing `post.jira.map is not a function`. Fixed the `CannyPost` type and all references in `ResponseTransformer` and `jiraLinkStatus` resource. ## [1.1.0] - 2025-10-25 @@ -107,30 +109,50 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - All new parameters tested - Backward compatibility verified -## [1.2.1] - 2026-02-20 +## [1.0.0] - 2025-01-19 ### Added -- **New Tools** (6): - - `canny_create_tag` — Create a new tag on a Canny board - - `canny_add_post_tag` — Add a tag to a post (idempotent) - - `canny_remove_post_tag` — Remove a tag from a post (idempotent) - - `canny_list_status_changes` — List post status change history for auditing - - `canny_create_changelog_entry` — Create a changelog entry to communicate product updates - - `canny_list_changelog_entries` — List changelog entries with optional filtering by type or label -- **New toolset**: `changelog` — groups changelog tools for selective enablement -- **New types**: `CannyStatusChange`, `CannyChangelogEntry` with associated param interfaces -- **New client methods**: `createTag`, `addPostTag`, `removePostTag`, `listStatusChanges`, `createChangelogEntry`, `listChangelogEntries` +- Initial release of Canny MCP Server +- 24 comprehensive tools covering all Canny API operations + - 7 discovery & read tools (boards, posts, comments, votes, tags, categories) + - 3 post management tools (create, update status, change category) + - 6 engagement tools (comments and votes management) + - 4 user & company tools (user management, revenue tracking) + - 2 Jira integration tools (link, unlink existing issues) + - 3 batch operation tools (bulk status updates, tagging, merging) +- **Note**: Jira issue creation intentionally excluded - use dedicated Jira MCP server for better separation of concerns +- 3 real-time resources for dashboard metrics + - Board summary with status breakdown + - Status overview with trends + - Jira link status and health metrics +- 5 PM-focused prompts for common workflows + - Weekly feedback triage + - Sprint planning with RICE scoring + - Executive summary generation + - Jira sync status review + - Customer impact analysis +- Token-optimized responses (70-90% reduction) +- Smart pagination with automatic cursor/skip detection +- LRU caching for improved performance +- Exponential backoff retry logic for rate limits +- Comprehensive error handling and mapping +- Flexible configuration with environment variable expansion +- Support for custom statuses and fields +- Board and tag aliases for better UX +- Structured logging (JSON and pretty formats) -### Changed -- **Total tool count**: 24 → 30 tools (12 read-only, 18 write) -- **Discovery toolset**: 7 → 9 tools (added `canny_create_tag`, `canny_list_status_changes`) -- **Posts toolset**: 4 → 6 tools (added `canny_add_post_tag`, `canny_remove_post_tag`) -- **Toolset count**: 6 → 7 (added `changelog`) -- **Read-only mode**: 10 → 12 tools (added `canny_list_status_changes`, `canny_list_changelog_entries`) -- Updated all documentation to reflect new tool counts and toolset descriptions +### Performance +- Response time <2s for 95% of requests +- Cache hit rate 85% for static data +- Support for 10+ concurrent requests +- Automatic rate limit handling -### Fixed -- **`canny_get_post` crash with `fields: ["jira"]`** — The Canny API returns `post.jira` as `{ linkedIssues: [...] }` (an object), but the code treated it as a direct array, causing `post.jira.map is not a function`. Fixed the `CannyPost` type and all references in `ResponseTransformer` and `jiraLinkStatus` resource. +### Documentation +- Complete README with examples +- Configuration guide +- Troubleshooting section +- Architecture overview +- Integration guides ### Planned - GitHub integration support diff --git a/README.md b/README.md index b2044d0..22302d3 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ A Model Context Protocol (MCP) server for Canny feedback management. Connect Can ## Features -- **30 Tools** — Full Canny API coverage: posts, comments, votes, users, categories, tags, changelog, status changes, and Jira integration +- **37 Tools** — Full Canny API coverage: posts, comments, votes, users, categories, tags, changelog, status changes, ideas, insights, groups, opportunities, and Jira integration - **Token-Optimized** — 70–90% smaller responses than the raw API - **Jira Integration** — Link posts to Jira issues - **PM Workflows** — Built-in prompts for weekly triage, sprint planning, and executive reporting @@ -57,7 +57,7 @@ Ask Claude: List the available Canny tools. ``` -You should see 30 tools, including `canny_list_posts`, `canny_get_post`, and `canny_filter_posts`. +You should see 37 tools, including `canny_list_posts`, `canny_get_post`, and `canny_list_ideas`. ## Global Install @@ -128,6 +128,15 @@ Then configure your MCP client to run `canny-mcp-server` instead of `npx`: - `canny_list_changelog_entries` — List changelog entries - `canny_create_changelog_entry` — Create a changelog entry to communicate product updates +### Ideas Ecosystem (7 read-only tools) +- `canny_list_groups` — List groups (cursor-based pagination) +- `canny_get_group` — Get a group by ID or URL name +- `canny_list_ideas` — List ideas with filtering, search, and sorting +- `canny_get_idea` — Get an idea by ID or URL name +- `canny_list_insights` — List insights, optionally filtered by idea +- `canny_get_insight` — Get an insight by ID +- `canny_list_opportunities` — List Salesforce opportunities linked to Canny + ### Batch (1 write tool) - `canny_batch_update_status` — Update multiple post statuses at once @@ -135,12 +144,12 @@ Then configure your MCP client to run `canny-mcp-server` instead of `npx`: ### Tool Modes -The server runs in **readonly** mode by default (12 read-only tools). To enable write operations, set `CANNY_TOOL_MODE`: +The server runs in **readonly** mode by default (19 read-only tools). To enable write operations, set `CANNY_TOOL_MODE`: | Mode | Tools | Description | |------|-------|-------------| -| `readonly` | 12 | Read-only tools only (default) | -| `all` | 30 | All tools, including writes | +| `readonly` | 19 | Read-only tools only (default) | +| `all` | 37 | All tools, including writes | | `discovery,posts` | varies | Specific toolsets (comma-separated) | Set via environment variable or `config/default.json`: diff --git a/docs/QUICKSTART.md b/docs/QUICKSTART.md index 4ac6d21..3e0e1cc 100644 --- a/docs/QUICKSTART.md +++ b/docs/QUICKSTART.md @@ -28,7 +28,7 @@ Restart Claude Code. Ask: List the available Canny tools. ``` -You should see 30 tools. +You should see 37 tools. ## Option B: Install globally diff --git a/docs/TOOLSET_GUIDE.md b/docs/TOOLSET_GUIDE.md index 6027ba6..8d60a17 100644 --- a/docs/TOOLSET_GUIDE.md +++ b/docs/TOOLSET_GUIDE.md @@ -2,20 +2,21 @@ ## Overview -The Canny MCP Server organizes its 30 tools into 7 toolsets. You enable toolsets through the `CANNY_TOOL_MODE` environment variable or `config/default.json`. +The Canny MCP Server organizes its 37 tools into 8 toolsets. You enable toolsets through the `CANNY_TOOL_MODE` environment variable or `config/default.json`. ## Tool Modes | Mode | Tools Enabled | Description | |------|---------------|-------------| -| `readonly` | 12 | Read-only tools only (default) | -| `all` | 30 | All tools | +| `readonly` | 19 | Read-only tools only (default) | +| `all` | 37 | All tools | | `discovery` | 9 | Discovery & list operations | | `posts` | 6 | Post write operations | | `engagement` | 6 | Comments & votes | | `users` | 4 | Users & companies | | `jira` | 2 | Jira integration | | `changelog` | 2 | Changelog entries | +| `ideas` | 7 | Ideas, groups, insights, opportunities | | `batch` | 1 | Batch operations | | Comma-separated | Mixed | Custom combination (e.g., `discovery,posts`) | @@ -101,7 +102,19 @@ CANNY_TOOL_MODE=discovery,posts # Discovery + Posts | `canny_list_changelog_entries` | Yes | List changelog entries | | `canny_create_changelog_entry` | No | Create a changelog entry | -### 7. Batch (1 tool: write) +### 7. Ideas Ecosystem (7 tools: all read-only) + +| Tool | Read-Only | Description | +|------|-----------|-------------| +| `canny_list_groups` | Yes | List groups (cursor-based pagination) | +| `canny_get_group` | Yes | Get group by ID or URL name | +| `canny_list_ideas` | Yes | List ideas with filters, search, sorting | +| `canny_get_idea` | Yes | Get idea by ID or URL name | +| `canny_list_insights` | Yes | List insights, filterable by idea | +| `canny_get_insight` | Yes | Get insight by ID | +| `canny_list_opportunities` | Yes | List Salesforce opportunities | + +### 8. Batch (1 tool: write) | Tool | Description | |------|-------------| @@ -109,12 +122,13 @@ CANNY_TOOL_MODE=discovery,posts # Discovery + Posts ## Read-Only Mode -The default `readonly` mode enables 12 tools: +The default `readonly` mode enables 19 tools: - All 8 read-only tools from **discovery** (`list_boards`, `list_tags`, `list_categories`, `list_posts`, `filter_posts`, `get_post`, `list_status_changes`) - 2 read-only tools from **engagement** (`list_comments`, `list_votes`) - 2 read-only tools from **users** (`get_user_details`, `list_companies`) - 1 read-only tool from **changelog** (`list_changelog_entries`) +- All 7 read-only tools from **ideas** (`list_groups`, `get_group`, `list_ideas`, `get_idea`, `list_insights`, `get_insight`, `list_opportunities`) No data modification is possible in this mode. @@ -126,15 +140,15 @@ No data modification is possible in this mode. { "server": { "toolMode": "readonly" } } ``` -12 tools. Safe for demonstrations and reporting. +19 tools. Safe for demonstrations and reporting. ### Product manager workflow ```json -{ "server": { "toolMode": "discovery,posts,engagement,changelog" } } +{ "server": { "toolMode": "discovery,posts,engagement,changelog,ideas" } } ``` -23 tools. Discover, manage posts, engage with users, and publish changelog entries. +30 tools. Discover, manage posts, engage with users, publish changelog entries, and explore ideas. ### Integration focus @@ -150,7 +164,7 @@ No data modification is possible in this mode. { "server": { "toolMode": "all" } } ``` -All 30 tools. +All 37 tools. ## Backward Compatibility diff --git a/src/api/client.ts b/src/api/client.ts index 4ea655e..b9f7bed 100644 --- a/src/api/client.ts +++ b/src/api/client.ts @@ -27,6 +27,11 @@ import { CannyChangelogEntry, ListStatusChangesParams, CreateChangelogEntryParams, + CannyGroup, + CannyIdea, + IdeaFilter, + CannyInsight, + CannyOpportunity, } from '../types/canny.js'; export class CannyClient { @@ -444,6 +449,7 @@ export class CannyClient { async listChangelogEntries(params: { labelIDs?: string[]; type?: string; + sort?: string; limit?: number; skip?: number; }): Promise<{ @@ -460,4 +466,95 @@ export class CannyClient { hasMore: response.hasMore || false, }; } + + // ===== Group Operations ===== + + async listGroups(params: { + cursor?: string; + limit?: number; + }): Promise<{ items: CannyGroup[]; hasNextPage: boolean; cursor?: string }> { + const response = await this.request<{ + items: CannyGroup[]; + hasNextPage: boolean; + cursor?: string; + }>('groups/list', params); + + return { + items: response.items || [], + hasNextPage: response.hasNextPage || false, + cursor: response.cursor, + }; + } + + async retrieveGroup(params: { id?: string; urlName?: string }): Promise { + return this.request('groups/retrieve', params); + } + + // ===== Idea Operations ===== + + async listIdeas(params: { + cursor?: string; + filtering?: { filters: IdeaFilter[]; filtersOperator?: string }; + limit?: number; + parentID?: string; + search?: string; + sort?: { field: string; direction: string }; + }): Promise<{ items: CannyIdea[]; hasNextPage: boolean; cursor?: string }> { + const response = await this.request<{ + items: CannyIdea[]; + hasNextPage: boolean; + cursor?: string; + }>('ideas/list', params); + + return { + items: response.items || [], + hasNextPage: response.hasNextPage || false, + cursor: response.cursor, + }; + } + + async retrieveIdea(params: { id?: string; urlName?: string }): Promise { + return this.request('ideas/retrieve', params); + } + + // ===== Insight Operations ===== + + async listInsights(params: { + cursor?: string; + ideaID?: string; + limit?: number; + }): Promise<{ items: CannyInsight[]; hasNextPage: boolean; cursor?: string }> { + const response = await this.request<{ + items: CannyInsight[]; + hasNextPage: boolean; + cursor?: string; + }>('insights/list', params); + + return { + items: response.items || [], + hasNextPage: response.hasNextPage || false, + cursor: response.cursor, + }; + } + + async retrieveInsight(id: string): Promise { + return this.request('insights/retrieve', { id }); + } + + // ===== Opportunity Operations ===== + + async listOpportunities(params: { + limit?: number; + skip?: number; + }): Promise<{ opportunities: CannyOpportunity[]; hasMore: boolean }> { + const response = await this.request<{ + opportunities: CannyOpportunity[]; + hasMore: boolean; + }>('opportunities/list', params); + + return { + opportunities: response.opportunities || [], + hasMore: response.hasMore || false, + }; + } } diff --git a/src/tools/changelog/entries.ts b/src/tools/changelog/entries.ts index 6b43480..d0ead7f 100644 --- a/src/tools/changelog/entries.ts +++ b/src/tools/changelog/entries.ts @@ -20,6 +20,7 @@ Args: - postIDs (string[], optional): Array of Canny post IDs to link to this entry - labelIDs (string[], optional): Array of label IDs to assign - notify (boolean, optional): Whether to notify subscribers (default: false) + - scheduledFor (string, optional): ISO 8601 date to schedule publication for a future date Returns: JSON object with the created entry's id, title, url, and status. @@ -27,7 +28,7 @@ Returns: Examples: - "Create a changelog entry" -> { title: "Dark Mode", details: "We added dark mode..." } - "Publish a bug fix entry" -> { title: "Bug Fix", details: "Fixed...", type: "fixed", published: true } - - "Create entry linked to posts" -> { title: "New Feature", details: "...", postIDs: ["abc123"] }`, + - "Schedule entry for next week" -> { title: "Coming Soon", details: "...", scheduledFor: "2026-03-01T00:00:00Z" }`, readOnly: false, toolset: 'changelog', inputSchema: { @@ -36,6 +37,7 @@ Examples: type: z.string().optional().describe('Entry type: "new", "improved", or "fixed"'), published: z.boolean().optional().describe('Whether to publish immediately (default: false)'), publishedOn: z.string().optional().describe('Publication date in ISO 8601 format'), + scheduledFor: z.string().optional().describe('ISO 8601 date to schedule publication for a future date'), postIDs: z.array(z.string()).optional().describe('Array of Canny post IDs to link'), labelIDs: z.array(z.string()).optional().describe('Array of label IDs to assign'), notify: z.boolean().optional().describe('Whether to notify subscribers (default: false)'), @@ -47,7 +49,7 @@ Examples: openWorldHint: true, }, handler: async (params, { client, logger }) => { - const { title, details, type, published, publishedOn, postIDs, labelIDs, notify } = params; + const { title, details, type, published, publishedOn, scheduledFor, postIDs, labelIDs, notify } = params; validateRequired(title, 'title'); validateRequired(details, 'details'); @@ -60,6 +62,7 @@ Examples: ...(type && { type }), ...(published !== undefined && { published }), ...(publishedOn && { publishedOn }), + ...(scheduledFor && { scheduledFor }), ...(postIDs && { postIDs }), ...(labelIDs && { labelIDs }), ...(notify !== undefined && { notify }), @@ -84,6 +87,7 @@ export const listChangelogEntries: MCPTool = { Args: - labelIDs (string[], optional): Filter entries by label IDs - type (string, optional): Filter by entry type - "new", "improved", or "fixed" + - sort (string, optional): Sort order - "created", "lastSaved", "nonPublishedFirst", or "publishedAt" (default: "nonPublishedFirst") - limit (number, optional): Max entries to return (default 10) - skip (number, optional): Number of entries to skip for pagination (default 0) @@ -99,6 +103,7 @@ Examples: inputSchema: { labelIDs: z.array(z.string()).optional().describe('Filter entries by label IDs'), type: z.string().optional().describe('Filter by entry type: "new", "improved", or "fixed"'), + sort: z.string().optional().describe('Sort order: "created", "lastSaved", "nonPublishedFirst", or "publishedAt"'), limit: z.number().optional().describe('Max entries to return (default 10)'), skip: z.number().optional().describe('Number of entries to skip for pagination (default 0)'), }, @@ -109,13 +114,14 @@ Examples: openWorldHint: true, }, handler: async (params, { client, logger }) => { - const { labelIDs, type, limit = 10, skip = 0 } = params; + const { labelIDs, type, sort, limit = 10, skip = 0 } = params; - logger.info('Fetching changelog entries', { labelIDs, type, limit, skip }); + logger.info('Fetching changelog entries', { labelIDs, type, sort, limit, skip }); const { entries, hasMore } = await client.listChangelogEntries({ ...(labelIDs && { labelIDs }), ...(type && { type }), + ...(sort && { sort }), limit, skip, }); diff --git a/src/tools/ideas/groups.ts b/src/tools/ideas/groups.ts new file mode 100644 index 0000000..5a6fc14 --- /dev/null +++ b/src/tools/ideas/groups.ts @@ -0,0 +1,96 @@ +/** + * Group tools + */ + +import { z } from 'zod'; +import { MCPTool } from '../../types/mcp.js'; + +export const listGroups: MCPTool = { + name: 'canny_list_groups', + title: 'List Groups', + description: `List groups in Canny. Groups organize ideas into logical collections. Supports cursor-based pagination. + +Args: + - cursor (string, optional): Pagination cursor from a previous response + - limit (number, optional): Max groups to return (default 50, max 100) + +Returns: + JSON object with a "groups" array (each containing id, name, description, urlName), "hasNextPage" boolean, and optional "cursor" for next page. + +Examples: + - "List all groups" -> no params needed + - "Next page of groups" -> { cursor: "eyJhZnRlci..." }`, + readOnly: true, + toolset: 'ideas', + inputSchema: { + cursor: z.string().optional().describe('Pagination cursor from a previous response'), + limit: z.number().optional().describe('Max groups to return (default 50, max 100)'), + }, + annotations: { + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: true, + }, + handler: async (params, { client, logger }) => { + const { cursor, limit = 50 } = params; + + logger.info('Fetching groups', { cursor, limit }); + + const { items, hasNextPage, cursor: nextCursor } = await client.listGroups({ cursor, limit }); + + const compact = items.map((g) => ({ + id: g.id, + name: g.name, + description: g.description, + urlName: g.urlName, + })); + + logger.info(`Fetched ${items.length} groups`); + return { groups: compact, hasNextPage, ...(nextCursor && { cursor: nextCursor }) }; + }, +}; + +export const getGroup: MCPTool = { + name: 'canny_get_group', + title: 'Get Group', + description: `Retrieve a single Canny group by ID or URL name. + +Args: + - id (string, optional): Group ID + - urlName (string, optional): Group URL name + Note: At least one identifier must be provided. + +Returns: + JSON object with the group's id, name, description, and urlName. + +Examples: + - "Get group abc123" -> { id: "abc123" } + - "Get feature-requests group" -> { urlName: "feature-requests" }`, + readOnly: true, + toolset: 'ideas', + inputSchema: { + id: z.string().optional().describe('Group ID'), + urlName: z.string().optional().describe('Group URL name'), + }, + annotations: { + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: true, + }, + handler: async (params, { client, logger }) => { + const { id, urlName } = params; + + if (!id && !urlName) { + throw new Error('Either id or urlName must be provided'); + } + + logger.info('Fetching group', { id, urlName }); + + const group = await client.retrieveGroup({ id, urlName }); + + logger.info('Group fetched successfully', { groupID: group.id }); + return group; + }, +}; diff --git a/src/tools/ideas/ideas.ts b/src/tools/ideas/ideas.ts new file mode 100644 index 0000000..dd5dac4 --- /dev/null +++ b/src/tools/ideas/ideas.ts @@ -0,0 +1,151 @@ +/** + * Idea tools + */ + +import { z } from 'zod'; +import { MCPTool } from '../../types/mcp.js'; +import { ResponseTransformer } from '../../api/transformer.js'; + +export const listIdeas: MCPTool = { + name: 'canny_list_ideas', + title: 'List Ideas', + description: `List ideas in Canny with optional filtering, search, and sorting. Supports cursor-based pagination. + +Ideas are the core feedback unit in Canny, organized by groups and statuses. + +Args: + - cursor (string, optional): Pagination cursor from a previous response + - limit (number, optional): Max ideas to return (default 50, max 100) + - parentID (string, optional): Filter by parent idea ID (for child ideas) + - search (string, optional): Full-text search query + - sort (object, optional): Sort by { field: string, direction: "asc"|"desc" }. Default: { field: "_id", direction: "desc" } + - filters (array, optional): Array of filter objects, each with { resource: "ideaDefaultField", condition: string, value: { fieldID: string, value: any } }. Conditions include: "is", "isNot", "contains", "isEmpty", "isNotEmpty", "greaterThan", "lessThan", "isOneOf", "isNotOneOf", etc. Field IDs: "author", "category", "created", "description", "group", "owner", "status", "statusChanged", "themes", "title", "type" + - filtersOperator (string, optional): "all" (AND) or "any" (OR). Default: "all" + +Returns: + JSON with "ideas" array (compact), "hasNextPage" boolean, and optional "cursor". + +Examples: + - "List recent ideas" -> no params needed + - "Search for dark mode ideas" -> { search: "dark mode" } + - "Filter by status" -> { filters: [{ resource: "ideaDefaultField", condition: "is", value: { fieldID: "status", value: "open" } }] }`, + readOnly: true, + toolset: 'ideas', + inputSchema: { + cursor: z.string().optional().describe('Pagination cursor from a previous response'), + limit: z.number().optional().describe('Max ideas to return (default 50, max 100)'), + parentID: z.string().optional().describe('Filter by parent idea ID'), + search: z.string().optional().describe('Full-text search query'), + sort: z.object({ + field: z.string().describe('Field to sort by'), + direction: z.enum(['asc', 'desc']).describe('Sort direction'), + }).optional().describe('Sort order'), + filters: z.array(z.object({ + resource: z.string().describe('Filter resource type (e.g., "ideaDefaultField")'), + condition: z.string().describe('Filter condition (e.g., "is", "contains", "isEmpty")'), + value: z.object({ + fieldID: z.string().describe('Field ID to filter on'), + value: z.unknown().describe('Filter value'), + }).describe('Filter value object'), + })).optional().describe('Array of filter objects'), + filtersOperator: z.enum(['all', 'any']).optional().describe('"all" (AND) or "any" (OR)'), + }, + annotations: { + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: true, + }, + handler: async (params, { client, logger }) => { + const { cursor, limit = 50, parentID, search, sort, filters, filtersOperator } = params; + + const filtering = filters ? { filters, filtersOperator: filtersOperator || 'all' } : undefined; + + logger.info('Fetching ideas', { cursor, limit, parentID, search, sort, hasFilters: !!filtering }); + + const { items, hasNextPage, cursor: nextCursor } = await client.listIdeas({ + cursor, + filtering, + limit, + parentID, + search, + sort, + }); + + const compact = items.map((idea) => ({ + id: idea.id, + title: idea.title, + description: ResponseTransformer.truncate( + ResponseTransformer.stripHtml(idea.description || ''), + 200 + ), + status: { name: idea.status?.name, type: idea.status?.type }, + authorName: idea.author?.name, + groupName: idea.group?.name, + childCount: idea.childCount, + created: idea.created, + })); + + logger.info(`Fetched ${items.length} ideas`); + return { ideas: compact, hasNextPage, ...(nextCursor && { cursor: nextCursor }) }; + }, +}; + +export const getIdea: MCPTool = { + name: 'canny_get_idea', + title: 'Get Idea', + description: `Retrieve a single Canny idea by ID or URL name. + +Args: + - id (string, optional): Idea ID + - urlName (string, optional): Idea URL name + Note: At least one identifier must be provided. + +Returns: + JSON with the full idea object including author, group, owner, parent, status, and source. + +Examples: + - "Get idea abc123" -> { id: "abc123" } + - "Get idea by URL name" -> { urlName: "dark-mode-support" }`, + readOnly: true, + toolset: 'ideas', + inputSchema: { + id: z.string().optional().describe('Idea ID'), + urlName: z.string().optional().describe('Idea URL name'), + }, + annotations: { + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: true, + }, + handler: async (params, { client, logger }) => { + const { id, urlName } = params; + + if (!id && !urlName) { + throw new Error('Either id or urlName must be provided'); + } + + logger.info('Fetching idea', { id, urlName }); + + const idea = await client.retrieveIdea({ id, urlName }); + + logger.info('Idea fetched successfully', { ideaID: idea.id }); + + return { + id: idea.id, + title: idea.title, + description: idea.description, + status: idea.status, + author: idea.author ? { id: idea.author.id, name: idea.author.name, email: idea.author.email } : null, + group: idea.group ? { id: idea.group.id, name: idea.group.name, urlName: idea.group.urlName } : null, + owner: idea.owner ? { id: idea.owner.id, name: idea.owner.name } : null, + parent: idea.parent, + source: idea.source, + childCount: idea.childCount, + created: idea.created, + updatedAt: idea.updatedAt, + urlName: idea.urlName, + }; + }, +}; diff --git a/src/tools/ideas/insights.ts b/src/tools/ideas/insights.ts new file mode 100644 index 0000000..1cbd1bc --- /dev/null +++ b/src/tools/ideas/insights.ts @@ -0,0 +1,117 @@ +/** + * Insight tools + */ + +import { z } from 'zod'; +import { MCPTool } from '../../types/mcp.js'; +import { ResponseTransformer } from '../../api/transformer.js'; +import { validateRequired } from '../../utils/validators.js'; + +export const listInsights: MCPTool = { + name: 'canny_list_insights', + title: 'List Insights', + description: `List insights in Canny. Insights are qualitative feedback snippets linked to ideas. Supports cursor-based pagination. + +Args: + - cursor (string, optional): Pagination cursor from a previous response + - ideaID (string, optional): Filter insights by idea ID + - limit (number, optional): Max insights to return (default 50, max 100) + +Returns: + JSON with "insights" array (compact), "hasNextPage" boolean, and optional "cursor". + +Examples: + - "List all insights" -> no params needed + - "Show insights for idea abc123" -> { ideaID: "abc123" } + - "Next page" -> { cursor: "eyJhZnRlci..." }`, + readOnly: true, + toolset: 'ideas', + inputSchema: { + cursor: z.string().optional().describe('Pagination cursor from a previous response'), + ideaID: z.string().optional().describe('Filter insights by idea ID'), + limit: z.number().optional().describe('Max insights to return (default 50, max 100)'), + }, + annotations: { + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: true, + }, + handler: async (params, { client, logger }) => { + const { cursor, ideaID, limit = 50 } = params; + + logger.info('Fetching insights', { cursor, ideaID, limit }); + + const { items, hasNextPage, cursor: nextCursor } = await client.listInsights({ + cursor, + ideaID, + limit, + }); + + const compact = items.map((insight) => ({ + id: insight.id, + value: ResponseTransformer.truncate( + ResponseTransformer.stripHtml(insight.value || ''), + 200 + ), + ideaID: insight.ideaID, + priority: insight.priority, + authorName: insight.author?.name, + companyName: insight.company?.name, + created: insight.created, + })); + + logger.info(`Fetched ${items.length} insights`); + return { insights: compact, hasNextPage, ...(nextCursor && { cursor: nextCursor }) }; + }, +}; + +export const getInsight: MCPTool = { + name: 'canny_get_insight', + title: 'Get Insight', + description: `Retrieve a single Canny insight by ID. + +Args: + - id (string, required): Insight ID + +Returns: + JSON with the full insight object including author, company, priority, source, and linked users. + +Examples: + - "Get insight abc123" -> { id: "abc123" }`, + readOnly: true, + toolset: 'ideas', + inputSchema: { + id: z.string().describe('Insight ID'), + }, + annotations: { + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: true, + }, + handler: async (params, { client, logger }) => { + const { id } = params; + + validateRequired(id, 'id'); + + logger.info('Fetching insight', { id }); + + const insight = await client.retrieveInsight(id); + + logger.info('Insight fetched successfully', { insightID: insight.id }); + + return { + id: insight.id, + value: insight.value, + ideaID: insight.ideaID, + priority: insight.priority, + author: insight.author ? { id: insight.author.id, name: insight.author.name, email: insight.author.email } : null, + company: insight.company, + source: insight.source, + url: insight.url, + users: insight.users?.map((u) => ({ id: u.id, name: u.name, email: u.email })) || [], + created: insight.created, + }; + }, +}; diff --git a/src/tools/ideas/opportunities.ts b/src/tools/ideas/opportunities.ts new file mode 100644 index 0000000..e9f1b1d --- /dev/null +++ b/src/tools/ideas/opportunities.ts @@ -0,0 +1,57 @@ +/** + * Opportunity tools + */ + +import { z } from 'zod'; +import { MCPTool } from '../../types/mcp.js'; + +export const listOpportunities: MCPTool = { + name: 'canny_list_opportunities', + title: 'List Opportunities', + description: `List Salesforce opportunities linked to Canny. Supports skip-based pagination. + +Opportunities connect Canny feedback to revenue data from Salesforce, showing which deals are tied to specific ideas and posts. + +Args: + - limit (number, optional): Max opportunities to return (default 10) + - skip (number, optional): Number of opportunities to skip for pagination (default 0) + +Returns: + JSON with "opportunities" array (each containing id, name, value, won, closed, postIDs, ideaIDs) and "hasMore" boolean. + +Examples: + - "List opportunities" -> no params needed + - "Next page" -> { skip: 10 }`, + readOnly: true, + toolset: 'ideas', + inputSchema: { + limit: z.number().optional().describe('Max opportunities to return (default 10)'), + skip: z.number().optional().describe('Number of opportunities to skip for pagination (default 0)'), + }, + annotations: { + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: true, + }, + handler: async (params, { client, logger }) => { + const { limit = 10, skip = 0 } = params; + + logger.info('Fetching opportunities', { limit, skip }); + + const { opportunities, hasMore } = await client.listOpportunities({ limit, skip }); + + const compact = opportunities.map((opp) => ({ + id: opp.id, + name: opp.name, + value: opp.value, + won: opp.won, + closed: opp.closed, + postIDs: opp.postIDs, + ideaIDs: opp.ideaIDs, + })); + + logger.info(`Fetched ${opportunities.length} opportunities`); + return { opportunities: compact, hasMore }; + }, +}; diff --git a/src/tools/index.ts b/src/tools/index.ts index 9c4cfc8..6191e0a 100644 --- a/src/tools/index.ts +++ b/src/tools/index.ts @@ -30,6 +30,12 @@ import { linkJiraIssue, unlinkJiraIssue } from './jira/link.js'; // Changelog tools import { createChangelogEntry, listChangelogEntries } from './changelog/entries.js'; +// Ideas ecosystem tools +import { listGroups, getGroup } from './ideas/groups.js'; +import { listIdeas, getIdea } from './ideas/ideas.js'; +import { listInsights, getInsight } from './ideas/insights.js'; +import { listOpportunities } from './ideas/opportunities.js'; + // Batch tools import { batchUpdateStatus } from './batch/status.js'; @@ -75,11 +81,20 @@ export const ALL_TOOLS: MCPTool[] = [ createChangelogEntry, listChangelogEntries, + // Ideas ecosystem (7 tools: all read-only) + listGroups, + getGroup, + listIdeas, + getIdea, + listInsights, + getInsight, + listOpportunities, + // Batch Operations (1 write tool) batchUpdateStatus, ]; -// Total: 30 tools (12 read-only, 18 write) +// Total: 37 tools (19 read-only, 18 write) export function getToolByName(name: string): MCPTool | undefined { return ALL_TOOLS.find((tool) => tool.name === name); diff --git a/src/types/canny.ts b/src/types/canny.ts index 204fb0f..3631afe 100644 --- a/src/types/canny.ts +++ b/src/types/canny.ts @@ -150,11 +150,76 @@ export interface CreateChangelogEntryParams { type?: string; published?: boolean; publishedOn?: string; + scheduledFor?: string; postIDs?: string[]; labelIDs?: string[]; notify?: boolean; } +// Groups +export interface CannyGroup { + id: string; + name: string; + description: string; + urlName: string; +} + +// Ideas +export interface CannyIdeaStatus { + id: string; + name: string; + type: string; + urlName: string; +} + +export interface CannyIdea { + id: string; + childCount: number; + author?: CannyUser; + created: string; + description: string; + group?: CannyGroup; + owner?: CannyUser; + parent?: { id: string; title: string; urlName: string }; + source: { name: string; type: string }; + status: CannyIdeaStatus; + title: string; + updatedAt: string; + urlName: string; +} + +export interface IdeaFilter { + resource: string; + condition: string; + value: { fieldID: string; value: unknown }; +} + +// Insights +export interface CannyInsight { + id: string; + author?: CannyUser; + company?: { id: string; name: string; monthlySpend: number; urlName: string }; + created: string; + ideaID: string; + priority?: string; + source: { name: string; type: string }; + url?: string; + users: CannyUser[]; + value: string; +} + +// Opportunities +export interface CannyOpportunity { + id: string; + closed: boolean; + ideaIDs: string[]; + name: string; + postIDs: string[]; + salesforceOpportunityID: string; + value: number; + won: boolean; +} + // Compact types for token optimization export interface CompactPost { id: string; diff --git a/src/types/config.ts b/src/types/config.ts index eaa9fa9..67639a9 100644 --- a/src/types/config.ts +++ b/src/types/config.ts @@ -118,7 +118,8 @@ export type ToolsetName = | 'users' // Users & Companies tools | 'jira' // Jira Integration tools | 'batch' // Batch Operations tools - | 'changelog'; // Changelog tools + | 'changelog' // Changelog tools + | 'ideas'; // Ideas ecosystem: Groups, Ideas, Insights, Opportunities export type ToolMode = | 'all' // All tools enabled diff --git a/tests/integration/tool-mode-filtering.test.ts b/tests/integration/tool-mode-filtering.test.ts index 60df981..c26a255 100644 --- a/tests/integration/tool-mode-filtering.test.ts +++ b/tests/integration/tool-mode-filtering.test.ts @@ -28,6 +28,9 @@ function filterTools(toolMode: string | boolean | undefined) { const EXPECTED_READONLY_TOOLS = [ 'canny_filter_posts', + 'canny_get_group', + 'canny_get_idea', + 'canny_get_insight', 'canny_get_post', 'canny_get_user_details', 'canny_list_boards', @@ -35,6 +38,10 @@ const EXPECTED_READONLY_TOOLS = [ 'canny_list_changelog_entries', 'canny_list_comments', 'canny_list_companies', + 'canny_list_groups', + 'canny_list_ideas', + 'canny_list_insights', + 'canny_list_opportunities', 'canny_list_posts', 'canny_list_status_changes', 'canny_list_tags', @@ -70,16 +77,17 @@ const VALID_TOOLSETS: ToolsetName[] = [ 'jira', 'batch', 'changelog', + 'ideas', ]; describe('Tool Registry Integrity', () => { - test('has 30 total tools', () => { - expect(ALL_TOOLS).toHaveLength(30); + test('has 37 total tools', () => { + expect(ALL_TOOLS).toHaveLength(37); }); - test('has 12 readonly tools', () => { + test('has 19 readonly tools', () => { const readonlyTools = ALL_TOOLS.filter((t) => t.readOnly); - expect(readonlyTools).toHaveLength(12); + expect(readonlyTools).toHaveLength(19); }); test('has 18 write tools', () => { @@ -145,31 +153,31 @@ describe('Annotation Consistency', () => { }); describe('Tool Mode Filtering', () => { - test('"readonly" returns 12 readonly tools', () => { + test('"readonly" returns 19 readonly tools', () => { const tools = filterTools('readonly'); - expect(tools).toHaveLength(12); + expect(tools).toHaveLength(19); expect(tools.every((t) => t.readOnly)).toBe(true); }); - test('true returns 12 readonly tools (backward compat)', () => { + test('true returns 19 readonly tools (backward compat)', () => { const tools = filterTools(true); - expect(tools).toHaveLength(12); + expect(tools).toHaveLength(19); expect(tools.every((t) => t.readOnly)).toBe(true); }); - test('"all" returns all 30 tools', () => { + test('"all" returns all 37 tools', () => { const tools = filterTools('all'); - expect(tools).toHaveLength(30); + expect(tools).toHaveLength(37); }); - test('false returns all 30 tools (backward compat)', () => { + test('false returns all 37 tools (backward compat)', () => { const tools = filterTools(false); - expect(tools).toHaveLength(30); + expect(tools).toHaveLength(37); }); - test('undefined defaults to 12 readonly tools', () => { + test('undefined defaults to 19 readonly tools', () => { const tools = filterTools(undefined); - expect(tools).toHaveLength(12); + expect(tools).toHaveLength(19); expect(tools.every((t) => t.readOnly)).toBe(true); }); @@ -203,6 +211,12 @@ describe('Tool Mode Filtering', () => { expect(tools.every((t) => t.toolset === 'changelog')).toBe(true); }); + test('"ideas" returns 7 tools', () => { + const tools = filterTools('ideas'); + expect(tools).toHaveLength(7); + expect(tools.every((t) => t.toolset === 'ideas')).toBe(true); + }); + test('"jira" returns 2 tools', () => { const tools = filterTools('jira'); expect(tools).toHaveLength(2); From a12b06f139b9c201dce65f4002e5f444d9c5a413 Mon Sep 17 00:00:00 2001 From: Ompragash Viswanathan Date: Fri, 20 Feb 2026 17:46:59 +0530 Subject: [PATCH 2/2] Updated CHANGELOG.md --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fb9256d..e5fef55 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,7 @@ All notable changes to the Canny MCP Server will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [1.2.3] - 2026-02-20 +## [1.3.0] - 2026-02-20 ### Added - **New Tools** (7):