diff --git a/CHANGELOG.md b/CHANGELOG.md index e5537ea..7d258ee 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -107,7 +107,30 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - All new parameters tested - Backward compatibility verified -## [Unreleased] +## [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. ### Planned - GitHub integration support diff --git a/README.md b/README.md index 6cb8c4b..b2044d0 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ A Model Context Protocol (MCP) server for Canny feedback management. Connect Can ## Features -- **24 Tools** — Full Canny API coverage: posts, comments, votes, users, categories, and Jira integration +- **30 Tools** — Full Canny API coverage: posts, comments, votes, users, categories, tags, changelog, status changes, 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 24 tools, including `canny_list_posts`, `canny_get_post`, and `canny_filter_posts`. +You should see 30 tools, including `canny_list_posts`, `canny_get_post`, and `canny_filter_posts`. ## Global Install @@ -87,20 +87,24 @@ Then configure your MCP client to run `canny-mcp-server` instead of `npx`: ## Available Tools -### Discovery (7 tools: 6 read-only, 1 write) +### Discovery (9 tools: 8 read-only, 1 write) - `canny_list_boards` — List all boards - `canny_list_tags` — List tags (optionally by board) +- `canny_create_tag` — Create a new tag on a board - `canny_list_categories` — List categories - `canny_list_posts` — List posts with filters (status, author, company, tags) - `canny_filter_posts` — Filter by category, company, segment, tag slugs, and date ranges - `canny_get_post` — Get full post details with comments and votes +- `canny_list_status_changes` — List post status change history for auditing - `canny_create_category` — Create a board category -### Posts (4 write tools) +### Posts (6 write tools) - `canny_create_post` — Create a post (supports images, ETA, owner) - `canny_update_post` — Update title, description, ETA, or images - `canny_update_post_status` — Change status with optional voter notification - `canny_change_category` — Move a post to a different category +- `canny_add_post_tag` — Add a tag to a post +- `canny_remove_post_tag` — Remove a tag from a post ### Engagement (6 tools: 2 read-only, 4 write) - `canny_list_comments` — List comments (filterable by company) @@ -120,6 +124,10 @@ Then configure your MCP client to run `canny-mcp-server` instead of `npx`: - `canny_link_jira_issue` — Link a Jira issue to a post - `canny_unlink_jira_issue` — Unlink a Jira issue +### Changelog (2 tools: 1 read-only, 1 write) +- `canny_list_changelog_entries` — List changelog entries +- `canny_create_changelog_entry` — Create a changelog entry to communicate product updates + ### Batch (1 write tool) - `canny_batch_update_status` — Update multiple post statuses at once @@ -127,12 +135,12 @@ Then configure your MCP client to run `canny-mcp-server` instead of `npx`: ### Tool Modes -The server runs in **readonly** mode by default (10 read-only tools). To enable write operations, set `CANNY_TOOL_MODE`: +The server runs in **readonly** mode by default (12 read-only tools). To enable write operations, set `CANNY_TOOL_MODE`: | Mode | Tools | Description | |------|-------|-------------| -| `readonly` | 10 | Read-only tools only (default) | -| `all` | 24 | All tools, including writes | +| `readonly` | 12 | Read-only tools only (default) | +| `all` | 30 | 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 9405903..4ac6d21 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 24 tools. +You should see 30 tools. ## Option B: Install globally diff --git a/docs/TOOLSET_GUIDE.md b/docs/TOOLSET_GUIDE.md index fc7589a..6027ba6 100644 --- a/docs/TOOLSET_GUIDE.md +++ b/docs/TOOLSET_GUIDE.md @@ -2,19 +2,20 @@ ## Overview -The Canny MCP Server organizes its 24 tools into 6 toolsets. You enable toolsets through the `CANNY_TOOL_MODE` environment variable or `config/default.json`. +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`. ## Tool Modes | Mode | Tools Enabled | Description | |------|---------------|-------------| -| `readonly` | 10 | Read-only tools only (default) | -| `all` | 24 | All tools | -| `discovery` | 7 | Discovery & list operations | -| `posts` | 4 | Post write operations | +| `readonly` | 12 | Read-only tools only (default) | +| `all` | 30 | 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 | | `batch` | 1 | Batch operations | | Comma-separated | Mixed | Custom combination (e.g., `discovery,posts`) | @@ -41,19 +42,21 @@ CANNY_TOOL_MODE=discovery,posts # Discovery + Posts ## Toolset Breakdown -### 1. Discovery (7 tools: 6 read-only, 1 write) +### 1. Discovery (9 tools: 8 read-only, 1 write) | Tool | Read-Only | Description | |------|-----------|-------------| | `canny_list_boards` | Yes | List all boards | | `canny_list_tags` | Yes | List tags (optionally by board) | +| `canny_create_tag` | No | Create a new tag on a board | | `canny_list_categories` | Yes | List categories | | `canny_list_posts` | Yes | List posts with filters | | `canny_filter_posts` | Yes | Filter by category, company, segment, tag, date range | | `canny_get_post` | Yes | Get full post details | +| `canny_list_status_changes` | Yes | List post status change history | | `canny_create_category` | No | Create a board category | -### 2. Posts (4 tools: all write) +### 2. Posts (6 tools: all write) | Tool | Description | |------|-------------| @@ -61,6 +64,8 @@ CANNY_TOOL_MODE=discovery,posts # Discovery + Posts | `canny_update_post` | Update title, description, ETA, images | | `canny_update_post_status` | Change status with optional notification | | `canny_change_category` | Move a post to a different category | +| `canny_add_post_tag` | Add a tag to a post | +| `canny_remove_post_tag` | Remove a tag from a post | ### 3. Engagement (6 tools: 2 read-only, 4 write) @@ -89,7 +94,14 @@ CANNY_TOOL_MODE=discovery,posts # Discovery + Posts | `canny_link_jira_issue` | Link a Jira issue to a post | | `canny_unlink_jira_issue` | Unlink a Jira issue | -### 6. Batch (1 tool: write) +### 6. Changelog (2 tools: 1 read-only, 1 write) + +| Tool | Read-Only | Description | +|------|-----------|-------------| +| `canny_list_changelog_entries` | Yes | List changelog entries | +| `canny_create_changelog_entry` | No | Create a changelog entry | + +### 7. Batch (1 tool: write) | Tool | Description | |------|-------------| @@ -97,11 +109,12 @@ CANNY_TOOL_MODE=discovery,posts # Discovery + Posts ## Read-Only Mode -The default `readonly` mode enables 10 tools: +The default `readonly` mode enables 12 tools: -- All 6 read-only tools from **discovery** +- 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`) No data modification is possible in this mode. @@ -113,15 +126,15 @@ No data modification is possible in this mode. { "server": { "toolMode": "readonly" } } ``` -10 tools. Safe for demonstrations and reporting. +12 tools. Safe for demonstrations and reporting. ### Product manager workflow ```json -{ "server": { "toolMode": "discovery,posts,engagement" } } +{ "server": { "toolMode": "discovery,posts,engagement,changelog" } } ``` -17 tools. Discover, manage posts, and engage with users. +23 tools. Discover, manage posts, engage with users, and publish changelog entries. ### Integration focus @@ -137,7 +150,7 @@ No data modification is possible in this mode. { "server": { "toolMode": "all" } } ``` -All 24 tools. +All 30 tools. ## Backward Compatibility diff --git a/src/api/client.ts b/src/api/client.ts index 5244ece..4ea655e 100644 --- a/src/api/client.ts +++ b/src/api/client.ts @@ -23,6 +23,10 @@ import { CreateCommentParams, CreateVoteParams, FindOrCreateUserParams, + CannyStatusChange, + CannyChangelogEntry, + ListStatusChangesParams, + CreateChangelogEntryParams, } from '../types/canny.js'; export class CannyClient { @@ -397,4 +401,63 @@ export class CannyClient { issueKey, }); } + + // ===== Tag Write Operations ===== + + async createTag(boardID: string, name: string): Promise { + return this.request('tags/create', { boardID, name }); + } + + // ===== Post Tag Operations ===== + + async addPostTag(postID: string, tagID: string): Promise<{ success: boolean }> { + return this.request<{ success: boolean }>('posts/add_tag', { postID, tagID }); + } + + async removePostTag(postID: string, tagID: string): Promise<{ success: boolean }> { + return this.request<{ success: boolean }>('posts/remove_tag', { postID, tagID }); + } + + // ===== Status Change Operations ===== + + async listStatusChanges(params: ListStatusChangesParams): Promise<{ + statusChanges: CannyStatusChange[]; + hasMore: boolean; + }> { + const response = await this.request<{ + statusChanges: CannyStatusChange[]; + hasMore: boolean; + }>('status_changes/list', params); + + return { + statusChanges: response.statusChanges || [], + hasMore: response.hasMore || false, + }; + } + + // ===== Changelog Operations ===== + + async createChangelogEntry(params: CreateChangelogEntryParams): Promise { + return this.request('changelog_entries/create', params); + } + + async listChangelogEntries(params: { + labelIDs?: string[]; + type?: string; + limit?: number; + skip?: number; + }): Promise<{ + entries: CannyChangelogEntry[]; + hasMore: boolean; + }> { + const response = await this.request<{ + entries: CannyChangelogEntry[]; + hasMore: boolean; + }>('changelog_entries/list', params); + + return { + entries: response.entries || [], + hasMore: response.hasMore || false, + }; + } } diff --git a/src/api/transformer.ts b/src/api/transformer.ts index bf11d82..d80db52 100644 --- a/src/api/transformer.ts +++ b/src/api/transformer.ts @@ -46,8 +46,8 @@ export class ResponseTransformer { compact.commentCount = post.commentCount; } - if (selectedFields.includes('jira') && post.jira) { - compact.jiraIssues = post.jira.map((j) => j.key); + if (selectedFields.includes('jira') && post.jira?.linkedIssues) { + compact.jiraIssues = post.jira.linkedIssues.map((j) => j.key); } if (selectedFields.includes('tags') && post.tags) { diff --git a/src/resources/jira.ts b/src/resources/jira.ts index 5e789c3..9779194 100644 --- a/src/resources/jira.ts +++ b/src/resources/jira.ts @@ -31,11 +31,11 @@ export const jiraLinkStatus: MCPResource = { }); const linkedPosts = postsResponse.posts.filter( - (post) => post.jira && post.jira.length > 0 + (post) => post.jira?.linkedIssues && post.jira.linkedIssues.length > 0 ); const unlinkedHighPriority = postsResponse.posts.filter( - (post) => (!post.jira || post.jira.length === 0) && post.score > 10 + (post) => (!post.jira?.linkedIssues || post.jira.linkedIssues.length === 0) && post.score > 10 ); const recentlyLinked = linkedPosts @@ -48,7 +48,7 @@ export const jiraLinkStatus: MCPResource = { .map((post) => ({ postID: post.id, postTitle: post.title, - issueKeys: post.jira?.map((j) => j.key) || [], + issueKeys: post.jira?.linkedIssues?.map((j) => j.key) || [], linkedAt: post.statusChangedAt, })); diff --git a/src/tools/changelog/entries.ts b/src/tools/changelog/entries.ts new file mode 100644 index 0000000..6b43480 --- /dev/null +++ b/src/tools/changelog/entries.ts @@ -0,0 +1,136 @@ +/** + * Changelog entry tools + */ + +import { z } from 'zod'; +import { MCPTool } from '../../types/mcp.js'; +import { validateRequired } from '../../utils/validators.js'; + +export const createChangelogEntry: MCPTool = { + name: 'canny_create_changelog_entry', + title: 'Create Changelog Entry', + description: `Create a new changelog entry in Canny to communicate product updates to users. + +Args: + - title (string, required): Title of the changelog entry + - details (string, required): Markdown content for the changelog entry body + - type (string, optional): Entry type - "new", "improved", or "fixed" + - published (boolean, optional): Whether to publish immediately (default: false, creates as draft) + - publishedOn (string, optional): Publication date in ISO 8601 format (e.g., "2025-01-15") + - 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) + +Returns: + JSON object with the created entry's id, title, url, and status. + +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"] }`, + readOnly: false, + toolset: 'changelog', + inputSchema: { + title: z.string().describe('Title of the changelog entry'), + details: z.string().describe('Markdown content for the changelog entry body'), + 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'), + 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)'), + }, + annotations: { + readOnlyHint: false, + destructiveHint: false, + idempotentHint: false, + openWorldHint: true, + }, + handler: async (params, { client, logger }) => { + const { title, details, type, published, publishedOn, postIDs, labelIDs, notify } = params; + + validateRequired(title, 'title'); + validateRequired(details, 'details'); + + logger.info('Creating changelog entry', { title, type, published }); + + const entry = await client.createChangelogEntry({ + title, + details, + ...(type && { type }), + ...(published !== undefined && { published }), + ...(publishedOn && { publishedOn }), + ...(postIDs && { postIDs }), + ...(labelIDs && { labelIDs }), + ...(notify !== undefined && { notify }), + }); + + logger.info('Changelog entry created successfully', { entryID: entry.id }); + + return { + id: entry.id, + title: entry.title, + url: entry.url, + status: entry.status, + }; + }, +}; + +export const listChangelogEntries: MCPTool = { + name: 'canny_list_changelog_entries', + title: 'List Changelog Entries', + description: `List changelog entries in Canny. Supports pagination via limit/skip and optional filtering by label or type. + +Args: + - labelIDs (string[], optional): Filter entries by label IDs + - type (string, optional): Filter by entry type - "new", "improved", or "fixed" + - limit (number, optional): Max entries to return (default 10) + - skip (number, optional): Number of entries to skip for pagination (default 0) + +Returns: + JSON object with an "entries" array (each containing id, title, status, publishedAt, types, url) and "hasMore" boolean. + +Examples: + - "List recent changelog entries" -> no params needed + - "Show only bug fix entries" -> { type: "fixed" } + - "Next page of entries" -> { skip: 10 }`, + readOnly: true, + toolset: 'changelog', + 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"'), + 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)'), + }, + annotations: { + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: true, + }, + handler: async (params, { client, logger }) => { + const { labelIDs, type, limit = 10, skip = 0 } = params; + + logger.info('Fetching changelog entries', { labelIDs, type, limit, skip }); + + const { entries, hasMore } = await client.listChangelogEntries({ + ...(labelIDs && { labelIDs }), + ...(type && { type }), + limit, + skip, + }); + + // Compact response + const compact = entries.map((entry) => ({ + id: entry.id, + title: entry.title, + status: entry.status, + publishedAt: entry.publishedAt, + types: entry.types, + url: entry.url, + })); + + logger.info(`Fetched ${entries.length} changelog entries`); + return { entries: compact, hasMore }; + }, +}; diff --git a/src/tools/discovery/status-changes.ts b/src/tools/discovery/status-changes.ts new file mode 100644 index 0000000..61caa3a --- /dev/null +++ b/src/tools/discovery/status-changes.ts @@ -0,0 +1,65 @@ +/** + * Status change discovery tools + */ + +import { z } from 'zod'; +import { MCPTool } from '../../types/mcp.js'; + +export const listStatusChanges: MCPTool = { + name: 'canny_list_status_changes', + title: 'List Status Changes', + description: `List status changes in Canny for auditing and history. Supports pagination via limit/skip. + +Returns a chronological record of post status changes including who changed the status and any associated comment. + +Args: + - boardID (string, optional): Board ID to filter status changes by + - limit (number, optional): Max status changes to return (default 10) + - skip (number, optional): Number of status changes to skip for pagination (default 0) + +Returns: + JSON object with a "statusChanges" array (each containing id, postID, postTitle, status, changerName, created, comment) and "hasMore" boolean. + +Examples: + - "Show recent status changes" -> no params needed + - "Status changes for board abc123" -> { boardID: "abc123" } + - "Next page of status changes" -> { skip: 10 }`, + readOnly: true, + toolset: 'discovery', + inputSchema: { + boardID: z.string().optional().describe('Board ID to filter status changes by'), + limit: z.number().optional().describe('Max status changes to return (default 10)'), + skip: z.number().optional().describe('Number of status changes to skip for pagination (default 0)'), + }, + annotations: { + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: true, + }, + handler: async (params, { client, logger }) => { + const { boardID, limit = 10, skip = 0 } = params; + + logger.info('Fetching status changes', { boardID, limit, skip }); + + const { statusChanges, hasMore } = await client.listStatusChanges({ + boardID, + limit, + skip, + }); + + // Compact response + const compact = statusChanges.map((sc) => ({ + id: sc.id, + postID: sc.post?.id, + postTitle: sc.post?.title, + status: sc.status, + changerName: sc.changer?.name, + created: sc.created, + comment: sc.changeComment?.value, + })); + + logger.info(`Fetched ${statusChanges.length} status changes`); + return { statusChanges: compact, hasMore }; + }, +}; diff --git a/src/tools/discovery/tags.ts b/src/tools/discovery/tags.ts index 1660281..f65a492 100644 --- a/src/tools/discovery/tags.ts +++ b/src/tools/discovery/tags.ts @@ -4,6 +4,7 @@ import { z } from 'zod'; import { MCPTool } from '../../types/mcp.js'; +import { validateRequired } from '../../utils/validators.js'; export const listTags: MCPTool = { name: 'canny_list_tags', @@ -55,3 +56,52 @@ Examples: return { tags: compact, hasMore }; }, }; + +export const createTag: MCPTool = { + name: 'canny_create_tag', + title: 'Create Tag', + description: `Create a new tag on a Canny board. + +Args: + - boardID (string, required): Board ID to create the tag on + - name (string, required): Name for the new tag + +Returns: + JSON object with the created tag's id and name. + +Examples: + - "Create an iOS tag" -> { boardID: "abc123", name: "iOS" } + - "Add a tag for mobile" -> { boardID: "abc123", name: "mobile" }`, + readOnly: false, + toolset: 'discovery', + inputSchema: { + boardID: z.string().describe('Board ID to create tag on'), + name: z.string().describe('Tag name'), + }, + annotations: { + readOnlyHint: false, + destructiveHint: false, + idempotentHint: false, + openWorldHint: true, + }, + handler: async (params, { client, cache, logger }) => { + const { boardID, name } = params; + + validateRequired(boardID, 'boardID'); + validateRequired(name, 'name'); + + logger.info('Creating tag', { boardID, name }); + + const tag = await client.createTag(boardID, name); + + // Invalidate tag cache for this board + cache.delete(`tags:board:${boardID}`); + + logger.info('Tag created successfully', { tagID: tag.id }); + + return { + id: tag.id, + name: tag.name, + }; + }, +}; diff --git a/src/tools/index.ts b/src/tools/index.ts index 4631b29..9c4cfc8 100644 --- a/src/tools/index.ts +++ b/src/tools/index.ts @@ -6,14 +6,15 @@ import { MCPTool } from '../types/mcp.js'; // Discovery tools import { listBoards } from './discovery/boards.js'; -import { listTags } from './discovery/tags.js'; +import { listTags, createTag } from './discovery/tags.js'; +import { listStatusChanges } from './discovery/status-changes.js'; import { listCategories, createCategory } from './discovery/categories.js'; // Post tools import { listPosts } from './posts/list.js'; import { filterPosts } from './posts/search.js'; import { getPost } from './posts/get.js'; -import { createPost, updatePost, updatePostStatus, changeCategory } from './posts/crud.js'; +import { createPost, updatePost, updatePostStatus, changeCategory, addPostTag, removePostTag } from './posts/crud.js'; // Engagement tools import { listComments, createComment, deleteComment } from './engagement/comments.js'; @@ -26,24 +27,31 @@ import { listCompanies, linkCompany } from './users/companies.js'; // Jira tools import { linkJiraIssue, unlinkJiraIssue } from './jira/link.js'; +// Changelog tools +import { createChangelogEntry, listChangelogEntries } from './changelog/entries.js'; + // Batch tools import { batchUpdateStatus } from './batch/status.js'; export const ALL_TOOLS: MCPTool[] = [ - // Discovery & List (6 read-only tools) + // Discovery & List (8 read-only + 2 write tools) listBoards, listTags, + createTag, listCategories, listPosts, filterPosts, getPost, + listStatusChanges, - // Posts Management (5 write tools) + // Posts Management (7 write tools) createPost, updatePost, updatePostStatus, changeCategory, createCategory, + addPostTag, + removePostTag, // Engagement (6 tools: 2 read-only, 4 write) listComments, @@ -63,11 +71,15 @@ export const ALL_TOOLS: MCPTool[] = [ linkJiraIssue, unlinkJiraIssue, + // Changelog (2 tools: 1 read-only, 1 write) + createChangelogEntry, + listChangelogEntries, + // Batch Operations (1 write tool) batchUpdateStatus, ]; -// Total: 24 tools (10 read-only, 14 write) +// Total: 30 tools (12 read-only, 18 write) export function getToolByName(name: string): MCPTool | undefined { return ALL_TOOLS.find((tool) => tool.name === name); diff --git a/src/tools/posts/crud.ts b/src/tools/posts/crud.ts index 09bdfb8..5864991 100644 --- a/src/tools/posts/crud.ts +++ b/src/tools/posts/crud.ts @@ -306,3 +306,99 @@ Examples: return { success: true }; }, }; + +export const addPostTag: MCPTool = { + name: 'canny_add_post_tag', + title: 'Add Tag to Post', + description: `Add a tag to a Canny post. Accepts post ID or Canny URL. Idempotent — adding a tag that already exists on the post has no effect. + +Args: + - postID (string, optional): Post ID to tag + - url (string, optional): Canny post URL (alternative to postID) + - boardID (string, optional): Board ID (helps resolve URL) + - tagID (string, required): Tag ID to add to the post + +Returns: + JSON with success boolean. + +Examples: + - "Tag post as iOS" -> postID: "abc123", tagID: "tag456" + - "Add tag to post from URL" -> url: "https://...", tagID: "tag456"`, + readOnly: false, + toolset: 'posts', + annotations: { + readOnlyHint: false, + destructiveHint: false, + idempotentHint: true, + openWorldHint: true, + }, + inputSchema: { + postID: z.string().optional().describe('Post ID to tag'), + url: z.string().optional().describe('Canny post URL (alternative to postID)'), + boardID: z.string().optional().describe('Board ID (optional, helps resolve URL)'), + tagID: z.string().describe('Tag ID to add to the post'), + }, + handler: async (params, { client, config, logger }) => { + const { postID: providedPostID, url, boardID, tagID } = params; + + const postID = await resolvePostID({ postID: providedPostID, url, boardID, config, client, logger }); + + validateRequired(tagID, 'tagID'); + + logger.info('Adding tag to post', { postID, tagID }); + + await client.addPostTag(postID, tagID); + + logger.info('Tag added to post successfully'); + + return { success: true }; + }, +}; + +export const removePostTag: MCPTool = { + name: 'canny_remove_post_tag', + title: 'Remove Tag from Post', + description: `Remove a tag from a Canny post. Accepts post ID or Canny URL. Idempotent — removing a tag that doesn't exist on the post has no effect. + +Args: + - postID (string, optional): Post ID to remove tag from + - url (string, optional): Canny post URL (alternative to postID) + - boardID (string, optional): Board ID (helps resolve URL) + - tagID (string, required): Tag ID to remove from the post + +Returns: + JSON with success boolean. + +Examples: + - "Remove iOS tag from post" -> postID: "abc123", tagID: "tag456" + - "Untag post from URL" -> url: "https://...", tagID: "tag456"`, + readOnly: false, + toolset: 'posts', + annotations: { + readOnlyHint: false, + destructiveHint: true, + idempotentHint: true, + openWorldHint: true, + }, + inputSchema: { + postID: z.string().optional().describe('Post ID to remove tag from'), + url: z.string().optional().describe('Canny post URL (alternative to postID)'), + boardID: z.string().optional().describe('Board ID (optional, helps resolve URL)'), + tagID: z.string().describe('Tag ID to remove from the post'), + }, + handler: async (params, { client, config, logger }) => { + const { postID: providedPostID, url, boardID, tagID } = params; + + const postID = await resolvePostID({ postID: providedPostID, url, boardID, config, client, logger }); + + validateRequired(tagID, 'tagID'); + + logger.info('Removing tag from post', { postID, tagID }); + + await client.removePostTag(postID, tagID); + + logger.info('Tag removed from post successfully'); + + return { success: true }; + }, +}; diff --git a/src/types/canny.ts b/src/types/canny.ts index 4d3fafe..204fb0f 100644 --- a/src/types/canny.ts +++ b/src/types/canny.ts @@ -41,7 +41,7 @@ export interface CannyPost { eta?: string; etaPublic: boolean; imageURLs: string[]; - jira?: CannyJiraIssue[]; + jira?: { linkedIssues: CannyJiraIssue[] }; owner?: CannyUser; score: number; status: string; @@ -112,6 +112,49 @@ export interface InternalPost { [key: string]: unknown; } +export interface CannyStatusChange { + id: string; + changeComment?: { value: string; imageURLs: string[] }; + changer: CannyUser; + created: string; + post: CannyPost; + status: string; +} + +export interface CannyChangelogEntry { + id: string; + created: string; + labels: { id: string; name: string }[]; + lastSaved: string; + markdownDetails: string; + plaintextDetails: string; + posts: CannyPost[]; + publishedAt?: string; + reactions: Record; + scheduledFor?: string; + status: string; + title: string; + types: string[]; + url: string; +} + +export interface ListStatusChangesParams { + boardID?: string; + limit?: number; + skip?: number; +} + +export interface CreateChangelogEntryParams { + title: string; + details: string; + type?: string; + published?: boolean; + publishedOn?: string; + postIDs?: string[]; + labelIDs?: string[]; + notify?: boolean; +} + // Compact types for token optimization export interface CompactPost { id: string; diff --git a/src/types/config.ts b/src/types/config.ts index 10fe600..eaa9fa9 100644 --- a/src/types/config.ts +++ b/src/types/config.ts @@ -117,7 +117,8 @@ export type ToolsetName = | 'engagement' // Engagement tools | 'users' // Users & Companies tools | 'jira' // Jira Integration tools - | 'batch'; // Batch Operations tools + | 'batch' // Batch Operations tools + | 'changelog'; // Changelog tools 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 7d62f24..60df981 100644 --- a/tests/integration/tool-mode-filtering.test.ts +++ b/tests/integration/tool-mode-filtering.test.ts @@ -32,24 +32,30 @@ const EXPECTED_READONLY_TOOLS = [ 'canny_get_user_details', 'canny_list_boards', 'canny_list_categories', + 'canny_list_changelog_entries', 'canny_list_comments', 'canny_list_companies', 'canny_list_posts', + 'canny_list_status_changes', 'canny_list_tags', 'canny_list_votes', ].sort(); const EXPECTED_WRITE_TOOLS = [ + 'canny_add_post_tag', 'canny_add_vote', 'canny_batch_update_status', 'canny_change_category', 'canny_create_category', + 'canny_create_changelog_entry', 'canny_create_comment', 'canny_create_post', + 'canny_create_tag', 'canny_delete_comment', 'canny_find_or_create_user', 'canny_link_company', 'canny_link_jira_issue', + 'canny_remove_post_tag', 'canny_remove_vote', 'canny_unlink_jira_issue', 'canny_update_post', @@ -63,21 +69,22 @@ const VALID_TOOLSETS: ToolsetName[] = [ 'users', 'jira', 'batch', + 'changelog', ]; describe('Tool Registry Integrity', () => { - test('has 24 total tools', () => { - expect(ALL_TOOLS).toHaveLength(24); + test('has 30 total tools', () => { + expect(ALL_TOOLS).toHaveLength(30); }); - test('has 10 readonly tools', () => { + test('has 12 readonly tools', () => { const readonlyTools = ALL_TOOLS.filter((t) => t.readOnly); - expect(readonlyTools).toHaveLength(10); + expect(readonlyTools).toHaveLength(12); }); - test('has 14 write tools', () => { + test('has 18 write tools', () => { const writeTools = ALL_TOOLS.filter((t) => !t.readOnly); - expect(writeTools).toHaveLength(14); + expect(writeTools).toHaveLength(18); }); test('readonly tool names match expected list', () => { @@ -138,37 +145,37 @@ describe('Annotation Consistency', () => { }); describe('Tool Mode Filtering', () => { - test('"readonly" returns 10 readonly tools', () => { + test('"readonly" returns 12 readonly tools', () => { const tools = filterTools('readonly'); - expect(tools).toHaveLength(10); + expect(tools).toHaveLength(12); expect(tools.every((t) => t.readOnly)).toBe(true); }); - test('true returns 10 readonly tools (backward compat)', () => { + test('true returns 12 readonly tools (backward compat)', () => { const tools = filterTools(true); - expect(tools).toHaveLength(10); + expect(tools).toHaveLength(12); expect(tools.every((t) => t.readOnly)).toBe(true); }); - test('"all" returns all 24 tools', () => { + test('"all" returns all 30 tools', () => { const tools = filterTools('all'); - expect(tools).toHaveLength(24); + expect(tools).toHaveLength(30); }); - test('false returns all 24 tools (backward compat)', () => { + test('false returns all 30 tools (backward compat)', () => { const tools = filterTools(false); - expect(tools).toHaveLength(24); + expect(tools).toHaveLength(30); }); - test('undefined defaults to 10 readonly tools', () => { + test('undefined defaults to 12 readonly tools', () => { const tools = filterTools(undefined); - expect(tools).toHaveLength(10); + expect(tools).toHaveLength(12); expect(tools.every((t) => t.readOnly)).toBe(true); }); - test('"discovery" returns 7 tools', () => { + test('"discovery" returns 9 tools', () => { const tools = filterTools('discovery'); - expect(tools).toHaveLength(7); + expect(tools).toHaveLength(9); expect(tools.every((t) => t.toolset === 'discovery')).toBe(true); }); @@ -184,12 +191,18 @@ describe('Tool Mode Filtering', () => { expect(tools.every((t) => t.toolset === 'users')).toBe(true); }); - test('"posts" returns 4 tools', () => { + test('"posts" returns 6 tools', () => { const tools = filterTools('posts'); - expect(tools).toHaveLength(4); + expect(tools).toHaveLength(6); expect(tools.every((t) => t.toolset === 'posts')).toBe(true); }); + test('"changelog" returns 2 tools', () => { + const tools = filterTools('changelog'); + expect(tools).toHaveLength(2); + expect(tools.every((t) => t.toolset === 'changelog')).toBe(true); + }); + test('"jira" returns 2 tools', () => { const tools = filterTools('jira'); expect(tools).toHaveLength(2); @@ -202,9 +215,9 @@ describe('Tool Mode Filtering', () => { expect(tools[0].toolset).toBe('batch'); }); - test('"discovery,engagement" returns 13 tools', () => { + test('"discovery,engagement" returns 15 tools', () => { const tools = filterTools('discovery,engagement'); - expect(tools).toHaveLength(13); + expect(tools).toHaveLength(15); expect( tools.every((t) => t.toolset === 'discovery' || t.toolset === 'engagement') ).toBe(true); @@ -222,6 +235,6 @@ describe('Tool Mode Filtering', () => { test('handles whitespace in comma-separated values', () => { const tools = filterTools(' discovery , engagement '); - expect(tools).toHaveLength(13); + expect(tools).toHaveLength(15); }); });